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 (
Rejected
{timesheet.notes}