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)
362 lines
13 KiB
React
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>
|
|
);
|
|
}
|