Files
planflow/src/components/shell/navigation.ts
T
Claude 2e9a4c5efe 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
2026-08-08 12:25:02 +00:00

146 lines
4.8 KiB
TypeScript

/**
* Modèle de navigation, repris de l'export Claude Design.
*
* Deux niveaux : une section dans l'en-tête, ses entrées dans la barre
* latérale. Les badges portent un décompte à traiter.
*
* Les entrées sans `href` correspondent à des écrans du périmètre qui n'ont pas
* encore d'implémentation : elles restent visibles pour que la navigation
* reflète le produit réel, et mènent à un écran d'attente explicite plutôt qu'à
* une page blanche.
*/
export interface NavItem {
id: string;
label: string;
href?: string;
badge?: string;
}
export interface NavSection {
id: string;
label: string;
items: NavItem[];
}
export const NAVIGATION: NavSection[] = [
{
id: 'apercu',
label: 'Aperçu',
items: [
{ id: 'taches', label: 'Tâches RH', href: '/' },
{ id: 'entrees', label: 'Entrées et sorties' },
{ id: 'profils', label: 'Profils incomplets', badge: '6' },
{ id: 'soldes-cp', label: 'Compteurs de congés payés' },
],
},
{
id: 'plannings',
label: 'Plannings',
items: [
{ id: 'semaine', label: 'Vue semaine', href: '/planning/semaine' },
{ id: 'jour', label: 'Vue jour', href: '/planning/jour' },
{ id: 'etiquettes', label: 'Vue par poste', href: '/planning/etiquettes' },
{ id: 'mois', label: 'Vue mois', href: '/planning/mois' },
{ id: 'besoins', label: 'Besoins non couverts', badge: '5' },
{ id: 'modeles', label: "Modèles d'ouverture" },
],
},
{
id: 'equipe',
label: 'Équipe',
items: [
{ id: 'membres', label: 'Membres', href: '/equipe' },
{ id: 'contrats', label: 'Modifications de contrat' },
],
},
{
id: 'conges',
label: 'Congés',
items: [
{ id: 'calendrier', label: 'Calendrier des absences', href: '/conges' },
{ id: 'attente', label: 'Demandes en attente', href: '/conges#attente', badge: '3' },
{ id: 'politiques', label: 'Politiques de congés' },
],
},
{
id: 'rapports',
label: 'Rapports',
items: [
{ 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é" },
],
},
{
id: 'reglages',
label: 'Réglages',
items: [
{ id: 'sites', label: 'Établissements', href: '/reglages/etablissements' },
{ id: 'registre', label: 'Registre de paramétrage', href: '/reglages/registre' },
{ id: 'convention', label: 'Convention collective' },
{ id: 'postes', label: 'Postes et étiquettes' },
{ id: 'roles', label: 'Rôles et permissions' },
],
},
];
/** Section contenant la route donnée, ou la première par défaut. */
export function sectionForPath(pathname: string): NavSection {
const active = activeItem(pathname);
const match = active
? NAVIGATION.find((section) =>
section.items.some((item) => item.id === active.id),
)
: undefined;
return match ?? (NAVIGATION[0] as NavSection);
}
/** Chemin vers l'écran d'attente d'une entrée non encore construite. */
export function placeholderHref(label: string): string {
return `/bientot?ecran=${encodeURIComponent(label)}`;
}
/**
* Destination d'un onglet de section.
*
* Une section dont aucun écran n'est construit ne doit pas renvoyer à
* l'accueil : le clic donnerait l'impression que l'onglet est cassé. Elle mène
* à l'écran d'attente de sa première entrée, qui dit ce qui viendra là.
*/
export function sectionHref(section: NavSection): string {
const built = section.items.find((item) => item.href);
if (built?.href) return built.href;
const first = section.items[0];
return first ? placeholderHref(first.label) : '/';
}
export function matches(href: string, pathname: string): boolean {
// Un lien d'ancre pointe à l'intérieur d'une page déjà représentée par une
// autre entrée : il ne concourt pas.
if (href.includes('#')) return false;
if (href === '/') return pathname === '/';
return pathname === href || pathname.startsWith(`${href}/`);
}
/**
* Entrée à mettre en évidence pour une route donnée.
*
* `/equipe` et `/equipe/camille-ferrand` correspondent tous deux quand on est
* sur une fiche. On retient la **plus spécifique** : deux entrées allumées à la
* fois, et le repère de position ne repère plus rien.
*/
export function activeItem(pathname: string): NavItem | undefined {
return NAVIGATION.flatMap((section) => section.items)
.filter((item) => item.href && matches(item.href, pathname))
.sort((a, b) => (b.href as string).length - (a.href as string).length)[0];
}
export function isActive(href: string, pathname: string): boolean {
const active = activeItem(pathname);
return active?.href === href;
}