Le format n'est plus déduit d'une documentation. Il est vérifié octet par octet sur un export du dossier (juillet 2026, 55 lignes) : un contrôle d'aller-retour a reproduit ce fichier **sans aucune ligne divergente**. Ce que le fichier réel a corrigé dans la spécification - L'export est en **ASCII pur**, pas en UTF-8 : aucun accent, y compris dans les libellés (« Heures travaillees », « Date debut »). Le sérialiseur translittère, pour qu'un salarié nommé « Rémi » n'introduise pas le premier octet non-ASCII du fichier. - Fins de ligne **CRLF**, la dernière comprise ; ni guillemet ni point-virgule final ; décimale point. - Heures avec au moins une décimale et au plus deux — `96.0`, `52.5`, `69.67` ; jours en entier nu — `14`, `22`. L'arrondi se fait **par ligne**, au centième : recomposer un total depuis les lignes peut donc s'écarter de quelques centièmes. C'est le comportement de l'export existant, et le reproduire est délibéré. - Un salarié sous contrat sans aucun créneau planifié figure quand même, avec sa durée contractuelle entière en heures manquantes. Ce que le fichier n'a pas dit Les codes sont maintenant connus — `AB-100`, `AB-200`, `AB-300`, `AB-630`, `EV-HDimanche`, `EV-HFerie`, `HS-HS25` — mais **savoir qu'un code existe ne dit pas ce qu'il désigne**. `EV-HDimanche` se lit ; `AB-300` non. Les premiers sont proposés, les seconds restent vides, et rien n'est confirmé d'office : l'export refuse de tourner tant que le gestionnaire de paie n'a pas validé chaque correspondance. C'est le signal d'arrêt de PLAN.md §8.2, maintenu. Refus plutôt que fichier partiel Un CSV incomplet se charge sans erreur dans Silae et rend la paie fausse pour les salariés qui en sont absents — l'échec est silencieux jusqu'au bulletin. L'export liste donc les manques et ne produit rien. Données personnelles L'export de référence porte les heures et les absences de salariés identifiables. Il **n'est pas versionné**, ni comme fixture ni comme donnée de démonstration. Ce sont les règles de forme qui sont figées dans les tests, avec les valeurs observées mais sans les matricules ni les volumes réels. Le fichier produit revient dans la réponse de l'action plutôt que par une URL : un fichier de paie ne doit pas rester adressable, mis en cache ou présent dans un historique de navigation. En base, seule l'empreinte est conservée — elle suffit à prouver qu'un réexport est identique. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
145 lines
4.7 KiB
TypeScript
145 lines
4.7 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' },
|
|
{ id: 'paie', label: 'Préparation de paie', href: '/paie' },
|
|
{ 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;
|
|
}
|