Files
coastal_timesheet/frontend/src/pages/Timesheet.jsx
T
BizzleBot f718a76153 Coastal Timesheet v2 — full feature buildout
Features:
- Copy Previous Week (entries duplication)
- Bulk Approve/Reject (admin workflow)
- Overtime tracking (employee + admin views)
- DayCard component with auto-save
- PDF generation, email notifications
- React + Tailwind frontend, Prisma + PostgreSQL backend
- Docker deployment (3 containers)
2026-02-16 10:01:39 +00:00

362 lines
13 KiB
React

import { useState, useEffect, useCallback, useRef } from 'react';
import api from '../api/client';
import { useAuth } from '../contexts/AuthContext';
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, Copy, TrendingUp } from 'lucide-react';
function toLocalDateStr(d) {
// Format as YYYY-MM-DD in local timezone (avoid UTC shift)
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function getWeekDates(date) {
const { start } = getWeekRange(date);
const dates = [];
for (let i = 0; i < 7; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
dates.push(toLocalDateStr(d));
}
return dates;
}
function formatWeekParam(date) {
const { start } = getWeekRange(date);
return toLocalDateStr(start);
}
export default function Timesheet() {
const { user } = useAuth();
const [selectedDate, setSelectedDate] = useState(new Date());
const [entries, setEntries] = useState({});
const [homeowners, setHomeowners] = useState([]);
const [timesheet, setTimesheet] = useState(null);
const [saveStatus, setSaveStatus] = useState('saved');
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({});
const weekDates = getWeekDates(selectedDate);
const weekParam = formatWeekParam(selectedDate);
// rejected timesheets can be edited and resubmitted
const timesheetId = timesheet?.id || timesheet?.timesheetId;
const isLocked = timesheet?.status === 'submitted' || timesheet?.status === 'approved';
// Load data for current week
const loadWeek = useCallback(async () => {
setLoading(true);
try {
const [entriesRes, timesheetRes, homeownersRes] = await Promise.all([
api.get(`/entries?week=${weekParam}`),
api.get(`/timesheets?week=${weekParam}`).catch(() => ({ data: null })),
api.get('/homeowners'),
]);
// Organize entries by date
const byDate = {};
weekDates.forEach((d) => { byDate[d] = []; });
(entriesRes.data.entries || entriesRes.data || []).forEach((e) => {
const dk = e.date?.split('T')[0] || e.date;
if (byDate[dk]) byDate[dk].push(e);
});
// Ensure at least one empty entry per day
weekDates.forEach((d) => {
if (byDate[d].length === 0) {
byDate[d] = [{ id: `new-${d}-0`, date: d, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true }];
}
});
setEntries(byDate);
setTimesheet(timesheetRes.data?.timesheet || timesheetRes.data);
setHomeowners(homeownersRes.data.homeowners || homeownersRes.data || []);
} catch (err) {
console.error('Failed to load week:', err);
} finally {
setLoading(false);
}
}, [weekParam]);
useEffect(() => { loadWeek(); }, [loadWeek]);
// Auto-save logic (debounced)
// Only save when entry has all required fields
function isEntryComplete(data) {
return data.date && data.homeownerId && parseFloat(data.hoursWorked) > 0 && data.workDescription?.trim();
}
const saveEntry = useCallback(async (entryId, data) => {
// Don't save incomplete entries
if (!isEntryComplete(data)) {
setSaveStatus('saved'); // Reset indicator, not an error
return;
}
setSaveStatus('saving');
// Normalize data for API
const payload = {
date: data.date,
homeownerId: data.homeownerId,
hoursWorked: parseFloat(data.hoursWorked),
workDescription: data.workDescription?.trim() || '',
};
try {
if (data._isNew || entryId.startsWith('new-')) {
const res = await api.post('/entries', payload);
// Replace temp ID with real ID
setEntries((prev) => {
const dk = data.date;
return {
...prev,
[dk]: prev[dk].map((e) => (e.id === entryId ? { ...res.data.entry || res.data, date: dk } : e)),
};
});
} else {
await api.put(`/entries/${entryId}`, payload);
}
setSaveStatus('saved');
} catch (err) {
console.error('Save failed:', err);
setSaveStatus('error');
}
}, []);
function handleEntryChange(entryId, updatedEntry) {
const dk = updatedEntry.date;
setEntries((prev) => ({
...prev,
[dk]: prev[dk].map((e) => (e.id === entryId ? updatedEntry : e)),
}));
// Debounced save
setSaveStatus('saving');
pendingChanges.current[entryId] = updatedEntry;
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
Object.entries(pendingChanges.current).forEach(([id, data]) => {
saveEntry(id, data);
});
pendingChanges.current = {};
}, 800);
}
function handleAddEntry(date) {
const newEntry = {
id: `new-${date}-${Date.now()}`,
date,
homeownerId: '',
hoursWorked: '',
workDescription: '',
_isNew: true,
};
setEntries((prev) => ({ ...prev, [date]: [...(prev[date] || []), newEntry] }));
}
async function handleDeleteEntry(entryId) {
try {
if (!entryId.startsWith('new-')) {
await api.delete(`/entries/${entryId}`);
}
setEntries((prev) => {
const updated = {};
Object.entries(prev).forEach(([dk, dayEntries]) => {
const filtered = dayEntries.filter((e) => e.id !== entryId);
updated[dk] = filtered.length > 0 ? filtered : [{ id: `new-${dk}-0`, date: dk, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true }];
});
return updated;
});
setSaveStatus('saved');
} catch (err) {
console.error('Delete failed:', err);
}
}
// 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 {
// Save any pending changes first
if (saveTimer.current) clearTimeout(saveTimer.current);
for (const [id, data] of Object.entries(pendingChanges.current)) {
await saveEntry(id, data);
}
pendingChanges.current = {};
const res = await api.post('/timesheets/submit', { weekStart: weekParam });
setTimesheet(res.data);
} catch (err) {
alert(err.response?.data?.error || 'Failed to submit timesheet');
} finally {
setSubmitting(false);
}
}
async function handleDownloadPdf() {
if (!timesheetId) return;
setPdfLoading(true);
try {
const res = await api.get(`/timesheets/${timesheetId}/pdf`, { responseType: 'blob' });
const url = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = `timesheet-${user.name}-${weekParam}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
alert('Failed to generate PDF');
} finally {
setPdfLoading(false);
}
}
const totalHours = Object.values(entries)
.flat()
.reduce((sum, e) => sum + (parseFloat(e.hoursWorked) || 0), 0);
const today = toLocalDateStr(new Date());
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 size={32} className="animate-spin text-sky-500" />
</div>
);
}
return (
<div className="space-y-4">
{/* Week Navigator + Status */}
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 space-y-3">
<WeekNavigator selectedDate={selectedDate} onDateChange={setSelectedDate} />
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<StatusBadge status={timesheet?.status || 'draft'} />
<SaveIndicator status={saveStatus} />
</div>
<div className="text-right">
<div className="text-2xl font-bold text-gray-900 dark:text-white">{totalHours.toFixed(1)}</div>
<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 */}
<div className="space-y-3">
{weekDates.map((date) => (
<DayCard
key={date}
date={date}
entries={entries[date] || []}
homeowners={homeowners}
onEntryChange={handleEntryChange}
onAddEntry={handleAddEntry}
onDeleteEntry={handleDeleteEntry}
disabled={isLocked}
defaultExpanded={date === today}
/>
))}
</div>
{/* Actions */}
<div className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-4 space-y-3">
{!isLocked ? (
<button
onClick={handleSubmit}
disabled={submitting || totalHours === 0}
className="w-full py-4 rounded-2xl bg-gradient-to-r from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700 text-white font-semibold text-lg shadow-lg shadow-emerald-500/25 hover:shadow-emerald-500/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 active:scale-[0.98]"
>
{submitting ? <Loader2 size={20} className="animate-spin" /> : <Send size={20} />}
{submitting ? 'Submitting...' : 'Submit Timesheet'}
</button>
) : (
<div className="flex gap-2">
<button
onClick={handleDownloadPdf}
disabled={pdfLoading}
className="flex-1 py-3.5 rounded-xl bg-sky-50 dark:bg-sky-500/10 text-sky-600 dark:text-sky-400 font-semibold flex items-center justify-center gap-2 hover:bg-sky-100 dark:hover:bg-sky-500/20 transition-all"
>
{pdfLoading ? <Loader2 size={18} className="animate-spin" /> : <Download size={18} />}
Download PDF
</button>
<button
onClick={() => {
if (timesheetId) {
window.open(`mailto:?subject=Timesheet - ${user.name} - Week of ${weekParam}&body=Please find my timesheet attached.`);
}
}}
className="flex-1 py-3.5 rounded-xl bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-400 font-semibold flex items-center justify-center gap-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
>
<Mail size={18} />
Email
</button>
</div>
)}
{timesheet?.status === 'rejected' && timesheet?.notes && (
<div className="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl px-4 py-3">
<p className="text-sm font-medium text-red-600 dark:text-red-400">Rejected</p>
<p className="text-sm text-red-500 dark:text-red-400/80 mt-1">{timesheet.notes}</p>
</div>
)}
</div>
</div>
);
}