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,135 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
saveActualHoursAction,
|
||||
validateHoursAction,
|
||||
type HoursActionState,
|
||||
} from '@/server/hours/actions';
|
||||
import type { HoursShiftRow } from '@/server/hours/queries';
|
||||
|
||||
const empty: HoursActionState = {};
|
||||
|
||||
/**
|
||||
* Saisie des heures réellement faites.
|
||||
*
|
||||
* Les champs sont **pré-remplis avec le prévu** quand rien n'a été saisi : le
|
||||
* cas courant est « comme prévu, sauf que », et retaper deux horaires pour
|
||||
* corriger un quart d'heure décourage la saisie — donc fausse le réalisé.
|
||||
*/
|
||||
export function ActualHoursForm({ shift }: { shift: HoursShiftRow }) {
|
||||
const [state, formAction, pending] = useActionState(
|
||||
saveActualHoursAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
const [plannedStart, plannedEnd] = shift.plannedRange.split('–');
|
||||
const [actualStart, actualEnd] = (shift.actualRange ?? '').split('–');
|
||||
|
||||
if (shift.locked) {
|
||||
return (
|
||||
<span className="text-micro text-ink-3">
|
||||
Période verrouillée — saisie fermée
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-wrap items-center gap-1.5">
|
||||
<input type="hidden" name="shiftId" value={shift.id} />
|
||||
|
||||
<label className="sr-only" htmlFor={`start-${shift.id}`}>
|
||||
Début réel
|
||||
</label>
|
||||
<input
|
||||
id={`start-${shift.id}`}
|
||||
name="start"
|
||||
type="time"
|
||||
defaultValue={actualStart || plannedStart}
|
||||
className="h-7 rounded-2 border border-line-2 bg-surface px-1.5 text-xs text-ink-1"
|
||||
/>
|
||||
|
||||
<label className="sr-only" htmlFor={`end-${shift.id}`}>
|
||||
Fin réelle
|
||||
</label>
|
||||
<input
|
||||
id={`end-${shift.id}`}
|
||||
name="end"
|
||||
type="time"
|
||||
defaultValue={actualEnd || plannedEnd}
|
||||
className="h-7 rounded-2 border border-line-2 bg-surface px-1.5 text-xs text-ink-1"
|
||||
/>
|
||||
|
||||
{/* Le motif n'apparaît qu'en correction : une première saisie ne corrige
|
||||
rien, et demander un motif à chaque ligne le ferait remplir
|
||||
machinalement. */}
|
||||
{shift.hasActual ? (
|
||||
<>
|
||||
<label className="sr-only" htmlFor={`reason-${shift.id}`}>
|
||||
Motif de la correction
|
||||
</label>
|
||||
<input
|
||||
id={`reason-${shift.id}`}
|
||||
name="reason"
|
||||
type="text"
|
||||
maxLength={500}
|
||||
placeholder="Motif"
|
||||
className="h-7 w-32 rounded-2 border border-warn bg-surface px-1.5 text-xs text-ink-1"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" size="sm" disabled={pending}>
|
||||
{shift.hasActual ? 'Corriger' : 'Saisir'}
|
||||
</Button>
|
||||
|
||||
{state.error ? (
|
||||
<span role="alert" className="text-micro text-danger">
|
||||
{state.error}
|
||||
</span>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation groupée d'un salarié.
|
||||
*
|
||||
* Le libellé dit ce que fait l'action : elle **qualifie** des heures, elle ne
|
||||
* les autorise pas à être payées. Elles le sont déjà.
|
||||
*/
|
||||
export function ValidateRow({
|
||||
shiftIds,
|
||||
allValidated,
|
||||
}: {
|
||||
shiftIds: string[];
|
||||
allValidated: boolean;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(
|
||||
validateHoursAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex items-center gap-2">
|
||||
{shiftIds.map((id) => (
|
||||
<input key={id} type="hidden" name="shiftIds" value={id} />
|
||||
))}
|
||||
<input
|
||||
type="hidden"
|
||||
name="validate"
|
||||
value={allValidated ? 'false' : 'true'}
|
||||
/>
|
||||
{state.error ? (
|
||||
<span role="alert" className="text-micro text-danger">
|
||||
{state.error}
|
||||
</span>
|
||||
) : null}
|
||||
<Button type="submit" size="sm" disabled={pending}>
|
||||
{allValidated ? 'Dévalider' : 'Valider les heures'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
createPeriodAction,
|
||||
deletePeriodAction,
|
||||
lockPeriodAction,
|
||||
unlockPeriodAction,
|
||||
type PeriodActionState,
|
||||
} from '@/server/payroll/period-actions';
|
||||
|
||||
const empty: PeriodActionState = {};
|
||||
|
||||
export function CreatePeriodForm({
|
||||
locationId,
|
||||
suggestedMonth,
|
||||
}: {
|
||||
locationId: string;
|
||||
suggestedMonth: string;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(
|
||||
createPeriodAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
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="locationId" value={locationId} />
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
|
||||
Mois
|
||||
</span>
|
||||
<input
|
||||
name="month"
|
||||
type="month"
|
||||
defaultValue={suggestedMonth}
|
||||
required
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
|
||||
Libellé
|
||||
</span>
|
||||
<input
|
||||
name="label"
|
||||
type="text"
|
||||
maxLength={80}
|
||||
placeholder="Facultatif"
|
||||
className="h-8 w-48 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Button type="submit" variant="primary" size="sm" disabled={pending}>
|
||||
Créer la période
|
||||
</Button>
|
||||
|
||||
{state.error ? (
|
||||
<p role="alert" className="w-full text-xs text-danger">
|
||||
{state.error}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verrouillage d'une période.
|
||||
*
|
||||
* Le verrou fige les instantanés **et** ferme la période aux mutations : plus
|
||||
* aucun créneau ni congé ne peut être posé sur ces dates tant qu'elle n'est pas
|
||||
* rouverte.
|
||||
*/
|
||||
export function LockButton({
|
||||
periodId,
|
||||
version,
|
||||
}: {
|
||||
periodId: string;
|
||||
version: number;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(lockPeriodAction, empty);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-wrap items-center gap-2">
|
||||
<input type="hidden" name="periodId" value={periodId} />
|
||||
<input type="hidden" name="expectedVersion" value={version} />
|
||||
{state.error ? (
|
||||
<span role="alert" className="text-xs text-danger">
|
||||
{state.error}
|
||||
</span>
|
||||
) : null}
|
||||
{state.message ? (
|
||||
<span className="text-xs text-ok-soft-ink">{state.message}</span>
|
||||
) : null}
|
||||
<Button type="submit" size="sm" variant="primary" disabled={pending}>
|
||||
Verrouiller la période
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Déverrouillage.
|
||||
*
|
||||
* La justification est exigée à la saisie, pas seulement côté serveur : rouvrir
|
||||
* une période périme les fichiers déjà transmis au cabinet, et six mois plus
|
||||
* tard personne ne saura pourquoi.
|
||||
*/
|
||||
export function UnlockForm({
|
||||
periodId,
|
||||
version,
|
||||
}: {
|
||||
periodId: string;
|
||||
version: number;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(
|
||||
unlockPeriodAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-wrap items-center gap-2">
|
||||
<input type="hidden" name="periodId" value={periodId} />
|
||||
<input type="hidden" name="expectedVersion" value={version} />
|
||||
<label className="flex items-center gap-1.5">
|
||||
<span className="sr-only">Motif du déverrouillage</span>
|
||||
<input
|
||||
name="reason"
|
||||
type="text"
|
||||
required
|
||||
maxLength={500}
|
||||
placeholder="Motif du déverrouillage"
|
||||
className="h-8 w-56 rounded-2 border border-warn bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</label>
|
||||
{state.error ? (
|
||||
<span role="alert" className="text-xs text-danger">
|
||||
{state.error}
|
||||
</span>
|
||||
) : null}
|
||||
{state.message ? (
|
||||
<span className="text-xs text-warn-soft-ink">{state.message}</span>
|
||||
) : null}
|
||||
<Button type="submit" size="sm" disabled={pending}>
|
||||
Déverrouiller
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppression d'une période.
|
||||
*
|
||||
* La confirmation demande de **retaper le libellé** : supprimer une période
|
||||
* verrouillée efface les instantanés sur lesquels un export a pu être bâti, et
|
||||
* un simple « êtes-vous sûr » se clique sans lire.
|
||||
*/
|
||||
export function DeletePeriodForm({
|
||||
periodId,
|
||||
label,
|
||||
}: {
|
||||
periodId: string;
|
||||
label: string;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(
|
||||
deletePeriodAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-wrap items-center gap-2">
|
||||
<input type="hidden" name="periodId" value={periodId} />
|
||||
<label className="flex items-center gap-1.5">
|
||||
<span className="sr-only">Confirmer en saisissant « {label} »</span>
|
||||
<input
|
||||
name="confirm"
|
||||
type="text"
|
||||
placeholder={`Saisir « ${label} »`}
|
||||
className="h-8 w-44 rounded-2 border border-line-2 bg-surface px-2 text-xs text-ink-1"
|
||||
/>
|
||||
</label>
|
||||
{state.error ? (
|
||||
<span role="alert" className="text-xs text-danger">
|
||||
{state.error}
|
||||
</span>
|
||||
) : null}
|
||||
<Button type="submit" size="sm" variant="danger" disabled={pending}>
|
||||
Supprimer
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -67,8 +67,9 @@ export const NAVIGATION: NavSection[] = [
|
||||
id: 'rapports',
|
||||
label: 'Rapports',
|
||||
items: [
|
||||
{ id: 'heures', label: 'Heures travaillées' },
|
||||
{ id: 'heures', label: 'Heures travaillées', href: '/rapports/heures' },
|
||||
{ id: 'paie', label: 'Préparation de paie', href: '/paie' },
|
||||
{ id: 'periodes', label: 'Périodes de paie', href: '/paie/periodes' },
|
||||
{ id: 'silae', label: 'Codes Silae', href: '/paie/silae' },
|
||||
{ id: 'activite', label: "Journal d'activité" },
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user