import { useState } from 'react'; import { Plus, Edit2, Trash2, Building2, Target, ChevronDown, ChevronUp, X } from 'lucide-react'; import { useClients } from '@/hooks/useClients'; import { useClientTargets } from '@/hooks/useClientTargets'; import { Modal } from '@/components/Modal'; import { ConfirmModal } from '@/components/ConfirmModal'; import { Spinner } from '@/components/Spinner'; import { formatDurationHoursMinutes } from '@/utils/dateUtils'; import type { Client, CreateClientInput, UpdateClientInput, ClientTargetWithBalance, CreateCorrectionInput, } from '@/types'; // Convert a value like "2026-W07" to the Monday date "2026-02-16" function weekInputToMonday(weekValue: string): string { const [yearStr, weekStr] = weekValue.split('-W'); const year = parseInt(yearStr, 10); const week = parseInt(weekStr, 10); // ISO week 1 is the week containing the first Thursday of January const jan4 = new Date(Date.UTC(year, 0, 4)); const jan4Day = jan4.getUTCDay() || 7; // Mon=1..Sun=7 const monday = new Date(jan4); monday.setUTCDate(jan4.getUTCDate() - jan4Day + 1 + (week - 1) * 7); return monday.toISOString().split('T')[0]; } // Convert a YYYY-MM-DD Monday to "YYYY-Www" for the week input function mondayToWeekInput(dateStr: string): string { const date = new Date(dateStr + 'T00:00:00Z'); // ISO week number calculation const jan4 = new Date(Date.UTC(date.getUTCFullYear(), 0, 4)); const jan4Day = jan4.getUTCDay() || 7; const firstMonday = new Date(jan4); firstMonday.setUTCDate(jan4.getUTCDate() - jan4Day + 1); const diff = date.getTime() - firstMonday.getTime(); const week = Math.floor(diff / (7 * 24 * 3600 * 1000)) + 1; // Handle year boundary: if week > 52 we might be in week 1 of next year const year = date.getUTCFullYear(); return `${year}-W${week.toString().padStart(2, '0')}`; } function balanceLabel(seconds: number): { text: string; color: string } { if (seconds === 0) return { text: '±0', color: 'text-gray-500' }; const abs = Math.abs(seconds); const text = (seconds > 0 ? '+' : '−') + formatDurationHoursMinutes(abs); const color = seconds > 0 ? 'text-green-600' : 'text-red-600'; return { text, color }; } // Inline target panel shown inside each client card function ClientTargetPanel({ target, onCreated, onDeleted, }: { client: Client; target: ClientTargetWithBalance | undefined; onCreated: (weeklyHours: number, startDate: string) => Promise; onDeleted: () => Promise; }) { const { addCorrection, deleteCorrection, updateTarget } = useClientTargets(); const [expanded, setExpanded] = useState(false); const [showForm, setShowForm] = useState(false); const [editing, setEditing] = useState(false); // Create/edit form state const [formHours, setFormHours] = useState(''); const [formWeek, setFormWeek] = useState(''); const [formError, setFormError] = useState(null); const [formSaving, setFormSaving] = useState(false); // Correction form state const [showCorrectionForm, setShowCorrectionForm] = useState(false); const [corrDate, setCorrDate] = useState(''); const [corrHours, setCorrHours] = useState(''); const [corrDesc, setCorrDesc] = useState(''); const [corrError, setCorrError] = useState(null); const [corrSaving, setCorrSaving] = useState(false); const openCreate = () => { setFormHours(''); const today = new Date(); const day = today.getUTCDay() || 7; const monday = new Date(today); monday.setUTCDate(today.getUTCDate() - day + 1); setFormWeek(mondayToWeekInput(monday.toISOString().split('T')[0])); setFormError(null); setEditing(false); setShowForm(true); }; const openEdit = () => { if (!target) return; setFormHours(String(target.weeklyHours)); setFormWeek(mondayToWeekInput(target.startDate)); setFormError(null); setEditing(true); setShowForm(true); }; const handleFormSubmit = async (e: React.FormEvent) => { e.preventDefault(); setFormError(null); const hours = parseFloat(formHours); if (isNaN(hours) || hours <= 0 || hours > 168) { setFormError('Weekly hours must be between 0 and 168'); return; } if (!formWeek) { setFormError('Please select a start week'); return; } const startDate = weekInputToMonday(formWeek); setFormSaving(true); try { if (editing && target) { await updateTarget.mutateAsync({ id: target.id, input: { weeklyHours: hours, startDate } }); } else { await onCreated(hours, startDate); } setShowForm(false); } catch (err) { setFormError(err instanceof Error ? err.message : 'Failed to save'); } finally { setFormSaving(false); } }; const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const handleDelete = async () => { try { await onDeleted(); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to delete'); } }; const handleAddCorrection = async (e: React.FormEvent) => { e.preventDefault(); setCorrError(null); if (!target) return; const hours = parseFloat(corrHours); if (isNaN(hours) || hours < -1000 || hours > 1000) { setCorrError('Hours must be between -1000 and 1000'); return; } if (!corrDate) { setCorrError('Please select a date'); return; } setCorrSaving(true); try { const input: CreateCorrectionInput = { date: corrDate, hours, description: corrDesc || undefined }; await addCorrection.mutateAsync({ targetId: target.id, input }); setCorrDate(''); setCorrHours(''); setCorrDesc(''); setShowCorrectionForm(false); } catch (err) { setCorrError(err instanceof Error ? err.message : 'Failed to add correction'); } finally { setCorrSaving(false); } }; const handleDeleteCorrection = async (correctionId: string) => { if (!target) return; try { await deleteCorrection.mutateAsync({ targetId: target.id, correctionId }); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to delete correction'); } }; if (!target && !showForm) { return (
); } if (showForm) { return (

{editing ? 'Edit target' : 'Set weekly target'}

{formError &&

{formError}

}
setFormHours(e.target.value)} className="input text-sm py-1" placeholder="e.g. 40" min="0.5" max="168" step="0.5" required />
setFormWeek(e.target.value)} className="input text-sm py-1" required />
); } // Target exists — show summary + expandable details const balance = balanceLabel(target!.totalBalanceSeconds); return (
{/* Target summary row */}
{target!.weeklyHours}h/week {balance.text}
{/* Expanded: corrections list + add form */} {expanded && (

Corrections

{target!.corrections.length === 0 && !showCorrectionForm && (

No corrections yet

)} {target!.corrections.map(c => (
{c.date} {c.description && — {c.description}}
= 0 ? 'text-green-600' : 'text-red-600'}`}> {c.hours >= 0 ? '+' : ''}{c.hours}h
))} {showCorrectionForm ? (
{corrError &&

{corrError}

}
setCorrDate(e.target.value)} className="input text-xs py-1" required />
setCorrHours(e.target.value)} className="input text-xs py-1" placeholder="+8 / -4" step="0.5" required />
setCorrDesc(e.target.value)} className="input text-xs py-1" placeholder="e.g. Public holiday" maxLength={255} />
) : ( )}
)} {showDeleteConfirm && ( setShowDeleteConfirm(false)} /> )}
); } export function ClientsPage() { const { clients, isLoading, createClient, updateClient, deleteClient } = useClients(); const { targets, createTarget, deleteTarget } = useClientTargets(); const [isModalOpen, setIsModalOpen] = useState(false); const [editingClient, setEditingClient] = useState(null); const [formData, setFormData] = useState({ name: '', description: '' }); const [error, setError] = useState(null); const handleOpenModal = (client?: Client) => { if (client) { setEditingClient(client); setFormData({ name: client.name, description: client.description || '' }); } else { setEditingClient(null); setFormData({ name: '', description: '' }); } setError(null); setIsModalOpen(true); }; const handleCloseModal = () => { setIsModalOpen(false); setEditingClient(null); setFormData({ name: '', description: '' }); setError(null); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); if (!formData.name.trim()) { setError('Client name is required'); return; } try { if (editingClient) { await updateClient.mutateAsync({ id: editingClient.id, input: formData as UpdateClientInput, }); } else { await createClient.mutateAsync(formData); } handleCloseModal(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save client'); } }; const [confirmClient, setConfirmClient] = useState(null); const handleDeleteConfirmed = async () => { if (!confirmClient) return; try { await deleteClient.mutateAsync(confirmClient.id); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to delete client'); } }; if (isLoading) { return ; } return (
{/* Page Header */}

Clients

Manage your clients and customers

{/* Clients List */} {clients?.length === 0 ? (

No clients yet

Get started by adding your first client

) : (
{clients?.map((client) => { const target = targets?.find(t => t.clientId === client.id); return (

{client.name}

{client.description && (

{client.description}

)}
{ await createTarget.mutateAsync({ clientId: client.id, weeklyHours, startDate }); }} onDeleted={async () => { if (target) await deleteTarget.mutateAsync(target.id); }} />
); })}
)} {/* Modal */} {isModalOpen && (
{error && (
{error}
)}
setFormData({ ...formData, name: e.target.value })} className="input" placeholder="Enter client name" autoFocus />