Louis Mazin e46e3f8ed0 nice
2026-10-05 18:14:32 +02:00

342 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react';
import { CalendarPlus, ChevronLeft, ChevronRight, Copy, Pencil, Plus, RefreshCw, Save, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ResponsivePickers';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { planningApi } from '@/api/planning';
import ScheduleGrid from './ScheduleGrid';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
import { duration, weekName } from './gridUtils';
import { cleanSlots, dateKey, dateLabel, inputClass, newSlot, slotLabel, WEEKDAYS } from './utils';
const MODES = {
precise: {
label: 'Horaires précis',
description: 'Indique les heures de début et de fin pour chaque jour.',
},
daily_hours: {
label: 'Heures par jour',
description: 'Indique uniquement combien d’heures doivent être faites chaque jour.',
},
weekly_hours: {
label: 'Total par semaine',
description: 'Indique uniquement le nombre total d’heures à faire dans la semaine.',
},
};
const emptyException = () => ({ label: '', kind: 'work', start_date: dateKey(), end_date: dateKey(), recurring: false, weekdays: [(new Date().getDay() + 6) % 7], every_weeks: 1, slots: [newSlot()] });
const activeSchedule = (config, day) => config.schedules.find((s) => s.valid_from <= day && (!s.valid_to || s.valid_to >= day));
const scheduleSlots = (schedule) => schedule?.slots.map((slot) => ({ ...cleanSlots([slot])[0], weekday: slot.weekday, week_index: slot.week_index || 0 })) || [];
const formatMinutes = (minutes = 0) => `${Math.floor(minutes / 60)} h${minutes % 60 ? ` ${String(minutes % 60).padStart(2, '0')}` : ''}`;
function DurationInput({ minutes = 0, onChange, maxHours = 24, disabled = false, ariaLabel }) {
const hours = Math.floor(Number(minutes || 0) / 60);
const mins = Number(minutes || 0) % 60;
const update = (nextHours, nextMinutes) => {
const safeHours = Math.max(0, Math.min(maxHours, Number(nextHours) || 0));
const safeMinutes = Math.max(0, Math.min(59, Number(nextMinutes) || 0));
const total = Math.min(maxHours * 60, safeHours * 60 + safeMinutes);
onChange(total);
};
return (
<div className="flex items-center gap-1.5" aria-label={ariaLabel}>
<input
type="number"
min="0"
max={maxHours}
inputMode="numeric"
disabled={disabled}
className={`${inputClass} w-20 text-right tabular-nums`}
value={hours}
onChange={(e) => update(e.target.value, mins)}
aria-label={`${ariaLabel || 'Durée'} en heures`}
/>
<span className="text-sm text-muted-foreground">h</span>
<input
type="number"
min="0"
max="59"
inputMode="numeric"
disabled={disabled || hours >= maxHours}
className={`${inputClass} w-20 text-right tabular-nums`}
value={hours >= maxHours ? 0 : mins}
onChange={(e) => update(hours, e.target.value)}
aria-label={`${ariaLabel || 'Durée'} en minutes`}
/>
<span className="text-sm text-muted-foreground">min</span>
</div>
);
}
export default function ScheduleManager({ userId }) {
const [config, setConfig] = useState(null);
const [validFrom, setValidFrom] = useState(dateKey());
const [slots, setSlots] = useState([]);
const [mode, setMode] = useState('precise');
const [dayTargets, setDayTargets] = useState([]);
const [weekTargets, setWeekTargets] = useState([]);
const [cycleWeeks, setCycleWeeks] = useState(1);
const [week, setWeek] = useState(0);
const [dirty, setDirty] = useState(false);
const [removeWeekOpen, setRemoveWeekOpen] = useState(false);
const [versionToLoad, setVersionToLoad] = useState(null);
const [loadedScheduleId, setLoadedScheduleId] = useState(null);
const [scheduleToDelete, setScheduleToDelete] = useState(null);
const [scheduleDeleteError, setScheduleDeleteError] = useState('');
const [reload, setReload] = useState(0);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [exception, setException] = useState(null);
const [exceptionError, setExceptionError] = useState('');
const [toDelete, setToDelete] = useState(null);
const loadSchedule = (schedule) => {
setSlots(scheduleSlots(schedule));
setMode(schedule?.mode || 'precise');
setDayTargets(schedule?.day_targets || []);
setWeekTargets(schedule?.week_targets || []);
setCycleWeeks(schedule?.cycle_weeks || 1);
};
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError('');
planningApi.config(userId, controller.signal)
.then((data) => {
setConfig(data);
const current = activeSchedule(data, validFrom);
loadSchedule(current);
setLoadedScheduleId(current?.id || null);
setWeek((old) => Math.min(old, (current?.cycle_weeks || 1) - 1));
setValidFrom(current?.valid_from || validFrom);
setDirty(false);
})
.catch((e) => { if (!controller.signal.aborted) setError(e.message); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
// La date d'effet édite le brouillon local ; seuls le salarié et l'actualisation rechargent la configuration.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId, reload]);
const markDirty = () => { setDirty(true); setNotice(''); };
const saveWeek = async (event) => {
event?.preventDefault();
if (busy) return;
setBusy(true); setError(''); setNotice('');
try {
await planningApi.saveSchedule({
user_id: Number(userId),
valid_from: validFrom,
cycle_weeks: cycleWeeks,
mode,
slots,
day_targets: dayTargets,
week_targets: weekTargets,
expected_revision: config.revision,
});
setNotice(`Emploi du temps enregistré à partir du ${dateLabel(validFrom)}.`);
setReload((n) => n + 1);
} catch (e) { setError(e.message); } finally { setBusy(false); }
};
const saveException = async (event) => {
event.preventDefault(); if (busy) return; setBusy(true); setExceptionError('');
try { await planningApi.saveException({ ...exception, user_id: Number(userId), expected_revision: config.revision, end_date: exception.end_date || null, slots: exception.kind === 'work' ? exception.slots : [] }, exception.id); setException(null); setNotice('Changement enregistré. Le planning est à jour.'); setReload((n) => n + 1); }
catch (e) { setExceptionError(e.message); } finally { setBusy(false); }
};
const deleteException = async () => {
if (busy) return; setBusy(true); setExceptionError('');
try { await planningApi.removeException(toDelete.id, config.revision); setToDelete(null); setNotice('Changement supprimé. Le planning est à jour.'); setReload((n) => n + 1); }
catch (e) { setExceptionError(e.message); } finally { setBusy(false); }
};
const deleteSchedule = async () => {
if (busy || !scheduleToDelete) return;
setBusy(true);
setScheduleDeleteError('');
try {
await planningApi.removeSchedule(scheduleToDelete.id, config.revision);
setScheduleToDelete(null);
setLoadedScheduleId(null);
setNotice('Planning supprimé. Les périodes restantes ont été raccordées automatiquement.');
setReload((n) => n + 1);
} catch (e) {
setScheduleDeleteError(e.message);
} finally {
setBusy(false);
}
};
const editException = (row) => { setExceptionError(''); setException(row ? { ...row, slots: cleanSlots(row.slots), end_date: row.end_date || '' } : emptyException()); };
const updateException = (values) => setException((old) => ({ ...old, ...values }));
const changeSlots = (values) => { setSlots(values); markDirty(); };
const setDayMinutes = (weekday, minutes) => {
setDayTargets((current) => [
...current.filter((item) => !(Number(item.week_index) === week && Number(item.weekday) === weekday)),
{ week_index: week, weekday, minutes },
]);
markDirty();
};
const setWeekMinutes = (minutes) => {
setWeekTargets((current) => [
...current.filter((item) => Number(item.week_index) !== week),
{ week_index: week, minutes },
]);
markDirty();
};
const getDayMinutes = (weekday) => Number(dayTargets.find((item) => Number(item.week_index) === week && Number(item.weekday) === weekday)?.minutes || 0);
const getWeekTarget = () => Number(weekTargets.find((item) => Number(item.week_index) === week)?.minutes || 0);
const addWeek = (copy = false) => {
if (cycleWeeks >= 52) return;
if (copy) {
setSlots((current) => [...current, ...current.filter((s) => s.week_index === week).map((s) => ({ ...s, week_index: cycleWeeks }))]);
setDayTargets((current) => [...current, ...current.filter((item) => Number(item.week_index) === week).map((item) => ({ ...item, week_index: cycleWeeks }))]);
const currentWeekTarget = weekTargets.find((item) => Number(item.week_index) === week);
if (currentWeekTarget) setWeekTargets((current) => [...current, { ...currentWeekTarget, week_index: cycleWeeks }]);
}
setCycleWeeks((n) => n + 1); setWeek(cycleWeeks); markDirty();
};
const removeWeek = () => {
const shift = (item) => ({ ...item, week_index: Number(item.week_index) > week ? Number(item.week_index) - 1 : Number(item.week_index) });
setSlots((current) => current.filter((s) => s.week_index !== week).map(shift));
setDayTargets((current) => current.filter((item) => Number(item.week_index) !== week).map(shift));
setWeekTargets((current) => current.filter((item) => Number(item.week_index) !== week).map(shift));
setCycleWeeks((n) => n - 1); setWeek((n) => Math.max(0, n - 1)); markDirty(); setRemoveWeekOpen(false);
};
const loadVersion = (version) => {
loadSchedule(version); setLoadedScheduleId(version.id); setValidFrom(version.valid_from); setWeek(0); setDirty(false); setVersionToLoad(null); setNotice('');
};
const preciseWeekMinutes = slots.filter((s) => s.week_index === week).reduce((sum, s) => sum + duration(s), 0);
const dailyWeekMinutes = WEEKDAYS.reduce((sum, _day, weekday) => sum + getDayMinutes(weekday), 0);
const weekMinutes = mode === 'daily_hours' ? dailyWeekMinutes : mode === 'weekly_hours' ? getWeekTarget() : preciseWeekMinutes;
return <div className="space-y-5">
<div className="flex items-center justify-between gap-2"><p className="text-sm text-muted-foreground">Prépare les horaires du salarié et les changements prévus.</p><Button type="button" variant="ghost" size="icon" disabled={loading || busy} aria-label="Actualiser les emplois du temps" onClick={() => setReload((n) => n + 1)}><RefreshCw className={`size-4 ${loading ? 'animate-spin' : ''}`} /></Button></div>
{error && <p role="alert" className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{error}</p>}
{notice && <p role="status" className="rounded-lg bg-emerald-500/10 p-3 text-sm text-emerald-700 dark:text-emerald-300">{notice}</p>}
{loading ? <p className="py-8 text-center text-sm text-muted-foreground">Chargement de l’emploi du temps…</p> : config && <>
<section className="space-y-5 rounded-xl border p-3 sm:p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="space-y-1"><h2 className="text-lg font-semibold">Emploi du temps habituel</h2><p className="text-sm text-muted-foreground">Choisis le niveau de précision utile pour ce salarié, puis remplis la semaine A. Tu peux ajouter B, C… si le planning tourne sur plusieurs semaines.</p></div>
{dirty && <Badge variant="outline">Modifications à enregistrer</Badge>}
</div>
<div className="grid items-end gap-3 sm:grid-cols-2">
<label className="grid gap-1.5 text-sm">Appliquer ce planning à partir du<DatePicker required disabled={busy} value={validFrom} onChange={(value) => { setValidFrom(value); markDirty(); }} /></label>
<div className="grid gap-1.5 text-sm">
<span>Reprendre un planning enregistré</span>
<div className="flex min-w-0 gap-2">
<select
disabled={busy}
className={`${inputClass} min-w-0 flex-1`}
value={loadedScheduleId ? String(loadedScheduleId) : ''}
onChange={(e) => {
const version = config.schedules.find((item) => String(item.id) === e.target.value);
if (!version) return;
if (dirty) setVersionToLoad(version); else loadVersion(version);
}}
>
<option value="">Choisir une période…</option>
{config.schedules.map((item) => <option key={item.id} value={item.id}>Depuis le {dateLabel(item.valid_from)} · {MODES[item.mode || 'precise'].label} · {item.cycle_weeks || 1} semaine(s)</option>)}
</select>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
aria-label="Supprimer ce planning enregistré"
title={dirty ? 'Enregistre ou annule d’abord les modifications en cours.' : 'Supprimer ce planning enregistré'}
disabled={busy || dirty || !loadedScheduleId}
onClick={() => {
const selected = config.schedules.find((item) => Number(item.id) === Number(loadedScheduleId));
if (selected) { setScheduleDeleteError(''); setScheduleToDelete(selected); }
}}
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
</div>
<p className="text-xs leading-relaxed text-muted-foreground">La semaine A est celle de la date choisie, du lundi au dimanche. Pour changer le planning à partir d’une nouvelle date tout en gardant l’historique, change cette date avant d’enregistrer.</p>
<div className="grid gap-2 sm:grid-cols-3" role="group" aria-label="Type de planning">
{Object.entries(MODES).map(([key, item]) => <Button key={key} type="button" variant={mode === key ? 'default' : 'outline'} className="h-auto min-h-20 items-start justify-start whitespace-normal p-3 text-left" disabled={busy} onClick={() => { setMode(key); markDirty(); }}><span><span className="block font-medium">{item.label}</span><span className={`mt-1 block text-xs font-normal ${mode === key ? 'text-primary-foreground/80' : 'text-muted-foreground'}`}>{item.description}</span></span></Button>)}
</div>
<div className="space-y-3 rounded-lg bg-muted/40 p-3">
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="icon" aria-label="Semaine précédente du cycle" disabled={busy || week === 0} onClick={() => setWeek((n) => n - 1)}><ChevronLeft className="size-4" /></Button>
<select aria-label="Semaine à remplir" className={`${inputClass.replace('w-full', 'w-auto')} min-w-32 flex-1 sm:flex-none`} value={week} disabled={busy} onChange={(e) => setWeek(Number(e.target.value))}>{Array.from({ length: cycleWeeks }, (_, i) => <option key={i} value={i}>{weekName(i)}</option>)}</select>
<Button type="button" variant="outline" size="icon" aria-label="Semaine suivante du cycle" disabled={busy || week === cycleWeeks - 1} onClick={() => setWeek((n) => n + 1)}><ChevronRight className="size-4" /></Button>
<span className="text-sm font-medium tabular-nums sm:ml-auto">{formatMinutes(weekMinutes)} prévu{weekMinutes !== 0 ? 'es' : ''}</span>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" disabled={busy || cycleWeeks >= 52} onClick={() => addWeek()}><Plus className="size-4" />Ajouter une semaine</Button>
<Button type="button" variant="outline" size="sm" disabled={busy || cycleWeeks >= 52} onClick={() => addWeek(true)}><Copy className="size-4" />Copier cette semaine</Button>
<Button type="button" variant="ghost" size="sm" disabled={busy || cycleWeeks === 1} onClick={() => setRemoveWeekOpen(true)}><Trash2 className="size-4" />Retirer cette semaine</Button>
</div>
<p className="text-sm">{cycleWeeks === 1 ? 'La semaine A se répète chaque semaine.' : `Ces ${cycleWeeks} semaines se répètent dans l’ordre. Après la ${weekName(cycleWeeks - 1).toLowerCase()}, le planning reprend à la semaine A.`}</p>
</div>
{mode === 'precise' && <ScheduleGrid slots={slots} onChange={changeSlots} week={week} cycleWeeks={cycleWeeks} disabled={busy} />}
{mode === 'daily_hours' && <div className="overflow-hidden rounded-xl border">
<div className="border-b bg-muted/50 px-4 py-3"><p className="font-medium">Nombre d’heures à faire chaque jour</p><p className="text-xs text-muted-foreground">Laisse 0 h pour un jour de repos. Aucun horaire de début ou de fin n’est imposé.</p></div>
<div className="divide-y">{WEEKDAYS.map((day, weekday) => <div key={day} className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-sm font-medium">{day}</p><p className="text-xs text-muted-foreground">{getDayMinutes(weekday) ? `${formatMinutes(getDayMinutes(weekday))} à effectuer` : 'Repos'}</p></div><DurationInput minutes={getDayMinutes(weekday)} onChange={(value) => setDayMinutes(weekday, value)} disabled={busy} ariaLabel={`Durée du ${day}`} /></div>)}</div>
</div>}
{mode === 'weekly_hours' && <div className="rounded-xl border p-4 sm:p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div><p className="font-medium">Total d’heures de la {weekName(week).toLowerCase()}</p><p className="mt-1 text-sm text-muted-foreground">Le salarié verra uniquement ce total pour la semaine, sans répartition par jour ni plage horaire.</p></div>
<DurationInput minutes={getWeekTarget()} onChange={setWeekMinutes} maxHours={168} disabled={busy} ariaLabel={`Total de la ${weekName(week).toLowerCase()}`} />
</div>
</div>}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">{mode === 'precise' ? 'Sans horaires renseignés, la journée est un jour de repos.' : mode === 'daily_hours' ? 'Un jour à 0 h est considéré comme un jour de repos.' : 'Le total hebdomadaire ne fixe aucun jour ni aucune heure précise.'}</p>
<Button type="button" onClick={saveWeek} disabled={busy || !validFrom}><Save className="size-4" />{busy ? 'Enregistrement…' : 'Enregistrer le planning'}</Button>
</div>
</section>
<section className="space-y-4 rounded-xl border p-4">
<div className="flex flex-wrap items-start justify-between gap-3"><div className="space-y-1"><h2 className="text-lg font-semibold">Changements prévus</h2><p className="text-sm text-muted-foreground">Une absence, un jour de repos ou des horaires différents.</p></div><Button disabled={busy || dirty} variant="outline" onClick={() => editException(null)}><Plus className="size-4" />Ajouter un changement</Button></div>
<p className="text-xs leading-relaxed text-muted-foreground">Un changement sur une période précise passe avant un changement qui se répète. Sinon, le planning habituel s’applique. Si deux changements de même type se croisent, le dernier ajouté est utilisé.</p>
{dirty && <p className="text-sm text-muted-foreground">Enregistre le planning ci-dessus avant de modifier les changements prévus.</p>}
{!config.exceptions.length ? <p className="py-5 text-sm text-muted-foreground">Aucun changement prévu.</p> : <div className="divide-y">{config.exceptions.map((row) => <div key={row.id} className="flex items-start justify-between gap-2 py-3">
<div className="min-w-0 space-y-1"><p className="break-words text-sm font-medium">{row.label || ({ work: 'Horaires différents', rest: 'Repos', absence: 'Congé / absence' }[row.kind])}</p><p className="text-xs text-muted-foreground">{dateLabel(row.start_date)} → {dateLabel(row.end_date)}</p>{row.recurring && <p className="text-xs text-muted-foreground">{row.weekdays.map((d) => WEEKDAYS[d]).join(', ')} · toutes les {row.every_weeks} semaine{row.every_weeks > 1 ? 's' : ''}</p>}<p className="text-xs">{row.mode === 'daily_hours' ? `${formatMinutes(row.target_minutes || 0)} à effectuer` : row.slots.map(slotLabel).join(' · ')}</p><div className="flex flex-wrap gap-1"><Badge variant="secondary">{row.recurring ? 'Se répète' : 'Période précise'}</Badge>{row.request_id && <Badge variant="outline">Demande #{row.request_id}</Badge>}</div></div>
<div className="flex shrink-0 gap-1"><Button variant="ghost" size="icon" aria-label="Modifier ce changement" disabled={busy || dirty} onClick={() => editException(row)}><Pencil className="size-4" /></Button><Button variant="ghost" size="icon" aria-label="Supprimer ce changement" disabled={busy || dirty} onClick={() => { setExceptionError(''); setToDelete(row); }}><Trash2 className="size-4" /></Button></div>
</div>)}</div>}
</section>
</>}
<Dialog open={Boolean(exception)} onOpenChange={(open) => { if (!open && !busy) setException(null); }}><DialogContent className="max-h-[90svh] overflow-y-auto sm:max-w-2xl" showCloseButton={!busy}>{exception && <>
<DialogHeader><DialogTitle className="flex items-center gap-2"><CalendarPlus className="size-5" />{exception.id ? 'Modifier un changement' : 'Ajouter un changement'}</DialogTitle><DialogDescription>Pour les jours choisis, ces horaires remplacent le planning habituel.</DialogDescription></DialogHeader>
<form onSubmit={saveException} className="space-y-4"><fieldset disabled={busy} className="space-y-4">
<label className="grid gap-1.5 text-sm">Motif (facultatif)<input className={inputClass} maxLength={160} value={exception.label} onChange={(e) => updateException({ label: e.target.value })} /></label>
<label className="grid gap-1.5 text-sm">Type<select className={inputClass} value={exception.kind} onChange={(e) => updateException({ kind: e.target.value })}><option value="work">Horaires différents</option><option value="rest">Repos</option><option value="absence">Congé / absence</option></select></label>
<label className="flex items-center gap-2 text-sm"><Switch checked={exception.recurring} onCheckedChange={(checked) => updateException({ recurring: checked })} />Répéter ce changement</label>
<div className="grid gap-3 sm:grid-cols-2"><label className="grid gap-1.5 text-sm">À partir du<DatePicker required value={exception.start_date} onChange={(value) => updateException({ start_date: value })} /></label><label className="grid gap-1.5 text-sm">{exception.recurring ? 'Jusqu’au (facultatif)' : 'Jusqu’au (inclus)'}<DatePicker required={!exception.recurring} min={exception.start_date} value={exception.end_date} onChange={(value) => updateException({ end_date: value })} /></label></div>
{exception.recurring && <div className="space-y-3 rounded-lg bg-muted/40 p-3"><label className="grid gap-1.5 text-sm">Répétition<select className={inputClass} value={exception.every_weeks} onChange={(e) => updateException({ every_weeks: Number(e.target.value) })}>{[1, 2, 3, 4].map((n) => <option key={n} value={n}>{n === 1 ? 'Chaque semaine' : `Toutes les ${n} semaines`}</option>)}</select></label><div className="flex flex-wrap gap-2">{WEEKDAYS.map((day, i) => <label key={day} className="flex items-center gap-1.5 text-xs"><Checkbox checked={exception.weekdays.includes(i)} onCheckedChange={(checked) => updateException({ weekdays: checked ? [...exception.weekdays, i] : exception.weekdays.filter((d) => d !== i) })} />{day}</label>)}</div></div>}
{exception.kind === 'work' && <ScheduleGrid dayOnly slots={exception.slots} onChange={(values) => updateException({ slots: values })} disabled={busy} />}
</fieldset>{exceptionError && <p role="alert" className="text-sm text-destructive">{exceptionError}</p>}<div className="flex flex-wrap items-center justify-end gap-2"><Button type="button" variant="outline" disabled={busy} onClick={() => setException(null)}>Annuler</Button><Button type="submit" disabled={busy}>{busy ? 'Enregistrement…' : 'Enregistrer'}</Button></div></form>
</>}</DialogContent></Dialog>
<Dialog open={Boolean(toDelete)} onOpenChange={(open) => { if (!open && !busy) setToDelete(null); }}><DialogContent><DialogHeader><DialogTitle>Supprimer ce changement ?</DialogTitle><DialogDescription>Les autres changements prévus, ou le planning habituel, s’appliqueront à nouveau. La demande associée reste dans l’historique.</DialogDescription></DialogHeader>{exceptionError && <p role="alert" className="text-sm text-destructive">{exceptionError}</p>}<div className="flex flex-wrap items-center justify-end gap-2"><Button variant="outline" disabled={busy} onClick={() => setToDelete(null)}>Conserver</Button><Button variant="destructive" disabled={busy} onClick={deleteException}>Supprimer</Button></div></DialogContent></Dialog>
<Dialog open={Boolean(scheduleToDelete)} onOpenChange={(open) => { if (!open && !busy) setScheduleToDelete(null); }}><DialogContent><DialogHeader><DialogTitle>Supprimer ce planning ?</DialogTitle><DialogDescription>{scheduleToDelete ? `Le planning enregistré à partir du ${dateLabel(scheduleToDelete.valid_from)} sera supprimé. Le planning précédent reprendra jusqu’au prochain planning enregistré, s’il existe.` : ''}</DialogDescription></DialogHeader>{scheduleDeleteError && <p role="alert" className="text-sm text-destructive">{scheduleDeleteError}</p>}<div className="flex flex-wrap items-center justify-end gap-2"><Button variant="outline" disabled={busy} onClick={() => setScheduleToDelete(null)}>Conserver</Button><Button variant="destructive" disabled={busy} onClick={deleteSchedule}>{busy ? 'Suppression…' : 'Supprimer le planning'}</Button></div></DialogContent></Dialog>
<Dialog open={removeWeekOpen} onOpenChange={setRemoveWeekOpen}><DialogContent><DialogHeader><DialogTitle>Retirer la {weekName(week).toLowerCase()} ?</DialogTitle><DialogDescription>Ses informations seront retirées du planning. Les semaines restantes se répéteront dans leur nouvel ordre. Il faudra enregistrer le planning pour appliquer ce changement.</DialogDescription></DialogHeader><div className="flex flex-wrap items-center justify-end gap-2"><Button variant="outline" onClick={() => setRemoveWeekOpen(false)}>Garder cette semaine</Button><Button variant="destructive" onClick={removeWeek}>Retirer la semaine</Button></div></DialogContent></Dialog>
<Dialog open={Boolean(versionToLoad)} onOpenChange={(open) => { if (!open) setVersionToLoad(null); }}><DialogContent><DialogHeader><DialogTitle>Reprendre ce planning ?</DialogTitle><DialogDescription>Les changements que tu n’as pas encore enregistrés seront remplacés.</DialogDescription></DialogHeader><div className="flex flex-wrap items-center justify-end gap-2"><Button variant="outline" onClick={() => setVersionToLoad(null)}>Continuer mes modifications</Button><Button onClick={() => loadVersion(versionToLoad)}>Reprendre le planning</Button></div></DialogContent></Dialog>
</div>;
}