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
+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 */}