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**.
*
+393
View File
@@ -0,0 +1,393 @@
import { can } from '@/domain/access/authorize';
import { shiftMinutes } from '@/domain/counters/week';
import {
formatMonthParam,
monthDates,
monthLabel,
nextMonth,
previousMonth,
isoDayOfWeek,
type Month,
} from '@/domain/planning/month';
import {
dayHeadings,
formatWeekParam,
nextIsoWeek,
previousIsoWeek,
weekBounds,
weekDates,
weekLabel,
zonedClock,
zonedDate,
zonedMidnight,
type IsoWeek,
} from '@/domain/planning/week';
import { POSTE_CODES, type PosteCode } from '@/lib/design/postes';
import { query } from '@/server/context';
import type { ScopedClient } from '@/server/tenant';
/**
* Les vues secondaires du planning — étiquettes et mois.
*
* Elles lisent **le même modèle** que la grille hebdomadaire : `Shift` reste
* la seule source. Une vue qui recopierait les créneaux dans sa propre table
* finirait par les afficher autrement, et personne ne saurait laquelle croire.
*/
const FALLBACK_POSTE: PosteCode = 'vte';
function posteOf(paletteKey: string | null | undefined): PosteCode {
if (!paletteKey) return FALLBACK_POSTE;
return (POSTE_CODES as readonly string[]).includes(paletteKey)
? (paletteKey as PosteCode)
: FALLBACK_POSTE;
}
interface LocationRef {
id: string;
name: string;
timezone: string;
}
/** Établissements visibles, avec celui qui est affiché. */
async function resolveLocation(
db: ScopedClient,
locationId: string | undefined,
): Promise<{ location: LocationRef | undefined; locations: LocationRef[] }> {
const locations = await db.location.findMany({
where: { archivedAt: null },
select: { id: true, name: true, timezone: true },
orderBy: { name: 'asc' },
});
return {
locations,
location:
locations.find((candidate) => candidate.id === locationId) ??
locations[0],
};
}
async function visibleScheduleIds(
db: ScopedClient,
locationId: string,
seesDrafts: boolean,
week?: IsoWeek,
): Promise<string[]> {
const teams = await db.team.findMany({
where: { locationId, archivedAt: null },
select: { id: true },
});
const schedules = await db.weeklySchedule.findMany({
where: {
teamId: { in: teams.map((team) => team.id) },
...(week ? { isoYear: week.isoYear, isoWeek: week.isoWeek } : {}),
...(seesDrafts ? {} : { status: 'PUBLISHED' }),
},
select: { id: true },
});
return schedules.map((schedule) => schedule.id);
}
// ---------------------------------------------------------------------------
// Vue étiquettes
// ---------------------------------------------------------------------------
export interface LabelBoardEntry {
id: string;
time: string;
who: string;
unassigned: boolean;
}
export interface LabelBoardRow {
labelId: string;
name: string;
poste: PosteCode;
/** Sept cases, lundi → dimanche. */
days: LabelBoardEntry[][];
minutes: number;
}
export interface LabelBoard {
location: LocationRef;
locations: Array<{ id: string; name: string }>;
label: string;
headings: string[];
weekParam: string;
previousParam: string;
nextParam: string;
rows: LabelBoardRow[];
}
/**
* La semaine vue par poste plutôt que par salarié.
*
* C'est la question du responsable d'ouverture : « la caisse est-elle tenue
* samedi ? » — impossible à lire d'un coup d'œil sur une grille dont les
* lignes sont des personnes.
*/
export async function getLabelBoard(
week: IsoWeek,
locationId?: string,
): Promise<LabelBoard | null> {
return query(
'planning.view',
async (db, actor) => {
const seesDrafts = can(actor, 'planning.view_unpublished');
const { location, locations } = await resolveLocation(db, locationId);
if (!location) return null;
const dates = weekDates(week);
const { from, to } = weekBounds(week, location.timezone);
const scheduleIds = await visibleScheduleIds(
db,
location.id,
seesDrafts,
week,
);
const shifts = await db.shift.findMany({
where: {
weeklyScheduleId: { in: scheduleIds },
startAt: { gte: from, lt: to },
},
orderBy: { startAt: 'asc' },
});
const labels = await db.label.findMany({
where: { archivedAt: null },
orderBy: { position: 'asc' },
});
const memberIds = [
...new Set(
shifts
.map((shift) => shift.membershipId)
.filter((id): id is string => Boolean(id)),
),
];
const members = await db.membership.findMany({
where: { id: { in: memberIds } },
include: { profile: { select: { firstName: true, lastName: true } } },
});
const nameById = new Map(
members.map((member) => [
member.id,
`${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim(),
]),
);
const columnOf = new Map(dates.map((date, index) => [date, index]));
const rowByLabel = new Map<string, LabelBoardRow>();
const rowFor = (labelId: string | null): LabelBoardRow => {
const key = labelId ?? 'sans-etiquette';
const existing = rowByLabel.get(key);
if (existing) return existing;
const label = labels.find((candidate) => candidate.id === labelId);
const created: LabelBoardRow = {
labelId: key,
name: label?.name ?? 'Sans étiquette',
poste: posteOf(label?.paletteKey),
days: Array.from({ length: 7 }, () => [] as LabelBoardEntry[]),
minutes: 0,
};
rowByLabel.set(key, created);
return created;
};
for (const shift of shifts) {
const column = columnOf.get(zonedDate(shift.startAt, location.timezone));
if (column === undefined) continue;
const row = rowFor(shift.labelId);
row.days[column]?.push({
id: shift.id,
time: `${zonedClock(shift.startAt, location.timezone)}–${zonedClock(shift.endAt, location.timezone)}`,
who: shift.membershipId
? (nameById.get(shift.membershipId) ?? 'Salarié')
: 'Non couvert',
unassigned: !shift.membershipId,
});
row.minutes += shiftMinutes(
shift.startAt,
shift.endAt,
shift.breakMinutes,
);
}
// Les postes non planifiés restent affichés : un poste vide toute la
// semaine est une information, pas une ligne à masquer.
for (const label of labels) rowFor(label.id);
const order = new Map(labels.map((label, index) => [label.id, index]));
const rows = [...rowByLabel.values()].sort(
(a, b) =>
(order.get(a.labelId) ?? Number.MAX_SAFE_INTEGER) -
(order.get(b.labelId) ?? Number.MAX_SAFE_INTEGER),
);
return {
location,
locations: locations.map(({ id, name }) => ({ id, name })),
label: weekLabel(week),
headings: dayHeadings(week),
weekParam: formatWeekParam(week),
previousParam: formatWeekParam(previousIsoWeek(week)),
nextParam: formatWeekParam(nextIsoWeek(week)),
rows,
} satisfies LabelBoard;
},
locationId ? { locationId } : undefined,
);
}
// ---------------------------------------------------------------------------
// Vue mois
// ---------------------------------------------------------------------------
export interface MonthDay {
isoDate: string;
day: number;
weekend: boolean;
}
export interface MonthRow {
membershipId: string;
name: string;
/** Minutes travaillées par jour, dans l'ordre de `days`. */
minutes: number[];
totalMinutes: number;
workedDays: number;
}
export interface MonthBoard {
location: LocationRef;
locations: Array<{ id: string; name: string }>;
label: string;
monthParam: string;
previousParam: string;
nextParam: string;
days: MonthDay[];
rows: MonthRow[];
}
/**
* Le mois par salarié.
*
* Vue de contrôle plutôt que d'édition : elle sert à repérer une série de
* journées trop longues ou un salarié sans repos, ce que sept colonnes ne
* laissent pas voir.
*/
export async function getMonthBoard(
month: Month,
locationId?: string,
): Promise<MonthBoard | null> {
return query(
'planning.view',
async (db, actor) => {
const seesDrafts = can(actor, 'planning.view_unpublished');
const { location, locations } = await resolveLocation(db, locationId);
if (!location) return null;
const dates = monthDates(month);
const first = dates[0] as string;
const last = dates[dates.length - 1] as string;
const from = zonedMidnight(first, location.timezone);
const to = zonedMidnight(
new Date(new Date(`${last}T00:00:00Z`).getTime() + 86_400_000)
.toISOString()
.slice(0, 10),
location.timezone,
);
const scheduleIds = await visibleScheduleIds(
db,
location.id,
seesDrafts,
);
const shifts = await db.shift.findMany({
where: {
weeklyScheduleId: { in: scheduleIds },
membershipId: { not: null },
startAt: { gte: from, lt: to },
},
select: {
membershipId: true,
startAt: true,
endAt: true,
breakMinutes: true,
},
});
const teams = await db.team.findMany({
where: { locationId: location.id, archivedAt: null },
select: { id: true },
});
const assignments = await db.teamMember.findMany({
where: { teamId: { in: teams.map((team) => team.id) } },
include: {
membership: {
include: {
profile: { select: { firstName: true, lastName: true } },
},
},
},
});
const columnOf = new Map(dates.map((date, index) => [date, index]));
const rowById = new Map<string, MonthRow>();
for (const assignment of assignments) {
if (rowById.has(assignment.membershipId)) continue;
const profile = assignment.membership.profile;
rowById.set(assignment.membershipId, {
membershipId: assignment.membershipId,
name: `${profile?.firstName ?? ''} ${profile?.lastName ?? assignment.membership.employeeNumber}`.trim(),
minutes: dates.map(() => 0),
totalMinutes: 0,
workedDays: 0,
});
}
for (const shift of shifts) {
if (!shift.membershipId) continue;
const row = rowById.get(shift.membershipId);
if (!row) continue;
const column = columnOf.get(zonedDate(shift.startAt, location.timezone));
if (column === undefined) continue;
const minutes = shiftMinutes(
shift.startAt,
shift.endAt,
shift.breakMinutes,
);
const before = row.minutes[column] ?? 0;
if (before === 0 && minutes > 0) row.workedDays += 1;
row.minutes[column] = before + minutes;
row.totalMinutes += minutes;
}
return {
location,
locations: locations.map(({ id, name }) => ({ id, name })),
label: monthLabel(month),
monthParam: formatMonthParam(month),
previousParam: formatMonthParam(previousMonth(month)),
nextParam: formatMonthParam(nextMonth(month)),
days: dates.map((isoDate) => ({
isoDate,
day: Number(isoDate.slice(8, 10)),
weekend: isoDayOfWeek(isoDate) >= 6,
})),
rows: [...rowById.values()].sort((a, b) =>
a.name.localeCompare(b.name, 'fr'),
),
} satisfies MonthBoard;
},
locationId ? { locationId } : undefined,
);
}