From e46e3f8ed005ca3de77dfa005cc1aa43ecbdec6e Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Mon, 5 Oct 2026 18:14:32 +0200 Subject: [PATCH] nice --- backend/planning/urls.py | 1 + backend/planning/views.py | 35 ++++++++++ frontend/src/api/planning.js | 1 + .../components/planning/ScheduleManager.jsx | 58 ++++++++++++++- .../statistics/MonthlyHoursRace.jsx | 70 ++++++++++--------- frontend/src/pages/MyspacePage.jsx | 4 +- 6 files changed, 133 insertions(+), 36 deletions(-) diff --git a/backend/planning/urls.py b/backend/planning/urls.py index e7b7a2d..62b894b 100644 --- a/backend/planning/urls.py +++ b/backend/planning/urls.py @@ -6,6 +6,7 @@ urlpatterns = [ path('config/', ConfigurationView.as_view()), path('effective/', EffectiveView.as_view()), path('schedules/', ScheduleView.as_view()), + path('schedules//', ScheduleView.as_view()), path('exceptions/', ExceptionView.as_view()), path('exceptions//', ExceptionView.as_view()), path('requests/', RequestListView.as_view()), diff --git a/backend/planning/views.py b/backend/planning/views.py index 6b936bd..1afe653 100644 --- a/backend/planning/views.py +++ b/backend/planning/views.py @@ -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] diff --git a/frontend/src/api/planning.js b/frontend/src/api/planning.js index 881c4b8..5c5d501 100644 --- a/frontend/src/api/planning.js +++ b/frontend/src/api/planning.js @@ -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, diff --git a/frontend/src/components/planning/ScheduleManager.jsx b/frontend/src/components/planning/ScheduleManager.jsx index bed0dc8..3b89158 100644 --- a/frontend/src/components/planning/ScheduleManager.jsx +++ b/frontend/src/components/planning/ScheduleManager.jsx @@ -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 }) {
- +
+ Reprendre un planning enregistré +
+ + +
+

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.

@@ -281,6 +334,7 @@ export default function ScheduleManager({ userId }) { {exceptionError &&

{exceptionError}

}
} { 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}

}
+ { 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}

}
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.
{ if (!open) setVersionToLoad(null); }}>Reprendre ce planning ?Les changements que tu n’as pas encore enregistrés seront remplacés.
; diff --git a/frontend/src/components/statistics/MonthlyHoursRace.jsx b/frontend/src/components/statistics/MonthlyHoursRace.jsx index 7538422..32fef7e 100644 --- a/frontend/src/components/statistics/MonthlyHoursRace.jsx +++ b/frontend/src/components/statistics/MonthlyHoursRace.jsx @@ -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() { -
- {animatedRows.map((participant) => { - const isMe = String(participant.id) === String(user?.id); - return ( -
-
-
- {personLabel(participant)} - {isMe && toi} -
- {formatMinutes(Math.round(participant.animatedMinutes))} -
+
+
+ {animatedRows.map((participant) => { + const isMe = String(participant.id) === String(user?.id); + const barHeight = maxFinalMinutes > 0 + ? clamp((participant.animatedMinutes / maxFinalMinutes) * 100, 0, 100) + : 0; -
-
- -
{finished && podium.length > 0 && ( diff --git a/frontend/src/pages/MyspacePage.jsx b/frontend/src/pages/MyspacePage.jsx index d4c3375..f5adbf2 100644 --- a/frontend/src/pages/MyspacePage.jsx +++ b/frontend/src/pages/MyspacePage.jsx @@ -246,12 +246,12 @@ const ProfileContent = ({ user }) => {
- - + +