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:
+321
-44
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user