v2.1.0: Copy Previous Week, Bulk Approve, Overtime Tracking

Features:
- Copy Previous Week: one-tap to duplicate last week's entries
- Bulk Approve/Reject: multi-select + batch actions for admin reviews
- Overtime Tracking: admin report tab + employee real-time OT warnings
- Homeowner filter on reports
- Homeowner edit/search/address fields
- Employee role management + deactivate/reactivate
- Reopen locked timesheets

Fixes:
- Timezone bug (UTC vs local date parsing)
- CORS, approve 400, PDF download, auto-save errors
- History page crash, rate limiting
This commit is contained in:
BizzleBot
2026-02-15 21:43:31 +00:00
parent a7c138add1
commit efafbea297
12 changed files with 758 additions and 52 deletions
+63
View File
@@ -0,0 +1,63 @@
# Changelog
All notable changes to Coastal Timesheet will be documented in this file.
## [2.1.0] - 2026-02-15
### Added
- **Copy Previous Week** — One-tap button to duplicate last week's entries into the current week. Shifts dates automatically, skips deactivated homeowners. Shows on empty weeks only.
- **Bulk Approve / Reject** — Admin Reviews tab now supports multi-select with "Select All" checkbox and bulk action buttons. Process 40+ timesheets in one click instead of one-by-one.
- **Overtime Tracking** — New admin "Overtime" tab with per-employee weekly breakdown, total/regular/OT summary cards, and date range filtering. Employee timesheet page shows real-time amber warning banner when hours exceed 40h/week (FLSA threshold).
- **Homeowner Filter on Reports** — Admin Reports tab now includes a homeowner dropdown filter to see all time logged at a specific property.
- **Homeowner Address Field** — Homeowners now have a separate address field. House numbers split from names into dedicated address column.
- **Homeowner Edit/Search** — Full inline editing for homeowner name and address, search bar, toggle active/inactive with "Show inactive" filter.
- **Employee Edit/Delete** — Admin Users tab now has role dropdown (employee/admin), deactivate button (soft-delete preserving history), and reactivate option.
- **Reopen Timesheet** — `PUT /api/admin/timesheets/:id/reopen` endpoint allows admins to unlock approved/rejected timesheets for corrections.
- **Timesheet History** — `GET /api/timesheets/history` endpoint returns all user timesheets for the History page.
- **Public Homeowners API** — `GET /api/homeowners` endpoint for authenticated users (used by timesheet entry form).
### Fixed
- **Timezone bug** — `new Date("YYYY-MM-DD")` parsed as UTC, causing wrong day names in US timezones. All date parsing now uses local time.
- **CORS policy violation** — Added Tailscale IP to allowed CORS origins.
- **Approve button 400 error** — Frontend now sends `{}` body on approve (validation required an object).
- **PDF download silent failure** — Frontend was using `timesheet.id` but API returns `timesheetId`. Fixed field mapping.
- **Auto-save errors on incomplete entries** — Auto-save now only fires when all required fields (homeowner, hours, description) are filled. Incomplete entries show amber border indicator.
- **History page crash** — `.map is not a function` when API returned object instead of array. Added dedicated history endpoint.
- **Rate limit too aggressive** — Auth endpoint bumped from 5 to 50 attempts per 15 min for development.
### API Endpoints Added
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/entries/copy-week` | Copy entries from one week to another |
| POST | `/api/admin/timesheets/bulk-approve` | Approve multiple timesheets |
| POST | `/api/admin/timesheets/bulk-reject` | Reject multiple timesheets |
| GET | `/api/admin/overtime` | Overtime report with filters |
| GET | `/api/timesheets/overtime` | Employee's own overtime for a week |
| PUT | `/api/admin/timesheets/:id/reopen` | Reopen locked timesheet |
| DELETE | `/api/admin/users/:id` | Deactivate employee (soft-delete) |
| GET | `/api/timesheets/history` | All user timesheets |
| GET | `/api/homeowners` | Public homeowner list |
## [2.0.0] - 2026-02-15
### Added
- Complete rewrite from static HTML to production-grade stack
- React 18 + Vite + Tailwind CSS (mobile-first design)
- Express + Prisma + PostgreSQL backend
- JWT authentication with role-based access (employee, admin, super_admin)
- Weekly Mon–Sun timesheets with auto-save (800ms debounce)
- Multiple homeowner entries per day
- Submit → Approve/Reject workflow
- Server-side PDF generation (@react-pdf/renderer)
- SMTP email integration with branded HTML templates
- Admin panel with employee/homeowner management
- Reporting with employee, status, and date range filters
- Dark mode (system-aware + manual toggle)
- Docker Compose single-command deployment
- Non-root containers, Helmet security headers, bcrypt, Zod validation
- Rate limiting on auth endpoints
- 46 homeowners seeded from v1 data
+24 -7
View File
@@ -25,11 +25,15 @@
- **🔐 JWT authentication** — Secure login with access/refresh tokens + rate limiting
- **📅 Weekly timesheets** — Monday–Sunday pay period with auto-save
- **🏠 Multiple homeowners per day** — Track work at different job sites
- **📋 Copy Previous Week** — One tap to duplicate last week's entries as a template
- **✅ Submit → Approve workflow** — Employees submit, admins approve or reject
- **⚡ Bulk Approve/Reject** — Select all + approve 40 timesheets in one click
- **⏱️ Overtime Tracking** — Real-time OT warnings for employees, admin reports with per-employee weekly breakdown
- **📄 PDF generation** — Professional server-side PDF export
- **📧 Email integration** — Send timesheets via email with SMTP
- **👥 Admin panel** — Manage employees, homeowners, review timesheets
- **📊 Reporting** — Filter by employee, date range, status with summary stats
- **📊 Reporting** — Filter by employee, homeowner, date range, status
- **🏡 Homeowner management** — Add, edit, search, activate/deactivate with address fields
- **🌙 Dark mode** — System-aware with manual toggle
- **🐳 One-command deploy** — Single `docker compose up` for the entire stack
@@ -40,26 +44,39 @@
<table>
<tr>
<td align="center"><strong>Timesheet Entry</strong></td>
<td align="center"><strong>Entry Form</strong></td>
<td align="center"><strong>Copy Previous Week</strong></td>
<td align="center"><strong>Dark Mode</strong></td>
</tr>
<tr>
<td><img src="docs/screenshots/02-timesheet-mobile.png" width="250" /></td>
<td><img src="docs/screenshots/03-entry-form-mobile.png" width="250" /></td>
<td><img src="docs/screenshots/08-timesheet-copy-week.png" width="250" /></td>
<td><img src="docs/screenshots/07-dark-mode-mobile.png" width="250" /></td>
</tr>
</table>
<table>
<tr>
<td align="center"><strong>Admin Panel</strong></td>
<td align="center"><strong>Bulk Approve</strong></td>
<td align="center"><strong>Overtime Tracking</strong></td>
<td align="center"><strong>Reports & Filters</strong></td>
<td align="center"><strong>History</strong></td>
</tr>
<tr>
<td><img src="docs/screenshots/05-admin-mobile.png" width="250" /></td>
<td><img src="docs/screenshots/06-admin-reports-mobile.png" width="250" /></td>
<td><img src="docs/screenshots/09-admin-bulk-approve.png" width="250" /></td>
<td><img src="docs/screenshots/10-admin-overtime.png" width="250" /></td>
<td><img src="docs/screenshots/12-admin-reports-filters.png" width="250" /></td>
</tr>
</table>
<table>
<tr>
<td align="center"><strong>Homeowner Management</strong></td>
<td align="center"><strong>History</strong></td>
<td align="center"><strong>Entry Form</strong></td>
</tr>
<tr>
<td><img src="docs/screenshots/11-admin-homeowners.png" width="250" /></td>
<td><img src="docs/screenshots/04-history-mobile.png" width="250" /></td>
<td><img src="docs/screenshots/03-entry-form-mobile.png" width="250" /></td>
</tr>
</table>
+179
View File
@@ -141,6 +141,65 @@ router.get('/timesheets/:id', async (req, res) => {
}
});
// ─────────────── POST /api/admin/timesheets/bulk-approve ───────────────
router.post('/timesheets/bulk-approve', async (req, res) => {
try {
const { timesheetIds, notes } = req.body;
if (!Array.isArray(timesheetIds) || timesheetIds.length === 0) {
return res.status(400).json({ error: 'timesheetIds array is required' });
}
if (timesheetIds.length > 100) {
return res.status(400).json({ error: 'Maximum 100 timesheets per batch' });
}
const results = { approved: 0, failed: [] };
for (const id of timesheetIds) {
try {
const ts = await req.prisma.timesheet.findUnique({ where: { id } });
if (!ts) { results.failed.push({ id, reason: 'Not found' }); continue; }
if (ts.status !== 'submitted') { results.failed.push({ id, reason: `Status is "${ts.status}", expected "submitted"` }); continue; }
await req.prisma.timesheet.update({
where: { id },
data: { status: 'approved', approvedBy: req.user.id, approvedAt: new Date(), notes: notes || null },
});
results.approved++;
} catch (e) { results.failed.push({ id, reason: e.message }); }
}
res.json(results);
} catch (err) {
console.error('Bulk approve error:', err);
res.status(500).json({ error: 'Failed to bulk approve' });
}
});
// ─────────────── POST /api/admin/timesheets/bulk-reject ───────────────
router.post('/timesheets/bulk-reject', async (req, res) => {
try {
const { timesheetIds, notes } = req.body;
if (!Array.isArray(timesheetIds) || timesheetIds.length === 0) {
return res.status(400).json({ error: 'timesheetIds array is required' });
}
const results = { rejected: 0, failed: [] };
for (const id of timesheetIds) {
try {
const ts = await req.prisma.timesheet.findUnique({ where: { id } });
if (!ts) { results.failed.push({ id, reason: 'Not found' }); continue; }
if (ts.status !== 'submitted') { results.failed.push({ id, reason: `Status is "${ts.status}"` }); continue; }
await req.prisma.timesheet.update({
where: { id },
data: { status: 'rejected', approvedBy: req.user.id, approvedAt: new Date(), notes: notes || 'Rejected' },
});
results.rejected++;
} catch (e) { results.failed.push({ id, reason: e.message }); }
}
res.json(results);
} catch (err) {
console.error('Bulk reject error:', err);
res.status(500).json({ error: 'Failed to bulk reject' });
}
});
// ─────────────── PUT /api/admin/timesheets/:id/approve ───────────────
router.put(
'/timesheets/:id/approve',
@@ -379,6 +438,25 @@ router.put('/users/:id', async (req, res) => {
});
// ═══════════════════════════════════════════════════════════════
// ─────────────── DELETE /api/admin/users/:id (deactivate) ───────────────
router.delete('/users/:id', async (req, res) => {
try {
const { id } = req.params;
if (id === req.user.id) {
return res.status(400).json({ error: 'Cannot delete your own account' });
}
const user = await req.prisma.user.findUnique({ where: { id } });
if (!user) return res.status(404).json({ error: 'User not found' });
// Soft-delete: deactivate instead of hard delete (preserves timesheet history)
await req.prisma.user.update({ where: { id }, data: { isActive: false } });
res.json({ message: 'User deactivated', id });
} catch (err) {
console.error('Delete user error:', err);
res.status(500).json({ error: 'Failed to delete user' });
}
});
// HOMEOWNERS
// ═══════════════════════════════════════════════════════════════
@@ -681,4 +759,105 @@ router.get('/reports/pdf', async (req, res) => {
}
});
// ═══════════════════════════════════════════════════════════════
// OVERTIME
// ═══════════════════════════════════════════════════════════════
// ─────────────── GET /api/admin/overtime ───────────────
router.get('/overtime', async (req, res) => {
try {
const { from, to, userId } = req.query;
const weeklyThreshold = parseFloat(req.query.threshold || '40');
const overtimeRate = parseFloat(req.query.rate || '1.5');
const where = {};
if (userId) where.userId = userId;
if (from || to) {
where.date = {};
if (from) where.date.gte = new Date(from + 'T00:00:00Z');
if (to) where.date.lte = new Date(to + 'T23:59:59Z');
}
const entries = await req.prisma.timeEntry.findMany({
where,
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: [{ userId: 'asc' }, { date: 'asc' }],
});
// Group by user → week
const userWeeks = {};
for (const e of entries) {
const uid = e.userId;
const d = new Date(e.date);
const day = d.getUTCDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
const monday = new Date(d);
monday.setUTCDate(d.getUTCDate() + mondayOffset);
const weekKey = monday.toISOString().split('T')[0];
if (!userWeeks[uid]) userWeeks[uid] = { user: e.user, weeks: {} };
if (!userWeeks[uid].weeks[weekKey]) userWeeks[uid].weeks[weekKey] = { totalHours: 0, entries: 0 };
userWeeks[uid].weeks[weekKey].totalHours += parseFloat(e.hoursWorked) || 0;
userWeeks[uid].weeks[weekKey].entries++;
}
// Compute overtime per user
const results = Object.values(userWeeks).map((uw) => {
let totalRegular = 0;
let totalOvertime = 0;
let totalHours = 0;
const weekBreakdown = [];
for (const [week, data] of Object.entries(uw.weeks)) {
const regular = Math.min(data.totalHours, weeklyThreshold);
const overtime = Math.max(0, data.totalHours - weeklyThreshold);
totalRegular += regular;
totalOvertime += overtime;
totalHours += data.totalHours;
weekBreakdown.push({
week,
totalHours: parseFloat(data.totalHours.toFixed(2)),
regularHours: parseFloat(regular.toFixed(2)),
overtimeHours: parseFloat(overtime.toFixed(2)),
entries: data.entries,
});
}
return {
user: uw.user,
summary: {
totalHours: parseFloat(totalHours.toFixed(2)),
regularHours: parseFloat(totalRegular.toFixed(2)),
overtimeHours: parseFloat(totalOvertime.toFixed(2)),
overtimeCost: parseFloat((totalOvertime * overtimeRate).toFixed(2)),
weeksWithOvertime: weekBreakdown.filter((w) => w.overtimeHours > 0).length,
},
weeks: weekBreakdown,
};
});
// Sort by most overtime first
results.sort((a, b) => b.summary.overtimeHours - a.summary.overtimeHours);
res.json({
config: { weeklyThreshold, overtimeRate },
employees: results,
totals: {
totalHours: parseFloat(results.reduce((s, r) => s + r.summary.totalHours, 0).toFixed(2)),
regularHours: parseFloat(results.reduce((s, r) => s + r.summary.regularHours, 0).toFixed(2)),
overtimeHours: parseFloat(results.reduce((s, r) => s + r.summary.overtimeHours, 0).toFixed(2)),
},
});
} catch (err) {
console.error('Overtime report error:', err);
res.status(500).json({ error: 'Failed to generate overtime report' });
}
});
// Employee sees their own overtime
// ─────────────── GET /api/timesheets/overtime ───────────────
// (mounted at /api/timesheets/overtime in timesheets router)
module.exports = router;
+88
View File
@@ -268,4 +268,92 @@ router.delete('/:id', async (req, res) => {
}
});
// ─────────────── POST /api/entries/copy-week ───────────────
router.post('/copy-week', async (req, res) => {
try {
const { fromWeek, toWeek } = req.body;
if (!fromWeek || !toWeek) {
return res.status(400).json({ error: 'fromWeek and toWeek are required (YYYY-MM-DD Monday)' });
}
const fromMonday = getWeekMonday(fromWeek);
const toMonday = getWeekMonday(toWeek);
const fromSunday = getWeekSunday(fromMonday);
// Check target week isn't locked
const targetTs = await req.prisma.timesheet.findUnique({
where: { userId_weekStart: { userId: req.user.id, weekStart: new Date(toMonday + 'T00:00:00Z') } },
});
if (targetTs && ['submitted', 'approved'].includes(targetTs.status)) {
return res.status(403).json({ error: 'Target week is locked (submitted or approved)' });
}
// Get source week entries
const sourceEntries = await req.prisma.timeEntry.findMany({
where: {
userId: req.user.id,
date: {
gte: new Date(fromMonday + 'T00:00:00Z'),
lte: new Date(fromSunday + 'T00:00:00Z'),
},
},
include: { homeowner: { select: { id: true, isActive: true } } },
});
if (sourceEntries.length === 0) {
return res.status(404).json({ error: 'No entries found in source week' });
}
// Calculate day offset (Mon=0 ... Sun=6)
const fromStart = new Date(fromMonday + 'T00:00:00Z');
const toStart = new Date(toMonday + 'T00:00:00Z');
const dayOffset = Math.round((toStart - fromStart) / (1000 * 60 * 60 * 24));
// Delete existing entries in target week (that are in draft)
const toSunday = getWeekSunday(toMonday);
await req.prisma.timeEntry.deleteMany({
where: {
userId: req.user.id,
date: {
gte: new Date(toMonday + 'T00:00:00Z'),
lte: new Date(toSunday + 'T00:00:00Z'),
},
},
});
// Copy entries with shifted dates, only for active homeowners
const created = [];
for (const entry of sourceEntries) {
if (!entry.homeowner.isActive) continue;
const oldDate = new Date(entry.date);
const newDate = new Date(oldDate);
newDate.setUTCDate(newDate.getUTCDate() + dayOffset);
const newEntry = await req.prisma.timeEntry.create({
data: {
userId: req.user.id,
date: newDate,
homeownerId: entry.homeownerId,
hoursWorked: entry.hoursWorked,
workDescription: entry.workDescription,
},
include: { homeowner: { select: { name: true } } },
});
created.push({
id: newEntry.id,
date: newEntry.date.toISOString().split('T')[0],
homeownerId: newEntry.homeownerId,
homeownerName: newEntry.homeowner.name,
hoursWorked: newEntry.hoursWorked,
workDescription: newEntry.workDescription,
});
}
res.status(201).json({ copied: created.length, entries: created });
} catch (err) {
console.error('Copy week error:', err);
res.status(500).json({ error: 'Failed to copy week' });
}
});
module.exports = router;
+32
View File
@@ -306,4 +306,36 @@ router.post('/:id/email', validateBody(emailTimesheetSchema), async (req, res) =
}
});
// ─────────────── GET /api/timesheets/overtime ───────────────
router.get('/overtime', async (req, res) => {
try {
const weekParam = req.query.week;
const monday = weekParam ? getWeekMonday(weekParam) : getWeekMonday(new Date().toISOString().split('T')[0]);
const sunday = getWeekSunday(monday);
const entries = await req.prisma.timeEntry.findMany({
where: {
userId: req.user.id,
date: { gte: new Date(monday + 'T00:00:00Z'), lte: new Date(sunday + 'T00:00:00Z') },
},
});
const totalHours = entries.reduce((s, e) => s + (parseFloat(e.hoursWorked) || 0), 0);
const threshold = 40;
const regular = Math.min(totalHours, threshold);
const overtime = Math.max(0, totalHours - threshold);
res.json({
week: monday,
totalHours: parseFloat(totalHours.toFixed(2)),
regularHours: parseFloat(regular.toFixed(2)),
overtimeHours: parseFloat(overtime.toFixed(2)),
threshold,
});
} catch (err) {
console.error('Employee overtime error:', err);
res.status(500).json({ error: 'Failed to get overtime' });
}
});
module.exports = router;
Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

+321 -44
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import api from '../api/client';
import StatusBadge from '../components/StatusBadge';
import { Check, X, Users, Home, Clock, Loader2, Plus, UserPlus, Download, BarChart3 } from 'lucide-react';
import { Check, X, Users, Home, Clock, Loader2, Plus, UserPlus, Download, BarChart3, TrendingUp, AlertTriangle } from 'lucide-react';
function TabButton({ active, onClick, icon: Icon, label, count }) {
return (
@@ -28,6 +28,8 @@ function PendingReviews() {
const [timesheets, setTimesheets] = useState([]);
const [loading, setLoading] = useState(true);
const [actionId, setActionId] = useState(null);
const [selected, setSelected] = useState(new Set());
const [bulkAction, setBulkAction] = useState(false);
useEffect(() => {
loadPending();
@@ -44,10 +46,52 @@ function PendingReviews() {
}
}
function toggleSelect(id) {
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function toggleSelectAll() {
if (selected.size === timesheets.length) {
setSelected(new Set());
} else {
setSelected(new Set(timesheets.map((t) => t.id)));
}
}
async function bulkApprove() {
if (selected.size === 0) return;
setBulkAction(true);
try {
const res = await api.post('/admin/timesheets/bulk-approve', { timesheetIds: [...selected] });
alert(`Approved: ${res.data.approved}${res.data.failed?.length ? `, Failed: ${res.data.failed.length}` : ''}`);
setSelected(new Set());
loadPending();
} catch (err) { alert('Bulk approve failed'); }
finally { setBulkAction(false); }
}
async function bulkReject() {
if (selected.size === 0) return;
const notes = prompt('Rejection reason:');
if (notes === null) return;
setBulkAction(true);
try {
const res = await api.post('/admin/timesheets/bulk-reject', { timesheetIds: [...selected], notes });
alert(`Rejected: ${res.data.rejected}`);
setSelected(new Set());
loadPending();
} catch (err) { alert('Bulk reject failed'); }
finally { setBulkAction(false); }
}
async function approve(id) {
setActionId(id);
try {
await api.put(`/admin/timesheets/${id}/approve`);
await api.put(`/admin/timesheets/${id}/approve`, {});
setTimesheets((prev) => prev.filter((t) => t.id !== id));
} catch (err) {
alert(err.response?.data?.error || 'Failed to approve');
@@ -83,17 +127,42 @@ function PendingReviews() {
return (
<div className="space-y-2">
{/* Bulk action bar */}
{timesheets.length > 1 && (
<div className="flex items-center justify-between bg-gray-50 dark:bg-gray-800/50 rounded-xl px-4 py-2">
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input type="checkbox" checked={selected.size === timesheets.length && timesheets.length > 0} onChange={toggleSelectAll} className="rounded" />
{selected.size > 0 ? `${selected.size} selected` : 'Select all'}
</label>
{selected.size > 0 && (
<div className="flex gap-2">
<button onClick={bulkApprove} disabled={bulkAction} className="px-3 py-1.5 rounded-lg bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600 disabled:opacity-50 flex items-center gap-1">
<Check size={14} /> Approve All
</button>
<button onClick={bulkReject} disabled={bulkAction} className="px-3 py-1.5 rounded-lg bg-red-500 text-white text-xs font-medium hover:bg-red-600 disabled:opacity-50 flex items-center gap-1">
<X size={14} /> Reject All
</button>
</div>
)}
</div>
)}
{timesheets.map((ts) => {
const start = new Date(ts.weekStart);
const end = new Date(ts.weekEnd);
const [sy, sm, sd] = (ts.weekStart || '').split('-').map(Number);
const [ey, em, ed] = (ts.weekEnd || '').split('-').map(Number);
const start = new Date(sy, sm - 1, sd);
const end = new Date(ey, em - 1, ed);
const fmt = (d) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
return (
<div key={ts.id} className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-semibold text-gray-900 dark:text-white">{ts.userName || ts.user?.name || 'Employee'}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{fmt(start)} — {fmt(end)} · {ts.totalHours ? `${parseFloat(ts.totalHours).toFixed(1)}h` : ''}
<div className="flex items-center gap-3">
<input type="checkbox" checked={selected.has(ts.id)} onChange={() => toggleSelect(ts.id)} className="rounded" />
<div>
<div className="font-semibold text-gray-900 dark:text-white">{ts.userName || ts.user?.name || 'Employee'}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{fmt(start)} — {fmt(end)} · {ts.totalHours ? `${parseFloat(ts.totalHours).toFixed(1)}h` : ''}
</div>
</div>
</div>
<div className="flex items-center gap-2">
@@ -188,18 +257,51 @@ function ManageUsers() {
)}
{users.map((u) => (
<div key={u.id} className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 flex items-center justify-between">
<div>
<div className="font-medium text-gray-900 dark:text-white">{u.name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{u.email}</div>
<div key={u.id} className={`bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 ${u.isActive === false ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-gray-900 dark:text-white">{u.name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{u.email}</div>
</div>
<div className="flex items-center gap-2">
<select
value={u.role}
onChange={async (e) => {
try {
await api.put(`/admin/users/${u.id}`, { role: e.target.value });
loadUsers();
} catch (err) { alert(err.response?.data?.error || 'Failed'); }
}}
className="text-xs px-2 py-1 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300"
>
<option value="employee">Employee</option>
<option value="admin">Admin</option>
</select>
{u.isActive === false ? (
<button
onClick={async () => {
try { await api.put(`/admin/users/${u.id}`, { isActive: true }); loadUsers(); }
catch (err) { alert('Failed to reactivate'); }
}}
className="text-xs px-2.5 py-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-500/20 font-medium"
>
Reactivate
</button>
) : (
<button
onClick={async () => {
if (!confirm(`Deactivate ${u.name}? They won't be able to log in.`)) return;
try { await api.delete(`/admin/users/${u.id}`); loadUsers(); }
catch (err) { alert(err.response?.data?.error || 'Failed'); }
}}
className="p-1.5 rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-all"
title="Deactivate"
>
<X size={14} />
</button>
)}
</div>
</div>
<span className={`text-xs font-medium px-2.5 py-1 rounded-full ${
u.role === 'super_admin' ? 'bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400' :
u.role === 'admin' ? 'bg-sky-100 dark:bg-sky-500/20 text-sky-600 dark:text-sky-400' :
'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400'
}`}>
{u.role}
</span>
</div>
))}
</div>
@@ -209,14 +311,19 @@ function ManageUsers() {
function ManageHomeowners() {
const [homeowners, setHomeowners] = useState([]);
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState('');
const [showInactive, setShowInactive] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const [newForm, setNewForm] = useState({ name: '', address: '' });
const [adding, setAdding] = useState(false);
const [editId, setEditId] = useState(null);
const [editForm, setEditForm] = useState({ name: '', address: '' });
const [search, setSearch] = useState('');
useEffect(() => { loadHomeowners(); }, []);
useEffect(() => { loadHomeowners(); }, [showInactive]);
async function loadHomeowners() {
try {
const res = await api.get('/admin/homeowners');
const res = await api.get(`/admin/homeowners?includeInactive=${showInactive}`);
setHomeowners(res.data.homeowners || res.data || []);
} catch (err) { console.error(err); }
finally { setLoading(false); }
@@ -224,42 +331,113 @@ function ManageHomeowners() {
async function addHomeowner(e) {
e.preventDefault();
if (!newName.trim()) return;
if (!newForm.name.trim()) return;
setAdding(true);
try {
await api.post('/admin/homeowners', { name: newName.trim() });
setNewName('');
await api.post('/admin/homeowners', { name: newForm.name.trim(), address: newForm.address.trim() || undefined });
setNewForm({ name: '', address: '' });
setShowAdd(false);
loadHomeowners();
} catch (err) {
alert(err.response?.data?.error || 'Failed to add');
} finally {
setAdding(false);
} finally { setAdding(false); }
}
async function saveEdit(id) {
try {
await api.put(`/admin/homeowners/${id}`, { name: editForm.name.trim(), address: editForm.address.trim() || null });
setEditId(null);
loadHomeowners();
} catch (err) {
alert(err.response?.data?.error || 'Failed to update');
}
}
async function toggleActive(h) {
try {
await api.put(`/admin/homeowners/${h.id}`, { isActive: !h.isActive });
loadHomeowners();
} catch (err) { alert('Failed'); }
}
const filtered = homeowners.filter((h) =>
h.name.toLowerCase().includes(search.toLowerCase()) ||
(h.address || '').toLowerCase().includes(search.toLowerCase())
);
if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-sky-500" /></div>;
return (
<div className="space-y-3">
<form onSubmit={addHomeowner} className="flex gap-2">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="New homeowner name..."
className="flex-1 px-4 py-2.5 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white"
/>
<button type="submit" disabled={adding || !newName.trim()} className="px-4 py-2.5 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600 disabled:opacity-50 flex items-center gap-1.5">
{/* Search + Controls */}
<div className="flex gap-2">
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search homeowners..." className="flex-1 px-4 py-2.5 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
<button onClick={() => setShowAdd(!showAdd)} className="px-3 py-2.5 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600 flex items-center gap-1.5">
<Plus size={16} /> Add
</button>
</form>
</div>
<label className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<input type="checkbox" checked={showInactive} onChange={(e) => setShowInactive(e.target.checked)} className="rounded" />
Show inactive homeowners
</label>
{/* Add Form */}
{showAdd && (
<form onSubmit={addHomeowner} className="bg-sky-50/50 dark:bg-sky-500/5 rounded-2xl border border-sky-200 dark:border-sky-500/20 p-4 space-y-3">
<input value={newForm.name} onChange={(e) => setNewForm({ ...newForm, name: e.target.value })} required placeholder="Homeowner name (e.g. Smith, 142)" className="w-full px-3 py-2.5 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
<input value={newForm.address} onChange={(e) => setNewForm({ ...newForm, address: e.target.value })} placeholder="Address (optional)" className="w-full px-3 py-2.5 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setShowAdd(false)} className="px-4 py-2 rounded-xl text-sm text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800">Cancel</button>
<button type="submit" disabled={adding} className="px-4 py-2 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600 disabled:opacity-50">{adding ? 'Adding...' : 'Add Homeowner'}</button>
</div>
</form>
)}
<div className="text-xs text-gray-400 dark:text-gray-500">{filtered.length} homeowners</div>
{/* Homeowner List */}
<div className="space-y-1">
{homeowners.map((h) => (
<div key={h.id} className="flex items-center justify-between px-4 py-3 rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
<span className="text-sm text-gray-900 dark:text-white">{h.name}</span>
<span className={`text-xs ${h.isActive !== false ? 'text-emerald-500' : 'text-gray-400'}`}>
{h.isActive !== false ? 'Active' : 'Inactive'}
</span>
{filtered.map((h) => (
<div key={h.id} className={`rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 ${h.isActive === false ? 'opacity-50' : ''}`}>
{editId === h.id ? (
/* Edit Mode */
<div className="p-3 space-y-2">
<input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} className="w-full px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" placeholder="Name" />
<input value={editForm.address} onChange={(e) => setEditForm({ ...editForm, address: e.target.value })} className="w-full px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" placeholder="Address" />
<div className="flex gap-2 justify-end">
<button onClick={() => setEditId(null)} className="px-3 py-1.5 rounded-lg text-xs text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800">Cancel</button>
<button onClick={() => saveEdit(h.id)} className="px-3 py-1.5 rounded-lg bg-sky-500 text-white text-xs font-medium hover:bg-sky-600">Save</button>
</div>
</div>
) : (
/* View Mode */
<div className="flex items-center justify-between px-4 py-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{h.name}</div>
{h.address && <div className="text-xs text-gray-500 dark:text-gray-400 truncate">#{h.address}</div>}
</div>
<div className="flex items-center gap-1.5 ml-2">
<button
onClick={() => { setEditId(h.id); setEditForm({ name: h.name, address: h.address || '' }); }}
className="p-1.5 rounded-lg text-gray-400 hover:text-sky-500 hover:bg-sky-50 dark:hover:bg-sky-500/10 transition-all"
title="Edit"
>
<UserPlus size={14} />
</button>
<button
onClick={() => toggleActive(h)}
className={`text-[10px] px-2 py-1 rounded-lg font-medium transition-all ${
h.isActive !== false
? 'text-emerald-600 bg-emerald-50 dark:bg-emerald-500/10 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-500/10'
: 'text-gray-400 bg-gray-100 dark:bg-gray-800 hover:bg-emerald-50 hover:text-emerald-500 dark:hover:bg-emerald-500/10'
}`}
>
{h.isActive !== false ? 'Active' : 'Inactive'}
</button>
</div>
</div>
)}
</div>
))}
</div>
@@ -271,7 +449,8 @@ function Reports() {
const [users, setUsers] = useState([]);
const [timesheets, setTimesheets] = useState([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({ userId: '', status: '', startDate: '', endDate: '' });
const [homeowners, setHomeowners] = useState([]);
const [filters, setFilters] = useState({ userId: '', status: '', startDate: '', endDate: '', homeownerId: '' });
const [selectedTs, setSelectedTs] = useState(null);
const [detail, setDetail] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
@@ -280,9 +459,11 @@ function Reports() {
Promise.all([
api.get('/admin/users'),
api.get('/admin/timesheets'),
]).then(([usersRes, tsRes]) => {
api.get('/admin/homeowners?includeInactive=true'),
]).then(([usersRes, tsRes, hoRes]) => {
setUsers(usersRes.data.users || usersRes.data || []);
setTimesheets(tsRes.data.timesheets || tsRes.data || []);
setHomeowners(hoRes.data.homeowners || hoRes.data || []);
}).catch(console.error).finally(() => setLoading(false));
}, []);
@@ -292,6 +473,9 @@ function Reports() {
const params = new URLSearchParams();
if (filters.userId) params.set('userId', filters.userId);
if (filters.status) params.set('status', filters.status);
if (filters.homeownerId) params.set('homeownerId', filters.homeownerId);
if (filters.startDate) params.set('from', filters.startDate);
if (filters.endDate) params.set('to', filters.endDate);
const res = await api.get(`/admin/timesheets?${params}`);
setTimesheets(res.data.timesheets || res.data || []);
} catch (err) { console.error(err); }
@@ -360,6 +544,10 @@ function Reports() {
<option value="">All Employees</option>
{users.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
<select value={filters.homeownerId} onChange={(e) => setFilters({ ...filters, homeownerId: e.target.value })} className="px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white">
<option value="">All Homeowners</option>
{homeowners.map((h) => <option key={h.id} value={h.id}>{h.name}{h.address ? ` #${h.address}` : ''}</option>)}
</select>
<select value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })} className="px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white">
<option value="">All Status</option>
<option value="draft">Draft</option>
@@ -367,6 +555,7 @@ function Reports() {
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
<div></div>
<input type="date" value={filters.startDate} onChange={(e) => setFilters({ ...filters, startDate: e.target.value })} className="px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" placeholder="Start" />
<input type="date" value={filters.endDate} onChange={(e) => setFilters({ ...filters, endDate: e.target.value })} className="px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" placeholder="End" />
</div>
@@ -450,6 +639,92 @@ function Reports() {
);
}
function OvertimeReport() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState({ from: '', to: '' });
useEffect(() => { loadOvertime(); }, []);
async function loadOvertime() {
setLoading(true);
try {
const params = new URLSearchParams();
if (dateRange.from) params.set('from', dateRange.from);
if (dateRange.to) params.set('to', dateRange.to);
const res = await api.get(`/admin/overtime?${params}`);
setData(res.data);
} catch (err) { console.error(err); }
finally { setLoading(false); }
}
if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-sky-500" /></div>;
if (!data) return <p className="text-center py-10 text-gray-400">Failed to load</p>;
return (
<div className="space-y-4">
{/* Date filters */}
<div className="flex gap-2 items-end">
<div className="flex-1">
<label className="text-xs text-gray-500 dark:text-gray-400">From</label>
<input type="date" value={dateRange.from} onChange={(e) => setDateRange({ ...dateRange, from: e.target.value })} className="w-full px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
</div>
<div className="flex-1">
<label className="text-xs text-gray-500 dark:text-gray-400">To</label>
<input type="date" value={dateRange.to} onChange={(e) => setDateRange({ ...dateRange, to: e.target.value })} className="w-full px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white" />
</div>
<button onClick={loadOvertime} className="px-4 py-2 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600">Filter</button>
</div>
{/* Summary */}
<div className="grid grid-cols-3 gap-2">
<div className="bg-sky-50 dark:bg-sky-500/10 rounded-xl p-3 text-center">
<div className="text-xl font-bold text-sky-600 dark:text-sky-400">{data.totals.totalHours}</div>
<div className="text-[10px] text-sky-500/70 font-medium">TOTAL HOURS</div>
</div>
<div className="bg-emerald-50 dark:bg-emerald-500/10 rounded-xl p-3 text-center">
<div className="text-xl font-bold text-emerald-600 dark:text-emerald-400">{data.totals.regularHours}</div>
<div className="text-[10px] text-emerald-500/70 font-medium">REGULAR</div>
</div>
<div className={`rounded-xl p-3 text-center ${data.totals.overtimeHours > 0 ? 'bg-amber-50 dark:bg-amber-500/10' : 'bg-gray-50 dark:bg-gray-800'}`}>
<div className={`text-xl font-bold ${data.totals.overtimeHours > 0 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-400'}`}>{data.totals.overtimeHours}</div>
<div className="text-[10px] text-amber-500/70 font-medium">OVERTIME</div>
</div>
</div>
{/* Employee list */}
{data.employees.length === 0 && <p className="text-center py-6 text-gray-400 text-sm">No data for selected period</p>}
{data.employees.map((emp) => (
<div key={emp.user.id} className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4">
<div className="flex items-center justify-between mb-2">
<div className="font-semibold text-gray-900 dark:text-white">{emp.user.name}</div>
<div className="flex items-center gap-2">
{emp.summary.overtimeHours > 0 && (
<span className="flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 px-2 py-1 rounded-lg">
<AlertTriangle size={12} /> {emp.summary.overtimeHours}h OT
</span>
)}
<span className="text-sm font-bold text-gray-700 dark:text-gray-300">{emp.summary.totalHours}h</span>
</div>
</div>
{/* Week breakdown */}
<div className="space-y-1">
{emp.weeks.map((w) => (
<div key={w.week} className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>Week of {w.week}</span>
<div className="flex items-center gap-3">
<span>{w.regularHours}h reg</span>
{w.overtimeHours > 0 && <span className="text-amber-500 font-medium">+{w.overtimeHours}h OT</span>}
</div>
</div>
))}
</div>
</div>
))}
</div>
);
}
export default function Admin() {
const [tab, setTab] = useState('reviews');
const [pendingCount, setPendingCount] = useState(0);
@@ -467,12 +742,14 @@ export default function Admin() {
<div className="flex gap-1 overflow-x-auto pb-1">
<TabButton active={tab === 'reviews'} onClick={() => setTab('reviews')} icon={Clock} label="Reviews" count={pendingCount} />
<TabButton active={tab === 'reports'} onClick={() => setTab('reports')} icon={Download} label="Reports" count={0} />
<TabButton active={tab === 'overtime'} onClick={() => setTab('overtime')} icon={TrendingUp} label="Overtime" count={0} />
<TabButton active={tab === 'users'} onClick={() => setTab('users')} icon={Users} label="Users" count={0} />
<TabButton active={tab === 'homeowners'} onClick={() => setTab('homeowners')} icon={Home} label="Homeowners" count={0} />
</div>
{tab === 'reviews' && <PendingReviews />}
{tab === 'reports' && <Reports />}
{tab === 'overtime' && <OvertimeReport />}
{tab === 'users' && <ManageUsers />}
{tab === 'homeowners' && <ManageHomeowners />}
</div>
+51 -1
View File
@@ -5,7 +5,7 @@ import WeekNavigator, { getWeekRange } from '../components/WeekNavigator';
import DayCard from '../components/DayCard';
import SaveIndicator from '../components/SaveIndicator';
import StatusBadge from '../components/StatusBadge';
import { Send, Download, Mail, Loader2 } from 'lucide-react';
import { Send, Download, Mail, Loader2, Copy, TrendingUp } from 'lucide-react';
function toLocalDateStr(d) {
// Format as YYYY-MM-DD in local timezone (avoid UTC shift)
@@ -41,6 +41,8 @@ export default function Timesheet() {
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [pdfLoading, setPdfLoading] = useState(false);
const [copying, setCopying] = useState(false);
const [overtime, setOvertime] = useState(null);
const saveTimer = useRef(null);
const pendingChanges = useRef({});
@@ -179,6 +181,32 @@ export default function Timesheet() {
}
}
// Fetch overtime for current week
useEffect(() => {
if (!weekParam) return;
api.get(`/timesheets/overtime?week=${weekParam}`)
.then((res) => setOvertime(res.data))
.catch(() => setOvertime(null));
}, [weekParam]);
async function handleCopyPrevWeek() {
const prevMonday = new Date(weekDates[0] + 'T00:00:00');
prevMonday.setDate(prevMonday.getDate() - 7);
const fromWeek = toLocalDateStr(prevMonday);
if (!confirm(`Copy entries from week of ${fromWeek} to this week? This will replace any existing entries.`)) return;
setCopying(true);
try {
const res = await api.post('/entries/copy-week', { fromWeek, toWeek: weekParam });
alert(`Copied ${res.data.copied} entries!`);
loadWeek();
} catch (err) {
alert(err.response?.data?.error || 'Failed to copy week');
} finally {
setCopying(false);
}
}
async function handleSubmit() {
setSubmitting(true);
try {
@@ -245,6 +273,28 @@ export default function Timesheet() {
<div className="text-xs text-gray-500 dark:text-gray-400">hours this week</div>
</div>
</div>
{/* Overtime indicator */}
{overtime && overtime.overtimeHours > 0 && (
<div className="flex items-center gap-2 px-3 py-2 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20">
<TrendingUp size={16} className="text-amber-500" />
<span className="text-sm font-medium text-amber-700 dark:text-amber-400">
{overtime.overtimeHours.toFixed(1)}h overtime (over {overtime.threshold}h)
</span>
</div>
)}
{/* Copy previous week button */}
{!isLocked && totalHours === 0 && (
<button
onClick={handleCopyPrevWeek}
disabled={copying}
className="w-full py-2.5 rounded-xl border-2 border-dashed border-sky-200 dark:border-sky-500/30 text-sky-600 dark:text-sky-400 text-sm font-medium flex items-center justify-center gap-2 hover:bg-sky-50 dark:hover:bg-sky-500/5 transition-all active:scale-[0.98]"
>
{copying ? <Loader2 size={16} className="animate-spin" /> : <Copy size={16} />}
{copying ? 'Copying...' : 'Copy Previous Week'}
</button>
)}
</div>
{/* Day Cards */}