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 (
update(e.target.value, mins)}
aria-label={`${ariaLabel || 'Durée'} en heures`}
/>
h
= 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`}
/>
min
);
}
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
Prépare les horaires du salarié et les changements prévus.
setReload((n) => n + 1)}>
{error &&
{error}
}
{notice &&
{notice}
}
{loading ?
Chargement de l’emploi du temps…
: config && <>
Emploi du temps habituel 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.
{dirty &&
Modifications à enregistrer }
Appliquer ce planning à partir du { setValidFrom(value); markDirty(); }} />
Reprendre un planning enregistré
{
const version = config.schedules.find((item) => String(item.id) === e.target.value);
if (!version) return;
if (dirty) setVersionToLoad(version); else loadVersion(version);
}}
>
Choisir une période…
{config.schedules.map((item) => Depuis le {dateLabel(item.valid_from)} · {MODES[item.mode || 'precise'].label} · {item.cycle_weeks || 1} semaine(s) )}
{
const selected = config.schedules.find((item) => Number(item.id) === Number(loadedScheduleId));
if (selected) { setScheduleDeleteError(''); setScheduleToDelete(selected); }
}}
>
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.
{Object.entries(MODES).map(([key, item]) => { setMode(key); markDirty(); }}>{item.label} {item.description} )}
setWeek((n) => n - 1)}>
setWeek(Number(e.target.value))}>{Array.from({ length: cycleWeeks }, (_, i) => {weekName(i)} )}
setWeek((n) => n + 1)}>
{formatMinutes(weekMinutes)} prévu{weekMinutes !== 0 ? 'es' : ''}
= 52} onClick={() => addWeek()}> Ajouter une semaine
= 52} onClick={() => addWeek(true)}> Copier cette semaine
setRemoveWeekOpen(true)}> Retirer cette semaine
{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.`}
{mode === 'precise' && }
{mode === 'daily_hours' &&
Nombre d’heures à faire chaque jour
Laisse 0 h pour un jour de repos. Aucun horaire de début ou de fin n’est imposé.
{WEEKDAYS.map((day, weekday) =>
{day}
{getDayMinutes(weekday) ? `${formatMinutes(getDayMinutes(weekday))} à effectuer` : 'Repos'}
setDayMinutes(weekday, value)} disabled={busy} ariaLabel={`Durée du ${day}`} /> )}
}
{mode === 'weekly_hours' &&
Total d’heures de la {weekName(week).toLowerCase()}
Le salarié verra uniquement ce total pour la semaine, sans répartition par jour ni plage horaire.
}
{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.'}
{busy ? 'Enregistrement…' : 'Enregistrer le planning'}
Changements prévus Une absence, un jour de repos ou des horaires différents.
editException(null)}> Ajouter un changement
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é.
{dirty && Enregistre le planning ci-dessus avant de modifier les changements prévus.
}
{!config.exceptions.length ? Aucun changement prévu.
: {config.exceptions.map((row) =>
{row.label || ({ work: 'Horaires différents', rest: 'Repos', absence: 'Congé / absence' }[row.kind])}
{dateLabel(row.start_date)} → {dateLabel(row.end_date)}
{row.recurring &&
{row.weekdays.map((d) => WEEKDAYS[d]).join(', ')} · toutes les {row.every_weeks} semaine{row.every_weeks > 1 ? 's' : ''}
}
{row.mode === 'daily_hours' ? `${formatMinutes(row.target_minutes || 0)} à effectuer` : row.slots.map(slotLabel).join(' · ')}
{row.recurring ? 'Se répète' : 'Période précise'} {row.request_id && Demande #{row.request_id} }
editException(row)}> { setExceptionError(''); setToDelete(row); }}>
)}
}
>}
{ if (!open && !busy) setException(null); }}>{exception && <>
{exception.id ? 'Modifier un changement' : 'Ajouter un changement'}Pour les jours choisis, ces horaires remplacent le planning habituel.
>}
{ if (!open && !busy) setToDelete(null); }}>Supprimer ce changement ? Les autres changements prévus, ou le planning habituel, s’appliqueront à nouveau. La demande associée reste dans l’historique. {exceptionError && {exceptionError}
} setToDelete(null)}>Conserver Supprimer
{ if (!open && !busy) setScheduleToDelete(null); }}>Supprimer ce planning ? {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.` : ''} {scheduleDeleteError && {scheduleDeleteError}
} setScheduleToDelete(null)}>Conserver {busy ? 'Suppression…' : 'Supprimer le planning'}
Retirer la {weekName(week).toLowerCase()} ? 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. setRemoveWeekOpen(false)}>Garder cette semaine Retirer la semaine
{ if (!open) setVersionToLoad(null); }}>Reprendre ce planning ? Les changements que tu n’as pas encore enregistrés seront remplacés. setVersionToLoad(null)}>Continuer mes modifications loadVersion(versionToLoad)}>Reprendre le planning
;
}