adds targets

This commit is contained in:
simon.franken
2026-02-18 14:27:44 +01:00
parent a352318e8a
commit 4cce62934e
12 changed files with 1198 additions and 32 deletions

View File

@@ -1,12 +1,393 @@
import { useState } from 'react';
import { Plus, Edit2, Trash2, Building2 } from 'lucide-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 { Spinner } from '@/components/Spinner';
import type { Client, CreateClientInput, UpdateClientInput } from '@/types';
import { formatDurationHoursMinutes } from '@/utils/dateUtils';
import type {
Client,
CreateClientInput,
UpdateClientInput,
ClientTargetWithBalance,
CreateCorrectionInput,
} from '@/types';
// Convert a <input type="week"> 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<void>;
onDeleted: () => Promise<void>;
}) {
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<string | null>(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<string | null>(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 handleDelete = async () => {
if (!confirm('Delete this target? All corrections will also be deleted.')) return;
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 < -24 || hours > 24) {
setCorrError('Hours must be between -24 and 24');
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 (
<div className="mt-3 pt-3 border-t border-gray-100">
<button
onClick={openCreate}
className="flex items-center gap-1.5 text-xs text-primary-600 hover:text-primary-700 font-medium"
>
<Target className="h-3.5 w-3.5" />
Set weekly target
</button>
</div>
);
}
if (showForm) {
return (
<div className="mt-3 pt-3 border-t border-gray-100">
<p className="text-xs font-medium text-gray-700 mb-2">
{editing ? 'Edit target' : 'Set weekly target'}
</p>
<form onSubmit={handleFormSubmit} className="space-y-2">
{formError && <p className="text-xs text-red-600">{formError}</p>}
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-0.5">Hours/week</label>
<input
type="number"
value={formHours}
onChange={e => setFormHours(e.target.value)}
className="input text-sm py-1"
placeholder="e.g. 40"
min="0.5"
max="168"
step="0.5"
required
/>
</div>
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-0.5">Starting week</label>
<input
type="week"
value={formWeek}
onChange={e => setFormWeek(e.target.value)}
className="input text-sm py-1"
required
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={() => setShowForm(false)}
className="btn-secondary text-xs py-1 px-3"
>
Cancel
</button>
<button
type="submit"
disabled={formSaving}
className="btn-primary text-xs py-1 px-3"
>
{formSaving ? 'Saving...' : editing ? 'Save' : 'Set target'}
</button>
</div>
</form>
</div>
);
}
// Target exists — show summary + expandable details
const balance = balanceLabel(target!.totalBalanceSeconds);
return (
<div className="mt-3 pt-3 border-t border-gray-100">
{/* Target summary row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Target className="h-3.5 w-3.5 text-gray-400 shrink-0" />
<span className="text-xs text-gray-600">
<span className="font-medium">{target!.weeklyHours}h</span>/week
</span>
<span className={`text-xs font-semibold ${balance.color}`}>{balance.text}</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={openEdit}
className="p-1 text-gray-400 hover:text-gray-600 rounded"
title="Edit target"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
onClick={handleDelete}
className="p-1 text-gray-400 hover:text-red-600 rounded"
title="Delete target"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setExpanded(v => !v)}
className="p-1 text-gray-400 hover:text-gray-600 rounded"
title="Show corrections"
>
{expanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
</div>
</div>
{/* Expanded: corrections list + add form */}
{expanded && (
<div className="mt-2 space-y-1.5">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Corrections</p>
{target!.corrections.length === 0 && !showCorrectionForm && (
<p className="text-xs text-gray-400">No corrections yet</p>
)}
{target!.corrections.map(c => (
<div key={c.id} className="flex items-center justify-between bg-gray-50 rounded px-2 py-1">
<div className="text-xs text-gray-700">
<span className="font-medium">{c.date}</span>
{c.description && <span className="text-gray-500 ml-1"> {c.description}</span>}
</div>
<div className="flex items-center gap-1.5">
<span className={`text-xs font-semibold ${c.hours >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{c.hours >= 0 ? '+' : ''}{c.hours}h
</span>
<button
onClick={() => handleDeleteCorrection(c.id)}
className="text-gray-300 hover:text-red-500"
>
<X className="h-3 w-3" />
</button>
</div>
</div>
))}
{showCorrectionForm ? (
<form onSubmit={handleAddCorrection} className="space-y-1.5 pt-1">
{corrError && <p className="text-xs text-red-600">{corrError}</p>}
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-0.5">Date</label>
<input
type="date"
value={corrDate}
onChange={e => setCorrDate(e.target.value)}
className="input text-xs py-1"
required
/>
</div>
<div className="w-24">
<label className="block text-xs text-gray-500 mb-0.5">Hours</label>
<input
type="number"
value={corrHours}
onChange={e => setCorrHours(e.target.value)}
className="input text-xs py-1"
placeholder="+8 / -4"
min="-24"
max="24"
step="0.5"
required
/>
</div>
</div>
<div>
<label className="block text-xs text-gray-500 mb-0.5">Description (optional)</label>
<input
type="text"
value={corrDesc}
onChange={e => setCorrDesc(e.target.value)}
className="input text-xs py-1"
placeholder="e.g. Public holiday"
maxLength={255}
/>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={() => setShowCorrectionForm(false)}
className="btn-secondary text-xs py-1 px-3"
>
Cancel
</button>
<button
type="submit"
disabled={corrSaving}
className="btn-primary text-xs py-1 px-3"
>
{corrSaving ? 'Saving...' : 'Add'}
</button>
</div>
</form>
) : (
<button
onClick={() => setShowCorrectionForm(true)}
className="flex items-center gap-1 text-xs text-primary-600 hover:text-primary-700 font-medium pt-0.5"
>
<Plus className="h-3.5 w-3.5" />
Add correction
</button>
)}
</div>
)}
</div>
);
}
export function ClientsPage() {
const { clients, isLoading, createClient, updateClient, deleteClient } = useClients();
const { targets, createTarget, deleteTarget } = useClientTargets();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingClient, setEditingClient] = useState<Client | null>(null);
const [formData, setFormData] = useState<CreateClientInput>({ name: '', description: '' });
@@ -108,36 +489,50 @@ export function ClientsPage() {
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{clients?.map((client) => (
<div key={client.id} className="card">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-medium text-gray-900 truncate">
{client.name}
</h3>
{client.description && (
<p className="mt-1 text-sm text-gray-600 line-clamp-2">
{client.description}
</p>
)}
</div>
<div className="flex space-x-2 ml-4">
<button
onClick={() => handleOpenModal(client)}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100"
>
<Edit2 className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(client)}
className="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</button>
{clients?.map((client) => {
const target = targets?.find(t => t.clientId === client.id);
return (
<div key={client.id} className="card">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-medium text-gray-900 truncate">
{client.name}
</h3>
{client.description && (
<p className="mt-1 text-sm text-gray-600 line-clamp-2">
{client.description}
</p>
)}
</div>
<div className="flex space-x-2 ml-4">
<button
onClick={() => handleOpenModal(client)}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100"
>
<Edit2 className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(client)}
className="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<ClientTargetPanel
client={client}
target={target}
onCreated={async (weeklyHours, startDate) => {
await createTarget.mutateAsync({ clientId: client.id, weeklyHours, startDate });
}}
onDeleted={async () => {
if (target) await deleteTarget.mutateAsync(target.id);
}}
/>
</div>
</div>
))}
);
})}
</div>
)}
@@ -202,4 +597,4 @@ export function ClientsPage() {
)}
</div>
);
}
}