WP-07 : heures prévu/réalisé/payé et périodes de paie verrouillables
Les trois grandeurs Sans pointeuse, le réalisé est saisi par le manager. La matrice impose de distinguer trois grandeurs et non deux, avec deux règles qui ne se négocient pas : - **Sans heures réelles, le prévu fait foi.** Attendre une saisie qui ne viendra pas ne produirait aucune paie. Une saisie partielle — un début sans fin — ne suffit pas : elle donnerait une durée fantaisiste. - **Le paiement ne dépend jamais de la validation.** Une ligne non validée part en paie sur la base du réalisé. Bloquer le paiement d'heures accomplies faute de validation est précisément ce que la matrice interdit ; l'écran l'énonce pour qu'aucun manager ne croie l'inverse. Toute correction d'heures déjà saisies exige un motif — sans lui, une correction est indistinguable d'une erreur de saisie. Une première saisie n'en demande pas : il n'y a rien à corriger, et exiger un motif à chaque ligne le ferait remplir machinalement. Le verrou de période - Verrouiller fige les instantanés **et** ferme le mois : plus aucun créneau, aucune absence, aucune heure réelle ne peut être écrit sur ces dates. Le contrôle passe avant l'écriture, dans planning, absences et heures. - Déverrouiller exige une justification. Rouvrir périme les fichiers déjà transmis au cabinet, et six mois plus tard personne ne saurait pourquoi. - La **péremption d'un export est déduite** de `generatedAt < unlockedAt`, jamais stockée : la stocker obligerait à réécrire une trace qui doit rester append-only, et une trace réécrite ne prouve plus rien. - Supprimer une période demande de **retaper son libellé** : un « êtes-vous sûr » se clique sans lire, et la suppression efface des instantanés sur lesquels un export a pu être bâti. Un seul calcul pour trois écrans Les instantanés viennent de `buildPayrollPeriod`, la même fonction que le rapport de paie et l'export. C'est ce qui satisfait le critère croisé : trois calculs séparés divergeraient, et l'écart ne se verrait qu'au bulletin. Un test d'intégration compare instantané et rapport sur le même jeu de données. Tests enfin idempotents La suite e2e échouait au second passage : les absences acceptées d'une exécution bloquaient les demandes de la suivante. Trois corrections, dans l'ordre d'importance — chaque test travaille sur **son** salarié (le chevauchement se juge par personne), chaque test libère ce qu'il a créé, et les fenêtres de dates sont écartées d'une exécution à l'autre. Trois passages consécutifs passent désormais. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
20 files changed
+2837
-25
No files matched your search
@@ -0,0 +1,177 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
CreatePeriodForm,
|
||||
DeletePeriodForm,
|
||||
LockButton,
|
||||
UnlockForm,
|
||||
} from '@/components/payroll/PeriodActions';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { getPeriods } from '@/server/payroll/period-queries';
|
||||
|
||||
export const metadata = { title: 'Périodes de paie · PlanFlow' };
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ etablissement?: string }>;
|
||||
}
|
||||
|
||||
export default async function PeriodesPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const view = await getPeriods(params.etablissement);
|
||||
|
||||
if (!view) {
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Périodes de paie"
|
||||
subtitle="Aucun établissement n'est encore créé."
|
||||
/>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Périodes de paie"
|
||||
subtitle={`${view.location.name} · ${view.periods.length} période${view.periods.length > 1 ? 's' : ''}`}
|
||||
actions={
|
||||
<Link
|
||||
href="/paie"
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
← Rapport de paie
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{view.locations.map((location) => (
|
||||
<Link
|
||||
key={location.id}
|
||||
href={`/paie/periodes?etablissement=${location.id}`}
|
||||
aria-current={location.id === view.location.id ? 'page' : undefined}
|
||||
className={
|
||||
location.id === view.location.id
|
||||
? 'rounded-2 border border-accent bg-accent-soft px-2.5 py-1 text-xs font-medium text-accent-soft-ink'
|
||||
: 'rounded-2 border border-line-2 px-2.5 py-1 text-xs text-ink-2 hover:bg-surface-2'
|
||||
}
|
||||
>
|
||||
{location.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CreatePeriodForm
|
||||
locationId={view.location.id}
|
||||
suggestedMonth={view.suggestedMonth}
|
||||
/>
|
||||
|
||||
{view.periods.length === 0 ? (
|
||||
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
|
||||
Aucune période. Verrouiller une période fige les heures transmises au
|
||||
cabinet et ferme le mois aux modifications.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{view.periods.map((period) => {
|
||||
const locked = period.status === 'LOCKED';
|
||||
const stale = period.exports.filter((entry) => entry.stale).length;
|
||||
|
||||
return (
|
||||
<section
|
||||
key={period.id}
|
||||
className="rounded-3 border border-line-1 bg-surface"
|
||||
>
|
||||
<header className="flex flex-wrap items-center gap-3 border-b border-line-1 px-4 py-3">
|
||||
<h2 className="text-base font-semibold text-ink-1">
|
||||
{period.label}
|
||||
</h2>
|
||||
<Badge tone={locked ? 'ok' : 'warn'}>
|
||||
{locked ? 'Verrouillée' : 'Ouverte'}
|
||||
</Badge>
|
||||
<span className="tnum text-xs text-ink-3">
|
||||
{frenchDate(period.startDate)} → {frenchDate(period.endDate)}
|
||||
</span>
|
||||
{locked ? (
|
||||
<span className="text-xs text-ink-3">
|
||||
{period.snapshotCount} instantané
|
||||
{period.snapshotCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap gap-6 border-b border-line-1 px-4 py-2 text-xs text-ink-2">
|
||||
<span>
|
||||
Entrées <span className="tnum font-semibold">{period.entries}</span>
|
||||
</span>
|
||||
<span>
|
||||
Sorties <span className="tnum font-semibold">{period.exits}</span>
|
||||
</span>
|
||||
<span>
|
||||
Extras <span className="tnum font-semibold">{period.extras}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{stale > 0 ? (
|
||||
<p className="border-b border-line-1 bg-warn-soft px-4 py-2 text-xs text-warn-soft-ink">
|
||||
{stale} export{stale > 1 ? 's' : ''} périmé{stale > 1 ? 's' : ''} :
|
||||
produit{stale > 1 ? 's' : ''} avant le déverrouillage du{' '}
|
||||
{period.unlockedAt?.toISOString().slice(0, 10)}, le fichier
|
||||
transmis ne correspond plus aux données.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{period.exports.length > 0 ? (
|
||||
<ul className="divide-y divide-line-1 border-b border-line-1">
|
||||
{period.exports.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex flex-wrap items-center gap-3 px-4 py-1.5 text-xs"
|
||||
>
|
||||
<Badge tone={entry.stale ? 'danger' : 'neutral'}>
|
||||
{entry.stale ? 'Périmé' : 'À jour'}
|
||||
</Badge>
|
||||
<span className="tnum text-ink-2">
|
||||
{entry.lineCount} lignes
|
||||
</span>
|
||||
<span className="tnum text-micro text-ink-3">
|
||||
{entry.checksum.slice(0, 16)}…
|
||||
</span>
|
||||
<span className="ml-auto text-micro text-ink-3">
|
||||
{entry.generatedAt.toISOString().slice(0, 16).replace('T', ' ')}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 px-4 py-3">
|
||||
{!locked && view.canLock ? (
|
||||
<LockButton periodId={period.id} version={period.version} />
|
||||
) : null}
|
||||
{locked && view.canUnlock ? (
|
||||
<UnlockForm periodId={period.id} version={period.version} />
|
||||
) : null}
|
||||
{view.canDelete ? (
|
||||
<DeletePeriodForm periodId={period.id} label={period.label} />
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<p className="text-micro text-ink-3">
|
||||
Une période verrouillée refuse toute création, modification ou
|
||||
suppression de créneau et d’absence sur ses dates. Une correction
|
||||
nécessaire passe soit par un déverrouillage justifié, soit par une
|
||||
régularisation sur la période suivante — le passé ne se réécrit pas.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
function frenchDate(isoDate: string): string {
|
||||
return isoDate.split('-').reverse().join('/');
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ActualHoursForm, ValidateRow } from '@/components/hours/ActualHoursForm';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { formatDelta, formatMinutes } from '@/domain/counters/week';
|
||||
import { monthOf, parseMonthParam } from '@/domain/planning/month';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { getHoursReport } from '@/server/hours/queries';
|
||||
|
||||
export const metadata = { title: 'Heures travaillées · PlanFlow' };
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ mois?: string; etablissement?: string }>;
|
||||
}
|
||||
|
||||
export default async function HeuresPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const month = parseMonthParam(params.mois) ?? monthOf(new Date());
|
||||
const report = await getHoursReport(month, params.etablissement);
|
||||
|
||||
if (!report) {
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Heures travaillées"
|
||||
subtitle="Aucun établissement n'est encore créé."
|
||||
/>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
const href = (mois: string, etablissement = report.location.id) =>
|
||||
`/rapports/heures?mois=${mois}&etablissement=${etablissement}`;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title={`Heures travaillées · ${report.label}`}
|
||||
subtitle={`${report.location.name} · prévu ${formatMinutes(report.totals.plannedMinutes)} · réalisé ${formatMinutes(report.totals.actualMinutes)} · écart ${formatDelta(report.totals.deltaMinutes)}`}
|
||||
actions={
|
||||
<>
|
||||
<Link
|
||||
href={href(report.previousParam)}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
← Mois précédent
|
||||
</Link>
|
||||
<Link
|
||||
href={href(report.nextParam)}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
Mois suivant →
|
||||
</Link>
|
||||
<Link
|
||||
href="/paie/periodes"
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
Périodes
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{report.locations.map((location) => (
|
||||
<Link
|
||||
key={location.id}
|
||||
href={href(report.monthParam, location.id)}
|
||||
aria-current={
|
||||
location.id === report.location.id ? 'page' : undefined
|
||||
}
|
||||
className={
|
||||
location.id === report.location.id
|
||||
? 'rounded-2 border border-accent bg-accent-soft px-2.5 py-1 text-xs font-medium text-accent-soft-ink'
|
||||
: 'rounded-2 border border-line-2 px-2.5 py-1 text-xs text-ink-2 hover:bg-surface-2'
|
||||
}
|
||||
>
|
||||
{location.name}
|
||||
</Link>
|
||||
))}
|
||||
{report.lockedLabels.map((label) => (
|
||||
<Badge key={label} tone="ok">
|
||||
{label} verrouillée
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="rounded-3 border border-info bg-info-soft p-4 text-sm text-info-soft-ink">
|
||||
<p>
|
||||
<strong>Sans heures réelles saisies, le prévu fait foi.</strong>{' '}
|
||||
Attendre une saisie qui ne viendra pas ne produirait aucune paie.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
La validation <strong>qualifie</strong> des heures, elle ne les
|
||||
autorise pas à être payées : des heures accomplies partent en paie
|
||||
qu’elles soient validées ou non.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{report.rows.length === 0 ? (
|
||||
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
|
||||
Aucun créneau planifié sur cette période.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{report.rows.map((row) => (
|
||||
<section
|
||||
key={row.membershipId}
|
||||
className="rounded-3 border border-line-1 bg-surface"
|
||||
>
|
||||
<header className="flex flex-wrap items-center gap-3 border-b border-line-1 px-4 py-2">
|
||||
<h2 className="text-sm font-semibold text-ink-1">{row.name}</h2>
|
||||
<span className="text-xs text-ink-3">
|
||||
prévu <span className="tnum">{formatMinutes(row.plannedMinutes)}</span>
|
||||
</span>
|
||||
<span className="text-xs text-ink-3">
|
||||
réalisé{' '}
|
||||
<span className="tnum">{formatMinutes(row.actualMinutes)}</span>
|
||||
</span>
|
||||
<span
|
||||
className={cx(
|
||||
'tnum rounded-2 px-1.5 py-0.5 text-xs',
|
||||
row.deltaMinutes === 0
|
||||
? 'text-ink-2'
|
||||
: row.deltaMinutes > 0
|
||||
? 'bg-warn-soft text-warn-soft-ink'
|
||||
: 'bg-danger-soft text-danger-soft-ink',
|
||||
)}
|
||||
>
|
||||
{formatDelta(row.deltaMinutes)}
|
||||
</span>
|
||||
<Badge tone={row.allValidated ? 'ok' : 'neutral'}>
|
||||
{row.allValidated ? 'Validé' : 'À valider'}
|
||||
</Badge>
|
||||
<span className="ml-auto text-xs text-ink-2">
|
||||
payé <span className="tnum font-semibold">
|
||||
{formatMinutes(row.payableMinutes)}
|
||||
</span>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<caption className="sr-only">
|
||||
Heures de {row.name}, {report.label}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-line-1 text-left text-micro text-ink-3 uppercase">
|
||||
<th className="px-4 py-1.5 font-semibold">Jour</th>
|
||||
<th className="px-2 py-1.5 font-semibold">Prévu</th>
|
||||
<th className="px-2 py-1.5 font-semibold">Réalisé</th>
|
||||
<th className="px-2 py-1.5 text-right font-semibold">Écart</th>
|
||||
<th className="px-2 py-1.5 font-semibold">Saisie</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{row.shifts.map((shift) => (
|
||||
<tr
|
||||
key={shift.id}
|
||||
className="border-b border-line-1 last:border-b-0"
|
||||
>
|
||||
<td className="tnum px-4 py-1.5 text-ink-2">
|
||||
{shift.localDate.split('-').reverse().join('/')}
|
||||
</td>
|
||||
<td className="tnum px-2 py-1.5 text-ink-2">
|
||||
{shift.plannedRange}{' '}
|
||||
<span className="text-ink-3">
|
||||
({formatMinutes(shift.plannedMinutes)})
|
||||
</span>
|
||||
</td>
|
||||
<td className="tnum px-2 py-1.5 text-ink-1">
|
||||
{shift.hasActual ? (
|
||||
<>
|
||||
{shift.actualRange}{' '}
|
||||
<span className="text-ink-3">
|
||||
({formatMinutes(shift.actualMinutes)})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-ink-3">
|
||||
prévu retenu ({formatMinutes(shift.actualMinutes)})
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={cx(
|
||||
'tnum px-2 py-1.5 text-right',
|
||||
shift.deltaMinutes === 0 ? 'text-ink-3' : 'text-ink-1',
|
||||
)}
|
||||
>
|
||||
{shift.deltaMinutes === 0 ? '—' : formatDelta(shift.deltaMinutes)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
{report.canEdit ? (
|
||||
<ActualHoursForm shift={shift} />
|
||||
) : (
|
||||
<span className="text-micro text-ink-3">
|
||||
Lecture seule
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{report.canValidate ? (
|
||||
<div className="border-t border-line-1 px-4 py-2">
|
||||
<ValidateRow
|
||||
shiftIds={row.shifts
|
||||
.filter((shift) => !shift.locked)
|
||||
.map((shift) => shift.id)}
|
||||
allValidated={row.allValidated}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user