Pointage enregistré et statistiques de présence
Étape 58 : le présent / absent du référent est enregistré sur
l'affectation (Assignment.attendance + heure, migration 0030,
PUT .../assignments/{id}/attendance) au lieu de disparaître au
rechargement.
Étape 59 : section « Présence » de l'écran Statistiques — créneaux en
cours et créneaux terminés, par poste, rafraîchie toutes les 30 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a37f6c2c41
commit
c57c70569c
@@ -1,5 +1,5 @@
|
||||
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
|
||||
import { hoursBetween, naiveMinutes } from '@/lib/time';
|
||||
import { hoursBetween, naiveMinutes, nowNaiveMinutes } from '@/lib/time';
|
||||
|
||||
import type {
|
||||
AccountRead,
|
||||
@@ -594,6 +594,53 @@ function buildStats(): EditionStats {
|
||||
}),
|
||||
volunteers_with_assignment: new Set(state.assignments.map((a) => a.volunteer_id)).size,
|
||||
},
|
||||
attendance: buildAttendance(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Même calcul que `app/api/stats.py:_attendance`. */
|
||||
function buildAttendance(): EditionStats['attendance'] {
|
||||
const now = nowNaiveMinutes();
|
||||
const empty = () => ({ assigned: 0, present: 0, absent: 0, unmarked: 0 });
|
||||
const count = (c: ReturnType<typeof empty>, a: AssignmentRead) => {
|
||||
c.assigned += 1;
|
||||
if (a.attendance === 'PRESENT') c.present += 1;
|
||||
else if (a.attendance === 'ABSENT') c.absent += 1;
|
||||
else c.unmarked += 1;
|
||||
};
|
||||
const past = empty();
|
||||
const live = empty();
|
||||
const rows = new Map(
|
||||
state.positions.map((p) => [
|
||||
p.id,
|
||||
{ position_id: p.id, name: p.name, past: empty(), live: empty(), live_shifts: 0 },
|
||||
]),
|
||||
);
|
||||
const phase = new Map<string, 'past' | 'live'>();
|
||||
for (const s of state.shifts) {
|
||||
if (naiveMinutes(s.ends_at) <= now) phase.set(s.id, 'past');
|
||||
else if (naiveMinutes(s.starts_at) <= now) {
|
||||
phase.set(s.id, 'live');
|
||||
const row = rows.get(s.position_id);
|
||||
if (row) row.live_shifts += 1;
|
||||
}
|
||||
}
|
||||
for (const a of state.assignments) {
|
||||
const when = phase.get(a.shift_id);
|
||||
const shift = state.shifts.find((s) => s.id === a.shift_id);
|
||||
const row = shift ? rows.get(shift.position_id) : undefined;
|
||||
if (!when || !row) continue;
|
||||
count(when === 'past' ? past : live, a);
|
||||
count(when === 'past' ? row.past : row.live, a);
|
||||
}
|
||||
return {
|
||||
as_of: touch(),
|
||||
past,
|
||||
live,
|
||||
live_shifts: [...rows.values()].reduce((n, r) => n + r.live_shifts, 0),
|
||||
by_position: [...rows.values()]
|
||||
.filter((r) => r.past.assigned || r.live.assigned || r.live_shifts)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'fr')),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1536,6 +1583,8 @@ export function createMockApi(): PlanningApi {
|
||||
shift_id: body.shift_id,
|
||||
source: body.source ?? 'MANUAL',
|
||||
locked: body.locked ?? false,
|
||||
attendance: null,
|
||||
attendance_marked_at: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -1551,6 +1600,13 @@ export function createMockApi(): PlanningApi {
|
||||
});
|
||||
return delay({ assignment: a, warnings: [] as Violation[] });
|
||||
},
|
||||
setAttendance: (_edition, id, body) => {
|
||||
const a = state.assignments.find((x) => x.id === id) ?? notFound();
|
||||
a.attendance = body.attendance;
|
||||
a.attendance_marked_at = body.attendance ? touch() : null;
|
||||
a.updated_at = touch();
|
||||
return delay({ ...a });
|
||||
},
|
||||
remove: (_edition, id) => {
|
||||
state.assignments = state.assignments.filter((x) => x.id !== id);
|
||||
return delay(undefined);
|
||||
|
||||
Reference in New Issue
Block a user