This commit is contained in:
Louis Mazin 2026-10-05 18:14:32 +02:00
parent 4db822aebe
commit e46e3f8ed0
6 changed files with 133 additions and 36 deletions

View File

@ -6,6 +6,7 @@ urlpatterns = [
path('config/', ConfigurationView.as_view()),
path('effective/', EffectiveView.as_view()),
path('schedules/', ScheduleView.as_view()),
path('schedules/<int:pk>/', ScheduleView.as_view()),
path('exceptions/', ExceptionView.as_view()),
path('exceptions/<int:pk>/', ExceptionView.as_view()),
path('requests/', RequestListView.as_view()),

View File

@ -98,6 +98,41 @@ class ScheduleView(APIView):
result = save_schedule(request.user, data.validated_data)
return Response(ScheduleSerializer(result).data, status=status.HTTP_201_CREATED)
@transaction.atomic
def delete(self, request, pk):
instance = get_object_or_404(WorkSchedule, pk=pk)
require_management_permission(
request.user, 'manage_schedules', instance.user_id,
'Vous n’avez pas l’autorisation de modifier le planning de ce salarié.',
)
lock_users([instance.user_id])
revision = serializers.IntegerField(min_value=0).run_validation(request.data.get('expected_revision'))
check_revision(instance.user_id, revision)
instance = WorkSchedule.objects.select_for_update().get(pk=pk)
previous = (
WorkSchedule.objects.select_for_update()
.filter(user_id=instance.user_id, valid_from__lt=instance.valid_from)
.order_by('-valid_from')
.first()
)
following = (
WorkSchedule.objects.select_for_update()
.filter(user_id=instance.user_id, valid_from__gt=instance.valid_from)
.order_by('valid_from')
.first()
)
if previous:
previous.valid_to = following.valid_from - timedelta(days=1) if following else None
previous.save(update_fields=['valid_to', 'updated_at'])
user_id = instance.user_id
instance.delete()
validate_calendar(user_id)
bump_revision(user_id)
return Response(status=status.HTTP_204_NO_CONTENT)
class ExceptionView(APIView):
permission_classes = [IsAuthenticated]

View File

@ -5,6 +5,7 @@ export const planningApi = {
config: async (userId, signal) => (await api.get('/planning/config/', { params: { user_id: userId }, signal })).data,
effective: async (userId, start, end, signal) => (await api.get('/planning/effective/', { params: { user_id: userId, start, end }, signal })).data,
saveSchedule: async (data) => (await api.post('/planning/schedules/', data)).data,
removeSchedule: async (id, revision) => api.delete(`/planning/schedules/${id}/`, { data: { expected_revision: revision } }),
saveException: async (data, id) => (await (id ? api.put(`/planning/exceptions/${id}/`, data) : api.post('/planning/exceptions/', data))).data,
removeException: async (id, revision) => api.delete(`/planning/exceptions/${id}/`, { data: { expected_revision: revision } }),
requests: async (params, signal) => (await api.get('/planning/requests/', { params, signal })).data,

View File

@ -82,6 +82,9 @@ export default function ScheduleManager({ userId }) {
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);
@ -108,6 +111,7 @@ export default function ScheduleManager({ userId }) {
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);
@ -153,6 +157,23 @@ export default function ScheduleManager({ userId }) {
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(); };
@ -196,7 +217,7 @@ export default function ScheduleManager({ userId }) {
};
const loadVersion = (version) => {
loadSchedule(version); setValidFrom(version.valid_from); setWeek(0); setDirty(false); setVersionToLoad(null); setNotice('');
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);
@ -215,7 +236,39 @@ export default function ScheduleManager({ userId }) {
</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>
<label className="grid gap-1.5 text-sm">Reprendre un planning enregistré<select disabled={busy} className={inputClass} value="" onChange={(e) => { const version = config.schedules.find((s) => String(s.id) === e.target.value); if (version) { if (dirty) setVersionToLoad(version); else loadVersion(version); } }}><option value="">Choisir une période…</option>{config.schedules.map((s) => <option key={s.id} value={s.id}>Depuis le {dateLabel(s.valid_from)} · {MODES[s.mode || 'precise'].label} · {s.cycle_weeks || 1} semaine(s)</option>)}</select></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>
@ -281,6 +334,7 @@ export default function ScheduleManager({ userId }) {
</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>;

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FlagTriangleRight, Play, RotateCcw, Trophy } from 'lucide-react';
import { Play, RotateCcw, Trophy } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -191,39 +191,45 @@ export default function MonthlyHoursRace() {
</div>
</div>
<div className="space-y-3" aria-label="Course des heures du mois précédent">
{animatedRows.map((participant) => {
const isMe = String(participant.id) === String(user?.id);
return (
<div key={participant.id} className={`rounded-lg border p-3 ${isMe ? 'ring-1 ring-primary/40' : ''}`}>
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
<span className="truncate font-medium">{personLabel(participant)}</span>
{isMe && <span className="ml-2 text-xs text-muted-foreground">toi</span>}
</div>
<span className="shrink-0 font-medium tabular-nums">{formatMinutes(Math.round(participant.animatedMinutes))}</span>
</div>
<div className="overflow-x-auto pb-1" aria-label="Course des heures du mois précédent">
<div className="flex min-w-max items-end justify-center gap-1.5 px-2 pt-6 sm:gap-2">
{animatedRows.map((participant) => {
const isMe = String(participant.id) === String(user?.id);
const barHeight = maxFinalMinutes > 0
? clamp((participant.animatedMinutes / maxFinalMinutes) * 100, 0, 100)
: 0;
<div className="relative h-10 overflow-hidden rounded-full bg-muted/70">
<div
className="absolute inset-y-0 left-0 rounded-full opacity-30"
style={{
width: `${participant.percent}%`,
backgroundColor: participant.color || 'var(--primary)',
}}
/>
<span
className="absolute top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 text-2xl transition-[left] duration-100"
style={{ left: `${clamp(participant.percent, 2.5, 97.5)}%` }}
aria-hidden="true"
>
🏃
</span>
<FlagTriangleRight className="absolute right-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
return (
<div
key={participant.id}
className="flex w-14 shrink-0 flex-col items-center sm:w-16"
title={`${personLabel(participant)} — ${formatMinutes(Math.round(participant.animatedMinutes))}`}
>
<div className="mb-1 h-5 text-center text-xs font-medium tabular-nums text-muted-foreground">
{formatMinutes(Math.round(participant.animatedMinutes))}
</div>
<div className="relative flex h-64 w-full items-end justify-center sm:h-72">
<div
className={`w-9 rounded-t-md transition-[height] duration-100 sm:w-10 ${isMe ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : ''}`}
style={{
height: `${Math.max(barHeight, participant.animatedMinutes > 0 ? 1.5 : 0)}%`,
backgroundColor: participant.color || 'var(--primary)',
}}
aria-hidden="true"
/>
</div>
<div className="mt-2 w-full min-w-0 text-center">
<p className="truncate text-xs font-medium" title={personLabel(participant)}>
{personLabel(participant)}
</p>
{isMe && <p className="text-[10px] text-muted-foreground">toi</p>}
</div>
</div>
</div>
);
})}
);
})}
</div>
</div>
{finished && podium.length > 0 && (

View File

@ -246,12 +246,12 @@ const ProfileContent = ({ user }) => {
</div>
</header>
<MyRequestsCard user={user} />
<PersonalStatistics />
<MonthlyHoursRace />
<MyRequestsCard user={user} />
<div className="grid items-start gap-5 lg:grid-cols-2">
<div className="min-w-0 space-y-5">
<section aria-labelledby="profile-details-title" className="rounded-xl border border-border bg-card p-4 text-card-foreground shadow-sm sm:p-5">