Un créneau portait un total de pause. Deux pauses de vingt minutes et une
coupure de deux heures s'écrivaient toutes deux « 120 », et rien ne permettait
de les distinguer — ni à l'écran, ni au contrôle. Or elles ne se planifient pas
de la même façon : la première satisfait la pause minimale au-delà de six
heures, la seconde interroge l'amplitude de la journée.
Chaque pause a désormais sa ligne : durée, début facultatif, libellé, et si elle
est rémunérée. Le début reste facultatif à dessein — beaucoup de pauses se
prennent « quand c'est calme », et imposer une heure inventerait une précision
que le planning n'a pas et qu'un contrôle prendrait pour un engagement.
Le créneau garde deux totaux dérivés, et la distinction porte de l'argent.
`breakMinutes` reste ce qu'il était, la part non rémunérée déduite du temps de
travail : la paie, les compteurs et l'export continuent de le lire sans changer
d'un caractère. `paidBreakMinutes` s'ajoute à côté, et la règle de pause
minimale regarde la somme des deux — sans quoi un créneau dont la pause de vingt
minutes est payée passerait pour un créneau sans pause, et l'employeur le plus
généreux serait le seul à recevoir une alerte.
La migration reprend les créneaux existants : leur total devient une pause
unique, non rémunérée. Laisser la table vide aurait fait disparaître de l'écran
des pauses qui comptent pourtant toujours dans les heures payées.
En modification, les pauses sont remplacées et non fusionnées : le formulaire
porte l'état voulu au complet, et rapprocher ligne à ligne ferait survivre une
pause qu'on vient de retirer.
L'avis d'affectation part enfin, sous sa propre nature d'envoi. Il est différé à
la fin de la transaction : un refus sur le dernier jour d'une répétition annule
les autres, et personne ne doit être prévenu d'un créneau qui n'existe pas. La
case est décochée par défaut, à l'inverse du produit audité — c'est la
publication de la semaine qui prévient, et cette case sert au créneau ajouté
après coup, le cas où le salarié ne verrait rien sans elle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le panneau créait un créneau, pour un jour. Or un même horaire se répète d'un
jour à l'autre bien plus souvent qu'il ne varie : ressaisir cinq fois
« 9 h–17 h, pause 30 » était le geste le plus répété de la semaine, et le plus
exposé à la faute de frappe.
Les sept jours sont désormais cochables. Le jour ouvert est coché et verrouillé
— on ne crée pas ailleurs qu'ici depuis la case d'un jour donné — et voyage dans
un champ caché, puisqu'une case désactivée n'est pas envoyée.
La création est **tout ou rien**. Un jour refusé, pour période close ou
chevauchement, annule les autres : une répétition à demi appliquée laisserait un
planning que personne n'a voulu, et qu'il faudrait défaire à la main pour le
refaire. Le contrôle de période porte donc sur tous les jours demandés avant la
première écriture.
Trois champs du modèle étaient inatteignables depuis l'écran : le nombre de
repas, la note du créneau, et l'étiquette en modification. Cette dernière était
un vrai défaut — une étiquette posée de travers se corrigeait en supprimant le
créneau pour le refaire, ce qui perdait sa validation et son historique.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cinq types d'amorce ne tiennent pas un registre. Un commerce de détail
rencontre un accident de trajet, une mise à pied, un congé pour examen, une
visite médicale — et faute de type, ces absences se saisissent en « congé sans
solde », ce qui fausse à la fois le décompte et la paie.
Vingt-quatre types désormais, relevés sur le sélecteur du produit audité et
recoupés avec les rubriques du dossier de paie. Les drapeaux sont des valeurs
d'amorce, modifiables depuis les réglages : la nature Sécurité sociale ne se
discute pas, mais l'acquisition de congés pendant une absence dépend de la
convention.
« Repos hebdomadaire » figure au sélecteur audité et pas ici. C'est un repos
légal, porté par le modèle `Rest`, pas une absence qui se demande — en faire un
type d'absence laisserait un manager refuser une obligation.
Les familles de couleur passent de cinq à neuf, et deviennent une liste fermée
avec ses libellés. Le champ des réglages était libre : une clé fautive s'y
enregistrait sans bruit et la grille affichait un gris inexplicable. Quatre
familles s'ajoutent — famille, formation, jour férié, sanction. Une sanction se
distingue d'une absence ordinaire : elle est subie, elle se conteste, elle ne se
lit pas comme un congé.
La couleur groupe, le libellé précise. Donner sa teinte à chacun des
vingt-quatre types rendrait la grille illisible bien avant la fin de la liste.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Une instance neuve passait par le seed pour être utilisable. Or le seed installe
une fiction : « Maison Rivage », des salariés inventés, quatre semaines de
planning, des absences. Sur une instance de travail, ces noms se confondent avec
de vrais salariés dans l'annuaire et au registre du personnel.
Le trou qu'il bouchait était réel : après les migrations, une instance avait le
schéma et rien à quoi l'accrocher. Aucun type d'absence, donc aucune demande
saisissable. Aucune étiquette, donc aucun créneau nommé. Aucune convention, donc
un moteur de règles muet qui laisse passer une semaine de soixante heures sans
rien dire.
L'écran d'installation pose donc désormais les référentiels : les douze
étiquettes, cinq types d'absence, la convention d'amorce IDCC 1517 avec
l'origine de chacun de ses paramètres, les durées de conservation et les jours
fériés des deux prochaines années. Ce ne sont pas des exemples mais des minima,
tous modifiables ensuite depuis les réglages.
Trois choses restent délibérément absentes. Aucun dimanche du maire : la liste
vient d'un arrêté municipal, et en inventer rendrait opposable un quota que
personne n'a accordé. Aucun code Silae sur les types d'absence : la
correspondance appartient au dossier du client. Aucun salarié, aucun créneau,
aucune absence.
Le seed devient `prisma/seed-demo.ts`, réservé au harnais Playwright. `db:seed`
disparaît au profit de `db:seed:demo` : il n'y a plus rien à semer pour
démarrer, et un nom qui le dit vaut mieux qu'un commentaire qui l'explique.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deux défauts la condamnaient. Elle rognait la largeur utile au moment précis où
la grille de planning en manque le plus. Et elle donnait à la navigation deux
grammaires : des onglets sur la fiche salarié, une liste verticale partout
ailleurs. Une grammaire se retient, deux se cherchent.
Les entrées de la section courante s'affichent donc en onglets, dans la même
forme que la fiche salarié. Une entrée sans écran construit reste visible et
mène à l'écran d'attente : la masquer ferait croire le produit plus étroit que
ce qu'il vise, la laisser inerte ferait croire la navigation cassée.
La colonne centrale passe en largeur fixe. Une largeur qui suit la fenêtre fait
bouger les colonnes du planning d'un poste à l'autre : la même semaine ne se lit
pas au même endroit sur deux écrans, et l'œil doit se réorienter à chaque
ouverture. Une largeur arrêtée rend la page reconnaissable ; sur une fenêtre
étroite, elle défile horizontalement plutôt que de comprimer la grille.
À l'impression, cette largeur n'a plus de sens et déborderait de la feuille :
elle y est relâchée, comme les autres largeurs minimales de la grille.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dix lots : référentiels, absences par état, vues de planning manquantes,
journal d'activité, analyses RH, modèles de documents et RGPD, politiques de
RTT, codes Silae du dossier, compte et préférences, correction du bandeau de
compteurs.
Trois migrations à appliquer avant démarrage : modèles de documents,
préférences de compte, impression et productivité.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le bandeau de la grille affichait contrat, planifié, écart, dimanches et jours
de repos. PLAN.md §7.4 en demande cinq autres : heures contractuelles,
planifié, absences, écart, repos compensateur. La capture du produit audité le
confirme.
L'écart n'était pas cosmétique. Les dimanches travaillés et les jours de repos
se lisent déjà sur la grille, colonne par colonne. Le repos compensateur, lui,
ne se lit nulle part ailleurs : c'est une contrepartie due — au dimanche
travaillé, au dépassement de contingent — et l'omettre du bandeau masquait la
seule valeur qui dit qu'une dette existe.
Le compteur RC n'était alimenté par rien : les repos posés n'étaient tout
simplement pas chargés avec la semaine. Ils le sont, et seul le repos
compensateur y entre — le repos hebdomadaire est un droit déjà pris, les
additionner ferait disparaître la dette dans un total qui ne veut rien dire.
Deux écrans de réglages complètent le lot. L'impression gouverne le planning
affiché en salle : orientation, densité, totaux, dimanche, colonne
d'émargement. Cette dernière porte un avertissement — un émargement papier ne
vaut pas décompte du temps de travail, et le décompte reste celui de PlanFlow.
L'objectif de productivité est nullable et non « zéro par défaut » : zéro est un
objectif inatteignable, l'absence de valeur dit qu'aucun objectif n'a été fixé.
Vider le champ efface donc l'objectif au lieu de le mettre à zéro. L'écran
rappelle aussi ce que l'indicateur n'est pas : il éclaire une décision
d'organisation, il ne mesure pas le travail d'une personne.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Quatre écrans manquaient faute de spécification. Les captures du produit audité
les fournissent : identité de l'entreprise, comportement des plannings, droits
ouverts aux salariés, décompte de paie.
Les préférences sont trois blocs typés, pas un sac de clés-valeurs. Ce n'est pas
de la rigueur gratuite : `smoothOvertimeMonthly` change le calcul des heures
supplémentaires, et un JSON libre rendrait indétectable une clé mal
orthographiée — le réglage paraîtrait actif sans l'être.
Chaque formulaire déclare les clés qu'il gouverne. Une case décochée n'arrive
pas dans le FormData ; sans cette déclaration, enregistrer « Plannings »
remettrait à faux tout « Droits », un formulaire partiel effaçant ce qu'il
n'affiche pas.
Trois réglages portent un avertissement en évidence parce qu'ils déplacent des
heures et non des pixels. Le lissage mensuel des heures supplémentaires suppose
un accord d'aménagement du temps de travail : le décompte hebdomadaire est le
principe, et sans accord l'aménagement est inopposable. Les pauses rémunérées et
l'inclusion des repos dans les heures normales changent la répartition entre
heures normales et majorées. Un interrupteur qui coûte de l'argent ne doit pas
se présenter comme un réglage d'affichage.
La ligne de préférences est créée à la demande : son absence vaut « tous les
défauts ». Semer une ligne à l'installation obligerait à migrer chaque compte au
premier réglage ajouté.
L'adresse va sur le compte et non sur l'établissement : le siège porte
l'identité juridique et figure au registre du personnel, là où l'établissement
porte le SIRET, le fuseau effectif et les plannings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le point d'arrêt de PLAN.md §0 est levé : les codes viennent du paramétrage
Silae du compte, relevé le 11 août 2026, et non d'une documentation publique —
il n'en existe pas. Silae impose que le fichier d'import soit le miroir du
dossier, si bien qu'un code n'a de sens que pour le client qui le porte.
Ils vivent donc dans le seed, jamais dans src/domain/payroll. Écrire HS-HS50
dans un calcul rendrait l'outil inutilisable au deuxième dossier et
incorrigible sans livraison le jour où le cabinet renumérote.
Trois éléments restés ouverts à la conception sont résolus : heures
supplémentaires à 50 % (HS-HS50), complémentaires à 10 % (HS-HC10) et à 25 %
(HS-HC25). Les cinq types d'absence semés reçoivent le leur.
Semés confirmés, contrairement aux propositions déduites d'un libellé : ceux-ci
sont relevés dans la configuration active, pas devinés.
Deux manques sont signalés au lieu d'être comblés. Le forfait jours n'a aucune
rubrique au dossier — l'export échouera au premier cadre autonome, ce qui vaut
mieux qu'un code inventé. Et quatre rubriques du dossier portent un préfixe
« AB- » sans numéro (évènement familial, repos compensateur de nuit, repos
compensateur d'habillement, visite médicale) : reprendre un code tronqué ferait
échouer l'import, ou pire, le ferait réussir en imputant à la mauvaise rubrique.
Le catalogue complet du dossier est conservé, y compris les rubriques hors
périmètre actuel. Le jour où les heures de nuit ou les paniers repas entrent au
calcul, le code est déjà là et n'aura pas à être redemandé au cabinet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le modèle existait depuis WP-06, l'écran d'embauche proposait déjà de rattacher
un salarié à une politique — mais rien ne permettait d'en créer une. La liste
déroulante était vide sur une instance neuve, et le droit à RTT restait
inatteignable.
Les deux actions de ligne sont celles relevées à l'audit : assigner des employés,
archiver. Pas de suppression. Une politique a produit des acquisitions au
registre des compteurs ; l'effacer rendrait ces écritures inexplicables, et un
solde de RTT est un droit ouvert, pas un affichage.
Archiver arrête la reconduction sans toucher au reste : les jours déjà acquis
restent au registre, les affectations en place. C'est la distinction que demande
le critère d'acceptation de WP-06 — une politique archivée cesse de reconduire,
elle ne défait pas ce qu'elle a fait.
Le début de période s'écrit « MM-JJ », sans millésime : une politique qui
porterait une année complète devrait être réécrite chaque janvier.
L'affectation passe par un upsert sur la clé composée : réassigner un salarié
déjà couvert ne doit ni échouer ni créer un doublon d'acquisition.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deux livrables de WP-10 manquaient, et le second n'existait nulle part.
Les modèles de documents produisent attestations et courriers types en
résolvant les variables du dossier. Le rendu vit dans le domaine, avec une règle
qui n'est pas négociable : une variable citée sans valeur fait échouer la
génération. Une attestation trouée est un document faux, pas un document
incomplet — le blanc se remarque à la relecture une fois sur deux, et la pièce
part signée. De même, une variable inconnue est refusée à l'enregistrement du
modèle, par l'administrateur qui l'écrit, plutôt que découverte par le
gestionnaire devant le salarié qui attend.
Le corps reste de l'HTML rédigé par un administrateur ; ce sont les valeurs
venues du dossier qui sont échappées à l'insertion. Un nom de famille importé
d'un autre outil ne doit pas pouvoir ouvrir une balise.
`Document.templateId` est en ON DELETE SET NULL : une pièce déjà remise ne
disparaît pas parce qu'on a effacé le modèle qui l'a produite.
L'écran RGPD livre le dictionnaire donnée → finalité → base légale →
destinataire → durée qu'exige la matrice n° 14. Le dictionnaire vit dans le code
parce qu'il décrit le schéma ; les durées viennent de RetentionPolicy parce
qu'elles se paramètrent et s'auditent. Une catégorie sans durée déclarée est
signalée en rouge : le plan interdit d'appliquer une durée par défaut.
L'affectation des bases légales porte un avertissement explicite. C'est une
qualification juridique, pas un réglage : elle doit être confirmée par le
responsable de traitement avant d'être opposée à quiconque.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le tableau de bord montrait un mois. Un effectif, un taux de rotation ou un
absentéisme lus sur un seul mois ne disent rien : c'est la pente qui informe, et
elle demande une série.
Trois analyses sur douze mois glissants — effectifs, heures, absences — servies
par une seule requête. C'est ce qui garantit que l'effectif moyen affiché en
« effectifs » soit exactement le dénominateur du taux d'absentéisme affiché en
« absences ». Trois calculs séparés finiraient par diverger d'un arrondi, et
l'écart se plaide mal devant un CSE.
Les mois se découpent en mémoire après une lecture par nature, pas en douze
requêtes par indicateur : sur une année glissante, la seconde approche rend
l'écran inutilisable.
Le graphique est en HTML. Douze barres ne justifient pas 90 ko de dépendance, et
la CSP interdit de toute façon toute origine tierce. La hauteur ne porte jamais
l'information seule — chaque barre affiche sa valeur, et le tableau reste la
source. Une valeur non calculable s'y dessine en gris plutôt qu'à zéro : « 0 %
de rotation » sur un établissement vide est une affirmation fausse, pas une
absence de mouvement.
Le mois de chaque ligne mène à ses lignes sources. Un indicateur qu'on ne peut
pas ouvrir ne se corrige pas, il se conteste.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
La piste d'audit était écrite à chaque mutation depuis WP-01, et lisible nulle
part. Une piste que personne ne peut consulter ne prouve rien le jour où on le
lui demande.
L'écran ne porte aucune action : le journal est append-only, imposé par un
trigger, et cette page ne fait que le lire. La capacité `audit.view` est
distincte de l'administration — on confie la lecture de la piste à un contrôleur
sans lui donner le droit de modifier ce qu'elle enregistre.
Seuls les noms des champs modifiés s'affichent, pas leurs valeurs. Le journal
enregistre bien l'avant et l'après en clair, c'est ce qui le rend opposable ;
mais les étaler dans une liste exposerait un salaire ou un motif médical à
quiconque ouvre l'écran. La liste dit ce qui a changé, la fiche dit quoi, sous
ses propres autorisations.
Les listes de filtres se déduisent du journal lui-même plutôt que d'une
énumération tenue à la main : une action ajoutée par un lot futur y apparaît
seule, et une énumération figée finirait par masquer ce qu'elle omet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deux des cinq vues de §9 manquaient.
La vue des présences répond à une question que les quatre autres posent mal :
qui est là aujourd'hui. La grille montre des créneaux, pas des personnes ; il
fallait la lire colonne par colonne et compter de tête.
Le planning à afficher est une mise en page pour la feuille punaisée en salle de
pause — toutes les équipes à la suite, sans panneau d'édition ni compteurs, un
aplat par créneau avec l'horaire et le code du poste écrits. Sans la capacité de
voir le non publié, la feuille ne la contourne pas : imprimer un brouillon
revient à le publier sur un mur.
La règle « absent ou présent » part dans le domaine plutôt que dans les écrans.
Les deux vues en tirent la même conclusion, et un export futur en tirera la
même : deux implémentations finiraient par se contredire sur le seul cas qui
compte, celui du congé validé après la construction de la semaine. Les créneaux
restent alors au planning, et l'absence l'emporte — afficher « présent »
enverrait un responsable chercher en rayon quelqu'un qui est chez lui.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le calendrier portait tout : les demandes en attente, les décisions rendues et
celles qui avaient expiré, distinguées par un simple badge. Une file d'attente
se lit mal dans un calendrier — elle se lit par ancienneté, et le calendrier
range par date d'absence.
Quatre routes désormais : le calendrier, la file à traiter, les traitées et les
expirées. Le calendrier a déménagé sous /absences plutôt que d'être dupliqué, et
/conges redirige en conservant le mois : un lien partagé dans un message
continue d'ouvrir la bonne page.
Les trois files partagent une requête et un rendu. C'est délibéré : la règle qui
masque le motif d'une absence Sécurité sociale vaut pour les trois, et trois
copies de cette règle garantiraient qu'une finisse par diverger. Une donnée de
santé qui ne fuit que sur l'écran « traitées » reste une fuite.
La file à traiter classe par ancienneté de demande, les files closes par date
d'absence décroissante : on cherche d'un côté ce qui attend depuis trop
longtemps, de l'autre ce qui vient d'être décidé.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Emplois, étiquettes et types d'absence vivaient en base sans écran : le seed
les posait, et rien ne permettait ensuite d'en ajouter un. Ils s'éditent
désormais depuis les réglages.
Trois choix portent la conformité plutôt que le confort :
- Archiver, jamais supprimer. Un emploi figure au registre du personnel, une
étiquette colore des semaines closes, un type d'absence justifie des
écritures de compteur. Supprimer l'un des trois rendrait illisible un dossier
dont la conservation court encore.
- Le code Silae manquant est signalé. Un type sans code fait échouer l'export
de la période ; l'écran le dit avant l'échéance de paie plutôt qu'après.
- La convention se réédite, elle ne se modifie pas. Chaque enregistrement crée
une version datée : une paie de mars doit rester reproductible après un
changement de seuil en juin. L'origine de chaque valeur — ordre public,
convention, accord d'entreprise — est affichée, parce que les trois ne se
corrigent pas de la même main.
Les mutations de types d'absence passent par settings.agreement.manage : le
catalogue §5 ne déclare pas de code dédié, et ces drapeaux relèvent du
paramétrage légal. Inventer une capacité aurait créé un droit sans rien
protéger de neuf.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Créer un salarié demandait cinq champs. Le contrat, la déclaration et le
registre en réclament une vingtaine, et rien ne permettait de les saisir :
le sexe, le nom de naissance, le pays et le département de naissance, la
situation de famille, les personnes à charge, le téléphone fixe, le
complément d'adresse et l'heure d'embauche n'existaient pas en base.
La migration les ajoute, toutes facultatives : un dossier incomplet doit
pouvoir exister — c'est au registre de signaler ce qui lui manque, pas à
la base de refuser l'embauche. Seul l'envoi des plannings par SMS fait
exception : c'est un consentement, donc faux par défaut, la charge de la
preuve pesant sur l'employeur.
Le pays et le département de naissance sont des colonnes à part et non une
commune saisie librement : la déclaration sociale les demande séparément,
et les rétro-extraire échouerait au premier « Bar-le-Duc (Meuse) ».
Le formulaire devient un panneau latéral : une trentaine de champs posés
au centre masquaient l'annuaire, et on embauche en regardant qui est déjà
là. Le matricule y est proposé à la suite du dernier, attribué dans la
transaction pour que deux embauches simultanées ne tombent pas sur le même
rang. Le nom de naissance vaut le nom de famille quand il n'en diffère
pas, plutôt que d'imposer une ressaisie à l'immense majorité des dossiers.
Le responsable hiérarchique et la politique RTT se posent enfin à
l'embauche : les deux modèles existaient sans qu'aucun écran ne les
alimente.
Le registre unique du personnel gagne sa colonne « sexe » et se tient
désormais au nom de naissance — le nom d'usage peut changer sans que la
personne change.
La migration s'applique au redéploiement : l'entrypoint du conteneur passe
`prisma migrate deploy` avant de démarrer le serveur. Elle n'ajoute que des
colonnes nullables et deux types énumérés, sans réécriture de table.
Écrite et validée contre le schéma, non exécutée ici : aucune base n'était
ouverte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Créer un salarié demandait cinq champs. Le contrat, la déclaration et le
registre en réclament une vingtaine, et rien ne permettait de les saisir :
le sexe, le nom de naissance, le pays et le département de naissance, la
situation de famille, les personnes à charge, le téléphone fixe, le
complément d'adresse et l'heure d'embauche n'existaient pas en base.
La migration les ajoute, toutes facultatives : un dossier incomplet doit
pouvoir exister — c'est au registre de signaler ce qui lui manque, pas à
la base de refuser l'embauche. Seul l'envoi des plannings par SMS fait
exception : c'est un consentement, donc faux par défaut, la charge de la
preuve pesant sur l'employeur.
Le pays et le département de naissance sont des colonnes à part et non une
commune saisie librement : la déclaration sociale les demande séparément,
et les rétro-extraire échouerait au premier « Bar-le-Duc (Meuse) ».
Le formulaire devient un panneau latéral : une trentaine de champs posés
au centre masquaient l'annuaire, et on embauche en regardant qui est déjà
là. Le matricule y est proposé à la suite du dernier, attribué dans la
transaction pour que deux embauches simultanées ne tombent pas sur le même
rang. Le nom de naissance vaut le nom de famille quand il n'en diffère
pas, plutôt que d'imposer une ressaisie à l'immense majorité des dossiers.
Le responsable hiérarchique et la politique RTT se posent enfin à
l'embauche : les deux modèles existaient sans qu'aucun écran ne les
alimente.
Le registre unique du personnel gagne sa colonne « sexe » et se tient
désormais au nom de naissance — le nom d'usage peut changer sans que la
personne change.
Migration écrite et validée contre le schéma, non appliquée : aucune base
n'était ouverte ici. À passer avec `pnpm db:migrate`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le bouton menait au registre de paramétrage juridique — un autre document,
qui ne répond à aucune demande d'inspection. Le registre unique du
personnel n'existait pas.
Il se tient par établissement : celui-ci est demandé avant l'édition
plutôt que déduit, car en tirer un au hasard pour une entreprise qui
compte trente sites ne répondrait à rien.
Une ligne par contrat et non par personne : un salarié réembauché a deux
entrées et deux sorties, et les fondre effacerait l'interruption. Les
partis y figurent aussi — c'est l'historique que l'inspection vient
chercher.
Les mentions manquantes sont annoncées avant le téléchargement, et
comptées par salarié parce que la contravention l'est aussi. Découvrir un
registre incomplet en l'ouvrant, c'est le découvrir devant l'inspection.
L'édition est journalisée : le document rassemble l'identité, la
nationalité et le parcours de tout le personnel d'un site.
Une réserve, portée par le code et par le document : PlanFlow ne collecte
pas le sexe, mention pourtant exigée. La colonne reste vide et alimente le
décompte des dossiers incomplets, en attendant le champ.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
L'écran s'ouvrait sur deux paragraphes de doctrine avant de montrer quoi
que ce soit. Le formulaire de saisie demande déjà valeur, source, date
d'effet, population et approbateur : la règle se lit dans les champs, où
elle s'applique, plutôt qu'au-dessus, où elle se saute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le formulaire d'embauche, posé au bas de la liste, occupait plus de place
que l'effectif qu'on vient consulter — une quinzaine de champs déployés en
permanence pour un geste occasionnel. Il s'ouvre désormais en modale,
depuis le bouton d'en-tête.
`<dialog>` natif plutôt qu'un panneau maison : le piège de focus, la
fermeture par Échap et le fond inerte viennent avec, et une `<div>` doit
les réimplémenter sans jamais les tenir tout à fait.
Le tableau perd sa carte et ses fonds : une liste de personnes se lit
mieux sans cadre autour. Les colonnes suivent l'annuaire de référence —
collaborateur, rôle, email, mobile, rattachement, invitation. Le contrat
quitte le tableau, où il doublait le filtre qui le cherche déjà ; son
absence reste signalée là où elle compte, dans le rattachement.
Le décompte ne s'affiche plus que filtré : « 87 salariés » au-dessus de 87
lignes n'apprend rien.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
L'onglet montrait les équipes et le périmètre sans jamais permettre de les
changer : un salarié rattaché à la mauvaise équipe n'apparaissait pas sur
la bonne grille, et rien à l'écran n'y remédiait.
Deux formulaires et deux capacités, parce que ce sont deux décisions.
Élargir un périmètre revient à donner accès à des dossiers et des
plannings qu'on n'avait pas : c'est distribuer des droits, donc
`settings.roles.manage`. Rattacher à une équipe relève de l'organisation
du travail, donc `settings.teams.manage`. Les réunir donnerait à l'une le
pouvoir de l'autre.
Le périmètre est remplacé, pas fusionné : un périmètre qui ne fait que
grandir ne se réduit jamais, et retirer un établissement resterait sans
effet — l'exact contraire de ce que l'écran promet. Un périmètre vide sans
accès généralisé est refusé : il ne laisserait plus rien voir.
L'établissement de rattachement reste en lecture seule. Il est porté par
le contrat, et le changer sans avenant ferait diverger le planning du
document opposable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Créer un salarié demandait quatre champs et produisait un dossier vide :
sans contrat il n'apparaît sur aucune grille et ne se déclare pas, sans
équipe il ne s'ordonne nulle part — et jusqu'ici aucun écran ne permettait
de rattraper l'un ni l'autre.
Le contrat est proposé coché, et reste décochable : un remplaçant se
saisit parfois avant que son établissement soit tranché.
Dossier, contrat et rattachement entrent dans la même transaction. Créés
séparément, un incident au milieu laisserait exactement le dossier
incomplet que ce changement vise à supprimer.
Ouvrir un contrat reste une capacité distincte de celle de créer un
dossier : un gestionnaire d'annuaire n'engage pas l'entreprise. Le refus
le dit, et propose de créer le salarié sans contrat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six colonnes et rien pour s'y retrouver : au-delà de vingt salariés,
l'effectif se parcourait au défilement. Recherche par prénom, nom ou
matricule, et quatre filtres — établissement, rôle, type de contrat,
présence — plus le tri.
Les critères vivent dans l'adresse et non dans le composant : un effectif
filtré s'envoie par lien, et le retour arrière défait le dernier choix
plutôt que de vider la page. Ils sont appliqués en base, pas après lecture :
plusieurs centaines de dossiers ne se filtrent pas en mémoire à chaque
affichage.
Le tri par nom reste en mémoire, lui : Postgres range « Étienne » après
« Etchegaray », un lecteur français avant.
Les colonnes suivent : rôle, mobile, rattachement et état de l'invitation
remplacent le matricule, qui ne s'affiche plus que sous le nom des salariés
sans compte applicatif — ceux pour qui c'est la seule adresse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`createContractAction` et `createAmendmentAction` existaient depuis le
début sans qu'aucun écran ne les appelle : du code mort côté interface, et
un contrat qu'on ne pouvait ni ouvrir ni modifier une fois le salarié créé.
Trois gestes sur le contrat en cours — lire le détail, déclarer un
changement, terminer — et l'ouverture d'un contrat quand il n'y en a pas.
`endContractAction` date et clôt le contrat plutôt que de le supprimer :
un contrôle porte sur une période révolue, et l'effacer effacerait la
preuve que le salarié a travaillé, ainsi que la base de son solde de tout
compte.
Le formulaire de forfait jours ne découvre la convention individuelle et
sa date d'accord qu'une fois le forfait choisi : la règle métier les exige,
les autres contrats n'en ont que faire.
Les établissements du formulaire de création se lisent avec
`members.contract.create` et non `settings.access` : ouvrir un contrat
n'est pas administrer un établissement.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
La fiche empilait tout sur une page, en lecture seule. Le dossier personnel
existait pourtant en base — date et lieu de naissance, nationalité, adresse,
contact d'urgence, NIR et IBAN chiffrés — sans qu'aucun écran ne l'écrive :
un salarié créé restait un dossier vide que rien ne permettait de remplir.
Cinq onglets, cinq routes : une fiche s'envoie par lien et se rouvre au même
endroit. Le bandeau porte ce qui ne change pas d'un onglet à l'autre, et le
gabarit le tient une fois pour toutes.
Deux points d'écriture et non un seul. L'état civil et les coordonnées
demandent `members.edit` ; le NIR, l'IBAN et le BIC exigent en plus de
pouvoir les lire. Fondus dans un même formulaire, un profil habilité à
modifier mais pas à lire aurait renvoyé des champs vides — et effacé ce qui
ne lui avait jamais été montré. Le journal retient qu'ils ont été renseignés,
jamais leur valeur.
Les parcours qui déposaient une pièce ou ouvraient un accès passent
désormais par l'onglet qui les porte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Repris tel quel, à la demande. Le fichier décrit un autre harnais que
Claude Code : les sections outils, artifacts et mémoire n'y ont pas
d'effet, et les conventions du projet n'y figurent pas encore.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le layout applicatif calcule l'écran d'enrôlement à son propre rendu, et
un layout n'est pas rejoué par la navigation client : une fois le second
facteur activé, l'écran restait posé sur toutes les routes, quel que soit
le menu choisi.
L'action d'enrôlement ne peut pas réactualiser la route elle-même sans
emporter les codes de secours, qui ne sont affichés qu'une fois. Le
rafraîchissement est donc déclenché par l'écran des codes, sur un bouton :
c'est leur destinataire qui décide du moment où il les a notés.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**Le test des heures ne pouvait que tomber.** Il interrogeait le mois
précédent, alors que le seed ne pose que deux semaines : la courante et la
précédente. Hors des premiers jours d'un mois, le mois précédent est donc
vide. Mesuré sur une base semée à neuf : un seul mois porte des créneaux,
le mois courant. Le défaut ne se voyait pas en développement, où la base
garde les créneaux des exécutions antérieures — 39 créneaux de juillet
survivaient chez moi à des semis d'il y a plusieurs semaines.
**Le seed ne remettait pas l'état de publication.** Son `update` était vide,
si bien qu'une semaine déjà semée gardait le statut qu'elle avait alors : la
semaine précédente, publiée par définition, restait en brouillon dès qu'elle
avait été semée du temps où elle était la semaine courante. D'où des tests
qui échouent en local et passent en intégration continue — l'écart le plus
coûteux à diagnostiquer. Le statut est désormais réimposé.
**Ports internes.** L'application écoute sur 9317 et la base sur 5439,
jusque dans l'image. Sur un réseau Docker deux conteneurs peuvent écouter le
même port sans se gêner — ce n'est donc pas une correction de collision —
mais une valeur unique de bout en bout lève l'ambiguïté quand plusieurs
piles cohabitent derrière le même proxy, et la configuration du
reverse-proxy porte partout le même nombre. Publication, sonde de santé,
serveur, chaîne de connexion et scripts d'amorçage sont alignés sur une
seule variable par service.
`pnpm verify` : 450 tests. Playwright : 77/77 après remise à zéro du semis.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
L'exploitant veut `db` sur `nginx_default`. Ce n'est pas requis pour que
l'application joigne la base — les deux partagent déjà `interne`, et le
journal le prouve : « password authentication failed for user "planflow" »
suppose que le nom a été résolu, la connexion établie et le dialogue
d'authentification engagé. Une absence de réseau commun donnerait une
résolution de nom impossible, pas un refus d'identifiants.
Le commentaire dit donc ce que cela coûte : la base devient joignable par
tous les conteneurs que sert ce proxy, `POSTGRES_PASSWORD` cesse d'être une
formalité, et le nom `db` publié sur un réseau partagé peut entrer en
collision avec celui d'une autre pile.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
`POSTGRES_PASSWORD` n'est lu qu'à la création du volume. Un volume né d'un
déploiement antérieur garde le mot de passe d'alors, et plus rien dans la
pile ne peut le corriger : ni l'application, ni `db-init`, tous deux refusés
à l'entrée. Le symptôme visible — un P1000 sur `planflow_app` — désigne le
mauvais coupable, et la seule issue était du SQL à la main.
La seule position d'où la réparation est possible est l'intérieur du
conteneur de la base, où PostgreSQL accepte la socket locale sans mot de
passe. Le service y réaligne donc le compte d'amorçage sur la valeur de la
pile à chaque démarrage, avant de céder la main au point d'entrée officiel.
Le SQL passe par l'entrée standard et non par `-c` : `psql -c` n'interpole
pas les variables, si bien que `:'pw'` y partait littéralement — mesuré,
« syntax error at or near ":" ». Par l'entrée standard, c'est psql qui met
le mot de passe entre guillemets, et une apostrophe dans le mot de passe ne
casse rien. Vérifié avec un mot de passe en contenant une.
Éprouvé en exécutant le corps de la commande contre un vrai serveur, avec un
point d'entrée factice : la reprise attend le serveur, le réalignement
aboutit, et le mot de passe est bien posé.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
`COPY` recopie le mode du fichier source, et ce mode se perd hors d'un clone
git : archive envoyée à Portainer, contexte reconstitué, système de fichiers
qui ne porte pas la permission.
La panne que cela produit ne ressemble à rien. La forme exec de `CMD` échoue
avant le premier octet de sortie, si bien que le conteneur s'arrête sans une
seule ligne de journal — et l'on cherche un défaut applicatif là où il n'y a
jamais eu de processus.
Mesuré : sans le bit, « Permission denied » et aucune sortie du script ;
avec, le script s'exécute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
Le diagnostic disait « mot de passe d'amorçage erroné » sans dire pourquoi
cela arrive, et on cherchait du côté du rôle applicatif — qui n'y est pour
rien.
`POSTGRES_PASSWORD` n'est lu qu'à l'initialisation du volume de la base. Un
volume créé lors d'un déploiement antérieur, fût-il un déploiement qui avait
échoué pour une autre raison, garde le mot de passe d'alors ; le changer
dans la pile n'y touche pas, le volume ne se réinitialise jamais. Le compte
d'amorçage est alors refusé, l'application ne peut plus réparer le rôle
applicatif, et le seul symptôme visible est un P1000 qui désigne le mauvais
coupable.
Le message nomme désormais ce cas et donne les deux issues : repartir d'un
volume neuf quand la base est vide, ou réaligner le mot de passe par la
socket locale — que l'image PostgreSQL accepte sans l'ancien.
Éprouvé sur la panne réelle, sous authentification par mot de passe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
Suite de PR #31. `PersistentForm` couvre désormais les formulaires où la
saisie compte : ajout d'un salarié, d'un établissement, d'une équipe, d'une
entrée de registre, demande d'absence, heures réelles, correspondances de
paie, réglages d'envoi, conservation, dépôt de pièce.
Le composant gère `select`, `textarea`, cases et boutons radio, et reçoit
`resetAfter` : passé l'état retourné par l'action en cas de succès, le
formulaire revient aux valeurs rendues par le serveur — vide pour un ajout,
la valeur enregistrée pour un champ d'édition. C'est ce qui rend visible une
normalisation faite côté serveur.
Les champs cachés sont exclus. Ils ne portent jamais une saisie mais l'état
de l'application, et les rétablir écrase ce que le serveur vient de
renvoyer. Le cas s'est produit sur `expectedVersion`, le verrou optimiste
d'une semaine de planning.
En éprouvant cela, un défaut plus grave est apparu, antérieur et sans
rapport avec la préservation : le formulaire de publication recevait tantôt
l'action de publication, tantôt celle de dépublication selon l'état, et
cessait de suivre le changement. Lecture de l'en-tête `Next-Action` sur
trois envois consécutifs :
1er envoi 60b8097e (dépublier) → brouillon
2e envoi 6012d40f (publier) → publiée
3e envoi 6012d40f (publier) → publiée
Le bouton affichait « Dépublier » et republiait, sans message d'erreur
puisque l'action réellement exécutée réussissait. Une seule action reçoit
maintenant l'intention, portée par le bouton déclencheur — un champ caché
serait remis à sa valeur d'origine par la réinitialisation que React
applique après chaque action, un bouton ne l'est jamais.
Le test du planning fait désormais deux allers-retours dans le même
chargement : le premier envoi d'une page emploie toujours la bonne action,
si bien qu'un seul aller-retour laissait passer le défaut selon l'état où
la semaine avait été trouvée. Vérifié : le test échoue sur l'ancien
câblage, passe sur le nouveau.
450 tests unitaires et d'intégration, 77 de bout en bout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
Les migrations posent le schéma et rien d'autre : une instance neuve n'a
aucun compte et aucun utilisateur, donc personne ne peut se connecter. Le
seed n'y remédie pas — il installe une démonstration et refuse de tourner
en production, à raison.
La première visite est désormais redirigée vers `/installation` : nom de
l'entreprise, premier établissement avec son fuseau, et compte
administrateur. L'écran crée le compte, le catalogue des capacités, les
cinq rôles fournis, le propriétaire et son périmètre, puis ouvre la session
par le chemin ordinaire — le rôle propriétaire exige aussitôt un second
facteur, comme il se doit.
Il ne se rouvre pas. Une table `Installation` d'une seule ligne, contrainte
en base et protégée par un trigger append-only, marque l'instance. Elle est
délibérément hors RLS, et c'est sa raison d'être : la politique d'`Account`
ne laisse voir que le compte courant, si bien qu'une instance installée
paraîtrait vierge à qui n'a pas de session — et la création d'un
propriétaire se rouvrirait à tout venant. Le recensement des politiques
porte l'exception, affirmée dans les deux sens.
Deux défauts trouvés en éprouvant l'écran sur une base réellement vierge :
`INSERT ... RETURNING` sur `Account` était refusé. L'insertion est permise,
mais la relecture de la ligne écrite passe par la politique de lecture, qui
exige un compte courant. L'identifiant est donc tiré côté application et
annoncé avant la création — la règle de partout, appliquée à la
transaction qui crée le compte.
Et un défaut qui dépassait cet écran : React 19 vide les champs non
contrôlés dès qu'une action se termine, refus compris. Les champs vidés
portant `required`, le clic suivant était arrêté par la validation du
navigateur avant d'émettre un `submit` — le formulaire paraissait mort. Le
formulaire de connexion en souffrait aussi ; la suite l'avait manqué parce
qu'aucun test ne soumettait deux fois de suite. `PersistentForm`
photographie la saisie à l'envoi et la rétablit, mots de passe exclus.
Au passage, `Field` rattache son indication par `aria-describedby` : placée
dans le `<label>`, elle entrait dans le nom accessible du champ.
Éprouvé sur une base vierge, image de production, rôle NOSUPERUSER
NOBYPASSRLS : redirection, trois refus motivés, installation, second
facteur exigé, écran refermé pour le propriétaire comme pour un visiteur.
450 tests unitaires et d'intégration, 77 tests de bout en bout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
`db-init` suppose un orchestrateur qui honore
`depends_on: service_completed_successfully`. Swarm l'ignore, et une pile
déployée avant l'ajout du service ne le contient même pas. L'application
redémarrait alors en boucle sur un refus d'authentification que le
diagnostic ajouté précédemment décrivait sans que personne puisse le
corriger.
Elle le corrige donc elle-même au démarrage, si on lui confie les
identifiants d'amorçage — et se tait sinon, pour ne pas contrarier un
déploiement qui préfère les garder hors du conteneur applicatif. Le point
d'entrée retire ces variables avant de lancer le serveur : le processus qui
sert les requêtes ne les voit jamais.
Le branchement create/alter se fait côté client et non dans un bloc `DO` :
le corps d'un `DO` est une chaîne littérale, où `$1` n'est pas un paramètre
de requête. L'échappement du mot de passe est confié à `quote_literal`, et
le nom de rôle est refusé s'il n'a pas la forme d'un identifiant.
Éprouvé sous authentification scram réelle : après réalignement, l'ancien
mot de passe est refusé et le nouveau accepté, le rôle reste NOSUPERUSER
NOBYPASSRLS et devient propriétaire de la base.
Au passage, `withTenant` pose un budget de transaction explicite. Tout accès
aux données passe par lui, si bien que le défaut Prisma de 5 s plafonnait en
réalité chaque requête de l'application, et l'échec se présentait en `P2028`
qui ne désigne ni la requête ni la cause.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
L'application redémarrait en boucle sur « P1000 » sans rien indiquer. Le point
d'entrée distingue désormais les deux causes et donne le remède.
Les deux codes ne disent pas la même chose, et c'est la mesure qui a permis de
les séparer : **P1010** signifie que le rôle n'existe pas, **P1000** qu'il existe
mais que son mot de passe ne correspond pas à celui que porte la pile — le cas
d'un rôle posé lors d'un déploiement antérieur avec une autre valeur. Le remède
est le même : `db-init` crée le rôle ou réaligne son mot de passe.
Le script d'initialisation lui-même a été éprouvé une fois de plus, y compris
avec l'argument parasite que Docker ajoute quand `entrypoint` est surchargé sans
`command` : il aboutit et pose le rôle.
Ce que je ne peux pas voir d'ici, et qu'il faut regarder sur le serveur : si le
service `db-init` figure dans la pile déployée, et ce qu'il a journalisé. Un
rôle dont le mot de passe ne correspond pas est précisément ce qu'il corrige.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
Le seed installe un premier compte de démonstration : il ne peut pas le
créer dans un contexte locataire qui n existe pas encore. Or il se
connectait avec le rôle applicatif, soumis à la row-level security en
FORCE — l upsert Prisma (INSERT ... ON CONFLICT) se heurtait à la politique
de lecture « id = planflow_current_account() », renvoyant NULL.
Utiliser ADMIN_DATABASE_URL (compte d amorçage), comme le fait déjà le
harnais de tests d intégration (tests/integration/admin-db.ts).