WP-04 (suite) : vues par poste et par mois, duplication, édition, impression

Édition
- Le panneau de créneau sert aussi à la modification : déplacer un créneau,
  c'est changer son jour, son salarié ou ses heures — exactement les champs de
  la création. Deux écrans distincts finiraient par diverger.
- Duplication d'une semaine vers une autre, refusée si la destination contient
  déjà des créneaux. Le report se fait **par heure locale**, pas par décalage de
  sept jours : entre mars et avril, sept jours d'écart ne redonnent pas la même
  heure.

Vues
- `/planning/etiquettes` : la semaine par poste. C'est la question du
  responsable d'ouverture — « la caisse est-elle tenue samedi ? » — illisible
  sur une grille dont les lignes sont des personnes. Les postes sans créneau
  restent affichés : un poste vide toute la semaine est une information.
- `/planning/mois` : les heures par salarié et par jour, avec les journées de
  plus de 10 h signalées. Vue de contrôle, pas d'édition.
- Les deux lisent `Shift` directement. Un test e2e pose un créneau et le
  retrouve identique dans les quatre vues — c'est ce qui empêche l'une d'elles
  de dériver vers son propre calcul.

Impression
- Feuille d'impression A4 paysage : les zones à défilement se déplient, les
  lignes ne se coupent pas entre deux pages, et les aplats de poste sortent
  (`print-color-adjust: exact`) sans être nécessaires à la lecture, puisque le
  code du poste est imprimé dans chaque bloc.
- Pas de moteur PDF côté serveur : le navigateur sait paginer et enregistrer en
  PDF. Un second moteur de rendu serait un second endroit où la grille peut
  diverger de ce qui est à l'écran.

Accessibilité
- La grille porte enfin ses rôles ARIA (`grid`, `row`, `columnheader`,
  `rowheader`, `gridcell`). Elle est faite de div pour la mise en page, mais se
  lit comme un tableau : un lecteur d'écran doit pouvoir annoncer « ligne
  Camille Ferrand, colonne mercredi ».

Cette structure a d'ailleurs révélé un test faux : `locator('div')` remontait au
conteneur de la grille, et le créneau du test atterrissait sur le premier
salarié de l'équipe au lieu de celui visé — une erreur invisible à l'écran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-08 07:20:36 +00:00
1 parent 023f236881
commit d9f55f866f
15 files changed
+1377 -56

No files matched your search

+142
View File
@@ -8,6 +8,9 @@ import { shiftMinutes } from '@/domain/counters/week';
import {
parseWeekParam,
weekBounds,
weekDates,
zonedClock,
zonedDate,
zonedInstant,
} from '@/domain/planning/week';
import { recordAudit } from '@/server/audit';
@@ -359,6 +362,145 @@ export async function unpublishWeekAction(
return setPublication(formData, false);
}
const duplicateInput = z.object({
teamId: z.string().min(1),
/** Semaine à copier. */
source: z.string().min(1),
/** Semaine de destination. */
target: z.string().min(1),
});
/**
* Duplique une semaine vers une autre.
*
* C'est le geste le plus fréquent du métier : les plannings de commerce se
* répètent d'une semaine à l'autre, à quelques ajustements près. La copie
* reporte les créneaux **jour à jour** — le lundi source devient le lundi
* cible — et recalcule les instants dans le fuseau de l'établissement, sans
* quoi une copie franchissant un changement d'heure décalerait tout d'une
* heure.
*
* La destination doit être **vide** : écraser silencieusement le travail de
* quelqu'un d'autre est pire que refuser.
*/
export async function duplicateWeekAction(
_previous: PlanningActionState,
formData: FormData,
): Promise<PlanningActionState> {
const parsed = duplicateInput.safeParse({
teamId: formData.get('teamId'),
source: formData.get('source'),
target: formData.get('target'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const source = parseWeekParam(parsed.data.source);
const target = parseWeekParam(parsed.data.target);
if (!source || !target) return { error: 'Semaine invalide.' };
if (source.isoYear === target.isoYear && source.isoWeek === target.isoWeek) {
return { error: 'La semaine source et la semaine cible sont identiques.' };
}
let copied = 0;
try {
await mutate('planning.duplicate', async (db, actor) => {
const { team, location } = await loadTeamContext(db, parsed.data.teamId);
const from = await db.weeklySchedule.findUnique({
where: {
teamId_isoYear_isoWeek: {
teamId: team.id,
isoYear: source.isoYear,
isoWeek: source.isoWeek,
},
},
});
if (!from) {
throw new ValidationError("La semaine à copier n'existe pas.");
}
const shifts = await db.shift.findMany({
where: { weeklyScheduleId: from.id },
});
if (shifts.length === 0) {
throw new ValidationError('La semaine à copier ne contient aucun créneau.');
}
const to = await ensureSchedule(
db,
team.id,
location.id,
target.isoYear,
target.isoWeek,
);
const existing = await db.shift.count({
where: { weeklyScheduleId: to.id },
});
if (existing > 0) {
throw new ValidationError(
'La semaine de destination contient déjà des créneaux. Videz-la avant de dupliquer.',
);
}
const sourceDates = weekDates(source);
const targetDates = weekDates(target);
const dayOf = new Map(sourceDates.map((date, index) => [date, index]));
for (const shift of shifts) {
const column = dayOf.get(zonedDate(shift.startAt, location.timezone));
if (column === undefined) continue;
const targetDate = targetDates[column] as string;
// Report par heure locale, pas par décalage de sept jours : entre
// mars et avril, sept jours d'écart ne redonnent pas la même heure.
const startClock = zonedClock(shift.startAt, location.timezone);
const endClock = zonedClock(shift.endAt, location.timezone);
const startAt = zonedInstant(targetDate, startClock, location.timezone);
let endAt = zonedInstant(targetDate, endClock, location.timezone);
if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000);
await db.shift.create({
data: {
weeklyScheduleId: to.id,
membershipId: shift.membershipId,
localDate: new Date(`${targetDate}T00:00:00Z`),
startAt,
endAt,
breakMinutes: shift.breakMinutes,
labelId: shift.labelId,
note: shift.note,
} as never,
});
copied += 1;
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'planning.week.duplicate',
entityType: 'WeeklySchedule',
entityId: to.id,
after: {
teamId: team.id,
from: `${source.isoYear}-W${source.isoWeek}`,
to: `${target.isoYear}-W${target.isoWeek}`,
shifts: copied,
},
});
});
} catch (error) {
return toState(error, 'Vous ne pouvez pas dupliquer cette semaine.');
}
revalidatePath('/planning/semaine');
return { ok: true };
}
/**
* Publie ou dépublie la semaine d'une **équipe**.
*