diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ae2e5b0 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 43ee03b..b494f8b 100644 --- a/README.md +++ b/README.md @@ -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 @@ - + - +
Timesheet EntryEntry FormCopy Previous Week Dark Mode
- + + - - - + + + + +
Admin PanelBulk ApproveOvertime Tracking Reports & FiltersHistory
+ + + + + + + + + +
Homeowner ManagementHistoryEntry Form
diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 3387151..d1780de 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -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; diff --git a/backend/src/routes/entries.js b/backend/src/routes/entries.js index 53c7b5f..a347eba 100644 --- a/backend/src/routes/entries.js +++ b/backend/src/routes/entries.js @@ -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; diff --git a/backend/src/routes/timesheets.js b/backend/src/routes/timesheets.js index ab3fc37..65e0b12 100644 --- a/backend/src/routes/timesheets.js +++ b/backend/src/routes/timesheets.js @@ -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; diff --git a/docs/screenshots/08-timesheet-copy-week.png b/docs/screenshots/08-timesheet-copy-week.png new file mode 100644 index 0000000..256366b Binary files /dev/null and b/docs/screenshots/08-timesheet-copy-week.png differ diff --git a/docs/screenshots/09-admin-bulk-approve.png b/docs/screenshots/09-admin-bulk-approve.png new file mode 100644 index 0000000..8810081 Binary files /dev/null and b/docs/screenshots/09-admin-bulk-approve.png differ diff --git a/docs/screenshots/10-admin-overtime.png b/docs/screenshots/10-admin-overtime.png new file mode 100644 index 0000000..3ab4ea6 Binary files /dev/null and b/docs/screenshots/10-admin-overtime.png differ diff --git a/docs/screenshots/11-admin-homeowners.png b/docs/screenshots/11-admin-homeowners.png new file mode 100644 index 0000000..58c2a6c Binary files /dev/null and b/docs/screenshots/11-admin-homeowners.png differ diff --git a/docs/screenshots/12-admin-reports-filters.png b/docs/screenshots/12-admin-reports-filters.png new file mode 100644 index 0000000..46399cb Binary files /dev/null and b/docs/screenshots/12-admin-reports-filters.png differ diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index b4617f9..a457e30 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -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 (
+ {/* Bulk action bar */} + {timesheets.length > 1 && ( +
+ + {selected.size > 0 && ( +
+ + +
+ )} +
+ )} + {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 (
-
-
{ts.userName || ts.user?.name || 'Employee'}
-
- {fmt(start)} — {fmt(end)} · {ts.totalHours ? `${parseFloat(ts.totalHours).toFixed(1)}h` : ''} +
+ toggleSelect(ts.id)} className="rounded" /> +
+
{ts.userName || ts.user?.name || 'Employee'}
+
+ {fmt(start)} — {fmt(end)} · {ts.totalHours ? `${parseFloat(ts.totalHours).toFixed(1)}h` : ''} +
@@ -188,18 +257,51 @@ function ManageUsers() { )} {users.map((u) => ( -
-
-
{u.name}
-
{u.email}
+
+
+
+
{u.name}
+
{u.email}
+
+
+ + {u.isActive === false ? ( + + ) : ( + + )} +
- - {u.role} -
))}
@@ -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
; return (
-
- 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" - /> - -
+
+ + + {/* Add Form */} + {showAdd && ( +
+ 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" /> + 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" /> +
+ + +
+
+ )} + +
{filtered.length} homeowners
+ + {/* Homeowner List */}
- {homeowners.map((h) => ( -
- {h.name} - - {h.isActive !== false ? 'Active' : 'Inactive'} - + {filtered.map((h) => ( +
+ {editId === h.id ? ( + /* Edit Mode */ +
+ 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" /> + 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" /> +
+ + +
+
+ ) : ( + /* View Mode */ +
+
+
{h.name}
+ {h.address &&
#{h.address}
} +
+
+ + +
+
+ )}
))}
@@ -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() { {users.map((u) => )} + +
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" /> 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" />
@@ -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
; + if (!data) return

Failed to load

; + + return ( +
+ {/* Date filters */} +
+
+ + 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" /> +
+
+ + 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" /> +
+ +
+ + {/* Summary */} +
+
+
{data.totals.totalHours}
+
TOTAL HOURS
+
+
+
{data.totals.regularHours}
+
REGULAR
+
+
0 ? 'bg-amber-50 dark:bg-amber-500/10' : 'bg-gray-50 dark:bg-gray-800'}`}> +
0 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-400'}`}>{data.totals.overtimeHours}
+
OVERTIME
+
+
+ + {/* Employee list */} + {data.employees.length === 0 &&

No data for selected period

} + {data.employees.map((emp) => ( +
+
+
{emp.user.name}
+
+ {emp.summary.overtimeHours > 0 && ( + + {emp.summary.overtimeHours}h OT + + )} + {emp.summary.totalHours}h +
+
+ {/* Week breakdown */} +
+ {emp.weeks.map((w) => ( +
+ Week of {w.week} +
+ {w.regularHours}h reg + {w.overtimeHours > 0 && +{w.overtimeHours}h OT} +
+
+ ))} +
+
+ ))} +
+ ); +} + export default function Admin() { const [tab, setTab] = useState('reviews'); const [pendingCount, setPendingCount] = useState(0); @@ -467,12 +742,14 @@ export default function Admin() {
setTab('reviews')} icon={Clock} label="Reviews" count={pendingCount} /> setTab('reports')} icon={Download} label="Reports" count={0} /> + setTab('overtime')} icon={TrendingUp} label="Overtime" count={0} /> setTab('users')} icon={Users} label="Users" count={0} /> setTab('homeowners')} icon={Home} label="Homeowners" count={0} />
{tab === 'reviews' && } {tab === 'reports' && } + {tab === 'overtime' && } {tab === 'users' && } {tab === 'homeowners' && }
diff --git a/frontend/src/pages/Timesheet.jsx b/frontend/src/pages/Timesheet.jsx index 23732af..b26efbd 100644 --- a/frontend/src/pages/Timesheet.jsx +++ b/frontend/src/pages/Timesheet.jsx @@ -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() {
hours this week
+ + {/* Overtime indicator */} + {overtime && overtime.overtimeHours > 0 && ( +
+ + + {overtime.overtimeHours.toFixed(1)}h overtime (over {overtime.threshold}h) + +
+ )} + + {/* Copy previous week button */} + {!isLocked && totalHours === 0 && ( + + )}
{/* Day Cards */}