WP-04: planning sur données réelles — grille semaine, vue jour, publication par équipe
Le planning quitte le module de démonstration : la grille lit `WeeklySchedule` et `Shift`, et les quatre derniers fichiers de `src/lib/demo/` qui portaient des salariés fictifs disparaissent. Modèle - `TeamMember` : rattachement d'un salarié à une équipe, distinct de `MembershipScope` (qui dit ce qu'un manager a le droit de voir). Sans lui, un salarié sans créneau n'apparaîtrait pas dans la grille — c'est-à-dire l'état de départ de toute semaine en construction. - RLS sur `WeeklySchedule`, `Shift`, `Rest`, `DailyNote` et `TeamMember`. Temps - `src/domain/planning/week.ts` : repérage par couple année ISO + semaine ISO, jamais par date de début. Le lundi 29 décembre 2025 appartient à la semaine 1 de 2026 ; une clé fondée sur la date ferait apparaître deux semaines 1. - `zonedInstant` / `zonedMidnight` corrigent le décalage mesuré **à l'instant visé**. Le 29 mars 2026, minuit est en UTC+1 et 09 h en UTC+2 : ajouter neuf heures à minuit donnerait 10 h locales. - La semaine du retour à l'heure d'hiver dure 169 h, celle du passage à l'heure d'été 167 — vérifié par test. Écritures - Création, modification, suppression de créneau ; publication et dépublication **par équipe**, avec verrou optimiste sur `version` : deux managers sur la même grille est le cas normal, pas l'exception. - Chevauchement refusé en transaction, pas seulement dans le formulaire. - Modifier une semaine publiée exige `planning.edit_published`, capacité que le rôle manager n'a pas : un salarié a organisé sa semaine sur ce qu'il a lu. - Toute mutation laisse une entrée d'audit ; la suppression écrit sa trace **avant** l'effacement, sinon l'état supprimé serait perdu. Lecture - `planning.view_unpublished` filtre en base : sans cette capacité, les brouillons ne sont pas chargés du tout. Un test vérifie que les horaires n'apparaissent pas dans le HTML servi — un masquage CSS les y laisserait. - Vue jour reconstruite sur les mêmes données, amplitude déduite de la journée réelle plutôt que figée à 06 h–21 h. Vérification - 26 tests unitaires sur le repérage des semaines et la mise en grille. - Parcours e2e : poser un créneau, refus de chevauchement, publier, dépublier ; et ce que voient un salarié et un manager sur la même semaine. - `scripts/dev-db.sh` : la base de développement est éphémère dans cet environnement, la remonter ne doit pas être une redécouverte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
27 files changed
+3373
-674
No files matched your search
@@ -1,13 +1,15 @@
|
||||
import { posteShort, posteTokens } from '@/lib/design/postes';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { clock, initials, type DemoEmployee, type DemoShift } from '@/lib/demo/types';
|
||||
import type { DayLane } from '@/server/planning/queries';
|
||||
|
||||
export interface DayLane {
|
||||
employee: DemoEmployee;
|
||||
shifts: DemoShift[];
|
||||
unassigned?: boolean;
|
||||
/** « 09:00 » depuis des minutes après minuit ; 25 h devient 01:00. */
|
||||
function clock(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60) % 24;
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export type { DayLane };
|
||||
|
||||
export interface DayTimelineProps {
|
||||
lanes: DayLane[];
|
||||
/** Amplitude affichée, en heures locales. */
|
||||
@@ -32,7 +34,9 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
|
||||
return lanes.filter(
|
||||
(lane) =>
|
||||
!lane.unassigned &&
|
||||
lane.shifts.some((shift) => shift.start < end && shift.end > start),
|
||||
lane.shifts.some(
|
||||
(shift) => shift.startMinutes < end && shift.endMinutes > start,
|
||||
),
|
||||
).length;
|
||||
});
|
||||
const peak = Math.max(1, ...coverage);
|
||||
@@ -95,7 +99,7 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
|
||||
|
||||
{lanes.map((lane) => (
|
||||
<div
|
||||
key={lane.employee.id}
|
||||
key={lane.id}
|
||||
className={cx(
|
||||
'flex border-b border-line-1 last:border-b-0',
|
||||
lane.unassigned && 'bg-surface-2',
|
||||
@@ -111,7 +115,7 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
|
||||
: 'bg-surface-3 text-ink-2',
|
||||
)}
|
||||
>
|
||||
{lane.unassigned ? '?' : initials(lane.employee)}
|
||||
{lane.initials}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
@@ -120,12 +124,10 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
|
||||
lane.unassigned ? 'text-ink-3' : 'text-ink-1',
|
||||
)}
|
||||
>
|
||||
{lane.unassigned
|
||||
? 'Non assigné'
|
||||
: `${lane.employee.firstName} ${lane.employee.lastName}`}
|
||||
{lane.name}
|
||||
</span>
|
||||
<span className="block truncate text-micro text-ink-3">
|
||||
{lane.employee.job}
|
||||
{lane.job}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -140,29 +142,29 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
|
||||
/>
|
||||
))}
|
||||
|
||||
{lane.shifts.map((shift, index) => {
|
||||
{lane.shifts.map((shift) => {
|
||||
const tokens = posteTokens(shift.poste);
|
||||
const ghost = shift.state === 'unassigned';
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
key={shift.id}
|
||||
className="absolute top-1 bottom-1 flex items-center gap-1.5 overflow-hidden rounded-2 px-2 text-micro"
|
||||
style={{
|
||||
left: offset(shift.start),
|
||||
width: `calc(${offset(shift.end)} - ${offset(shift.start)})`,
|
||||
left: offset(shift.startMinutes),
|
||||
width: `calc(${offset(shift.endMinutes)} - ${offset(shift.startMinutes)})`,
|
||||
background: ghost ? 'transparent' : tokens.bg,
|
||||
color: ghost ? 'var(--color-ink-2)' : tokens.fg,
|
||||
border: ghost
|
||||
? `1px dashed ${tokens.edge}`
|
||||
: '1px solid transparent',
|
||||
}}
|
||||
title={`${posteShort(shift.poste)} · ${clock(shift.start)}–${clock(shift.end)}`}
|
||||
title={`${posteShort(shift.poste)} · ${clock(shift.startMinutes)}–${clock(shift.endMinutes)}`}
|
||||
>
|
||||
<span className="font-semibold tracking-wide">
|
||||
{posteShort(shift.poste)}
|
||||
</span>
|
||||
<span className="tnum truncate opacity-90">
|
||||
{clock(shift.start)}–{clock(shift.end)}
|
||||
{clock(shift.startMinutes)}–{clock(shift.endMinutes)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useEffect, useState } from 'react';
|
||||
|
||||
import { WeekGrid } from '@/components/planning/WeekGrid';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { formatMinutes } from '@/domain/counters/week';
|
||||
import { displayName, type BoardRow } from '@/domain/planning/board';
|
||||
import {
|
||||
createShiftAction,
|
||||
deleteShiftAction,
|
||||
publishWeekAction,
|
||||
unpublishWeekAction,
|
||||
type PlanningActionState,
|
||||
} from '@/server/planning/actions';
|
||||
import type { BoardLabel, BoardSection } from '@/server/planning/queries';
|
||||
|
||||
export interface TeamSectionProps {
|
||||
section: BoardSection;
|
||||
days: string[];
|
||||
dates: string[];
|
||||
labels: BoardLabel[];
|
||||
weekParam: string;
|
||||
/** Faux quand la capacité de modification manque : la grille reste lisible. */
|
||||
canEdit: boolean;
|
||||
canPublish: boolean;
|
||||
}
|
||||
|
||||
const empty: PlanningActionState = {};
|
||||
|
||||
/**
|
||||
* Une équipe, une grille, un état de publication.
|
||||
*
|
||||
* La publication est **par équipe** : l'audit montre un bouton « Dépublier »
|
||||
* sur chaque section, pas un pour l'établissement. Le regrouper ferait
|
||||
* republier la caisse quand on corrige la réserve.
|
||||
*/
|
||||
export function TeamSection({
|
||||
section,
|
||||
days,
|
||||
dates,
|
||||
labels,
|
||||
weekParam,
|
||||
canEdit,
|
||||
canPublish,
|
||||
}: TeamSectionProps) {
|
||||
const [target, setTarget] = useState<{
|
||||
membershipId: string | null;
|
||||
dayIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
const published = section.status === 'PUBLISHED';
|
||||
const plannedMinutes = section.rows.reduce(
|
||||
(total, row) => total + row.counters.plannedMinutes,
|
||||
0,
|
||||
);
|
||||
|
||||
if (section.hidden) {
|
||||
// Le brouillon n'est pas seulement masqué : il n'a pas été chargé. Dire
|
||||
// « pas encore publié » est honnête et n'apprend rien de son contenu.
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-base font-semibold text-ink-1">
|
||||
{section.teamName}
|
||||
</h2>
|
||||
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
|
||||
Le planning de cette équipe n’est pas encore publié.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold text-ink-1">
|
||||
{section.teamName}
|
||||
</h2>
|
||||
<Badge tone={published ? 'ok' : 'warn'}>
|
||||
{published ? 'Publiée' : 'Brouillon'}
|
||||
</Badge>
|
||||
<span className="text-xs text-ink-3">
|
||||
{formatMinutes(plannedMinutes)} planifiées
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canPublish ? (
|
||||
<PublishControl
|
||||
teamId={section.teamId}
|
||||
weekParam={weekParam}
|
||||
version={section.version}
|
||||
published={published}
|
||||
/>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<WeekGrid
|
||||
days={days}
|
||||
dates={dates}
|
||||
rows={section.rows}
|
||||
unassignedRow={section.unassignedRow}
|
||||
{...(canEdit
|
||||
? {
|
||||
cellAction: (row: BoardRow, dayIndex: number) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setTarget({ membershipId: row.membershipId, dayIndex })
|
||||
}
|
||||
className="mt-auto rounded-2 border border-dashed border-line-2 px-1 py-0.5 text-micro text-ink-3 opacity-0 transition-opacity hover:border-accent hover:text-accent focus-visible:opacity-100 group-hover/cell:opacity-100"
|
||||
>
|
||||
+ Créneau
|
||||
<span className="sr-only">
|
||||
{` pour ${row.unassigned ? 'un besoin non couvert' : displayName(row)} le ${days[dayIndex] ?? ''}`}
|
||||
</span>
|
||||
</button>
|
||||
),
|
||||
shiftAction: (shiftId: string) => (
|
||||
<DeleteShift shiftId={shiftId} />
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
|
||||
{target ? (
|
||||
<ShiftComposer
|
||||
teamId={section.teamId}
|
||||
weekParam={weekParam}
|
||||
labels={labels}
|
||||
rows={section.rows}
|
||||
localDate={dates[target.dayIndex] ?? ''}
|
||||
dayLabel={days[target.dayIndex] ?? ''}
|
||||
membershipId={target.membershipId}
|
||||
onClose={() => setTarget(null)}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PublishControl({
|
||||
teamId,
|
||||
weekParam,
|
||||
version,
|
||||
published,
|
||||
}: {
|
||||
teamId: string;
|
||||
weekParam: string;
|
||||
version: number;
|
||||
published: boolean;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(
|
||||
published ? unpublishWeekAction : publishWeekAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex items-center gap-2">
|
||||
<input type="hidden" name="teamId" value={teamId} />
|
||||
<input type="hidden" name="week" value={weekParam} />
|
||||
{/* Verrou optimiste : la version lue au rendu est renvoyée telle quelle. */}
|
||||
<input type="hidden" name="expectedVersion" value={version} />
|
||||
{state.error ? (
|
||||
<span role="alert" className="text-xs text-danger">
|
||||
{state.error}
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant={published ? 'secondary' : 'primary'}
|
||||
disabled={pending}
|
||||
>
|
||||
{published ? 'Dépublier' : 'Publier'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteShift({ shiftId }: { shiftId: string }) {
|
||||
const [state, formAction, pending] = useActionState(deleteShiftAction, empty);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="absolute -top-1 -right-1">
|
||||
<input type="hidden" name="shiftId" value={shiftId} />
|
||||
{/* Volontairement pas désactivé pendant l'envoi : la suppression fait
|
||||
disparaître le créneau, donc le bouton, et un état désactivé
|
||||
transitoire ne protège de rien tout en rendant la cible instable. */}
|
||||
<button
|
||||
type="submit"
|
||||
aria-busy={pending}
|
||||
title={state.error ?? 'Supprimer le créneau'}
|
||||
className="flex size-4 items-center justify-center rounded-full border border-line-2 bg-surface text-micro leading-none text-ink-3 opacity-0 hover:border-danger hover:text-danger focus-visible:opacity-100 group-hover/cell:opacity-100"
|
||||
>
|
||||
<span aria-hidden>×</span>
|
||||
<span className="sr-only">Supprimer le créneau</span>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ShiftComposer({
|
||||
teamId,
|
||||
weekParam,
|
||||
labels,
|
||||
rows,
|
||||
localDate,
|
||||
dayLabel,
|
||||
membershipId,
|
||||
onClose,
|
||||
}: {
|
||||
teamId: string;
|
||||
weekParam: string;
|
||||
labels: BoardLabel[];
|
||||
rows: BoardRow[];
|
||||
localDate: string;
|
||||
dayLabel: string;
|
||||
membershipId: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(createShiftAction, empty);
|
||||
|
||||
// La grille est déjà revalidée côté serveur ; refermer le panneau évite de
|
||||
// reposter le même créneau par inadvertance.
|
||||
useEffect(() => {
|
||||
if (state.ok) onClose();
|
||||
}, [state.ok, onClose]);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3"
|
||||
>
|
||||
<input type="hidden" name="teamId" value={teamId} />
|
||||
<input type="hidden" name="week" value={weekParam} />
|
||||
<input type="hidden" name="localDate" value={localDate} />
|
||||
|
||||
<Field label="Jour">
|
||||
<span className="flex h-8 items-center px-1 text-sm text-ink-2">
|
||||
{dayLabel}
|
||||
</span>
|
||||
</Field>
|
||||
|
||||
<Field label="Salarié" htmlFor="composer-membership">
|
||||
<select
|
||||
id="composer-membership"
|
||||
name="membershipId"
|
||||
defaultValue={membershipId ?? ''}
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
>
|
||||
<option value="">Besoin non couvert</option>
|
||||
{rows.map((row) => (
|
||||
<option key={row.membershipId} value={row.membershipId ?? ''}>
|
||||
{displayName(row)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Début" htmlFor="composer-start">
|
||||
<input
|
||||
id="composer-start"
|
||||
name="start"
|
||||
type="time"
|
||||
required
|
||||
defaultValue="09:00"
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Fin" htmlFor="composer-end">
|
||||
<input
|
||||
id="composer-end"
|
||||
name="end"
|
||||
type="time"
|
||||
required
|
||||
defaultValue="17:00"
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Pause (min)" htmlFor="composer-break">
|
||||
<input
|
||||
id="composer-break"
|
||||
name="breakMinutes"
|
||||
type="number"
|
||||
min={0}
|
||||
max={600}
|
||||
step={5}
|
||||
defaultValue={0}
|
||||
className="h-8 w-20 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Poste" htmlFor="composer-label">
|
||||
<select
|
||||
id="composer-label"
|
||||
name="labelId"
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
>
|
||||
{labels.map((label) => (
|
||||
<option key={label.id} value={label.id}>
|
||||
{label.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" variant="primary" size="sm" disabled={pending}>
|
||||
Ajouter
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={onClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{state.error ? (
|
||||
<p role="alert" className="w-full text-xs text-danger">
|
||||
{state.error}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
import { AbsenceBar } from '@/components/planning/AbsenceBar';
|
||||
import { CounterStrip } from '@/components/planning/CounterStrip';
|
||||
import { ShiftChip } from '@/components/planning/ShiftChip';
|
||||
import { computeWeekCounters } from '@/domain/counters/week';
|
||||
import { cx } from '@/lib/cx';
|
||||
import {
|
||||
fullName,
|
||||
initials,
|
||||
timeRange,
|
||||
type DemoWeekRow,
|
||||
} from '@/lib/demo/types';
|
||||
displayName,
|
||||
initialsOf,
|
||||
type BoardRow,
|
||||
} from '@/domain/planning/board';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
export interface WeekGridProps {
|
||||
days: readonly string[];
|
||||
rows: DemoWeekRow[];
|
||||
unassignedRow?: DemoWeekRow;
|
||||
dates: readonly string[];
|
||||
rows: BoardRow[];
|
||||
unassignedRow?: BoardRow | null;
|
||||
/** Rendu de la case d'un jour : sert à greffer l'ajout d'un créneau. */
|
||||
cellAction?: (row: BoardRow, dayIndex: number) => React.ReactNode;
|
||||
/** Rendu attaché à un créneau : suppression, édition. */
|
||||
shiftAction?: (shiftId: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,14 +27,41 @@ export interface WeekGridProps {
|
||||
* majoré et repos compensateur — et les distinguer d'un coup d'œil évite de les
|
||||
* planifier par inadvertance.
|
||||
*/
|
||||
export function WeekGrid({ days, rows, unassignedRow }: WeekGridProps) {
|
||||
export function WeekGrid({
|
||||
days,
|
||||
dates,
|
||||
rows,
|
||||
unassignedRow,
|
||||
cellAction,
|
||||
shiftAction,
|
||||
}: WeekGridProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
|
||||
<div className="min-w-[1040px]">
|
||||
<GridHeader days={days} />
|
||||
{unassignedRow ? <Row row={unassignedRow} days={days} /> : null}
|
||||
{unassignedRow ? (
|
||||
<Row
|
||||
row={unassignedRow}
|
||||
days={days}
|
||||
dates={dates}
|
||||
{...(cellAction ? { cellAction } : {})}
|
||||
{...(shiftAction ? { shiftAction } : {})}
|
||||
/>
|
||||
) : null}
|
||||
{rows.length === 0 && !unassignedRow ? (
|
||||
<p className="px-4 py-6 text-sm text-ink-3">
|
||||
Aucun salarié rattaché à cette équipe.
|
||||
</p>
|
||||
) : null}
|
||||
{rows.map((row) => (
|
||||
<Row key={row.employee.id} row={row} days={days} />
|
||||
<Row
|
||||
key={row.membershipId ?? 'unassigned'}
|
||||
row={row}
|
||||
days={days}
|
||||
dates={dates}
|
||||
{...(cellAction ? { cellAction } : {})}
|
||||
{...(shiftAction ? { shiftAction } : {})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,9 +91,21 @@ function GridHeader({ days }: { days: readonly string[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) {
|
||||
function Row({
|
||||
row,
|
||||
days,
|
||||
dates,
|
||||
cellAction,
|
||||
shiftAction,
|
||||
}: {
|
||||
row: BoardRow;
|
||||
days: readonly string[];
|
||||
dates: readonly string[];
|
||||
cellAction?: (row: BoardRow, dayIndex: number) => React.ReactNode;
|
||||
shiftAction?: (shiftId: string) => React.ReactNode;
|
||||
}) {
|
||||
const counters = computeWeekCounters(row.counters);
|
||||
const name = row.unassigned ? 'Non assigné' : fullName(row.employee);
|
||||
const name = row.unassigned ? 'Non assigné' : displayName(row);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -86,7 +128,7 @@ function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) {
|
||||
aria-hidden
|
||||
className="flex size-6 items-center justify-center rounded-full bg-surface-3 text-micro font-semibold text-ink-2"
|
||||
>
|
||||
{initials(row.employee)}
|
||||
{initialsOf(row)}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
@@ -99,57 +141,43 @@ function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) {
|
||||
{name}
|
||||
</span>
|
||||
<span className="block truncate text-micro text-ink-3">
|
||||
{row.employee.job}
|
||||
{row.job}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<CounterStrip
|
||||
counters={counters}
|
||||
forfaitJours={row.employee.forfaitJours ?? false}
|
||||
unassigned={row.unassigned ?? false}
|
||||
forfaitJours={row.forfaitJours}
|
||||
unassigned={row.unassigned}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative grid flex-1 grid-cols-7">
|
||||
{row.absence ? (
|
||||
<AbsenceBar
|
||||
kind={row.absence.kind}
|
||||
label={row.absence.label}
|
||||
duration={`${row.absence.span} j`}
|
||||
startDay={row.absence.startDay}
|
||||
span={row.absence.span}
|
||||
className="top-1.5"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{days.map((day, index) => {
|
||||
const shifts = row.days[index] ?? [];
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
key={dates[index] ?? day}
|
||||
className={cx(
|
||||
'flex min-h-[var(--row-h)] flex-col gap-1 border-r border-line-1 p-1 last:border-r-0',
|
||||
'group/cell flex min-h-[var(--row-h)] flex-col gap-1 border-r border-line-1 p-1 last:border-r-0',
|
||||
index > 4 && 'bg-surface-2',
|
||||
row.absence &&
|
||||
index >= row.absence.startDay &&
|
||||
index < row.absence.startDay + row.absence.span &&
|
||||
'pt-7',
|
||||
)}
|
||||
>
|
||||
{shifts.map((shift, position) => (
|
||||
<ShiftChip
|
||||
key={`${day}-${position}`}
|
||||
poste={shift.poste}
|
||||
time={timeRange(shift)}
|
||||
state={shift.state ?? 'published'}
|
||||
{...(shift.delta ? { delta: shift.delta } : {})}
|
||||
{...(shift.alert ? { alert: shift.alert } : {})}
|
||||
{...(shift.breakMinutes
|
||||
? { breakMinutes: shift.breakMinutes }
|
||||
: {})}
|
||||
/>
|
||||
{shifts.map((shift) => (
|
||||
<div key={shift.id} className="relative">
|
||||
<ShiftChip
|
||||
poste={shift.poste}
|
||||
time={shift.time}
|
||||
state={shift.state}
|
||||
{...(shift.breakMinutes
|
||||
? { breakMinutes: shift.breakMinutes }
|
||||
: {})}
|
||||
/>
|
||||
{shiftAction?.(shift.id)}
|
||||
</div>
|
||||
))}
|
||||
{cellAction?.(row, index)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in new issue
Block a user