342 lines
27 KiB
JavaScript
342 lines
27 KiB
JavaScript
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>;
|
||
}
|