Author SHA1 Message Date
MichaelandClaude Opus 5 5f656d05af Corriger le bandeau de compteurs, et régler impression et productivité
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>
2026-08-11 13:18:31 +02:00
MichaelandClaude Opus 5 d8060eb4a9 Ouvrir le compte et les préférences au paramétrage
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>
2026-08-11 12:59:53 +02:00
MichaelandClaude Opus 5 f0ef6f12a1 Charger les codes Silae du dossier
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>
2026-08-11 12:53:42 +02:00
MichaelandClaude Opus 5 8c12b08961 Rendre les politiques de RTT administrables
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>
2026-08-11 12:47:51 +02:00
MichaelandClaude Opus 5 db03768025 Poser les modèles de documents et le dictionnaire RGPD
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>
2026-08-11 12:44:44 +02:00
MichaelandClaude Opus 5 5b60e7ddb6 Donner aux indicateurs RH leur profondeur temporelle
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>
2026-08-11 12:35:08 +02:00
MichaelandClaude Opus 5 1519b6bf9d Ouvrir le journal d'activité à la lecture
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>
2026-08-11 12:32:03 +02:00
MichaelandClaude Opus 5 e4be93a208 Compléter les vues de planning
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>
2026-08-11 12:30:02 +02:00
MichaelandClaude Opus 5 98b289784a Séparer les absences par état
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>
2026-08-11 12:23:16 +02:00
MichaelandClaude Opus 5 527ea2e78a Ouvrir les référentiels au paramétrage
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>
2026-08-11 12:17:50 +02:00
LogiFlow 9e57467138 Merge pull request #39 from R0m1k3/claude/embauche-etat-civil
Recueillir à l'embauche ce qu'exige le contrat
2026-08-10 17:37:13 +02:00
MichaelandClaude Opus 5 b608aa87cf Recueillir à l'embauche ce qu'exige le contrat
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>
2026-08-10 17:34:10 +02:00
MichaelandClaude Opus 5 06198ddbce Recueillir à l'embauche ce qu'exige le contrat
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>
2026-08-10 17:28:28 +02:00
Michael e27d61dd4c Merge branch 'claude/mfa-lever-barriere-enrolement' 2026-08-10 17:05:09 +02:00
MichaelandClaude Opus 5 3bf2cbabff Éditer le registre unique du personnel
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>
2026-08-10 16:46:15 +02:00
MichaelandClaude Opus 5 8808fcdb95 Retirer le préambule du registre de paramétrage
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>
2026-08-10 16:25:37 +02:00
LogiFlow b49ceb1f29 Merge pull request #38 from R0m1k3/claude/mfa-lever-barriere-enrolement
Alléger l'annuaire
2026-08-10 16:17:56 +02:00
MichaelandClaude Opus 5 894e880198 Alléger l'annuaire
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>
2026-08-10 15:45:28 +02:00
LogiFlow 1d64989239 Merge pull request #37 from R0m1k3/claude/mfa-lever-barriere-enrolement
Claude/mfa lever barriere enrolement
2026-08-10 12:32:28 +02:00
MichaelandClaude Opus 5 93724b9d66 Rendre le rattachement et le périmètre modifiables
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>
2026-08-10 12:05:01 +02:00
MichaelandClaude Opus 5 91f3b240e1 Poser le contrat au moment de l'embauche
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>
2026-08-10 12:02:45 +02:00
MichaelandClaude Opus 5 5a113de02f Donner à l'annuaire une recherche et des filtres
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>
2026-08-10 11:41:33 +02:00
MichaelandClaude Opus 5 bb8f84696a Rendre le contrat vivant depuis sa fiche
`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>
2026-08-10 11:37:31 +02:00
MichaelandClaude Opus 5 c6abbaadc5 Faire de la fiche salarié un dossier à onglets, et le rendre saisissable
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>
2026-08-10 11:20:18 +02:00
MichaelandClaude Opus 5 5a05fda2f9 Poser un CLAUDE.md à partir du prompt système Fable 5
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>
2026-08-10 10:32:24 +02:00
MichaelandClaude Opus 5 61a273548b Lever la barrière d'enrôlement une fois les codes notés
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>
2026-08-10 10:32:18 +02:00
114 changed files with 16803 additions and 768 deletions

No files matched your search

+3736
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -34,6 +34,7 @@
"@prisma/client": "7.9.1",
"next": "16.3.0",
"nodemailer": "^9.0.5",
"pdf-lib": "^1.17.1",
"pg": "8.22.0",
"qrcode": "^1.5.4",
"react": "19.2.8",
+37
View File
@@ -23,6 +23,9 @@ importers:
nodemailer:
specifier: ^9.0.5
version: 9.0.5
pdf-lib:
specifier: ^1.17.1
version: 1.17.1
pg:
specifier: 8.22.0
version: 8.22.0
@@ -769,6 +772,12 @@ packages:
'@oxc-project/types@0.143.0':
resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==}
'@pdf-lib/standard-fonts@1.0.0':
resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==}
'@pdf-lib/upng@1.0.1':
resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
'@playwright/test@1.62.1':
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
engines: {node: '>=20'}
@@ -2689,6 +2698,9 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -2707,6 +2719,9 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pdf-lib@1.17.1:
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
perfect-debounce@2.1.0:
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
@@ -3137,6 +3152,9 @@ packages:
tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -3900,6 +3918,14 @@ snapshots:
'@oxc-project/types@0.143.0': {}
'@pdf-lib/standard-fonts@1.0.0':
dependencies:
pako: 1.0.11
'@pdf-lib/upng@1.0.1':
dependencies:
pako: 1.0.11
'@playwright/test@1.62.1':
dependencies:
playwright: 1.62.1
@@ -5943,6 +5969,8 @@ snapshots:
p-try@2.2.0: {}
pako@1.0.11: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -5955,6 +5983,13 @@ snapshots:
pathe@2.0.3: {}
pdf-lib@1.17.1:
dependencies:
'@pdf-lib/standard-fonts': 1.0.0
'@pdf-lib/upng': 1.0.1
pako: 1.0.11
tslib: 1.14.1
perfect-debounce@2.1.0: {}
pg-cloudflare@1.4.0:
@@ -6432,6 +6467,8 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
tslib@1.14.1: {}
tslib@2.8.1: {}
tsx@4.20.6:
@@ -0,0 +1,44 @@
-- État civil complet du dossier salarié.
--
-- Ces mentions manquaient au schéma alors que la loi les réclame ou que la
-- paie s'appuie dessus :
--
-- - le **sexe** est exigé au registre unique du personnel (art. D1221-23) ;
-- sans lui, tout dossier y sort incomplet ;
-- - le **nom de naissance** ne se déduit pas du nom d'usage, et c'est lui que
-- portent la déclaration sociale et le registre ;
-- - le **pays** et le **département** de naissance sont demandés séparément
-- par la déclaration : les extraire d'une commune saisie librement échouerait
-- au premier « Bar-le-Duc (Meuse) » ;
-- - la **situation de famille** et les **personnes à charge** commandent des
-- droits que le salarié seul peut déclarer ;
-- - l'**heure d'embauche** est demandée par la DPAE, que la date seule ne
-- suffit pas à remplir.
--
-- Toutes les colonnes sont facultatives : un dossier incomplet doit pouvoir
-- exister — c'est au registre de signaler ce qui lui manque, pas à la base de
-- refuser l'embauche. Seule exception, l'envoi des plannings par SMS, qui est
-- un consentement : explicitement faux par défaut, la charge de la preuve
-- pesant sur l'employeur.
-- CreateEnum
CREATE TYPE "Gender" AS ENUM ('FEMALE', 'MALE', 'UNSPECIFIED');
-- Le PACS est distinct du concubinage et du mariage : les trois n'ouvrent pas
-- les mêmes droits, et les confondre fausserait la paie.
-- CreateEnum
CREATE TYPE "MaritalStatus" AS ENUM ('SINGLE', 'MARRIED', 'PACS', 'COHABITING', 'DIVORCED', 'WIDOWED');
-- AlterTable
ALTER TABLE "EmployeeProfile" ADD COLUMN "addressLine2" TEXT,
ADD COLUMN "birthCountry" TEXT,
ADD COLUMN "birthDepartment" TEXT,
ADD COLUMN "birthName" TEXT,
ADD COLUMN "dependents" INTEGER,
ADD COLUMN "gender" "Gender",
ADD COLUMN "landline" TEXT,
ADD COLUMN "maritalStatus" "MaritalStatus",
ADD COLUMN "smsSchedules" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "UserContract" ADD COLUMN "startTime" TEXT;
@@ -0,0 +1,51 @@
-- ---------------------------------------------------------------------------
-- Modèles de documents — PLAN.md §4.7 et WP-10.
--
-- `templateId` sur Document est en ON DELETE SET NULL et non CASCADE : une
-- pièce déjà remise à un salarié ne doit pas disparaître parce qu'on a effacé
-- le modèle qui l'a produite. Le lien se perd, le document reste.
-- ---------------------------------------------------------------------------
CREATE TABLE "DocumentTemplate" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"bodyHtml" TEXT NOT NULL,
"availableFields" JSONB NOT NULL,
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DocumentTemplate_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "DocumentTemplate_accountId_name_key" ON "DocumentTemplate"("accountId", "name");
CREATE INDEX "DocumentTemplate_accountId_idx" ON "DocumentTemplate"("accountId");
ALTER TABLE "DocumentTemplate" ADD CONSTRAINT "DocumentTemplate_accountId_fkey"
FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Document" ADD COLUMN "templateId" TEXT;
CREATE INDEX "Document_templateId_idx" ON "Document"("templateId");
ALTER TABLE "Document" ADD CONSTRAINT "Document_templateId_fkey"
FOREIGN KEY ("templateId") REFERENCES "DocumentTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- ---------------------------------------------------------------------------
-- Isolation par compte — même défense en profondeur que les autres tables.
--
-- Un modèle porte le corps d'attestations et de courriers types : sa fuite
-- livrerait la raison sociale, les mentions et la forme des actes d'un autre
-- client. L'extension Prisma filtre déjà ; ces règles valent pour le cas où
-- elle serait contournée.
-- ---------------------------------------------------------------------------
ALTER TABLE "DocumentTemplate" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "DocumentTemplate" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "DocumentTemplate"
USING ("accountId" = planflow_current_account());
CREATE POLICY tenant_insert ON "DocumentTemplate"
FOR INSERT WITH CHECK ("accountId" = planflow_current_account());
@@ -0,0 +1,67 @@
-- ---------------------------------------------------------------------------
-- Siège social et préférences du compte — PLAN.md §9.
--
-- Les colonnes d'adresse vont sur Account et non sur Location : le siège figure
-- sur les documents édités et au registre du personnel, et il n'est pas
-- nécessairement celui d'un magasin.
--
-- Les préférences sont une table à part plutôt que dix colonnes de plus sur
-- Account : elles changent des calculs, se relisent ensemble, et la séparation
-- garde lisible ce qui identifie l'entreprise et ce qui paramètre l'outil.
-- ---------------------------------------------------------------------------
ALTER TABLE "Account" ADD COLUMN "addressLine" TEXT;
ALTER TABLE "Account" ADD COLUMN "postalCode" TEXT;
ALTER TABLE "Account" ADD COLUMN "city" TEXT;
ALTER TABLE "Account" ADD COLUMN "country" TEXT NOT NULL DEFAULT 'FR';
ALTER TABLE "Account" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'Europe/Paris';
CREATE TABLE "AccountPreferences" (
"accountId" TEXT NOT NULL,
"defaultMealPerShift" BOOLEAN NOT NULL DEFAULT false,
"paidBreaks" BOOLEAN NOT NULL DEFAULT false,
"lockEmployeeMeals" BOOLEAN NOT NULL DEFAULT false,
"employeesSeeOwnTotals" BOOLEAN NOT NULL DEFAULT true,
"employeesSeeTeamPlanning" BOOLEAN NOT NULL DEFAULT false,
"hideForfaitCounters" BOOLEAN NOT NULL DEFAULT true,
"eveningShiftStartMinutes" INTEGER NOT NULL DEFAULT 1200,
"employeesEditOwnProfile" BOOLEAN NOT NULL DEFAULT true,
"employeesSeeOwnTimesheets" BOOLEAN NOT NULL DEFAULT true,
"employeesSeeTeamContacts" BOOLEAN NOT NULL DEFAULT false,
"managerCreatedArePlannable" BOOLEAN NOT NULL DEFAULT false,
"directorsSharePeople" BOOLEAN NOT NULL DEFAULT false,
"directorsAccessHrDashboard" BOOLEAN NOT NULL DEFAULT true,
"directorsSeePaidLeave" BOOLEAN NOT NULL DEFAULT true,
"managersSeePaidLeave" BOOLEAN NOT NULL DEFAULT false,
"smoothOvertimeMonthly" BOOLEAN NOT NULL DEFAULT false,
"includeRestInNormalHours" BOOLEAN NOT NULL DEFAULT false,
"autoEmployeeNumber" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AccountPreferences_pkey" PRIMARY KEY ("accountId")
);
ALTER TABLE "AccountPreferences" ADD CONSTRAINT "AccountPreferences_accountId_fkey"
FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- ---------------------------------------------------------------------------
-- Isolation par compte.
--
-- La clé primaire *est* l'identifiant de compte, si bien qu'une fuite
-- supposerait déjà de connaître l'identifiant visé. La politique reste posée :
-- la défense en profondeur ne se dispense pas au motif que l'attaque serait
-- malcommode.
-- ---------------------------------------------------------------------------
ALTER TABLE "AccountPreferences" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "AccountPreferences" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "AccountPreferences"
USING ("accountId" = planflow_current_account());
CREATE POLICY tenant_insert ON "AccountPreferences"
FOR INSERT WITH CHECK ("accountId" = planflow_current_account());
@@ -0,0 +1,17 @@
-- ---------------------------------------------------------------------------
-- Impression et productivité — PLAN.md §9, `/reglages/impression` et
-- `/reglages/productivite`.
--
-- `productivityTargetPerHour` est nullable et non « 0 par défaut » : zéro est un
-- objectif — inatteignable — alors que l'absence de valeur dit qu'aucun objectif
-- n'a été fixé. Un écran qui affiche « 0 €/h attendu » pousse à corriger un
-- réglage qui n'existe pas.
-- ---------------------------------------------------------------------------
ALTER TABLE "AccountPreferences" ADD COLUMN "printLandscape" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "AccountPreferences" ADD COLUMN "printDensity" TEXT NOT NULL DEFAULT 'large';
ALTER TABLE "AccountPreferences" ADD COLUMN "printContractTotals" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "AccountPreferences" ADD COLUMN "printOtherTeams" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "AccountPreferences" ADD COLUMN "printSunday" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "AccountPreferences" ADD COLUMN "printSignatureColumn" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "AccountPreferences" ADD COLUMN "productivityTargetPerHour" DECIMAL(10,2);
+171 -2
View File
@@ -19,6 +19,14 @@ model Account {
name String
siren String?
apeCode String?
/// Siège social. Il figure sur les documents édités et au registre unique du
/// personnel ; il n'est pas déduit de l'établissement, qui peut être ailleurs.
addressLine String?
postalCode String?
city String?
country String @default("FR")
/// Fuseau du compte. Un établissement peut le surcharger (Location.timezone).
timezone String @default("Europe/Paris")
collectiveAgreementId String?
/// Surcharges d'accord d'entreprise (PLAN.md §6.3). Vide aujourd'hui :
/// l'organisation auditée n'a pas d'accord d'entreprise.
@@ -34,8 +42,91 @@ model Account {
jobTitles JobTitle[]
labels Label[]
absenceTypes AbsenceType[]
documentTemplates DocumentTemplate[]
legalConfig LegalConfigEntry[]
installation Installation?
preferences AccountPreferences?
}
/// Préférences du compte — PLAN.md §9, réglages « préférences » et « paie ».
///
/// Trois blocs typés plutôt qu'un sac de clés-valeurs. Ce n'est pas de la
/// rigueur pour la rigueur : `smoothOvertimeMonthly` change le **calcul** des
/// heures supplémentaires, et un réglage qui change un calcul doit être lisible
/// dans le schéma, migrable, et impossible à renseigner de travers. Un JSON
/// libre rendrait indétectable une clé mal orthographiée — la préférence
/// paraîtrait active et ne le serait pas.
///
/// Une ligne par compte, créée à la demande : l'absence de ligne vaut « tous
/// les défauts », et les défauts sont ceux du modèle.
model AccountPreferences {
accountId String @id
// --- Plannings ---------------------------------------------------------
/// Compter un repas par créneau sans saisie.
defaultMealPerShift Boolean @default(false)
/// Pauses payées : elles cessent alors d'être déduites du temps de travail.
paidBreaks Boolean @default(false)
/// Interdire au salarié de modifier son repas.
lockEmployeeMeals Boolean @default(false)
/// Le salarié voit son total d'heures planifiées et réelles de la semaine.
employeesSeeOwnTotals Boolean @default(true)
/// Le salarié voit le planning des autres membres de son équipe.
employeesSeeTeamPlanning Boolean @default(false)
/// Masquer les compteurs — et les alertes — des salariés au forfait jours.
/// Un forfait jours ne se compte pas en heures : afficher un écart
/// hebdomadaire sur ces lignes produit une alerte qui n'a pas de sens.
hideForfaitCounters Boolean @default(true)
/// Heure à partir de laquelle un créneau est « du soir », en minutes depuis
/// minuit. 1200 = 20:00.
eveningShiftStartMinutes Int @default(1200)
// --- Droits ------------------------------------------------------------
employeesEditOwnProfile Boolean @default(true)
employeesSeeOwnTimesheets Boolean @default(true)
/// Annuaire ouvert : téléphone et adresse e-mail de toute l'équipe.
employeesSeeTeamContacts Boolean @default(false)
/// Un salarié créé par un manager est planifiable sans validation.
managerCreatedArePlannable Boolean @default(false)
directorsSharePeople Boolean @default(false)
directorsAccessHrDashboard Boolean @default(true)
directorsSeePaidLeave Boolean @default(true)
managersSeePaidLeave Boolean @default(false)
// --- Paie --------------------------------------------------------------
/// Décompter les heures supplémentaires au mois plutôt qu'à la semaine.
///
/// **Risque juridique.** Le décompte hebdomadaire est le principe (art.
/// L3121-28) ; lisser au mois suppose un accord d'aménagement du temps de
/// travail. Faux par défaut, et l'écran le dit.
smoothOvertimeMonthly Boolean @default(false)
/// Inclure repos et absences dans les heures normales.
includeRestInNormalHours Boolean @default(false)
/// Générer le matricule à la création plutôt que de le saisir.
autoEmployeeNumber Boolean @default(false)
// --- Impression --------------------------------------------------------
/// Paysage par défaut : une semaine tient en largeur, pas en hauteur.
printLandscape Boolean @default(true)
/// Densité : « large » lisible à distance, « compact » pour un gros effectif.
printDensity String @default("large")
printContractTotals Boolean @default(true)
/// Créneaux des autres équipes, pour situer une journée d'ensemble.
printOtherTeams Boolean @default(false)
printSunday Boolean @default(true)
/// Colonne d'émargement — une signature manuscrite ne vaut pas décompte du
/// temps de travail, mais reste demandée pour l'affichage en salle.
printSignatureColumn Boolean @default(true)
// --- Productivité ------------------------------------------------------
/// Objectif de chiffre d'affaires par heure travaillée, en euros.
/// Nul tant qu'aucun objectif n'est fixé : zéro serait un objectif, pas une
/// absence d'objectif.
productivityTargetPerHour Decimal? @db.Decimal(10, 2)
updatedAt DateTime @updatedAt
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
}
/// Marqueur d'installation de l'instance — une ligne, jamais deux.
@@ -481,16 +572,41 @@ model EmployeeProfile {
firstName String
lastName String
/// Nom de naissance, distinct du nom d'usage. La paie, la déclaration
/// sociale et le registre du personnel se tiennent au nom de naissance ;
/// l'un ne remplace pas l'autre.
birthName String?
/// Sexe — mention exigée au registre unique du personnel (art. D1221-23).
/// Facultatif en base : un dossier incomplet doit pouvoir exister, et c'est
/// le registre qui signale ce qui lui manque.
gender Gender?
birthDate DateTime? @db.Date
birthPlace String?
/// Commune de naissance. Le pays et le département sont portés à part :
/// la déclaration sociale les demande séparément, et les recoller depuis une
/// chaîne libre échouerait au premier « Bar-le-Duc (Meuse) ».
birthPlace String?
birthCountry String?
birthDepartment String?
nationality String?
/// Situation de famille et personnes à charge : la paie en dépend, et le
/// salarié seul peut les déclarer.
maritalStatus MaritalStatus?
dependents Int?
addressLine1 String?
addressLine2 String?
postalCode String?
city String?
country String?
phone String?
landline String?
personalEmail String?
/// Envoi des plannings par SMS. Un consentement, donc explicitement faux
/// par défaut : la charge de la preuve pèse sur l'employeur.
smsSchedules Boolean @default(false)
socialSecurityNumberEnc Bytes?
ibanEnc Bytes?
bicEnc Bytes?
@@ -505,6 +621,26 @@ model EmployeeProfile {
@@index([accountId])
}
/// Sexe au sens de l'état civil, tel que le registre du personnel le demande.
/// Trois valeurs et non deux : un dossier peut être ouvert avant que la
/// mention soit connue, et « non précisé » se distingue d'un champ jamais rempli.
enum Gender {
FEMALE
MALE
UNSPECIFIED
}
/// Situation de famille. Le PACS est distinct du concubinage et du mariage :
/// les trois n'ouvrent pas les mêmes droits, et les confondre fausserait la paie.
enum MaritalStatus {
SINGLE
MARRIED
PACS
COHABITING
DIVORCED
WIDOWED
}
/// Titre de séjour et son échéance. Le tableau de bord RH surveille les
/// expirations : un titre périmé interdit l'emploi.
model WorkPermit {
@@ -530,6 +666,9 @@ model UserContract {
contractType ContractType
startDate DateTime @db.Date
/// Heure de prise de poste le premier jour, au format « HH:MM ».
/// Une date seule ne suffit pas à la DPAE, qui demande l'heure d'embauche.
startTime String?
endDate DateTime? @db.Date
trialEndDate DateTime? @db.Date
@@ -744,6 +883,33 @@ model DailyNote {
/// pouvoir reproduire à l'identique une paie antérieure après un changement de
/// règle. Une mise à jour en place rendrait cette reproduction impossible, et
/// l'impossibilité ne se découvrirait qu'au premier contrôle.
/// Modèle de document — PLAN.md §4.7 et §9.
///
/// `bodyHtml` porte le corps avec ses variables sous la forme `{{cle}}`.
/// `availableFields` déclare les variables offertes à la rédaction : elles sont
/// **scopées par établissement**, si bien qu'un modèle rendu pour un salarié
/// résout ses variables dans le contexte de l'établissement de son contrat. Une
/// même attestation ne porte donc pas la même raison sociale d'un magasin à
/// l'autre.
///
/// Archivé plutôt que supprimé : un document généré garde `templateId`, et
/// perdre le modèle rendrait inexplicable la forme d'une pièce déjà remise.
model DocumentTemplate {
id String @id @default(cuid())
accountId String
name String
bodyHtml String
availableFields Json
archivedAt DateTime?
createdAt DateTime @default(now())
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
documents Document[]
@@unique([accountId, name])
@@index([accountId])
}
/// Pièce du dossier salarié — PLAN.md §4.7.
///
/// Le contenu vit sur disque, **chiffré**, jamais dans la base : une pièce
@@ -763,6 +929,8 @@ model Document {
/// Empreinte du clair, pour détecter une altération du fichier stocké.
checksum String
/// Donnée de santé : sa lecture est journalisée (§3.6).
/// Modèle dont la pièce est issue. Nul pour un fichier déposé à la main.
templateId String?
isSensitive Boolean @default(false)
/// Échéance de conservation. Nulle tant qu'aucune politique ne l'a fixée :
/// le plan interdit d'appliquer une durée par défaut (§12.5).
@@ -772,7 +940,8 @@ model Document {
deletedAt DateTime?
deletedBy String?
membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Cascade)
membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Cascade)
template DocumentTemplate? @relation(fields: [templateId], references: [id], onDelete: SetNull)
@@index([accountId])
@@index([membershipId])
+147
View File
@@ -0,0 +1,147 @@
/**
* Correspondances Silae du dossier FROUARD DISTRIBUTION.
*
* **Données de dossier, pas de produit.** Ces codes appartiennent au
* paramétrage Silae de ce client et n'ont aucune valeur pour un autre : deux
* clients du même cabinet ne partagent pas leurs rubriques. 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.
*
* **Source** : configuration de l'intégration Silae du compte, relevée le
* 11 août 2026. Ce ne sont pas des codes déduits d'une documentation publique —
* il n'en existe pas : Silae impose que le fichier d'import soit le miroir du
* paramétrage du dossier.
*
* `À VALIDER` — quatre rubriques du dossier portent un préfixe `AB-` **sans
* numéro** (voir `INCOMPLETE_IN_DOSSIER`). Elles ne sont pas reprises ici :
* importer un code tronqué ferait échouer l'import de paie, ou pire, le ferait
* réussir en imputant à la mauvaise rubrique.
*/
/** Rubriques laissées vides ou incomplètes dans le paramétrage du dossier. */
export const INCOMPLETE_IN_DOSSIER = [
{ label: 'Évènement familial', observed: 'AB-' },
{ label: 'Repos compensateur de nuit', observed: 'AB-' },
{ label: "Repos compensateur d'habillement", observed: 'AB-' },
{ label: 'Visite médicale', observed: 'AB-' },
] as const;
/**
* Code d'absence par type PlanFlow.
*
* La valeur est la **partie après `AB-`**, comme le stocke
* `AbsenceType.silaeCode` : le préfixe est ajouté à la sérialisation.
*/
export const ABSENCE_CODES: Record<string, string> = {
// Congés payés → AB-300
CP: '300',
// RTT → AB-310
RTT: '310',
// Arrêt maladie - Initial → AB-100. L'extension n'est pas paramétrée au
// dossier : un arrêt prolongé se saisit aujourd'hui à la main côté paie.
MAL: '100',
// Congé sans solde → AB-632
SS: '632',
// Repos compensateur de remplacement → AB-335
RC: '335',
};
/**
* Code Silae par élément de paie calculé.
*
* Les trois codes qui manquaient à la conception — 50 %, complémentaires à
* 10 % et à 25 % — sont désormais connus. `FORFAIT_DAYS` reste absent : aucune
* rubrique de forfait jours n'existe au paramétrage, et l'inventer produirait
* un export refusé au premier cadre autonome.
*/
export const ELEMENT_CODES: Record<string, string> = {
WORKED_DAYS: 'Nombre total de jours travailles',
WORKED_HOURS: 'Heures travaillees',
MISSING_HOURS: 'Heures manquantes au contrat',
SUNDAY_HOURS: 'EV-HDimanche',
HOLIDAY_HOURS: 'EV-HFerie',
OVERTIME_25: 'HS-HS25',
OVERTIME_50: 'HS-HS50',
COMPLEMENTARY_10: 'HS-HC10',
COMPLEMENTARY_25: 'HS-HC25',
};
/**
* Catalogue complet relevé au dossier.
*
* Conservé en entier — y compris les rubriques que PlanFlow ne calcule pas
* encore — parce qu'il documente ce que le dossier accepte. Le jour où les
* heures de nuit ou les paniers repas entrent au périmètre, le code est déjà
* là et n'aura pas à être redemandé au cabinet.
*
* Les taux de nuit sont recopiés **tels quels**. Leur numérotation surprend
* (`EV-HNuit24-2` pour 10 %, `EV-HNuit2-4` pour 20 %) : c'est le paramétrage du
* dossier, et le « corriger » ici imputerait la majoration à la mauvaise
* rubrique.
*/
export const DOSSIER_CATALOGUE: Array<{ label: string; code: string }> = [
{ label: 'Absence formation rémunérée', code: 'AB-410' },
{ label: 'Absence injustifiée - Non payé', code: 'AB-630' },
{ label: 'Absence injustifiée - Payé', code: 'AB-631' },
{ label: 'Absence justifiée - Non payé', code: 'AB-630' },
{ label: 'Absence justifiée - Payé', code: 'AB-631' },
{ label: 'Accident du travail - Initial', code: 'AB-120' },
{ label: 'Arrêt maladie - Initial', code: 'AB-100' },
{ label: 'Chômage partiel', code: 'AB-609' },
{ label: 'Congé maternité', code: 'AB-200' },
{ label: 'Congé parental', code: 'AB-230' },
{ label: 'Congé paternité', code: 'AB-210' },
{ label: 'Congé sans solde', code: 'AB-632' },
{ label: 'Congés payés', code: 'AB-300' },
{ label: 'Jours fériés chômés (heures)', code: 'AB-301' },
{ label: 'Maladie professionnelle - Initial', code: 'AB-110' },
{ label: 'Mise à pied conservatoire', code: 'AB-641' },
{ label: 'Mise à pied disciplinaire', code: 'AB-640' },
{ label: 'Repos compensateur de remplacement', code: 'AB-335' },
{ label: 'Retard', code: 'AB-630' },
{ label: 'RTT', code: 'AB-310' },
{ label: 'Heures complémentaires (5 %)', code: 'HS-HC5' },
{ label: 'Heures complémentaires (10 %)', code: 'HS-HC10' },
{ label: 'Heures complémentaires (25 %)', code: 'HS-HC25' },
{ label: 'Heures supplémentaires (0 %)', code: 'HS-HS00' },
{ label: 'Heures supplémentaires (10 %)', code: 'HS-HS10' },
{ label: 'Heures supplémentaires (15 %)', code: 'HS-HS15' },
{ label: 'Heures supplémentaires (20 %)', code: 'HS-HS20' },
{ label: 'Heures supplémentaires (25 %)', code: 'HS-HS25' },
{ label: 'Heures supplémentaires (50 %)', code: 'HS-HS50' },
{ label: 'Heures supplémentaires (100 %)', code: 'HS-HS100' },
{ label: 'Heures travaillées majorées (dimanche) 10 %', code: 'HS-HS10-D' },
{ label: 'Heures travaillées majorées (dimanche) 15 %', code: 'HS-HS15-D' },
{ label: 'Heures travaillées majorées (dimanche) 30 %', code: 'HS-HS30-D' },
{ label: 'Heures travaillées majorées (dimanche) 33 %', code: 'HS-HS33-D' },
{ label: 'Heures travaillées majorées (dimanche) 50 %', code: 'HS-HS50-D' },
{ label: 'Heures travaillées majorées (dimanche) 100 %', code: 'EV-HDimanche' },
{ label: 'Heures travaillées majorées (1er mai)', code: 'C01' },
{ label: 'Heures travaillées majorées (jours fériés)', code: 'EV-HFerie' },
{ label: 'Coupures', code: 'EV-nb-coupure' },
{ label: 'Heures de nuit (0 %)', code: 'EV-HNuit-0' },
{ label: 'Heures de nuit (5 %)', code: 'EV-HNuit2-5' },
{ label: 'Heures de nuit (10 %)', code: 'EV-HNuit24-2' },
{ label: 'Heures de nuit (20 %)', code: 'EV-HNuit2-4' },
{ label: 'Heures de nuit (25 %)', code: 'EV-HNuit2-6' },
{ label: 'Heures de nuit (30 %)', code: 'EV-HNuit2-3' },
{ label: 'Heures de nuit (50 %)', code: 'EV-HNuit' },
{ label: 'Repas : avantage en nature', code: 'EV-A01' },
{ label: 'Repas : indemnité repas', code: 'EV-A02' },
{ label: 'Repas : prime de panier', code: 'EV-PrimePanier' },
{ label: 'Repas : titre restaurant', code: 'EV-TitreRestaurant' },
{ label: 'Entrée / Sortie', code: 'Entree / Sortie' },
{ label: 'Heures manquantes au contrat', code: 'Heures manquantes au contrat' },
{ label: 'Heures normales', code: 'Heures normales' },
{ label: 'Heures travaillées', code: 'Heures travaillees' },
{ label: 'Heures travaillées (extra)', code: 'Heures travaillees (extra)' },
{ label: 'Nombre total de jours travaillés', code: 'Nombre total de jours travailles' },
{ label: 'Note transport', code: 'Note transport' },
{ label: 'Nouveau solde RCR', code: 'Nouveau solde RCR' },
{ label: 'RCR à payer', code: 'RCR a payer' },
{ label: 'Solde de modulation', code: 'Solde de modulation' },
];
+43 -13
View File
@@ -22,6 +22,11 @@ import {
} from '../src/domain/compliance/idcc1517';
import { frenchHolidays } from '../src/domain/absences/holidays';
import { PAYROLL_ELEMENT_DEFINITIONS } from '../src/domain/payroll/elements';
import {
ABSENCE_CODES,
ELEMENT_CODES,
INCOMPLETE_IN_DOSSIER,
} from './seed-data/silae-frouard';
import { evaluateSchedule } from '../src/server/compliance/evaluate';
import { withTenant } from '../src/server/tenant';
@@ -586,9 +591,10 @@ async function main() {
console.log('→ Types d’absence');
// `silaeCode` reste **null** : les codes AB-* du dossier sont connus, leur
// signification ne l'est pas. Les associer ici reviendrait à deviner à quel
// type d'absence correspond AB-300, ce qui produirait une paie fausse.
// `silaeCode` vient du paramétrage Silae du dossier, relevé le 11 août 2026
// (voir `seed-data/silae-frouard.ts`). Il n'a rien d'universel : la
// correspondance entre AB-300 et « congés payés » appartient à ce dossier, et
// un autre client du même cabinet peut la numéroter autrement.
const absenceTypes = [
{ code: 'CP', name: 'Congés payés', colorKey: 'cp', isPaid: true, social: false, notice: 30 },
{ code: 'RTT', name: 'RTT', colorKey: 'rtt', isPaid: true, social: false, notice: 7 },
@@ -600,12 +606,17 @@ async function main() {
for (const type of absenceTypes) {
await prisma.absenceType.upsert({
where: { accountId_code: { accountId: account.id, code: type.code } },
update: { name: type.name, colorKey: type.colorKey },
update: {
name: type.name,
colorKey: type.colorKey,
silaeCode: ABSENCE_CODES[type.code] ?? null,
},
create: {
accountId: account.id,
code: type.code,
name: type.name,
colorKey: type.colorKey,
silaeCode: ABSENCE_CODES[type.code] ?? null,
isPaid: type.isPaid,
countsAsWorkTime: false,
affectsPaidLeaveAccrual: !type.social,
@@ -709,12 +720,18 @@ async function main() {
console.log(` ${demoAbsences.length} absences`);
console.log('→ Correspondances Silae');
// Semées **non confirmées**, y compris quand le code se lit dans son libellé
// (`EV-HDimanche`). Proposer n'est pas confirmer : seul le gestionnaire de
// paie sait si le code est le bon dans ce dossier, et l'export refuse de
// tourner tant qu'il ne l'a pas dit.
// Les codes viennent du paramétrage du dossier, actif et coché côté Combo :
// ils sont donc semés **confirmés**. Un code déduit d'un libellé le serait
// non confirmé — proposer n'est pas confirmer — mais ceux-ci sont relevés
// dans la configuration réelle, pas devinés.
//
// Ce qui n'a pas de code au dossier reste absent plutôt que semé à vide :
// l'export refuse alors de tourner en listant les manques, ce qui est le
// comportement voulu (§8).
let seededMappings = 0;
for (const definition of PAYROLL_ELEMENT_DEFINITIONS) {
if (!definition.suggestedCode) continue;
const code = ELEMENT_CODES[definition.key] ?? definition.suggestedCode;
if (!code) continue;
const existing = await prisma.silaeCodeMapping.findFirst({
where: { accountId: account.id, sourceKey: definition.key },
});
@@ -724,16 +741,29 @@ async function main() {
data: {
accountId: account.id,
sourceKey: definition.key,
silaeCode: definition.suggestedCode,
silaeCode: code,
label: definition.label,
kind: definition.kind,
confirmed: false,
confirmed: Boolean(ELEMENT_CODES[definition.key]),
},
});
seededMappings += 1;
}
console.log(
` ${PAYROLL_ELEMENT_DEFINITIONS.filter((d) => d.suggestedCode).length} proposées, aucune confirmée`,
const missingElements = PAYROLL_ELEMENT_DEFINITIONS.filter(
(definition) =>
!ELEMENT_CODES[definition.key] && !definition.suggestedCode,
);
console.log(` ${seededMappings} correspondances`);
if (missingElements.length > 0) {
console.log(
` ⚠ sans code au dossier : ${missingElements.map((d) => d.label).join(', ')}`,
);
}
if (INCOMPLETE_IN_DOSSIER.length > 0) {
console.log(
` ⚠ ${INCOMPLETE_IN_DOSSIER.length} rubriques incomplètes côté Silae (préfixe « AB- » sans numéro) : ${INCOMPLETE_IN_DOSSIER.map((row) => row.label).join(', ')}`,
);
}
console.log('→ Matricules Silae');
// Fictifs, à la forme observée dans le dossier : cinq chiffres cadrés à zéro.
+36
View File
@@ -0,0 +1,36 @@
import { AbsenceQueueView } from '@/components/absences/AbsenceQueue';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { getAbsenceQueue } from '@/server/absences/queries';
export const metadata = { title: 'Demandes à traiter · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function ATraiterPage() {
const result = await getAbsenceQueue('pending');
return (
<PageBody>
<PageHeader
title="Demandes à traiter"
subtitle={
result.rows.length === 0
? 'Aucune demande en attente'
: `${result.rows.length} demande${result.rows.length > 1 ? 's' : ''} en attente, la plus ancienne en premier`
}
/>
<AbsenceQueueView
result={result}
title="File d’attente"
description="Aucune demande en attente de décision."
/>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Les demandes sont classées par ancienneté : celle qui attend depuis le
plus longtemps arrive en tête. Accepter écrit au registre des compteurs ;
annuler ensuite contre-passe l’écriture plutôt que de l’effacer, et le
solde revient au même chiffre.
</p>
</PageBody>
);
}
+332
View File
@@ -0,0 +1,332 @@
import Link from 'next/link';
import { CancelButton, DecisionButtons } from '@/components/absences/DecisionButtons';
import { RequestForm } from '@/components/absences/RequestForm';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge, type Tone } from '@/components/ui/Badge';
import { datesBetween, isoWeekday } from '@/domain/absences/count';
import { monthOf, parseMonthParam } from '@/domain/planning/month';
import { cx } from '@/lib/cx';
import { getAbsenceBoard, statusLabel } from '@/server/absences/queries';
export const metadata = { title: 'Calendrier des absences · PlanFlow' };
interface PageProps {
searchParams: Promise<{ mois?: string }>;
}
const COUNTER_LABELS: Record<string, string> = {
PAID_LEAVE: 'Congés payés',
RTT: 'RTT',
COMPENSATORY_REST: 'Repos compensateur',
MODULATION: 'Modulation',
OVERTIME: 'Heures supplémentaires',
};
const STATUS_TONES: Record<string, Tone> = {
PENDING: 'warn',
ACCEPTED: 'ok',
DECLINED: 'danger',
CANCELLED: 'neutral',
EXPIRED: 'neutral',
};
export default async function CongesPage({ searchParams }: PageProps) {
const params = await searchParams;
const month = parseMonthParam(params.mois) ?? monthOf(new Date());
const board = await getAbsenceBoard(month);
const href = (mois: string) => `/absences/calendrier?mois=${mois}`;
return (
<PageBody>
<PageHeader
title="Calendrier des absences"
subtitle={`${board.label} · ${board.inMonth.length} absence${board.inMonth.length > 1 ? 's' : ''} sur le mois`}
actions={
<>
<Link
href={href(board.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(board.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>
</>
}
/>
<RequestForm
absenceTypes={board.absenceTypes}
people={board.people}
ownMembershipId={board.ownMembershipId}
/>
<section id="attente" className="rounded-3 border border-line-1 bg-surface">
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Demandes en attente
{board.pending.length > 0 ? ` · ${board.pending.length}` : ''}
</h2>
{board.pending.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-3">
Aucune demande à traiter.
</p>
) : (
<ul className="divide-y divide-line-1">
{board.pending.map((request) => (
<li
key={request.id}
className="flex flex-wrap items-center gap-3 px-4 py-3"
>
<div className="min-w-56 flex-1">
<p className="text-sm font-medium text-ink-1">
{request.name} · {request.typeLabel}
</p>
<p className="text-micro text-ink-3">
du {frenchDate(request.startDate)} au{' '}
{frenchDate(request.endDate)} ·{' '}
<span className="tnum">{request.days}</span> jour
{request.days > 1 ? 's' : ''} décompté
{request.days > 1 ? 's' : ''}
</p>
{request.comment ? (
<p className="mt-0.5 text-micro text-ink-2">
« {request.comment} »
</p>
) : null}
</div>
{board.canDecide ? (
<DecisionButtons timeOffId={request.id} />
) : (
<CancelButton timeOffId={request.id} />
)}
</li>
))}
</ul>
)}
</section>
<MonthCalendar board={board} />
<section className="rounded-3 border border-line-1 bg-surface">
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Absences du mois
</h2>
{board.inMonth.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-3">
Aucune absence sur cette période.
</p>
) : (
<ul className="divide-y divide-line-1">
{board.inMonth.map((request) => (
<li
key={request.id}
className="flex flex-wrap items-center gap-3 px-4 py-2.5"
>
<Badge tone={STATUS_TONES[request.status] ?? 'neutral'}>
{statusLabel(request.status)}
</Badge>
<span className="text-sm text-ink-1">{request.name}</span>
<span className="text-sm text-ink-2">{request.typeLabel}</span>
<span className="text-micro text-ink-3">
{frenchDate(request.startDate)} → {frenchDate(request.endDate)}
</span>
<span className="tnum ml-auto text-xs text-ink-2">
{request.days} j
</span>
{request.status === 'ACCEPTED' ? (
<CancelButton timeOffId={request.id} />
) : null}
</li>
))}
</ul>
)}
</section>
<section
id="compteurs"
className="rounded-3 border border-line-1 bg-surface"
>
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Compteurs
</h2>
{board.counters.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-3">
Aucun compteur ouvert. Un compteur se crée à la première écriture —
acceptation d’un congé ou ajustement manuel.
</p>
) : (
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
<th className="px-4 py-2 text-micro font-semibold text-ink-3 uppercase">
Salarié
</th>
<th className="px-4 py-2 text-micro font-semibold text-ink-3 uppercase">
Compteur
</th>
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
Acquis
</th>
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
Pris
</th>
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
Solde
</th>
</tr>
</thead>
<tbody>
{board.counters.map((counter) => (
<tr
key={`${counter.membershipId}-${counter.counterType}`}
className="border-b border-line-1 last:border-b-0"
>
<td className="px-4 py-1.5 text-ink-1">{counter.name}</td>
<td className="px-4 py-1.5 text-ink-2">
{COUNTER_LABELS[counter.counterType] ?? counter.counterType}
</td>
<td className="tnum px-4 py-1.5 text-right text-ink-2">
{counter.accrued}
</td>
<td className="tnum px-4 py-1.5 text-right text-ink-2">
{counter.taken}
</td>
<td className="tnum px-4 py-1.5 text-right font-semibold text-ink-1">
{counter.balance}
</td>
</tr>
))}
</tbody>
</table>
)}
<p className="border-t border-line-1 px-4 py-2 text-micro text-ink-3">
Le solde est la somme des écritures du registre, jamais une valeur
stockée : un solde stocké se désynchronise, et la désynchronisation ne
se voit qu’au moment où un salarié conteste.
</p>
</section>
</PageBody>
);
}
/** Calendrier du mois, une colonne par jour. */
function MonthCalendar({
board,
}: {
board: Awaited<ReturnType<typeof getAbsenceBoard>>;
}) {
const accepted = board.inMonth.filter(
(request) => request.status === 'ACCEPTED' || request.status === 'PENDING',
);
const people = [...new Set(accepted.map((request) => request.membershipId))];
if (people.length === 0) return null;
return (
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
<table className="w-full border-collapse text-xs">
<caption className="sr-only">
Calendrier des absences, {board.label}
</caption>
<thead>
<tr className="border-b border-line-2 bg-surface-2">
<th
scope="col"
className="sticky left-0 z-10 min-w-40 bg-surface-2 px-3 py-2 text-left text-micro font-semibold text-ink-3 uppercase"
>
Salarié
</th>
{board.days.map((date) => (
<th
key={date}
scope="col"
className={cx(
'tnum w-7 px-0.5 py-2 text-center font-semibold',
isoWeekday(date) >= 6
? 'bg-surface-3 text-ink-2'
: 'text-ink-1',
)}
>
{Number(date.slice(8, 10))}
</th>
))}
</tr>
</thead>
<tbody>
{people.map((membershipId) => {
const own = accepted.filter(
(request) => request.membershipId === membershipId,
);
const covered = new Map<string, (typeof own)[number]>();
for (const request of own) {
for (const date of datesBetween(
request.startDate,
request.endDate,
)) {
covered.set(date, request);
}
}
return (
<tr
key={membershipId}
className="border-b border-line-1 last:border-b-0"
>
<th
scope="row"
className="sticky left-0 z-10 bg-surface px-3 py-1.5 text-left text-sm font-medium text-ink-1"
>
{own[0]?.name ?? ''}
</th>
{board.days.map((date) => {
const request = covered.get(date);
return (
<td
key={date}
className={cx(
'border-l border-line-1 px-0.5 py-1.5 text-center',
isoWeekday(date) >= 6 && 'bg-surface-2',
)}
title={
request
? `${request.typeLabel} — ${statusLabel(request.status)}`
: undefined
}
>
{request ? (
<span
className={cx(
'block h-4 rounded-1',
request.status === 'PENDING'
? 'border border-dashed border-warn bg-warn-soft'
: 'bg-info-soft',
)}
>
<span className="sr-only">
{request.typeLabel} le {date}
</span>
</span>
) : null}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function frenchDate(isoDate: string): string {
return isoDate.split('-').reverse().join('/');
}
+33
View File
@@ -0,0 +1,33 @@
import { AbsenceQueueView } from '@/components/absences/AbsenceQueue';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { getAbsenceQueue } from '@/server/absences/queries';
export const metadata = { title: 'Absences expirées · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function ExpireesPage() {
const result = await getAbsenceQueue('expired');
return (
<PageBody>
<PageHeader
title="Absences expirées"
subtitle="Demandes dont la date est passée sans décision"
/>
<AbsenceQueueView
result={result}
title="Demandes expirées"
description="Aucune demande n’a expiré."
/>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Une demande expirée n’a jamais été décidée : elle n’a donc rien écrit au
registre des compteurs, et le solde du salarié n’en porte aucune trace.
Elle reste visible parce qu’une absence non traitée à temps est un fait
opposable — au salarié qui l’a demandée comme à l’employeur qui ne l’a
pas tranchée.
</p>
</PageBody>
);
}
+64
View File
@@ -0,0 +1,64 @@
import Link from 'next/link';
import { AbsenceQueueView } from '@/components/absences/AbsenceQueue';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { getAbsenceQueue } from '@/server/absences/queries';
export const metadata = { title: 'Absences traitées · PlanFlow' };
export const dynamic = 'force-dynamic';
interface PageProps {
searchParams: Promise<{ nature?: string }>;
}
export default async function TraiteesPage({ searchParams }: PageProps) {
const { nature } = await searchParams;
// Filtre relevé à l'audit des menus : « Toutes les absences » ou
// « Uniquement les absences Sécurité sociale ». Deux valeurs, pas trois.
const socialSecurityOnly = nature === 'ss';
const result = await getAbsenceQueue('treated', { socialSecurityOnly });
const tab = (value: string | null, label: string) => {
const active = (value === 'ss') === socialSecurityOnly;
return (
<Link
href={value ? `/absences/traitees?nature=${value}` : '/absences/traitees'}
aria-current={active ? 'page' : undefined}
className={
active
? 'flex h-7 items-center rounded-2 border border-line-3 bg-surface-2 px-2.5 text-xs font-medium text-ink-1'
: 'flex h-7 items-center rounded-2 px-2.5 text-xs text-ink-2 hover:bg-surface-2'
}
>
{label}
</Link>
);
};
return (
<PageBody>
<PageHeader
title="Absences traitées"
subtitle="Décisions rendues : acceptées, refusées et annulées"
/>
<AbsenceQueueView
result={result}
title="Décisions rendues"
description="Aucune absence traitée pour ce filtre."
filterBar={
<div className="flex flex-wrap items-center gap-1">
{tab(null, 'Toutes les absences')}
{tab('ss', 'Uniquement Sécurité sociale')}
</div>
}
/>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Le motif d’une absence Sécurité sociale est une donnée de santé : il
n’apparaît qu’aux personnes habilitées à lire les pièces du dossier. Pour
les autres, la ligne dit « Absence » et rien de plus.
</p>
</PageBody>
);
}
+17 -327
View File
@@ -1,332 +1,22 @@
import Link from 'next/link';
import { permanentRedirect } from 'next/navigation';
import { CancelButton, DecisionButtons } from '@/components/absences/DecisionButtons';
import { RequestForm } from '@/components/absences/RequestForm';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge, type Tone } from '@/components/ui/Badge';
import { datesBetween, isoWeekday } from '@/domain/absences/count';
import { monthOf, parseMonthParam } from '@/domain/planning/month';
import { cx } from '@/lib/cx';
import { getAbsenceBoard, statusLabel } from '@/server/absences/queries';
export const metadata = { title: 'Calendrier des absences · PlanFlow' };
interface PageProps {
searchParams: Promise<{ mois?: string }>;
}
const COUNTER_LABELS: Record<string, string> = {
PAID_LEAVE: 'Congés payés',
RTT: 'RTT',
COMPENSATORY_REST: 'Repos compensateur',
MODULATION: 'Modulation',
OVERTIME: 'Heures supplémentaires',
};
const STATUS_TONES: Record<string, Tone> = {
PENDING: 'warn',
ACCEPTED: 'ok',
DECLINED: 'danger',
CANCELLED: 'neutral',
EXPIRED: 'neutral',
};
export default async function CongesPage({ searchParams }: PageProps) {
const params = await searchParams;
const month = parseMonthParam(params.mois) ?? monthOf(new Date());
const board = await getAbsenceBoard(month);
const href = (mois: string) => `/conges?mois=${mois}`;
return (
<PageBody>
<PageHeader
title="Calendrier des absences"
subtitle={`${board.label} · ${board.inMonth.length} absence${board.inMonth.length > 1 ? 's' : ''} sur le mois`}
actions={
<>
<Link
href={href(board.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(board.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>
</>
}
/>
<RequestForm
absenceTypes={board.absenceTypes}
people={board.people}
ownMembershipId={board.ownMembershipId}
/>
<section id="attente" className="rounded-3 border border-line-1 bg-surface">
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Demandes en attente
{board.pending.length > 0 ? ` · ${board.pending.length}` : ''}
</h2>
{board.pending.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-3">
Aucune demande à traiter.
</p>
) : (
<ul className="divide-y divide-line-1">
{board.pending.map((request) => (
<li
key={request.id}
className="flex flex-wrap items-center gap-3 px-4 py-3"
>
<div className="min-w-56 flex-1">
<p className="text-sm font-medium text-ink-1">
{request.name} · {request.typeLabel}
</p>
<p className="text-micro text-ink-3">
du {frenchDate(request.startDate)} au{' '}
{frenchDate(request.endDate)} ·{' '}
<span className="tnum">{request.days}</span> jour
{request.days > 1 ? 's' : ''} décompté
{request.days > 1 ? 's' : ''}
</p>
{request.comment ? (
<p className="mt-0.5 text-micro text-ink-2">
« {request.comment} »
</p>
) : null}
</div>
{board.canDecide ? (
<DecisionButtons timeOffId={request.id} />
) : (
<CancelButton timeOffId={request.id} />
)}
</li>
))}
</ul>
)}
</section>
<MonthCalendar board={board} />
<section className="rounded-3 border border-line-1 bg-surface">
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Absences du mois
</h2>
{board.inMonth.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-3">
Aucune absence sur cette période.
</p>
) : (
<ul className="divide-y divide-line-1">
{board.inMonth.map((request) => (
<li
key={request.id}
className="flex flex-wrap items-center gap-3 px-4 py-2.5"
>
<Badge tone={STATUS_TONES[request.status] ?? 'neutral'}>
{statusLabel(request.status)}
</Badge>
<span className="text-sm text-ink-1">{request.name}</span>
<span className="text-sm text-ink-2">{request.typeLabel}</span>
<span className="text-micro text-ink-3">
{frenchDate(request.startDate)} → {frenchDate(request.endDate)}
</span>
<span className="tnum ml-auto text-xs text-ink-2">
{request.days} j
</span>
{request.status === 'ACCEPTED' ? (
<CancelButton timeOffId={request.id} />
) : null}
</li>
))}
</ul>
)}
</section>
<section
id="compteurs"
className="rounded-3 border border-line-1 bg-surface"
>
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Compteurs
</h2>
{board.counters.length === 0 ? (
<p className="px-4 py-6 text-sm text-ink-3">
Aucun compteur ouvert. Un compteur se crée à la première écriture —
acceptation d’un congé ou ajustement manuel.
</p>
) : (
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
<th className="px-4 py-2 text-micro font-semibold text-ink-3 uppercase">
Salarié
</th>
<th className="px-4 py-2 text-micro font-semibold text-ink-3 uppercase">
Compteur
</th>
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
Acquis
</th>
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
Pris
</th>
<th className="px-4 py-2 text-right text-micro font-semibold text-ink-3 uppercase">
Solde
</th>
</tr>
</thead>
<tbody>
{board.counters.map((counter) => (
<tr
key={`${counter.membershipId}-${counter.counterType}`}
className="border-b border-line-1 last:border-b-0"
>
<td className="px-4 py-1.5 text-ink-1">{counter.name}</td>
<td className="px-4 py-1.5 text-ink-2">
{COUNTER_LABELS[counter.counterType] ?? counter.counterType}
</td>
<td className="tnum px-4 py-1.5 text-right text-ink-2">
{counter.accrued}
</td>
<td className="tnum px-4 py-1.5 text-right text-ink-2">
{counter.taken}
</td>
<td className="tnum px-4 py-1.5 text-right font-semibold text-ink-1">
{counter.balance}
</td>
</tr>
))}
</tbody>
</table>
)}
<p className="border-t border-line-1 px-4 py-2 text-micro text-ink-3">
Le solde est la somme des écritures du registre, jamais une valeur
stockée : un solde stocké se désynchronise, et la désynchronisation ne
se voit qu’au moment où un salarié conteste.
</p>
</section>
</PageBody>
);
}
/** Calendrier du mois, une colonne par jour. */
function MonthCalendar({
board,
/**
* Ancienne adresse du calendrier des absences.
*
* Les absences vivent désormais sous `/absences`, avec une route par état
* (§9). Le calendrier a déménagé plutôt que d'être dupliqué : deux écrans
* lisant le même modèle finissent toujours par diverger d'une correction.
*
* La redirection est **permanente** et conserve le mois demandé : un lien de
* planning partagé dans un message continue d'ouvrir la bonne page.
*/
export default async function CongesRedirect({
searchParams,
}: {
board: Awaited<ReturnType<typeof getAbsenceBoard>>;
searchParams: Promise<{ mois?: string }>;
}) {
const accepted = board.inMonth.filter(
(request) => request.status === 'ACCEPTED' || request.status === 'PENDING',
);
const people = [...new Set(accepted.map((request) => request.membershipId))];
if (people.length === 0) return null;
return (
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
<table className="w-full border-collapse text-xs">
<caption className="sr-only">
Calendrier des absences, {board.label}
</caption>
<thead>
<tr className="border-b border-line-2 bg-surface-2">
<th
scope="col"
className="sticky left-0 z-10 min-w-40 bg-surface-2 px-3 py-2 text-left text-micro font-semibold text-ink-3 uppercase"
>
Salarié
</th>
{board.days.map((date) => (
<th
key={date}
scope="col"
className={cx(
'tnum w-7 px-0.5 py-2 text-center font-semibold',
isoWeekday(date) >= 6
? 'bg-surface-3 text-ink-2'
: 'text-ink-1',
)}
>
{Number(date.slice(8, 10))}
</th>
))}
</tr>
</thead>
<tbody>
{people.map((membershipId) => {
const own = accepted.filter(
(request) => request.membershipId === membershipId,
);
const covered = new Map<string, (typeof own)[number]>();
for (const request of own) {
for (const date of datesBetween(
request.startDate,
request.endDate,
)) {
covered.set(date, request);
}
}
return (
<tr
key={membershipId}
className="border-b border-line-1 last:border-b-0"
>
<th
scope="row"
className="sticky left-0 z-10 bg-surface px-3 py-1.5 text-left text-sm font-medium text-ink-1"
>
{own[0]?.name ?? ''}
</th>
{board.days.map((date) => {
const request = covered.get(date);
return (
<td
key={date}
className={cx(
'border-l border-line-1 px-0.5 py-1.5 text-center',
isoWeekday(date) >= 6 && 'bg-surface-2',
)}
title={
request
? `${request.typeLabel} — ${statusLabel(request.status)}`
: undefined
}
>
{request ? (
<span
className={cx(
'block h-4 rounded-1',
request.status === 'PENDING'
? 'border border-dashed border-warn bg-warn-soft'
: 'bg-info-soft',
)}
>
<span className="sr-only">
{request.typeLabel} le {date}
</span>
</span>
) : null}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
const { mois } = await searchParams;
permanentRedirect(
mois ? `/absences/calendrier?mois=${mois}` : '/absences/calendrier',
);
}
function frenchDate(isoDate: string): string {
return isoDate.split('-').reverse().join('/');
}
@@ -0,0 +1,78 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { AddEmployeeForm } from '@/app/(app)/equipe/AddEmployeeForm';
import { Button } from '@/components/ui/Button';
import type {
ContractLocation,
HiringOptions,
} from '@/server/employees/queries';
/**
* Embauche, en panneau latéral.
*
* Une trentaine de champs : posés au bas de la liste, ils occupaient plus de
* place que l'effectif qu'on vient consulter. Le panneau glisse par la droite
* et laisse l'annuaire visible — on embauche en regardant qui est déjà là.
*
* `<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.
*/
export function AddEmployeeDialog({
locations,
options,
}: {
locations: ContractLocation[];
options: HiringOptions;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const [open, setOpen] = useState(false);
useEffect(() => {
const element = dialog.current;
if (!element) return;
if (open && !element.open) element.showModal();
if (!open && element.open) element.close();
}, [open]);
return (
<>
<Button variant="primary" onClick={() => setOpen(true)}>
Ajouter un collaborateur
</Button>
<dialog
ref={dialog}
aria-labelledby="titre-embauche"
onClose={() => setOpen(false)}
className="mt-0 mr-0 mb-0 ml-auto h-full max-h-none w-[min(30rem,100vw)] max-w-none border-l border-line-1 bg-surface p-0 text-ink-1 backdrop:bg-[rgb(0_0_0/0.4)]"
>
<div className="flex h-full flex-col">
<header className="flex items-center gap-3 border-b border-line-1 px-5 py-4">
<h2 id="titre-embauche" className="text-md font-semibold">
Nouvel employé
</h2>
<span className="flex-1" />
<Button variant="ghost" onClick={() => setOpen(false)}>
Fermer
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-5 pt-5">
{open ? (
// Monté à l'ouverture : un formulaire démonté entre deux usages
// repart vide, sans traîner la saisie abandonnée la fois d'avant.
<AddEmployeeForm
locations={locations}
options={options}
onSaved={() => setOpen(false)}
/>
) : null}
</div>
</div>
</dialog>
</>
);
}
+308 -17
View File
@@ -1,6 +1,6 @@
'use client';
import { useActionState } from 'react';
import { useActionState, useState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
@@ -8,38 +8,329 @@ import {
createEmployeeAction,
type ActionState,
} from '@/server/employees/actions';
import type {
ContractLocation,
HiringOptions,
} from '@/server/employees/queries';
export function AddEmployeeForm() {
/**
* Embauche.
*
* Deux sections et un seul envoi : ce qui identifie la personne, puis ce qui
* l'emploie. Un salarié créé sans contrat ni équipe est un dossier que rien ne
* rattache — il n'apparaît sur aucune grille et ne se déclare pas — d'où un
* contrat proposé d'emblée, et décochable seulement pour les cas où
* l'établissement n'est pas encore tranché.
*/
const empty: ActionState = {};
const CONTRACT_TYPES = [
['CDI', 'CDI'],
['CDD', 'CDD'],
['APPRENTISSAGE', 'Apprentissage'],
['STAGIAIRE', 'Stagiaire'],
['SAISONNIER', 'Saisonnier'],
['EXTRA', 'Extra'],
['INTERIM', 'Intérim'],
['DIRIGEANT_ASSIMILE_SALARIE', 'Dirigeant assimilé salarié'],
['DIRIGEANT_NON_SALARIE', 'Dirigeant non salarié'],
] as const;
const selectClass =
'h-9 min-w-0 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus';
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-3">
<h3 className="text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
{title}
</h3>
{children}
</section>
);
}
const today = () => new Date().toISOString().slice(0, 10);
export function AddEmployeeForm({
locations,
options,
onSaved,
}: {
/** Vide quand la capacité d'ouvrir un contrat manque : la section disparaît. */
locations: ContractLocation[];
options: HiringOptions;
/** Appelé une fois l'ajout accepté — le panneau s'y referme. */
onSaved?: () => void;
}) {
const [state, formAction] = useActionState<ActionState, FormData>(
createEmployeeAction,
{},
empty,
);
const [withContract, setWithContract] = useState(locations.length > 0);
const [locationId, setLocationId] = useState(locations[0]?.id ?? '');
const [forfait, setForfait] = useState(false);
const [autoNumber, setAutoNumber] = useState(true);
const [acknowledged, setAcknowledged] = useState<ActionState>(empty);
// Acquitté une fois pour toutes : sans cela, un succès resté en mémoire
// refermerait le panneau à sa réouverture.
if (state !== acknowledged && state.ok) {
setAcknowledged(state);
onSaved?.();
}
const teams =
locations.find((location) => location.id === locationId)?.teams ?? [];
return (
<PersistentForm
action={formAction}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-3"
className="flex flex-col gap-6"
>
<div className="flex flex-wrap gap-3">
<Section title="Informations salarié">
<Field label="Prénom" name="firstName" required />
<Field label="Nom" name="lastName" required />
<Field label="Matricule" name="employeeNumber" required />
</div>
<Field
label="Nom de naissance"
name="birthName"
hint="Laissez vide s’il est identique au nom de famille."
/>
<Field label="Nom de famille" name="lastName" required />
<Field
label="Adresse électronique"
name="email"
type="email"
hint="Facultative. Un salarié sans adresse reste plannifiable et déclarable ; il n'aura simplement pas d'accès à l'application."
/>
<div className="flex flex-col gap-1.5">
<div className="flex items-baseline gap-3">
<span className="text-sm font-medium">Matricule</span>
<span className="flex-1" />
<button
type="button"
onClick={() => setAutoNumber(!autoNumber)}
className="text-micro font-medium text-accent-soft-ink underline underline-offset-2"
>
{autoNumber
? 'Saisir un matricule'
: 'Passer à la génération automatique'}
</button>
</div>
{autoNumber ? (
<p className="text-micro text-ink-3">
Attribué à la suite du dernier, au moment de l’enregistrement.
</p>
) : (
<input
name="employeeNumber"
aria-label="Matricule"
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1 outline-none focus-visible:border-focus"
/>
)}
</div>
<Field label="Date de naissance" name="birthDate" type="date" />
<Field
label="Adresse électronique"
name="email"
type="email"
hint="Permet de se connecter à PlanFlow et de recevoir ses plannings. Sans elle, le salarié reste planifiable et déclarable."
/>
<Field label="Téléphone mobile" name="phone" type="tel" />
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name="smsSchedules"
className="mt-0.5 size-4 accent-[var(--accent)]"
/>
<span>
<span className="font-medium">Envoyer les plannings par SMS</span>
<span className="block text-micro text-ink-3">
Consentement du salarié : à recueillir avant de l’activer.
</span>
</span>
</label>
<Field label="Téléphone fixe" name="landline" type="tel" />
</Section>
{locations.length > 0 ? (
<Section title="Contrat">
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
name="withContract"
checked={withContract}
onChange={(event) => setWithContract(event.target.checked)}
className="size-4 accent-[var(--accent)]"
/>
Ouvrir un contrat maintenant
</label>
{withContract ? (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-3">
<Field
label="Date de début de contrat"
name="startDate"
type="date"
required
defaultValue={today()}
/>
<Field
label="Heure de début de contrat"
name="startTime"
type="time"
required
defaultValue="09:00"
hint="Demandée par la DPAE."
/>
</div>
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm font-medium">Type de contrat</span>
<select name="contractType" className={selectClass}>
{CONTRACT_TYPES.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
<div className="flex flex-wrap gap-3">
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">
Organisation du temps
</span>
<select
name="workTimeArrangement"
className={selectClass}
onChange={(event) =>
setForfait(event.target.value === 'FORFAIT_JOURS')
}
>
<option value="HOURLY">Décompte horaire</option>
<option value="FORFAIT_JOURS">Forfait jours</option>
</select>
</label>
{forfait ? (
<Field
label="Jours par an"
name="forfaitDaysPerYear"
type="number"
step="0.5"
min="0"
max="218"
defaultValue="218"
/>
) : (
<Field
label="Temps de travail hebdomadaire"
name="weeklyHours"
type="number"
step="0.5"
min="0"
max="60"
defaultValue="35"
/>
)}
</div>
{forfait ? (
<div className="flex flex-wrap gap-3">
<Field
label="Référence de la convention de forfait"
name="forfaitAgreementRef"
required
hint="Sans convention individuelle écrite, le forfait est inopposable."
/>
<Field
label="Date d’accord du salarié"
name="forfaitAgreedAt"
type="date"
required
/>
</div>
) : null}
<Field
label="Fin du contrat"
name="endDate"
type="date"
hint="Vide pour un contrat sans terme."
/>
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm font-medium">
Établissement par défaut
</span>
<select
name="locationId"
className={selectClass}
value={locationId}
onChange={(event) => setLocationId(event.target.value)}
>
{locations.map((location) => (
<option key={location.id} value={location.id}>
{location.name}
</option>
))}
</select>
</label>
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm font-medium">Équipe</span>
<select name="teamId" className={selectClass}>
<option value="">Aucune pour l’instant</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
</label>
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm font-medium">
Responsable hiérarchique
</span>
<select name="lineManagerId" className={selectClass}>
<option value="">Aucun</option>
{options.managers.map((manager) => (
<option key={manager.id} value={manager.id}>
{manager.name}
</option>
))}
</select>
</label>
{options.rttPolicies.length > 0 ? (
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm font-medium">Politique RTT</span>
<select name="rttPolicyId" className={selectClass}>
<option value="">Aucune</option>
{options.rttPolicies.map((policy) => (
<option key={policy.id} value={policy.id}>
{policy.name}
</option>
))}
</select>
</label>
) : null}
</div>
) : null}
</Section>
) : null}
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Ajouter</SubmitButton>
<div className="sticky bottom-0 -mx-5 border-t border-line-1 bg-surface px-5 py-4">
<SubmitButton className="w-full">Enregistrer</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Salarié ajouté.</span>
<p className="mt-2 text-center text-xs text-ok-soft-ink">
Salarié ajouté.
</p>
) : null}
</div>
</PersistentForm>
+125
View File
@@ -0,0 +1,125 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useRef } from 'react';
/**
* Filtres de l'annuaire.
*
* L'état vit dans l'adresse, pas dans le composant : un effectif filtré
* s'envoie par lien, se met en favori, et le retour arrière défait le dernier
* choix plutôt que de vider la page.
*/
/** Pastilles plutôt que champs : une barre de filtres se survole, elle ne se
* remplit pas. */
const selectClass =
'h-10 min-w-0 cursor-pointer rounded-full border border-line-2 bg-surface px-4 text-sm text-ink-1 outline-none hover:bg-surface-2 focus-visible:border-focus';
export interface FilterOptions {
locations: Array<{ id: string; name: string }>;
roles: Array<{ id: string; name: string }>;
contractTypes: Array<{ value: string; label: string }>;
}
export function DirectoryFilters({ options }: { options: FilterOptions }) {
const router = useRouter();
const params = useSearchParams();
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
const current = (key: string) => params.get(key) ?? '';
function apply(key: string, value: string) {
const next = new URLSearchParams(params.toString());
if (value) next.set(key, value);
else next.delete(key);
router.replace(next.size > 0 ? `/equipe?${next}` : '/equipe');
}
return (
<div className="flex flex-col gap-3">
<label className="flex flex-col gap-1.5">
<span className="sr-only">Rechercher un collaborateur</span>
<input
name="q"
type="search"
defaultValue={current('q')}
placeholder="Rechercher par prénom, nom ou matricule."
className="h-11 rounded-full border border-line-2 bg-surface px-4 text-sm text-ink-1 outline-none placeholder:text-ink-3 focus-visible:border-focus"
onChange={(event) => {
// La frappe ne déclenche pas une requête par caractère : elle
// attend un silence. Sans cela, chercher « Dupont » en lance six.
const value = event.target.value;
if (debounce.current) clearTimeout(debounce.current);
debounce.current = setTimeout(() => apply('q', value), 300);
}}
/>
</label>
<div className="flex flex-wrap gap-2">
<select
aria-label="Établissement"
className={selectClass}
value={current('etablissement')}
onChange={(event) => apply('etablissement', event.target.value)}
>
<option value="">Tous les établissements</option>
{options.locations.map((location) => (
<option key={location.id} value={location.id}>
{location.name}
</option>
))}
</select>
<select
aria-label="Rôle"
className={selectClass}
value={current('role')}
onChange={(event) => apply('role', event.target.value)}
>
<option value="">Tous les rôles</option>
{options.roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
<select
aria-label="Type de contrat"
className={selectClass}
value={current('contrat')}
onChange={(event) => apply('contrat', event.target.value)}
>
<option value="">Tous les types de contrats</option>
{options.contractTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
<select
aria-label="Présence"
className={selectClass}
value={current('presence')}
onChange={(event) => apply('presence', event.target.value)}
>
<option value="">Utilisateurs actifs</option>
<option value="archived">Archivés</option>
<option value="all">Tous</option>
</select>
<select
aria-label="Tri"
className={selectClass}
value={current('tri')}
onChange={(event) => apply('tri', event.target.value)}
>
<option value="">Tri par nom</option>
<option value="number">Tri par matricule</option>
</select>
</div>
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/Button';
/**
* Édition du registre unique du personnel.
*
* L'établissement est demandé avant le téléchargement, et non déduit : le
* registre se tient **par établissement**, et en tirer un au hasard pour une
* entreprise qui en compte trente ne répondrait à aucune demande d'inspection.
*
* L'avertissement sur les mentions manquantes arrive avant le fichier, pas
* dedans : découvrir un registre incomplet en l'ouvrant, c'est le découvrir
* devant l'inspection.
*/
export interface RegisterLocation {
id: string;
name: string;
incomplete: number;
total: number;
}
export function RegisterDialog({
locations,
}: {
locations: RegisterLocation[];
}) {
const dialog = useRef<HTMLDialogElement>(null);
const [open, setOpen] = useState(false);
const [locationId, setLocationId] = useState('');
useEffect(() => {
const element = dialog.current;
if (!element) return;
if (open && !element.open) element.showModal();
if (!open && element.open) element.close();
}, [open]);
const chosen = locations.find((location) => location.id === locationId);
return (
<>
<Button onClick={() => setOpen(true)}>
Registre unique du personnel
</Button>
<dialog
ref={dialog}
aria-labelledby="titre-rup"
onClose={() => setOpen(false)}
className="m-auto w-[min(38rem,calc(100vw-2rem))] rounded-3 border border-line-1 bg-surface p-0 text-ink-1 backdrop:bg-[rgb(0_0_0/0.4)]"
>
<header className="flex items-center gap-3 px-5 pt-5">
<h2 id="titre-rup" className="text-lg font-semibold">
Téléchargez votre registre unique du personnel
</h2>
<span className="flex-1" />
<Button variant="ghost" onClick={() => setOpen(false)}>
Fermer
</Button>
</header>
<div className="flex flex-col gap-4 p-5">
<p className="rounded-3 border border-line-1 bg-surface-2 p-4 text-sm leading-[var(--lh-prose)] text-ink-2">
Tout employeur doit tenir un registre unique du personnel par
établissement. Aucune forme n’est imposée : en cas de contrôle par
l’inspection du travail, le format numérique peut être présenté.
</p>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">
Établissement pour lequel éditer le registre
</span>
<select
value={locationId}
onChange={(event) => setLocationId(event.target.value)}
className="h-10 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1 outline-none focus-visible:border-focus"
>
<option value="">Choisissez un établissement…</option>
{locations.map((location) => (
<option key={location.id} value={location.id}>
{location.name}
</option>
))}
</select>
</label>
{chosen && chosen.incomplete > 0 ? (
<div
role="alert"
className="rounded-3 border border-warn bg-warn-soft p-4 text-sm text-warn-soft-ink"
>
<p className="font-semibold">
Des mentions manquent pour {chosen.incomplete} salarié
{chosen.incomplete > 1 ? 's' : ''} de cet établissement
</p>
<p className="mt-1 leading-[var(--lh-prose)]">
Le registre reste éditable, et les manques y figurent en clair.
Un registre incomplet expose à une contravention par salarié
concerné : complétez les dossiers avant de le présenter.
</p>
</div>
) : null}
{chosen && chosen.total === 0 ? (
<p className="text-sm text-ink-3">
Aucun contrat n’est rattaché à cet établissement : le registre
sera vide.
</p>
) : null}
</div>
<footer className="flex items-center justify-end gap-3 border-t border-line-1 px-5 py-4">
<Button onClick={() => setOpen(false)}>Annuler</Button>
{/* Un lien, pas un bouton : le navigateur télécharge sans quitter la
page, et l'adresse reste rejouable. */}
<a
href={
locationId ? `/equipe/registre?etablissement=${locationId}` : '#'
}
aria-disabled={!locationId}
onClick={(event) => {
if (!locationId) event.preventDefault();
else setOpen(false);
}}
className={
locationId
? 'inline-flex h-8 items-center rounded-2 border border-accent bg-accent px-3.5 text-sm font-medium text-accent-ink hover:bg-accent-hover'
: 'pointer-events-none inline-flex h-8 items-center rounded-2 border border-line-2 bg-surface-3 px-3.5 text-sm font-medium text-ink-3'
}
>
Télécharger
</a>
</footer>
</dialog>
</>
);
}
+66
View File
@@ -0,0 +1,66 @@
import type { ReactNode } from 'react';
/**
* Carte de consultation du dossier.
*
* Deux colonnes par ligne — l'intitulé, puis la valeur — et un filet entre
* chaque : c'est la forme d'un état civil, qui se parcourt du regard en
* cherchant un champ précis plutôt qu'en lisant de haut en bas.
*/
export function InfoCard({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<section className="rounded-3 border border-line-1 bg-surface p-5">
<header className="flex items-center gap-2.5">
<span
aria-hidden
className="size-7 flex-none rounded-2 border border-accent bg-accent-soft"
/>
<h2 className="text-md font-semibold">{title}</h2>
</header>
<dl className="mt-4">{children}</dl>
</section>
);
}
export function InfoRow({
label,
value,
tnum = false,
}: {
label: string;
value: string | null | undefined;
/** Chiffres alignés : dates, téléphones, numéros. */
tnum?: boolean;
}) {
return (
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border-b border-line-1 py-3 last:border-b-0">
<dt className="min-w-[11rem] flex-none text-sm font-medium text-ink-1">
{label}
</dt>
<dd
className={
value
? `text-sm text-ink-2${tnum ? ' tnum' : ''}`
: 'text-sm text-ink-3'
}
>
{value || 'Non renseigné'}
</dd>
</div>
);
}
/** Grille à deux colonnes des cartes du dossier. */
export function InfoGrid({ children }: { children: ReactNode }) {
return (
<div className="grid items-start gap-5 [grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr))]">
{children}
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cx } from '@/lib/cx';
/**
* Onglets de la fiche salarié.
*
* Chaque onglet est une **route**, pas un état de composant : une fiche
* s'envoie par lien, se rouvre au même endroit, et le retour arrière ramène à
* l'onglet précédent plutôt qu'à la liste.
*/
const TABS = [
{ segment: '', label: 'Informations personnelles' },
{ segment: 'contrats', label: 'Contrats' },
{ segment: 'planification', label: 'Planification et accès' },
{ segment: 'absences', label: 'Congés et Absences' },
{ segment: 'documents', label: 'Documents' },
] as const;
export function MemberTabs({ id }: { id: string }) {
const pathname = usePathname();
const base = `/equipe/${id}`;
return (
<nav
aria-label="Sections de la fiche"
className="flex gap-1 overflow-x-auto rounded-3 border border-line-1 bg-surface p-1.5"
>
{TABS.map((tab) => {
const href = tab.segment ? `${base}/${tab.segment}` : base;
const active = tab.segment
? pathname.startsWith(href)
: pathname === base;
return (
<Link
key={tab.segment}
href={href}
aria-current={active ? 'page' : undefined}
className={cx(
'shrink-0 rounded-full px-3.5 py-1.5 text-sm font-medium whitespace-nowrap',
'transition-colors duration-[var(--d-1)] ease-organic',
active
? 'bg-surface-3 text-ink-1'
: 'text-ink-2 hover:bg-surface-2 hover:text-ink-1',
)}
>
{tab.label}
</Link>
);
})}
</nav>
);
}
@@ -0,0 +1,396 @@
'use client';
import { useActionState, useState } from 'react';
import { InfoCard, InfoGrid, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard';
import { Button } from '@/components/ui/Button';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import {
GENDERS,
MARITAL_STATUSES,
genderLabel,
maritalStatusLabel,
} from '@/domain/hr/civil-status';
import {
updateProfileAction,
updateSensitiveAction,
type ProfileActionState,
} from '@/server/employees/profile-actions';
/**
* Dossier personnel : consultation, puis saisie.
*
* Un seul écran et deux modes, plutôt qu'une page de lecture et une page de
* formulaire. Le dossier se corrige champ par champ, souvent au téléphone avec
* la personne concernée : la faire changer de page pour changer un chiffre
* ferait perdre le reste de vue.
*/
export interface ProfileFields {
gender: string;
firstName: string;
birthName: string;
lastName: string;
birthDate: string;
birthPlace: string;
birthCountry: string;
birthDepartment: string;
nationality: string;
maritalStatus: string;
dependents: string;
personalEmail: string;
phone: string;
landline: string;
smsSchedules: boolean;
addressLine1: string;
addressLine2: string;
postalCode: string;
city: string;
country: string;
emergencyContactName: string;
emergencyContactPhone: string;
}
export interface SensitiveFields {
socialSecurityNumber: string;
iban: string;
bic: string;
}
const empty: ProfileActionState = {};
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
/**
* Liste déroulante à valeur facultative.
*
* L'option vide est explicite : sans elle, ouvrir le formulaire poserait la
* première valeur de la liste sur un champ que personne n'a renseigné.
*/
function Select({
label,
name,
defaultValue,
options,
}: {
label: string;
name: string;
defaultValue: string;
options: ReadonlyArray<readonly [string, string]>;
}) {
return (
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm font-medium">{label}</span>
<select
name={name}
defaultValue={defaultValue}
className="h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus"
>
<option value="">Non renseigné</option>
{options.map(([value, text]) => (
<option key={value} value={value}>
{text}
</option>
))}
</select>
</label>
);
}
function readableDate(value: string): string {
if (!value) return '';
const parsed = new Date(`${value}T00:00:00Z`);
return Number.isNaN(parsed.getTime()) ? value : dateFormat.format(parsed);
}
export function PersonalInfoPanel({
membershipId,
profile,
sensitive,
canEdit,
}: {
membershipId: string;
profile: ProfileFields;
/** Absent quand la capacité de lecture manque : la carte n'est pas rendue. */
sensitive: SensitiveFields | null;
canEdit: boolean;
}) {
const [editing, setEditing] = useState(false);
const [state, save] = useActionState(updateProfileAction, empty);
const [acknowledged, setAcknowledged] = useState<ProfileActionState>(empty);
// L'enregistrement referme le mode saisie, mais seulement après la réponse :
// refermer à l'envoi ferait disparaître le message d'erreur avec le
// formulaire, et avec lui ce que la personne venait de taper.
//
// La réponse est **acquittée** une fois pour toutes : sans cela, un succès
// resté en mémoire refermerait aussitôt le formulaire à la prochaine
// ouverture, et le dossier ne serait plus jamais modifiable.
if (state !== acknowledged && state.ok) {
setAcknowledged(state);
setEditing(false);
}
return (
<div className="flex flex-col gap-5">
{editing ? (
<form action={save} className="flex flex-col gap-5">
<input type="hidden" name="membershipId" value={membershipId} />
<InfoGrid>
<InfoCard title="État civil">
<div className="flex flex-col gap-3 pt-1">
<Select
label="Genre"
name="gender"
defaultValue={profile.gender}
options={GENDERS}
/>
<Field label="Prénom" name="firstName" required defaultValue={profile.firstName} />
<Field
label="Nom de naissance"
name="birthName"
defaultValue={profile.birthName}
/>
<Field label="Nom de famille" name="lastName" required defaultValue={profile.lastName} />
<Field label="Nationalité" name="nationality" defaultValue={profile.nationality} />
<Field
label="Date de naissance"
name="birthDate"
type="date"
defaultValue={profile.birthDate}
/>
<Field
label="Pays de naissance"
name="birthCountry"
defaultValue={profile.birthCountry}
/>
<Field
label="Département de naissance"
name="birthDepartment"
defaultValue={profile.birthDepartment}
/>
<Field
label="Commune de naissance"
name="birthPlace"
defaultValue={profile.birthPlace}
/>
<Select
label="Situation familiale"
name="maritalStatus"
defaultValue={profile.maritalStatus}
options={MARITAL_STATUSES}
/>
<Field
label="Nombre de personnes à charge"
name="dependents"
type="number"
min="0"
max="99"
defaultValue={profile.dependents}
/>
</div>
</InfoCard>
<InfoCard title="Coordonnées">
<div className="flex flex-col gap-3 pt-1">
<Field
label="Adresse électronique personnelle"
name="personalEmail"
type="email"
defaultValue={profile.personalEmail}
/>
<Field label="Téléphone mobile" name="phone" type="tel" defaultValue={profile.phone} />
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name="smsSchedules"
defaultChecked={profile.smsSchedules}
className="mt-0.5 size-4 accent-[var(--accent)]"
/>
<span>
<span className="font-medium">Notifications SMS</span>
<span className="block text-micro text-ink-3">
Consentement du salarié : à recueillir avant de l’activer.
</span>
</span>
</label>
<Field label="Téléphone fixe" name="landline" type="tel" defaultValue={profile.landline} />
<Field label="Adresse" name="addressLine1" defaultValue={profile.addressLine1} />
<Field
label="Complément d’adresse"
name="addressLine2"
defaultValue={profile.addressLine2}
/>
<div className="flex flex-wrap gap-3">
<Field label="Code postal" name="postalCode" defaultValue={profile.postalCode} />
<Field label="Ville" name="city" defaultValue={profile.city} />
</div>
<Field label="Pays" name="country" defaultValue={profile.country} />
</div>
</InfoCard>
<InfoCard title="Contact d’urgence">
<div className="flex flex-col gap-3 pt-1">
<Field
label="Nom"
name="emergencyContactName"
defaultValue={profile.emergencyContactName}
/>
<Field
label="Téléphone"
name="emergencyContactPhone"
type="tel"
defaultValue={profile.emergencyContactPhone}
/>
</div>
</InfoCard>
</InfoGrid>
<FormError>{state.error}</FormError>
{/* Barre collante : le dossier est long, et le bouton d'enregistrement
ne doit pas se trouver hors de l'écran au moment où l'on finit de
corriger un champ du haut. */}
<div className="sticky bottom-4 flex flex-wrap items-center justify-center gap-3 rounded-3 border border-line-1 bg-surface p-3 shadow-[var(--shadow-1,0_1px_2px_rgba(0,0,0,0.06))]">
<Button type="button" onClick={() => setEditing(false)}>
Annuler
</Button>
<SubmitButton>Enregistrer les modifications</SubmitButton>
</div>
</form>
) : (
<>
<InfoGrid>
<InfoCard title="État civil">
<InfoRow label="Genre" value={genderLabel(profile.gender || null)} />
<InfoRow label="Prénom" value={profile.firstName} />
<InfoRow label="Nom de naissance" value={profile.birthName} />
<InfoRow label="Nom de famille" value={profile.lastName} />
<InfoRow label="Nationalité" value={profile.nationality} />
<InfoRow label="Date de naissance" value={readableDate(profile.birthDate)} tnum />
<InfoRow label="Pays de naissance" value={profile.birthCountry} />
<InfoRow label="Département de naissance" value={profile.birthDepartment} />
<InfoRow label="Commune de naissance" value={profile.birthPlace} />
<InfoRow
label="Situation familiale"
value={maritalStatusLabel(profile.maritalStatus || null)}
/>
<InfoRow
label="Nombre de personnes à charge"
value={profile.dependents}
tnum
/>
</InfoCard>
<InfoCard title="Coordonnées">
<InfoRow label="Adresse électronique personnelle" value={profile.personalEmail} />
<InfoRow label="Téléphone mobile" value={profile.phone} tnum />
<InfoRow
label="Notifications SMS"
value={profile.smsSchedules ? 'Oui' : 'Non'}
/>
<InfoRow label="Téléphone fixe" value={profile.landline} tnum />
<InfoRow label="Adresse" value={profile.addressLine1} />
<InfoRow label="Complément d’adresse" value={profile.addressLine2} />
<InfoRow label="Code postal" value={profile.postalCode} tnum />
<InfoRow label="Ville" value={profile.city} />
<InfoRow label="Pays" value={profile.country} />
</InfoCard>
<InfoCard title="Contact d’urgence">
<InfoRow label="Nom" value={profile.emergencyContactName} />
<InfoRow label="Téléphone" value={profile.emergencyContactPhone} tnum />
</InfoCard>
</InfoGrid>
{canEdit ? (
<div className="flex justify-center">
<Button variant="primary" onClick={() => setEditing(true)}>
Modifier les informations personnelles
</Button>
</div>
) : null}
</>
)}
{sensitive ? (
<SensitivePanel
membershipId={membershipId}
sensitive={sensitive}
canEdit={canEdit}
/>
) : null}
</div>
);
}
/**
* NIR, IBAN et BIC.
*
* Séparés du reste, et pas seulement à l'écran : ils sont chiffrés au repos,
* leur lecture demande sa propre capacité, et leur écriture passe par une autre
* action. Les mêler au formulaire commun ferait dépendre leur sort d'un droit
* qui ne les concerne pas.
*/
function SensitivePanel({
membershipId,
sensitive,
canEdit,
}: {
membershipId: string;
sensitive: SensitiveFields;
canEdit: boolean;
}) {
const [editing, setEditing] = useState(false);
const [state, save] = useActionState(updateSensitiveAction, empty);
const [acknowledged, setAcknowledged] = useState<ProfileActionState>(empty);
if (state !== acknowledged && state.ok) {
setAcknowledged(state);
setEditing(false);
}
return (
<InfoCard title="Données protégées">
{editing ? (
<form action={save} className="flex flex-col gap-3 pt-1">
<input type="hidden" name="membershipId" value={membershipId} />
<Field
label="Numéro de sécurité sociale"
name="socialSecurityNumber"
inputMode="numeric"
defaultValue={sensitive.socialSecurityNumber}
hint="Quinze chiffres. Conservé chiffré ; jamais écrit au journal."
/>
<Field label="IBAN" name="iban" defaultValue={sensitive.iban} />
<Field label="BIC" name="bic" defaultValue={sensitive.bic} />
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Enregistrer</SubmitButton>
<Button type="button" onClick={() => setEditing(false)}>
Annuler
</Button>
</div>
</form>
) : (
<>
<InfoRow
label="Numéro de sécurité sociale"
value={sensitive.socialSecurityNumber}
tnum
/>
<InfoRow label="IBAN" value={sensitive.iban} tnum />
<InfoRow label="BIC" value={sensitive.bic} tnum />
{canEdit ? (
<div className="pt-3">
<Button onClick={() => setEditing(true)}>Modifier</Button>
</div>
) : null}
</>
)}
</InfoCard>
);
}
@@ -0,0 +1,99 @@
import { Badge, type Tone } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import { listMemberAbsences, statusLabel } from '@/server/absences/queries';
export const dynamic = 'force-dynamic';
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' });
const STATUS_TONES: Record<string, Tone> = {
PENDING: 'warn',
ACCEPTED: 'ok',
DECLINED: 'danger',
CANCELLED: 'neutral',
EXPIRED: 'neutral',
};
const readable = (iso: string) => dateFormat.format(new Date(`${iso}T00:00:00Z`));
export default async function AbsencesTab({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const absences = await listMemberAbsences(id);
if (!absences) {
return (
<EmptyState
title="Absences non consultables"
description="Votre rôle ne donne pas accès aux congés des autres salariés."
/>
);
}
return (
<div className="flex flex-col gap-5">
<Card>
<CardHeader title="Compteurs" />
{absences.counters.length === 0 ? (
<EmptyState
title="Aucun compteur"
description="Les compteurs s’ouvrent à la première acquisition de la période."
/>
) : (
<div className="grid gap-x-8 gap-y-4 p-4 [grid-template-columns:repeat(auto-fit,minmax(170px,1fr))]">
{absences.counters.map((counter) => (
<div key={`${counter.membershipId}-${counter.counterType}`}>
<p className="text-micro font-semibold text-ink-1">
{counter.counterType}
</p>
<p className="tnum mt-1 text-lg font-semibold">
{counter.balance}
</p>
<p className="tnum text-micro text-ink-3">
{counter.accrued} acquis · {counter.taken} pris
</p>
</div>
))}
</div>
)}
</Card>
<Card>
<CardHeader
title="Congés et absences"
badge={<Badge tone="neutral">{absences.requests.length}</Badge>}
/>
{absences.requests.length === 0 ? (
<EmptyState title="Aucune absence enregistrée" />
) : (
<ul>
{absences.requests.map((request) => (
<li
key={request.id}
className="flex flex-wrap items-center gap-2 border-b border-line-1 px-4 py-3 last:border-b-0"
>
<span className="font-medium">{request.typeLabel}</span>
<span className="tnum text-xs text-ink-2">
{readable(request.startDate)}
{request.endDate !== request.startDate
? ` → ${readable(request.endDate)}`
: ''}
</span>
<span className="tnum text-xs text-ink-3">
{request.days} j
</span>
<span className="flex-1" />
<Badge tone={STATUS_TONES[request.status] ?? 'neutral'}>
{statusLabel(request.status)}
</Badge>
</li>
))}
</ul>
)}
</Card>
</div>
);
}
@@ -0,0 +1,254 @@
'use client';
import { useActionState, useState } from 'react';
import { InfoCard, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard';
import { Button } from '@/components/ui/Button';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import {
createAmendmentAction,
endContractAction,
type ActionState,
} from '@/server/employees/actions';
/**
* Vie du contrat en cours.
*
* Trois gestes et un seul menu : lire le détail, déclarer un changement,
* terminer. Ils sont réunis parce qu'ils portent sur le même objet, et séparés
* du formulaire de création parce qu'un contrat qui existe ne se recrée pas.
*/
const empty: ActionState = {};
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
export interface ActiveContract {
id: string;
label: string;
startDate: string;
endDate: string | null;
trialEndDate: string | null;
weeklyHours: string;
forfaitJours: boolean;
forfaitDaysPerYear: string | null;
forfaitAgreementRef: string | null;
isModulated: boolean;
classification: string | null;
coefficient: string | null;
locationName: string | null;
monthlySalary: string | null;
jobTitle: string | null;
}
const readable = (iso: string | null) =>
iso ? dateFormat.format(new Date(`${iso}T00:00:00Z`)) : '';
type Panel = 'none' | 'detail' | 'amend' | 'end';
export function ContractActions({
contract,
canEdit,
canSeeSalary,
}: {
contract: ActiveContract;
canEdit: boolean;
canSeeSalary: boolean;
}) {
const [panel, setPanel] = useState<Panel>('none');
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-md font-semibold">Contrat en cours</h2>
<span className="flex-1" />
<Button
onClick={() => setPanel(panel === 'detail' ? 'none' : 'detail')}
>
{panel === 'detail' ? 'Masquer le détail' : 'Afficher le détail'}
</Button>
{canEdit ? (
<>
<Button
onClick={() => setPanel(panel === 'amend' ? 'none' : 'amend')}
>
Déclarer un changement
</Button>
<Button
variant="danger"
onClick={() => setPanel(panel === 'end' ? 'none' : 'end')}
>
Terminer le contrat
</Button>
</>
) : null}
</div>
<InfoCard title={contract.label}>
<InfoRow label="Début du contrat" value={readable(contract.startDate)} tnum />
<InfoRow label="Fin du contrat" value={readable(contract.endDate)} tnum />
<InfoRow label="Emploi" value={contract.jobTitle} />
<InfoRow
label="Organisation du temps"
value={
contract.forfaitJours
? `Forfait jours · ${contract.forfaitDaysPerYear ?? '—'} jours par an`
: `${contract.weeklyHours} heures hebdomadaires`
}
/>
{canSeeSalary ? (
<InfoRow
label="Rémunération mensuelle brute"
value={contract.monthlySalary ? `${contract.monthlySalary} €` : ''}
tnum
/>
) : null}
<InfoRow label="Établissement" value={contract.locationName} />
{panel === 'detail' ? (
<>
<InfoRow
label="Fin de période d’essai"
value={readable(contract.trialEndDate)}
tnum
/>
<InfoRow label="Classification" value={contract.classification} />
<InfoRow label="Coefficient" value={contract.coefficient} tnum />
<InfoRow
label="Modulation"
value={contract.isModulated ? 'Oui' : 'Non'}
/>
{contract.forfaitJours ? (
<InfoRow
label="Convention de forfait"
value={contract.forfaitAgreementRef}
/>
) : null}
</>
) : null}
</InfoCard>
{panel === 'amend' ? (
<AmendmentForm
contractId={contract.id}
weeklyHours={contract.weeklyHours}
onDone={() => setPanel('none')}
/>
) : null}
{panel === 'end' ? (
<EndForm contractId={contract.id} onDone={() => setPanel('none')} />
) : null}
</div>
);
}
/**
* Avenant.
*
* Le motif est obligatoire : un changement de durée sans raison écrite est
* indéfendable devant un contrôle, et illisible six mois plus tard.
*/
function AmendmentForm({
contractId,
weeklyHours,
onDone,
}: {
contractId: string;
weeklyHours: string;
onDone: () => void;
}) {
const [state, submit] = useActionState(createAmendmentAction, empty);
const [acknowledged, setAcknowledged] = useState<ActionState>(empty);
if (state !== acknowledged && state.ok) {
setAcknowledged(state);
onDone();
}
return (
<InfoCard title="Déclarer un changement">
<form action={submit} className="flex flex-col gap-3 pt-1">
<input type="hidden" name="contractId" value={contractId} />
<div className="flex flex-wrap gap-3">
<Field
label="Date d’effet"
name="effectiveDate"
type="date"
required
/>
<Field
label="Durée hebdomadaire"
name="weeklyHours"
type="number"
step="0.5"
min="0"
max="60"
defaultValue={weeklyHours}
/>
</div>
<Field
label="Motif"
name="reason"
required
hint="Ce que l’avenant change, et pourquoi. Conservé au journal."
/>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Enregistrer l’avenant</SubmitButton>
<Button type="button" onClick={onDone}>
Annuler
</Button>
</div>
</form>
</InfoCard>
);
}
function EndForm({
contractId,
onDone,
}: {
contractId: string;
onDone: () => void;
}) {
const [state, submit] = useActionState(endContractAction, empty);
const [acknowledged, setAcknowledged] = useState<ActionState>(empty);
if (state !== acknowledged && state.ok) {
setAcknowledged(state);
onDone();
}
return (
<InfoCard title="Terminer le contrat">
<form action={submit} className="flex flex-col gap-3 pt-1">
<input type="hidden" name="contractId" value={contractId} />
<p className="text-sm text-ink-2">
Le contrat est daté et clos, non supprimé : la période travaillée
reste opposable, et le solde de tout compte s’appuie dessus.
</p>
<div className="flex flex-wrap gap-3">
<Field label="Date de fin" name="endDate" type="date" required />
<Field
label="Motif de fin"
name="endReason"
required
hint="Démission, fin de CDD, rupture conventionnelle…"
/>
</div>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Terminer le contrat</SubmitButton>
<Button type="button" onClick={onDone}>
Annuler
</Button>
</div>
</form>
</InfoCard>
);
}
@@ -0,0 +1,166 @@
'use client';
import { useActionState, useState } from 'react';
import { InfoCard } from '@/app/(app)/equipe/[id]/InfoCard';
import { Button } from '@/components/ui/Button';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import {
createContractAction,
type ActionState,
} from '@/server/employees/actions';
/**
* Ouverture d'un contrat.
*
* Le forfait jours découvre ses propres champs : la convention individuelle
* écrite et sa date d'accord sont exigées par la règle métier, et les demander
* en permanence ferait porter à tous les contrats horaires deux champs qui ne
* les concernent pas.
*/
const empty: ActionState = {};
const CONTRACT_TYPES = [
['CDI', 'CDI'],
['CDD', 'CDD'],
['APPRENTISSAGE', 'Apprentissage'],
['STAGIAIRE', 'Stagiaire'],
['SAISONNIER', 'Saisonnier'],
['EXTRA', 'Extra'],
['INTERIM', 'Intérim'],
['DIRIGEANT_ASSIMILE_SALARIE', 'Dirigeant assimilé salarié'],
['DIRIGEANT_NON_SALARIE', 'Dirigeant non salarié'],
] as const;
const selectClass =
'h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus';
export function NewContractForm({
membershipId,
locations,
}: {
membershipId: string;
locations: Array<{ id: string; name: string }>;
}) {
const [state, submit] = useActionState(createContractAction, empty);
const [forfait, setForfait] = useState(false);
const [open, setOpen] = useState(false);
if (!open) {
return (
<div className="flex justify-center">
<Button variant="primary" onClick={() => setOpen(true)}>
Ouvrir un contrat
</Button>
</div>
);
}
return (
<InfoCard title="Nouveau contrat">
<form action={submit} className="flex flex-col gap-3 pt-1">
<input type="hidden" name="membershipId" value={membershipId} />
<div className="flex flex-wrap gap-3">
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">Établissement</span>
<select name="locationId" required className={selectClass}>
{locations.map((location) => (
<option key={location.id} value={location.id}>
{location.name}
</option>
))}
</select>
</label>
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">Type de contrat</span>
<select name="contractType" required className={selectClass}>
{CONTRACT_TYPES.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
</div>
<div className="flex flex-wrap gap-3">
<Field label="Début du contrat" name="startDate" type="date" required />
<Field
label="Fin du contrat"
name="endDate"
type="date"
hint="Laissez vide pour un contrat sans terme."
/>
</div>
<div className="flex flex-wrap gap-3">
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">Organisation du temps</span>
<select
name="workTimeArrangement"
className={selectClass}
onChange={(event) =>
setForfait(event.target.value === 'FORFAIT_JOURS')
}
>
<option value="HOURLY">Décompte horaire</option>
<option value="FORFAIT_JOURS">Forfait jours</option>
</select>
</label>
{forfait ? (
<Field
label="Jours par an"
name="forfaitDaysPerYear"
type="number"
step="0.5"
min="0"
max="218"
defaultValue="218"
hint="Plafond conventionnel : 218 jours, journée de solidarité incluse."
/>
) : (
<Field
label="Durée hebdomadaire"
name="weeklyHours"
type="number"
step="0.5"
min="0"
max="60"
defaultValue="35"
/>
)}
</div>
{forfait ? (
<div className="flex flex-wrap gap-3">
<Field
label="Référence de la convention de forfait"
name="forfaitAgreementRef"
required
hint="Sans convention individuelle écrite, le forfait est inopposable."
/>
<Field
label="Date d’accord du salarié"
name="forfaitAgreedAt"
type="date"
required
/>
</div>
) : null}
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Ouvrir le contrat</SubmitButton>
<Button type="button" onClick={() => setOpen(false)}>
Annuler
</Button>
</div>
</form>
</InfoCard>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { notFound } from 'next/navigation';
import {
ContractActions,
type ActiveContract,
} from '@/app/(app)/equipe/[id]/contrats/ContractActions';
import { NewContractForm } from '@/app/(app)/equipe/[id]/contrats/NewContractForm';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import {
getEmployee,
listContractLocations,
} from '@/server/employees/queries';
export const dynamic = 'force-dynamic';
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
/** Les dates traversent la frontière serveur/client en ISO court : un `Date`
* y perd son fuseau, et « 1er novembre » deviendrait « 31 octobre » à l'ouest. */
const iso = (value: Date | null) => value?.toISOString().slice(0, 10) ?? null;
export default async function ContractsTab({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const employee = await getEmployee(id);
if (!employee) notFound();
const active = employee.contracts.find(
(contract) => contract.status === 'ACTIVE',
);
// La liste n'est chargée que si elle sert : sans contrat en cours, l'écran
// propose d'en ouvrir un, et la capacité peut manquer.
const locations = active ? [] : await listContractLocations().catch(() => []);
const activeContract: ActiveContract | null = active
? {
id: active.id,
label: active.label,
startDate: iso(active.startDate) as string,
endDate: iso(active.endDate),
trialEndDate: iso(active.trialEndDate),
weeklyHours: active.weeklyHours,
forfaitJours: active.forfaitJours,
forfaitDaysPerYear: active.forfaitDaysPerYear,
forfaitAgreementRef: active.forfaitAgreementRef,
isModulated: active.isModulated,
classification: active.classification,
coefficient: active.coefficient,
locationName: active.locationName,
monthlySalary: active.monthlySalary,
jobTitle: employee.headline.jobTitle,
}
: null;
return (
<div className="flex flex-col gap-5">
{activeContract ? (
<ContractActions
contract={activeContract}
canEdit={employee.canEdit}
canSeeSalary={employee.canSeeSalary}
/>
) : (
<div className="flex flex-col gap-4">
<EmptyState
title="Aucun contrat en cours"
description="Ce salarié ne peut être ni planifié ni déclaré tant qu’aucune période n’est ouverte."
/>
{employee.canEdit && locations.length > 0 ? (
<NewContractForm membershipId={employee.id} locations={locations} />
) : null}
</div>
)}
<Card>
<CardHeader
title="Tous les contrats et avenants"
badge={<Badge tone="neutral">{employee.contracts.length}</Badge>}
/>
{employee.contracts.length === 0 ? (
<EmptyState title="Aucun contrat enregistré" />
) : (
<ul>
{employee.contracts.map((contract) => (
<li
key={contract.id}
className="border-b border-line-1 px-4 py-3 last:border-b-0"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{contract.label}</span>
{contract.forfaitJours ? (
<Badge tone="accent">Forfait jours</Badge>
) : (
<Badge tone="neutral">{contract.weeklyHours} h</Badge>
)}
<span className="tnum text-xs text-ink-2">
{dateFormat.format(contract.startDate)}
{contract.endDate
? ` → ${dateFormat.format(contract.endDate)}`
: ' → en cours'}
</span>
<span className="flex-1" />
{employee.canSeeSalary && contract.monthlySalary ? (
<span className="tnum text-sm">
{contract.monthlySalary} € brut
</span>
) : null}
<Badge tone={contract.status === 'ACTIVE' ? 'ok' : 'neutral'}>
{contract.status === 'ACTIVE' ? 'En cours' : 'Terminé'}
</Badge>
</div>
{contract.endReason ? (
<p className="mt-1 text-xs text-ink-3">
Fin · {contract.endReason}
</p>
) : null}
{contract.amendments.length > 0 ? (
<ul className="mt-2 border-l-2 border-line-2 pl-3">
{contract.amendments.map((amendment) => (
<li key={amendment.id} className="py-1 text-xs text-ink-2">
<span className="tnum">
{dateFormat.format(amendment.effectiveDate)}
</span>{' '}
— avenant
{amendment.reason ? ` · ${amendment.reason}` : ''}
</li>
))}
</ul>
) : null}
</li>
))}
</ul>
)}
</Card>
</div>
);
}
@@ -0,0 +1,59 @@
import { notFound } from 'next/navigation';
import { DocumentsPanel } from '@/app/(app)/equipe/[id]/DocumentsPanel';
import { InvitationPanel } from '@/app/(app)/equipe/[id]/InvitationPanel';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import { listDocuments } from '@/server/documents/queries';
import { getEmployee } from '@/server/employees/queries';
export const dynamic = 'force-dynamic';
export default async function DocumentsTab({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const employee = await getEmployee(id);
if (!employee) notFound();
// `members.documents.view` peut manquer là où `members.view` est accordée :
// la section disparaît alors, plutôt que d'échouer sur toute la page.
const documents = await listDocuments(id).catch(() => null);
return (
<div className="flex flex-col gap-5">
{documents ? (
<Card>
<CardHeader
title="Pièces du dossier"
badge={<Badge tone="neutral">{documents.documents.length}</Badge>}
/>
<DocumentsPanel
membershipId={employee.id}
documents={documents.documents}
canManage={documents.canManage}
/>
</Card>
) : (
<EmptyState
title="Documents non consultables"
description="Votre rôle ne donne pas accès aux pièces du dossier."
/>
)}
{employee.canInvite ? (
<Card>
<CardHeader title="Accès à l’application" />
<InvitationPanel
membershipId={employee.id}
defaultEmail={employee.email ?? employee.profile?.personalEmail ?? ''}
hasAccount={employee.hasAccount}
invitation={employee.invitation}
/>
</Card>
) : null}
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { notFound } from 'next/navigation';
import type { ReactNode } from 'react';
import { MemberTabs } from '@/app/(app)/equipe/[id]/MemberTabs';
import { PageBody } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { getEmployee, getMemberPlacement } from '@/server/employees/queries';
export const dynamic = 'force-dynamic';
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'full' });
/**
* Gabarit de la fiche salarié.
*
* Le bandeau et les onglets sont tenus ici, et non répétés dans chaque onglet :
* ils ne changent pas d'un onglet à l'autre, et les rejouer ferait clignoter
* l'identité de la personne consultée à chaque navigation.
*/
export default async function MemberLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const employee = await getEmployee(id);
if (!employee) notFound();
const placement = await getMemberPlacement(id);
const primaryTeam =
placement?.teams.find((team) => team.isPrimary) ?? placement?.teams[0];
return (
<PageBody>
<header className="rounded-3 bg-surface-2 p-6">
<div className="flex flex-wrap items-start gap-4">
<span
aria-hidden
className="flex size-14 flex-none items-center justify-center rounded-full bg-accent-soft text-md font-semibold text-accent-soft-ink"
>
{employee.firstName.charAt(0)}
{employee.lastName.charAt(0)}
</span>
<div className="min-w-0">
<h1 className="text-2xl font-semibold tracking-[-0.018em]">
{employee.firstName} {employee.lastName}
</h1>
<p className="mt-0.5 text-sm text-ink-2">
{employee.headline.jobTitle ??
`Matricule ${employee.employeeNumber}`}
</p>
</div>
<span className="flex-1" />
<div className="flex flex-wrap items-center gap-2">
{!employee.hasAccount ? (
<Badge tone="info">Sans accès applicatif</Badge>
) : null}
<Badge tone="accent">{employee.roleName}</Badge>
</div>
</div>
<dl className="mt-6 grid gap-x-6 gap-y-4 [grid-template-columns:repeat(auto-fit,minmax(150px,1fr))]">
<HeadlineItem
label="Début du contrat"
value={
employee.contract
? dateFormat.format(employee.contract.since)
: null
}
/>
<HeadlineItem
label="Fin du contrat"
value={
employee.headline.contractEnd
? dateFormat.format(employee.headline.contractEnd)
: null
}
/>
<HeadlineItem
label="Type de contrat"
value={employee.contract?.label ?? null}
/>
<HeadlineItem
label="Établissement"
value={employee.locationName}
/>
<HeadlineItem label="Équipe" value={primaryTeam?.name ?? null} />
<HeadlineItem
label="Responsable hiérarchique"
value={employee.headline.lineManagerName}
/>
</dl>
</header>
<MemberTabs id={id} />
{children}
</PageBody>
);
}
/** Un tiret, et non « non renseigné » : le bandeau se lit d'un balayage. */
function HeadlineItem({
label,
value,
}: {
label: string;
value: string | null;
}) {
return (
<div className="min-w-0">
<dt className="text-micro font-semibold text-ink-1">{label}</dt>
<dd className="mt-1 truncate text-sm text-ink-2">{value ?? '—'}</dd>
</div>
);
}
+63 -181
View File
@@ -1,18 +1,20 @@
import { notFound } from 'next/navigation';
import { DocumentsPanel } from '@/app/(app)/equipe/[id]/DocumentsPanel';
import { InvitationPanel } from '@/app/(app)/equipe/[id]/InvitationPanel';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import { listDocuments } from '@/server/documents/queries';
import {
PersonalInfoPanel,
type ProfileFields,
type SensitiveFields,
} from '@/app/(app)/equipe/[id]/PersonalInfoPanel';
import { EmptyState } from '@/components/ui/Card';
import { getEmployee } from '@/server/employees/queries';
export const dynamic = 'force-dynamic';
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
/** `null` et `undefined` deviennent la chaîne vide : un `<input>` non contrôlé
* affiche « null » sinon. */
const text = (value: string | null | undefined): string => value ?? '';
export default async function FichePage({
export default async function PersonalTab({
params,
}: {
params: Promise<{ id: string }>;
@@ -21,181 +23,61 @@ export default async function FichePage({
const employee = await getEmployee(id);
if (!employee) notFound();
// `members.documents.view` peut manquer là où `members.view` est accordée :
// la section disparaît alors, plutôt que d'échouer sur toute la page.
const documents = await listDocuments(id).catch(() => null);
if (!employee.profile) {
return (
<EmptyState
title="Aucun dossier personnel"
description="Ce salarié n’a pas de dossier. Il a probablement été créé hors de l’application ; reprenez sa création depuis la liste de l’équipe."
/>
);
}
const active = employee.contracts.find(
(contract) => contract.status === 'ACTIVE',
);
const profile: ProfileFields = {
gender: text(employee.profile.gender),
firstName: text(employee.profile.firstName),
birthName: text(employee.profile.birthName),
lastName: text(employee.profile.lastName),
birthDate: employee.profile.birthDate
? employee.profile.birthDate.toISOString().slice(0, 10)
: '',
birthPlace: text(employee.profile.birthPlace),
birthCountry: text(employee.profile.birthCountry),
birthDepartment: text(employee.profile.birthDepartment),
nationality: text(employee.profile.nationality),
maritalStatus: text(employee.profile.maritalStatus),
dependents:
employee.profile.dependents === null
? ''
: String(employee.profile.dependents),
personalEmail: text(employee.profile.personalEmail),
phone: text(employee.profile.phone),
landline: text(employee.profile.landline),
smsSchedules: employee.profile.smsSchedules,
addressLine1: text(employee.profile.addressLine1),
addressLine2: text(employee.profile.addressLine2),
postalCode: text(employee.profile.postalCode),
city: text(employee.profile.city),
country: text(employee.profile.country),
emergencyContactName: text(employee.profile.emergencyContactName),
emergencyContactPhone: text(employee.profile.emergencyContactPhone),
};
const sensitive: SensitiveFields | null = employee.profile.sensitive
? {
socialSecurityNumber: text(
employee.profile.sensitive.socialSecurityNumber,
),
iban: text(employee.profile.sensitive.iban),
bic: text(employee.profile.sensitive.bic),
}
: null;
return (
<PageBody>
<PageHeader
title={`${employee.firstName} ${employee.lastName}`}
subtitle={[
employee.contract?.label,
employee.locationName,
`matricule ${employee.employeeNumber}`,
]
.filter(Boolean)
.join(' · ')}
/>
<div className="flex flex-wrap items-center gap-3 rounded-3 border border-line-1 bg-surface p-4">
<span
aria-hidden
className="flex size-11 flex-none items-center justify-center rounded-full bg-surface-3 text-sm font-semibold text-ink-2"
>
{employee.firstName.charAt(0)}
{employee.lastName.charAt(0)}
</span>
<div className="min-w-0">
<p className="text-sm font-medium">
{employee.email ?? 'Aucun compte applicatif'}
</p>
<p className="text-micro text-ink-3">Rôle · {employee.roleName}</p>
</div>
<span className="flex-1" />
{active?.forfaitJours ? (
<Badge tone="accent">
Forfait jours · {active.forfaitDaysPerYear ?? '—'} j
</Badge>
) : active ? (
<Badge tone="neutral">{active.weeklyHours} h hebdomadaires</Badge>
) : null}
{!employee.hasAccount ? (
<Badge tone="info">Sans accès applicatif</Badge>
) : null}
</div>
{documents ? (
<Card>
<CardHeader
title="Pièces du dossier"
badge={<Badge tone="neutral">{documents.documents.length}</Badge>}
/>
<DocumentsPanel
membershipId={employee.id}
documents={documents.documents}
canManage={documents.canManage}
/>
</Card>
) : null}
{employee.canInvite ? (
<Card>
<CardHeader title="Accès à l’application" />
<InvitationPanel
membershipId={employee.id}
defaultEmail={
employee.email ?? employee.profile?.personalEmail ?? ''
}
hasAccount={employee.hasAccount}
invitation={employee.invitation}
/>
</Card>
) : null}
<Card>
<CardHeader
title="Contrats et avenants"
badge={<Badge tone="neutral">{employee.contracts.length}</Badge>}
/>
{employee.contracts.length === 0 ? (
<EmptyState
title="Aucun contrat"
description="Ce salarié n’a pas encore de contrat. Il ne peut pas être planifié tant qu’aucune période n’est ouverte."
/>
) : (
<ul>
{employee.contracts.map((contract) => (
<li
key={contract.id}
className="border-b border-line-1 px-4 py-3 last:border-b-0"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{contract.label}</span>
{contract.forfaitJours ? (
<Badge tone="accent">Forfait jours</Badge>
) : (
<Badge tone="neutral">{contract.weeklyHours} h</Badge>
)}
<span className="tnum text-xs text-ink-2">
{dateFormat.format(contract.startDate)}
{contract.endDate
? ` → ${dateFormat.format(contract.endDate)}`
: ' → en cours'}
</span>
<span className="flex-1" />
{employee.canSeeSalary && contract.monthlySalary ? (
<span className="tnum text-sm">
{contract.monthlySalary} € brut
</span>
) : null}
<Badge
tone={contract.status === 'ACTIVE' ? 'ok' : 'neutral'}
>
{contract.status === 'ACTIVE' ? 'En cours' : 'Terminé'}
</Badge>
</div>
{contract.amendments.length > 0 ? (
<ul className="mt-2 border-l-2 border-line-2 pl-3">
{contract.amendments.map((amendment) => (
<li
key={amendment.id}
className="py-1 text-xs text-ink-2"
>
<span className="tnum">
{dateFormat.format(amendment.effectiveDate)}
</span>{' '}
— avenant{amendment.reason ? ` · ${amendment.reason}` : ''}
</li>
))}
</ul>
) : null}
</li>
))}
</ul>
)}
</Card>
{employee.profile ? (
<Card>
<CardHeader title="Dossier personnel" />
<dl className="grid gap-x-8 gap-y-3 p-4 text-sm [grid-template-columns:repeat(auto-fit,minmax(200px,1fr))]">
<div>
<dt className="text-micro text-ink-3">Téléphone</dt>
<dd className="tnum">{employee.profile.phone ?? '—'}</dd>
</div>
<div>
<dt className="text-micro text-ink-3">Ville</dt>
<dd>{employee.profile.city ?? '—'}</dd>
</div>
<div>
<dt className="text-micro text-ink-3">
Numéro de sécurité sociale
</dt>
<dd className="tnum">
{/* Non chargé quand la capacité manque : un champ absent de la
réponse ne peut fuiter ni par le HTML ni par un journal. */}
{employee.profile.socialSecurityNumber ?? (
<span className="text-ink-3">Accès non autorisé</span>
)}
</dd>
</div>
<div>
<dt className="text-micro text-ink-3">IBAN</dt>
<dd className="tnum">
{employee.profile.iban ?? (
<span className="text-ink-3">Accès non autorisé</span>
)}
</dd>
</div>
</dl>
</Card>
) : null}
</PageBody>
<PersonalInfoPanel
membershipId={employee.id}
profile={profile}
sensitive={sensitive}
canEdit={employee.canEdit}
/>
);
}
@@ -0,0 +1,186 @@
'use client';
import { useActionState, useState } from 'react';
import { InfoCard } from '@/app/(app)/equipe/[id]/InfoCard';
import { Button } from '@/components/ui/Button';
import { FormError, SubmitButton } from '@/components/ui/Form';
import {
updateScopeAction,
updateTeamsAction,
type PlacementActionState,
} from '@/server/employees/placement-actions';
import type { ContractLocation } from '@/server/employees/queries';
/**
* Périmètre et rattachement, en saisie.
*
* Deux formulaires plutôt qu'un : ils ne portent ni la même décision ni la
* même capacité, et les réunir donnerait à l'un le pouvoir de l'autre.
*/
const empty: PlacementActionState = {};
export function ScopeForm({
membershipId,
locations,
allLocations,
scopedLocationIds,
}: {
membershipId: string;
locations: ContractLocation[];
allLocations: boolean;
scopedLocationIds: string[];
}) {
const [state, save] = useActionState(updateScopeAction, empty);
const [acknowledged, setAcknowledged] = useState<PlacementActionState>(empty);
const [editing, setEditing] = useState(false);
const [all, setAll] = useState(allLocations);
if (state !== acknowledged && state.ok) {
setAcknowledged(state);
setEditing(false);
}
if (!editing) {
return (
<Button onClick={() => setEditing(true)}>Modifier le périmètre</Button>
);
}
return (
<form action={save} className="flex flex-col gap-3 pt-3">
<input type="hidden" name="membershipId" value={membershipId} />
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name="allLocations"
checked={all}
onChange={(event) => setAll(event.target.checked)}
className="mt-0.5 size-4 accent-[var(--accent)]"
/>
<span>
<span className="font-medium">Accès généralisé</span>
<span className="block text-micro text-ink-3">
Tous les établissements, y compris ceux qui ouvriront plus tard.
</span>
</span>
</label>
{all ? null : (
<fieldset className="flex flex-col gap-2 rounded-3 border border-line-1 p-3">
<legend className="px-1 text-micro font-semibold text-ink-1">
Établissements
</legend>
{locations.map((location) => (
<label key={location.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="locationIds"
value={location.id}
defaultChecked={scopedLocationIds.includes(location.id)}
className="size-4 accent-[var(--accent)]"
/>
{location.name}
</label>
))}
</fieldset>
)}
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Enregistrer le périmètre</SubmitButton>
<Button type="button" onClick={() => setEditing(false)}>
Annuler
</Button>
</div>
</form>
);
}
export function TeamsForm({
membershipId,
locations,
teamIds,
primaryTeamId,
}: {
membershipId: string;
locations: ContractLocation[];
teamIds: string[];
primaryTeamId: string | null;
}) {
const [state, save] = useActionState(updateTeamsAction, empty);
const [acknowledged, setAcknowledged] = useState<PlacementActionState>(empty);
const [editing, setEditing] = useState(false);
if (state !== acknowledged && state.ok) {
setAcknowledged(state);
setEditing(false);
}
const withTeams = locations.filter((location) => location.teams.length > 0);
if (!editing) {
return (
<Button onClick={() => setEditing(true)}>Modifier les équipes</Button>
);
}
return (
<form action={save} className="flex flex-col gap-3 pt-3">
<input type="hidden" name="membershipId" value={membershipId} />
{withTeams.length === 0 ? (
<p className="text-sm text-ink-2">
Aucune équipe déclarée. Elles se créent dans les réglages des
établissements.
</p>
) : (
<InfoCard title="Équipes">
{withTeams.map((location) => (
<fieldset key={location.id} className="flex flex-col gap-2 py-2">
<legend className="text-micro font-semibold text-ink-1">
{location.name}
</legend>
{location.teams.map((team) => (
<div key={team.id} className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="teamIds"
value={team.id}
defaultChecked={teamIds.includes(team.id)}
className="size-4 accent-[var(--accent)]"
/>
{team.name}
</label>
<label className="flex items-center gap-2 text-micro text-ink-2">
<input
type="radio"
name="primaryTeamId"
value={team.id}
defaultChecked={primaryTeamId === team.id}
className="size-3.5 accent-[var(--accent)]"
/>
Principale
</label>
</div>
))}
</fieldset>
))}
</InfoCard>
)}
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Enregistrer les équipes</SubmitButton>
<Button type="button" onClick={() => setEditing(false)}>
Annuler
</Button>
</div>
</form>
);
}
@@ -0,0 +1,130 @@
import { notFound } from 'next/navigation';
import { InfoCard, InfoGrid, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard';
import {
ScopeForm,
TeamsForm,
} from '@/app/(app)/equipe/[id]/planification/PlacementPanel';
import { Badge } from '@/components/ui/Badge';
import { can } from '@/domain/access/authorize';
import { requireSession } from '@/server/context';
import {
getEmployee,
getMemberPlacement,
listPlacementOptions,
} from '@/server/employees/queries';
export const dynamic = 'force-dynamic';
/**
* Rattachement et périmètre.
*
* 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.
*/
export default async function PlacementTab({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const employee = await getEmployee(id);
if (!employee) notFound();
const [placement, locations, session] = await Promise.all([
getMemberPlacement(id),
listPlacementOptions(),
requireSession(),
]);
const canScope = can(session.actor, 'settings.roles.manage');
const canTeams = can(session.actor, 'settings.teams.manage');
return (
<InfoGrid>
<InfoCard title="Rattachement">
<InfoRow
label="Établissement"
value={placement?.contractLocationName ?? employee.locationName}
/>
<InfoRow
label="Équipes"
value={
placement && placement.teams.length > 0
? placement.teams
.map((team) =>
team.isPrimary ? `${team.name} (principale)` : team.name,
)
.join(', ')
: ''
}
/>
<InfoRow label="Matricule" value={employee.employeeNumber} tnum />
<InfoRow
label="Responsable hiérarchique"
value={employee.headline.lineManagerName}
/>
<p className="pt-3 text-micro text-ink-3">
L’établissement vient du contrat : il se change par avenant, pas ici.
</p>
{canTeams ? (
<div className="pt-3">
<TeamsForm
membershipId={employee.id}
locations={locations}
teamIds={placement?.teamIds ?? []}
primaryTeamId={placement?.primaryTeamId ?? null}
/>
</div>
) : null}
</InfoCard>
<InfoCard title="Périmètre d’accès">
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border-b border-line-1 py-3">
<dt className="min-w-[11rem] flex-none text-sm font-medium text-ink-1">
Accès généralisé
</dt>
<dd className="text-sm">
{placement?.allLocations ? (
<Badge tone="ok">Tous les établissements</Badge>
) : (
<Badge tone="neutral">Limité</Badge>
)}
</dd>
</div>
<InfoRow
label="Établissements"
value={
placement?.allLocations
? 'Tous, ouvertures futures comprises'
: (placement?.scopedLocations.join(', ') ?? '')
}
/>
<InfoRow
label="Équipes"
value={placement?.scopedTeams.join(', ') ?? ''}
/>
<InfoRow label="Rôle" value={employee.roleName} />
{canScope ? (
<div className="pt-3">
<ScopeForm
membershipId={employee.id}
locations={locations}
allLocations={placement?.allLocations ?? false}
scopedLocationIds={placement?.scopedLocationIds ?? []}
/>
</div>
) : (
<p className="pt-3 text-micro text-ink-3">
Élargir un périmètre revient à distribuer des droits : la capacité
« Gérer les rôles » est requise.
</p>
)}
</InfoCard>
</InfoGrid>
);
}
+178 -106
View File
@@ -1,129 +1,201 @@
import Link from 'next/link';
import { Suspense } from 'react';
import { AddEmployeeDialog } from '@/app/(app)/equipe/AddEmployeeDialog';
import { DirectoryFilters } from '@/app/(app)/equipe/DirectoryFilters';
import { RegisterDialog } from '@/app/(app)/equipe/RegisterDialog';
import { listRegisterLocations } from '@/server/employees/rup';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge, type Tone } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import { AddEmployeeForm } from '@/app/(app)/equipe/AddEmployeeForm';
import { listEmployees } from '@/server/employees/queries';
import { EmptyState } from '@/components/ui/Card';
import {
listContractLocations,
listEmployees,
listHiringOptions,
} from '@/server/employees/queries';
export const metadata = { title: 'Équipe · PlanFlow' };
export const dynamic = 'force-dynamic';
const STATUS_TONES: Record<string, { label: string; tone: Tone }> = {
ACTIVE: { label: 'Actif', tone: 'ok' },
INVITED: { label: 'Invitation en attente', tone: 'info' },
ARCHIVED: { label: 'Archivé', tone: 'neutral' },
const INVITATION_TONES: Record<string, { label: string; tone: Tone }> = {
ACCEPTED: { label: 'Acceptée', tone: 'ok' },
PENDING: { label: 'En attente', tone: 'info' },
EXPIRED: { label: 'Expirée', tone: 'warn' },
REVOKED: { label: 'Révoquée', tone: 'neutral' },
};
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' });
const one = (value: string | string[] | undefined): string | undefined =>
Array.isArray(value) ? value[0] : value;
export default async function EquipePage() {
const employees = await listEmployees();
const withoutAccount = employees.filter((e) => !e.hasAccount).length;
export default async function EquipePage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const presence = one(params.presence);
const directory = await listEmployees({
...(one(params.q) ? { q: one(params.q) as string } : {}),
...(one(params.etablissement)
? { locationId: one(params.etablissement) as string }
: {}),
...(one(params.role) ? { roleId: one(params.role) as string } : {}),
...(one(params.contrat)
? { contractType: one(params.contrat) as string }
: {}),
...(presence === 'archived' || presence === 'all'
? { presence }
: { presence: 'active' as const }),
...(one(params.tri) === 'number' ? { sort: 'number' as const } : {}),
});
// Sans la capacité d'ouvrir un contrat, la liste revient vide et le
// formulaire s'en tient à l'identité.
const hiringLocations = await listContractLocations().catch(() => []);
// Sans `members.register.export`, le bouton du registre ne paraît pas.
const registerLocations = await listRegisterLocations().catch(() => []);
const hiringOptions = await listHiringOptions().catch(() => ({
managers: [],
rttPolicies: [],
}));
const filtered = directory.rows.length !== directory.total;
return (
<PageBody>
<PageHeader
title="Équipe"
subtitle={`${employees.length} salarié${employees.length > 1 ? 's' : ''}${
withoutAccount > 0 ? ` · ${withoutAccount} sans compte applicatif` : ''
}`}
// Le décompte n'apparaît que s'il apprend quelque chose : « 87 salariés »
// au-dessus d'une liste de 87 lignes ne dit rien que la liste ne dise.
{...(filtered
? { subtitle: `${directory.rows.length} sur ${directory.total} salariés` }
: {})}
actions={
<>
{registerLocations.length > 0 ? (
<RegisterDialog locations={registerLocations} />
) : null}
<AddEmployeeDialog
locations={hiringLocations}
options={hiringOptions}
/>
</>
}
/>
<Card>
<CardHeader title="Effectif" />
{employees.length === 0 ? (
<EmptyState
title="Aucun salarié"
description="Ajoutez un premier salarié pour commencer à planifier."
/>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[880px] border-collapse text-sm">
<thead>
<tr className="border-b border-line-2 bg-surface-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
<th className="px-4 py-2.5">Collaborateur</th>
<th className="px-4 py-2.5">Matricule</th>
<th className="px-4 py-2.5">Contrat</th>
<th className="px-4 py-2.5">Établissement</th>
<th className="px-4 py-2.5">Rôle</th>
<th className="px-4 py-2.5">Statut</th>
</tr>
</thead>
<tbody>
{employees.map((employee) => {
const status =
STATUS_TONES[employee.status] ?? STATUS_TONES.ACTIVE!;
return (
<tr
key={employee.id}
className="border-b border-line-1 last:border-b-0 hover:bg-surface-2"
>
<td className="px-4 py-2.5">
<Link
href={`/equipe/${employee.id}`}
className="flex items-center gap-2.5 rounded-2"
>
<span
aria-hidden
className="flex size-7 flex-none items-center justify-center rounded-full bg-surface-3 text-micro font-semibold text-ink-2"
>
{employee.firstName.charAt(0)}
{employee.lastName.charAt(0)}
</span>
<span className="min-w-0">
<span className="block truncate font-medium">
{employee.firstName} {employee.lastName}
</span>
<span className="block truncate text-micro text-ink-3">
{employee.email ?? 'Sans compte applicatif'}
</span>
</span>
</Link>
</td>
<td className="tnum px-4 py-2.5 text-ink-2">
{employee.employeeNumber}
</td>
<td className="px-4 py-2.5">
{employee.contract ? (
<span className="flex flex-wrap items-center gap-1.5">
<span>{employee.contract.label}</span>
{employee.contract.forfaitJours ? (
<Badge tone="accent">Forfait jours</Badge>
) : null}
<span className="tnum text-micro text-ink-3">
depuis le{' '}
{dateFormat.format(employee.contract.since)}
</span>
</span>
) : (
<Badge tone="warn">Sans contrat</Badge>
)}
</td>
<td className="px-4 py-2.5 text-ink-2">
{employee.locationName ?? '—'}
</td>
<td className="px-4 py-2.5 text-ink-2">
{employee.roleName}
</td>
<td className="px-4 py-2.5">
<Badge tone={status.tone}>{status.label}</Badge>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card>
{/* `useSearchParams` impose une frontière de suspense : sans elle, la
page entière serait rendue à la demande au lieu d'être servie puis
hydratée. */}
<Suspense fallback={<div className="h-[6rem]" />}>
<DirectoryFilters
options={{
locations: directory.locations,
roles: directory.roles,
contractTypes: directory.contractTypes,
}}
/>
</Suspense>
<Card>
<CardHeader title="Ajouter un salarié" />
<div className="p-4">
<AddEmployeeForm />
{directory.rows.length === 0 ? (
<EmptyState
title={filtered ? 'Aucun résultat' : 'Aucun salarié'}
description={
filtered
? 'Aucun salarié ne répond à ces critères. Élargissez la recherche.'
: 'Ajoutez un premier salarié pour commencer à planifier.'
}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[940px] border-collapse text-sm">
<thead>
<tr className="border-b border-line-2 text-left text-micro font-semibold tracking-[0.04em] text-ink-3">
<th className="px-3 py-3 font-semibold">Collaborateur</th>
<th className="px-3 py-3 font-semibold">Rôle</th>
<th className="px-3 py-3 font-semibold">Email</th>
<th className="px-3 py-3 font-semibold">Mobile</th>
<th className="px-3 py-3 font-semibold">Rattachement</th>
<th className="px-3 py-3 font-semibold">Invitation</th>
</tr>
</thead>
<tbody>
{directory.rows.map((employee) => {
const invitation = employee.invitationState
? INVITATION_TONES[employee.invitationState]
: null;
return (
<tr
key={employee.id}
className="border-b border-line-1 last:border-b-0 hover:bg-surface-2"
>
<td className="px-3 py-3">
<Link
href={`/equipe/${employee.id}`}
className="flex items-center gap-2.5 rounded-2"
>
<span
aria-hidden
className="flex size-8 flex-none items-center justify-center rounded-full bg-accent-soft text-micro font-semibold text-accent-soft-ink"
>
{employee.firstName.charAt(0)}
{employee.lastName.charAt(0)}
</span>
<span className="min-w-0">
<span className="block truncate font-medium underline decoration-line-3 underline-offset-2">
{employee.firstName} {employee.lastName}
</span>
{/* Le matricule ne double la colonne « Email » que
lorsqu'il n'y en a pas : c'est alors la seule
adresse à laquelle on désigne ce salarié. */}
{employee.email ? null : (
<span className="block truncate text-micro text-ink-3">
Matricule {employee.employeeNumber}
</span>
)}
</span>
</Link>
</td>
<td className="px-3 py-3">
<Badge tone="accent">{employee.roleName}</Badge>
</td>
<td className="px-3 py-3 text-ink-2">
{employee.email ?? (
<span className="text-ink-3">Sans compte</span>
)}
</td>
<td className="tnum px-3 py-3 text-ink-2">
{employee.phone ?? (
<span className="text-ink-3">Non renseigné</span>
)}
</td>
<td className="px-3 py-3 text-ink-2">
{employee.locationName ? (
<>
{employee.locationName}
{employee.teamName ? ` / ${employee.teamName}` : ''}
</>
) : (
<Badge tone="warn">Sans contrat</Badge>
)}
</td>
<td className="px-3 py-3">
{invitation ? (
<Badge tone={invitation.tone}>{invitation.label}</Badge>
) : (
<span className="text-micro text-ink-3">—</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
)}
</PageBody>
);
}
+1 -1
View File
@@ -276,7 +276,7 @@ function EmployeeHome() {
</li>
<li>
<Link
href="/conges"
href="/absences/calendrier"
className="flex h-full flex-col gap-1 rounded-3 border border-line-1 bg-surface p-4 hover:bg-surface-2"
>
<span className="text-sm font-medium text-ink-1">
+219
View File
@@ -0,0 +1,219 @@
import Link from 'next/link';
import { PrintButton } from '@/components/planning/PrintButton';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import { can } from '@/domain/access/authorize';
import { formatMinutes } from '@/domain/counters/week';
import { displayName, type BoardRow } from '@/domain/planning/board';
import { absenceOnDay } from '@/domain/planning/presence';
import { isoWeekOf, parseWeekParam } from '@/domain/planning/week';
import { isPosteCode, posteTokens } from '@/lib/design/postes';
import { requireSession } from '@/server/context';
import { getWeekBoard } from '@/server/planning/queries';
export const metadata = { title: 'Planning · impression · PlanFlow' };
/**
* Planning à afficher — PLAN.md §9, `/planning/impression`.
*
* Ce n'est pas la grille d'édition avec les boutons cachés. C'est une mise en
* page faite pour la feuille punaisée en salle de pause : toutes les équipes à
* la suite, pas de panneau d'édition, pas de compteurs — et un seul aplat par
* créneau, avec l'horaire et le code du poste écrits en toutes lettres.
*
* Rien n'est recalculé : le même `getWeekBoard` que les cinq vues. Une feuille
* imprimée qui contredit l'écran est pire qu'une absence de feuille, parce
* qu'elle survit à la correction.
*
* Le rendu PDF passe par la boîte d'impression du navigateur (voir
* `PrintButton`) : un second moteur de rendu serait un second endroit où la
* grille peut diverger.
*/
interface PageProps {
searchParams: Promise<{ semaine?: string; etablissement?: string }>;
}
function ShiftLine({ shift }: { shift: BoardRow['days'][number][number] }) {
const tokens = isPosteCode(shift.poste) ? posteTokens(shift.poste) : null;
return (
<li
className="print-keep rounded-2 border px-1.5 py-1 text-micro leading-tight"
style={
tokens
? {
background: tokens.bg,
color: tokens.fg,
borderColor: tokens.edge,
}
: undefined
}
>
<span className="block font-semibold tnum">{shift.time}</span>
<span className="block">{shift.poste.toUpperCase()}</span>
{shift.breakMinutes > 0 ? (
<span className="block tnum">pause {shift.breakMinutes} min</span>
) : null}
</li>
);
}
export default async function ImpressionPage({ searchParams }: PageProps) {
const params = await searchParams;
const session = await requireSession();
const week = parseWeekParam(params.semaine) ?? isoWeekOf(new Date());
const board = await getWeekBoard(week, params.etablissement);
if (!board) {
return (
<PageBody>
<PageHeader
title="Impression"
subtitle="Aucun établissement n'est encore créé."
/>
</PageBody>
);
}
// Sans la capacité de voir le non publié, la feuille ne doit pas la
// contourner : imprimer un brouillon revient à le publier sur un mur.
const seeUnpublished = can(session.actor, 'planning.view_unpublished');
const sections = board.sections.filter(
(section) =>
!section.hidden && (seeUnpublished || section.status === 'PUBLISHED'),
);
const hiddenDrafts = board.sections.filter(
(section) => !section.hidden && section.status !== 'PUBLISHED',
).length;
return (
<PageBody>
<div data-print="hide">
<PageHeader
title={`Planning à afficher · semaine ${week.isoWeek}`}
subtitle={`${board.location.name} · ${formatMinutes(board.totals.plannedMinutes)} planifiées`}
actions={
<>
<Link
href={`/planning/semaine?semaine=${board.weekParam}&etablissement=${board.location.id}`}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
Retour à la grille
</Link>
<PrintButton label="Imprimer la semaine" />
</>
}
/>
</div>
<p className="text-sm text-ink-2">
{board.location.name} — semaine {week.isoWeek}, {board.label}
</p>
{hiddenDrafts > 0 && seeUnpublished ? (
<p
data-print="hide"
role="status"
className="rounded-2 border border-warn bg-warn-soft px-3 py-2 text-xs text-warn-soft-ink"
>
{hiddenDrafts} équipe{hiddenDrafts > 1 ? 's' : ''} non publiée
{hiddenDrafts > 1 ? 's' : ''} figure
{hiddenDrafts > 1 ? 'nt' : ''} sur cette feuille. Un planning affiché
en salle vaut information des salariés : publiez avant d’imprimer.
</p>
) : null}
{sections.length === 0 ? (
<Card>
<EmptyState
title="Rien à imprimer"
description="Aucune équipe publiée sur cette semaine."
/>
</Card>
) : null}
{sections.map((section) => (
<Card key={section.teamId} className="print-keep">
<CardHeader
title={section.teamName}
badge={
section.status === 'PUBLISHED' ? (
<Badge tone="ok">Publié</Badge>
) : (
<Badge tone="warn">Brouillon</Badge>
)
}
/>
<div className="overflow-x-auto">
<table className="w-full min-w-[1040px] text-sm">
<thead>
<tr className="border-b border-line-1">
<th className="w-48 px-3 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Salarié
</th>
{board.headings.map((heading) => (
<th
key={heading}
className="px-2 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{[
...section.rows,
...(section.unassignedRow ? [section.unassignedRow] : []),
].map((row) => (
<tr key={row.membershipId ?? 'non-assignes'} className="print-keep">
<th className="px-3 py-2 text-left align-top font-medium">
{displayName(row)}
<span className="block text-micro font-normal text-ink-3">
{row.unassigned ? 'À pourvoir' : row.job}
</span>
</th>
{board.dates.map((date, index) => {
const absence = absenceOnDay(row, index);
const shifts = row.days[index] ?? [];
return (
<td key={date} className="px-2 py-2 align-top">
{absence ? (
<span className="block rounded-2 border border-line-2 bg-surface-2 px-1.5 py-1 text-micro">
{absence.label}
</span>
) : shifts.length === 0 ? (
<span className="text-micro text-ink-3">—</span>
) : (
<ul className="flex flex-col gap-1">
{shifts.map((shift) => (
<ShiftLine key={shift.id} shift={shift} />
))}
</ul>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</Card>
))}
<p className="text-micro text-ink-3">
Édité depuis PlanFlow · {board.location.name} · semaine {week.isoWeek}.
Les horaires font foi tels qu’ils étaient à l’édition ; une modification
postérieure du planning n’apparaît pas sur cette feuille.
</p>
</PageBody>
);
}
+259
View File
@@ -0,0 +1,259 @@
import Link from 'next/link';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge, type Tone } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import { formatMinutes } from '@/domain/counters/week';
import { displayName } from '@/domain/planning/board';
import {
absenceOnDay,
dayState,
headcountByDay,
type DayState,
} from '@/domain/planning/presence';
import { isoWeekOf, parseWeekParam } from '@/domain/planning/week';
import { cx } from '@/lib/cx';
import { getWeekBoard } from '@/server/planning/queries';
export const metadata = { title: 'Planning · présences · PlanFlow' };
/**
* Vue des présences et absences — cinquième vue de §9.
*
* Elle répond à une question que les quatre autres posent mal : **qui est là
* aujourd'hui ?** La grille hebdomadaire montre des créneaux, pas des
* personnes ; il faut la lire colonne par colonne et compter de tête.
*
* Elle lit le **même modèle** que les autres vues, sans requête propre : une
* modification faite dans la grille est correcte ici à l'instant même. Une vue
* qui recalcule ses présences de son côté finit par contredire le planning
* qu'elle prétend résumer.
*/
interface PageProps {
searchParams: Promise<{ semaine?: string; etablissement?: string }>;
}
const STATE_TONE: Record<DayState, Tone> = {
present: 'ok',
absent: 'warn',
off: 'neutral',
};
const STATE_LABEL: Record<DayState, string> = {
present: 'Présent',
absent: 'Absent',
off: 'Repos',
};
/**
* État d'un salarié un jour donné.
*
* L'absence l'emporte sur le créneau : un salarié dont l'absence a été acceptée
* après la construction du planning garde des créneaux en base, et l'afficher
* « présent » enverrait un manager le chercher en rayon.
*/
export default async function PresencesPage({ searchParams }: PageProps) {
const params = await searchParams;
const week = parseWeekParam(params.semaine) ?? isoWeekOf(new Date());
const board = await getWeekBoard(week, params.etablissement);
if (!board) {
return (
<PageBody>
<PageHeader
title="Présences et absences"
subtitle="Aucun établissement n'est encore créé."
/>
</PageBody>
);
}
const href = (semaine: string, etablissement = board.location.id) =>
`/planning/presences?semaine=${semaine}&etablissement=${etablissement}`;
// Les besoins non couverts n'ont personne à compter : ils appartiennent à la
// grille, pas à un état de présence.
const sections = board.sections
.filter((section) => !section.hidden)
.map((section) => ({
...section,
rows: section.rows.filter((row) => !row.unassigned),
}));
const perDay = headcountByDay(
sections.flatMap((section) => section.rows),
board.dates.length,
).map((tally, index) => ({ date: board.dates[index] as string, ...tally }));
return (
<PageBody>
<PageHeader
title={`Présences · semaine ${week.isoWeek}`}
subtitle={`${board.location.name} · ${formatMinutes(board.totals.plannedMinutes)} planifiées`}
actions={
<>
<Link
href={href(board.previousParam)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
← Semaine précédente
</Link>
<Link
href={href(board.nextParam)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
Semaine suivante →
</Link>
</>
}
/>
<Card>
<CardHeader title="Effectif présent par jour" />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1">
<th className="px-4 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Jour
</th>
{board.headings.map((heading) => (
<th
key={heading}
className="px-3 py-2 text-center text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
<tr>
<th className="px-4 py-2 text-left font-medium">Présents</th>
{perDay.map((day) => (
<td key={day.date} className="px-3 py-2 text-center tnum">
{day.present}
</td>
))}
</tr>
<tr>
<th className="px-4 py-2 text-left font-medium">Absents</th>
{perDay.map((day) => (
<td
key={day.date}
className={cx(
'px-3 py-2 text-center tnum',
day.absent > 0 ? 'text-warn-soft-ink' : 'text-ink-3',
)}
>
{day.absent}
</td>
))}
</tr>
<tr>
<th className="px-4 py-2 text-left font-medium">Repos</th>
{perDay.map((day) => (
<td
key={day.date}
className="px-3 py-2 text-center tnum text-ink-3"
>
{day.off}
</td>
))}
</tr>
</tbody>
</table>
</div>
</Card>
{sections.length === 0 ? (
<Card>
<EmptyState
title="Aucune équipe"
description="Créez une équipe et rattachez-y des salariés pour suivre les présences."
/>
</Card>
) : null}
{sections.map((section) => (
<Card key={section.teamId}>
<CardHeader
title={section.teamName}
badge={
<Badge
tone={section.status === 'PUBLISHED' ? 'ok' : 'warn'}
>
{section.status === 'PUBLISHED' ? 'Publié' : 'Non publié'}
</Badge>
}
/>
{section.rows.length === 0 ? (
<EmptyState title="Aucun salarié rattaché" />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1">
<th className="px-4 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Salarié
</th>
{board.headings.map((heading) => (
<th
key={heading}
className="px-3 py-2 text-center text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{section.rows.map((row) => (
<tr key={row.membershipId ?? displayName(row)}>
<th className="px-4 py-2 text-left font-medium">
{row.membershipId ? (
<Link
href={`/equipe/${row.membershipId}`}
className="hover:underline"
>
{displayName(row)}
</Link>
) : (
displayName(row)
)}
<span className="block text-micro font-normal text-ink-3">
{row.job}
</span>
</th>
{board.dates.map((date, index) => {
const state = dayState(row, index);
const absence = absenceOnDay(row, index);
return (
<td key={date} className="px-3 py-2 text-center">
<Badge tone={STATE_TONE[state]}>
{absence ? absence.label : STATE_LABEL[state]}
</Badge>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
))}
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Une absence acceptée l’emporte sur les créneaux restés au planning : un
salarié dont le congé a été validé après la construction de la semaine
apparaît absent, même si ses créneaux n’ont pas encore été retirés.
L’inverse enverrait un responsable le chercher en rayon.
</p>
</PageBody>
);
}
+197
View File
@@ -0,0 +1,197 @@
import Link from 'next/link';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import { getActivity } from '@/server/reports/activity';
export const metadata = { title: 'Journal d’activité · PlanFlow' };
export const dynamic = 'force-dynamic';
interface PageProps {
searchParams: Promise<{
domaine?: string;
objet?: string;
du?: string;
au?: string;
}>;
}
const stamp = new Intl.DateTimeFormat('fr-FR', {
dateStyle: 'short',
timeStyle: 'short',
});
export default async function ActivitePage({ searchParams }: PageProps) {
const params = await searchParams;
// `exactOptionalPropertyTypes` : un filtre absent ne se transmet pas comme
// « undefined », il ne se transmet pas du tout.
const page = await getActivity({
...(params.domaine ? { domain: params.domaine } : {}),
...(params.objet ? { entityType: params.objet } : {}),
...(params.du ? { from: params.du } : {}),
...(params.au ? { to: params.au } : {}),
});
const withParam = (key: string, value: string | undefined) => {
const next = new URLSearchParams();
for (const [name, current] of Object.entries(params)) {
if (current) next.set(name, current);
}
if (value) next.set(key, value);
else next.delete(key);
const qs = next.toString();
return qs ? `/rapports/activite?${qs}` : '/rapports/activite';
};
const chip = (href: string, label: string, active: boolean) => (
<Link
key={`${href}-${label}`}
href={href}
aria-current={active ? 'page' : undefined}
className={
active
? 'flex h-7 items-center rounded-2 border border-line-3 bg-surface-2 px-2.5 text-xs font-medium text-ink-1'
: 'flex h-7 items-center rounded-2 px-2.5 text-xs text-ink-2 hover:bg-surface-2'
}
>
{label}
</Link>
);
return (
<PageBody>
<PageHeader
title="Journal d’activité"
subtitle={`${page.total} entrée${page.total > 1 ? 's' : ''} sur la période`}
/>
<Card>
<CardHeader
title="Filtres"
badge={
page.truncated ? (
<Badge tone="warn">200 dernières affichées</Badge>
) : null
}
/>
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex flex-wrap items-center gap-1">
{chip(withParam('domaine', undefined), 'Tous les domaines', !params.domaine)}
{page.domains.map((domain) =>
chip(
withParam('domaine', domain),
domain,
params.domaine === domain,
),
)}
</div>
<div className="flex flex-wrap items-center gap-1">
{chip(withParam('objet', undefined), 'Tous les objets', !params.objet)}
{page.entityTypes.map((type) =>
chip(withParam('objet', type), type, params.objet === type),
)}
</div>
<form className="flex flex-wrap items-end gap-3" method="get">
{params.domaine ? (
<input type="hidden" name="domaine" value={params.domaine} />
) : null}
{params.objet ? (
<input type="hidden" name="objet" value={params.objet} />
) : null}
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Du</span>
<input
type="date"
name="du"
defaultValue={params.du}
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Au</span>
<input
type="date"
name="au"
defaultValue={params.au}
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1"
/>
</label>
<button
type="submit"
className="h-9 rounded-2 border border-line-3 px-3 text-sm hover:bg-surface-2"
>
Appliquer
</button>
</form>
</div>
</Card>
<Card>
<CardHeader title="Entrées" />
{page.entries.length === 0 ? (
<EmptyState
title="Aucune entrée"
description="Aucune action enregistrée pour ces filtres."
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
{['Horodatage', 'Auteur', 'Action', 'Objet', 'Champs', 'Justification'].map(
(heading) => (
<th
key={heading}
className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
),
)}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{page.entries.map((entry) => (
<tr key={entry.id}>
<td className="px-4 py-2 tnum whitespace-nowrap">
{stamp.format(entry.occurredAt)}
</td>
<td className="px-4 py-2">{entry.actorName}</td>
<td className="px-4 py-2 font-mono text-xs">{entry.action}</td>
<td className="px-4 py-2 text-ink-2">
{entry.entityType}
<span className="block font-mono text-micro text-ink-3">
{entry.entityId}
</span>
</td>
<td className="px-4 py-2 text-xs text-ink-2">
{entry.changedFields.length === 0
? '—'
: entry.changedFields.join(', ')}
</td>
<td className="px-4 py-2 text-xs text-ink-2">
{entry.reason ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Le journal enregistre l’avant et l’après de chaque mutation — c’est ce
qui le rend opposable. Cet écran n’affiche que le <em>nom</em> des champs
modifiés : la valeur se lit sur la fiche concernée, sous ses propres
autorisations. Un salaire ou un motif médical n’a pas à être visible de
quiconque ouvre la piste d’audit.
</p>
</PageBody>
);
}
+44
View File
@@ -0,0 +1,44 @@
import Link from 'next/link';
import { AccountIdentityForm } from '@/components/settings/AccountIdentityForm';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import { getAccountIdentity } from '@/server/settings/account';
export const metadata = { title: 'Informations du compte · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function ComptePage() {
const account = await getAccountIdentity();
return (
<PageBody>
<PageHeader
title="Informations du compte"
subtitle="Identité de l’entreprise et siège social"
/>
{account === null ? (
<Card>
<EmptyState title="Compte introuvable" />
</Card>
) : (
<Card>
<CardHeader title={account.name} />
<AccountIdentityForm account={account} />
</Card>
)}
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Le siège n’est pas un établissement : il porte l’identité juridique, là
où{' '}
<Link href="/reglages/etablissements" className="underline">
les établissements
</Link>{' '}
portent le SIRET, le fuseau effectif et les plannings. Une entreprise
dont le siège n’ouvre aucun magasin garde donc une adresse ici et aucune
équipe.
</p>
</PageBody>
);
}
@@ -0,0 +1,187 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import type { AgreementParameters } from '@/domain/compliance/parameters';
import {
publishAgreementVersionAction,
type ActionState,
} from '@/server/settings/agreement';
const empty: ActionState = {};
function Group({
title,
note,
children,
}: {
title: string;
note?: string;
children: React.ReactNode;
}) {
return (
<fieldset className="flex flex-col gap-2 border-t border-line-1 pt-3">
<legend className="text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
{title}
</legend>
{note ? <p className="text-micro text-ink-3">{note}</p> : null}
<div className="flex flex-wrap gap-3">{children}</div>
</fieldset>
);
}
function Area({
label,
name,
defaultValue,
hint,
}: {
label: string;
name: string;
defaultValue: string;
hint: string;
}) {
return (
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">{label}</span>
<textarea
name={name}
rows={4}
defaultValue={defaultValue}
className="rounded-2 border border-line-2 bg-surface px-3 py-2 font-mono text-xs text-ink-1 outline-none focus-visible:border-focus"
/>
<span className="text-micro text-ink-3">{hint}</span>
</label>
);
}
export function AgreementForm({
idcc,
name,
parameters,
}: {
idcc: string;
name: string;
parameters: AgreementParameters;
}) {
const [state, action] = useActionState(publishAgreementVersionAction, empty);
const p = parameters;
const tiers = p.overtime.tiers
.map((tier) => `${tier.fromMinutes};${tier.toMinutes ?? ''};${tier.ratePercent}`)
.join('\n');
const derogations = p.partTime.derogations
.map((row) => `${row.code};${row.label};${row.minWeeklyMinutes}`)
.join('\n');
return (
<PersistentForm action={action} className="flex flex-col gap-4 p-4">
<Group title="Version" note="Les durées sont en minutes entières.">
<Field label="IDCC" name="idcc" defaultValue={idcc} required maxLength={10} />
<Field label="Intitulé" name="name" defaultValue={name} required maxLength={160} />
<Field
label="En vigueur à partir du"
name="effectiveFrom"
type="date"
required
hint="La version précédente reste applicable aux semaines antérieures."
/>
<Field
label="Source"
name="source"
required
maxLength={400}
placeholder="Legifrance, texte consolidé au…"
hint="Sans source, le paramètre n’est pas opposable."
/>
<Field label="Approuvé par" name="approvedBy" maxLength={160} />
</Group>
<Group title="Durées">
<Field label="Référence hebdomadaire" name="weeklyReferenceMinutes" type="number" defaultValue={p.weeklyReferenceMinutes} required />
<Field label="Maximum quotidien" name="maxDailyWorkMinutes" type="number" defaultValue={p.maxDailyWorkMinutes} required />
<Field
label="Amplitude quotidienne"
name="maxDailyAmplitudeMinutes"
type="number"
defaultValue={p.maxDailyAmplitudeMinutes ?? ''}
hint="Vide : la convention n’en fixe pas, la règle reste muette."
/>
<Field label="Repos quotidien minimal" name="minDailyRestMinutes" type="number" defaultValue={p.minDailyRestMinutes} required />
<Field label="Repos hebdomadaire minimal" name="minWeeklyRestMinutes" type="number" defaultValue={p.minWeeklyRestMinutes} required />
<Field label="Maximum hebdomadaire absolu" name="maxWeeklyWorkAbsoluteMinutes" type="number" defaultValue={p.maxWeeklyWorkAbsoluteMinutes} required />
<Field label="Fenêtre de moyenne (semaines)" name="averagedWindowWeeks" type="number" defaultValue={p.averagedWeeklyWork.windowWeeks} required />
<Field label="Moyenne maximale" name="averagedMaxMinutes" type="number" defaultValue={p.averagedWeeklyWork.maxAverageMinutes} required />
<Field label="Jours consécutifs maximum" name="maxConsecutiveWorkDays" type="number" defaultValue={p.maxConsecutiveWorkDays} required />
<Field label="Seuil de pause" name="breakThresholdMinutes" type="number" defaultValue={p.breakAfterThreshold.thresholdMinutes} required />
<Field label="Pause minimale" name="breakMinMinutes" type="number" defaultValue={p.breakAfterThreshold.minBreakMinutes} required />
<Field label="Tolérance d’écart au contrat" name="deviationToleranceMinutes" type="number" defaultValue={p.contractDeviationToleranceMinutes} required />
</Group>
<Group title="Temps partiel">
<Field label="Minimum hebdomadaire" name="partTimeMinWeeklyMinutes" type="number" defaultValue={p.partTime.minWeeklyMinutes} required />
<Area
label="Dérogations"
name="derogations"
defaultValue={derogations}
hint="Une par ligne : code;libellé;minutes."
/>
</Group>
<Group title="Heures supplémentaires">
<Area
label="Tranches"
name="overtimeTiers"
defaultValue={tiers}
hint="Une par ligne : début;fin;taux. Fin vide = sans plafond. Cumulatives depuis le début de la semaine."
/>
<Field label="Contingent annuel" name="overtimeQuotaMinutes" type="number" defaultValue={p.overtime.annualQuotaMinutes} required />
<Field label="Repos au-delà du contingent (%)" name="overtimeBeyondRestPercent" type="number" step="0.01" defaultValue={p.overtime.beyondQuotaRestPercent} required />
</Group>
<Group title="Heures complémentaires">
<Field label="Taux première tranche (%)" name="complementaryFirstRate" type="number" step="0.01" defaultValue={p.complementaryHours.firstTierRatePercent} required />
<Field label="Taux au-delà (%)" name="complementaryBeyondRate" type="number" step="0.01" defaultValue={p.complementaryHours.beyondRatePercent} required />
<Field label="Fraction première tranche" name="complementaryFirstFraction" type="number" step="0.01" defaultValue={p.complementaryHours.firstTierFraction} required />
<Field label="Plafond (fraction)" name="complementaryCapFraction" type="number" step="0.01" defaultValue={p.complementaryHours.capFraction} required />
</Group>
<Group
title="Dimanche"
note="La majoration et le repos compensateur vont ensemble : l’un sans l’autre est un manquement à L3132-27."
>
<Field label="Majoration (%)" name="sundayPremiumPercent" type="number" step="0.01" defaultValue={p.sunday.premiumPercent} required />
<Field label="Repos compensateur (%)" name="sundayRestPercent" type="number" step="0.01" defaultValue={p.sunday.compensatoryRestPercent} required />
<Field label="Dimanches du maire par an" name="sundayMayorQuota" type="number" defaultValue={p.sunday.mayorQuotaPerYear} required />
</Group>
<Group title="Jours fériés">
<Field label="Indemnité si travaillé (%)" name="holidayWorkedPremium" type="number" step="0.01" defaultValue={p.holiday.workedPremiumPercent} required />
<Field label="1er mai travaillé (%)" name="holidayLabourDayPremium" type="number" step="0.01" defaultValue={p.holiday.labourDayPremiumPercent} required />
<Field label="Fériés chômés garantis" name="holidayPaidOffDays" type="number" defaultValue={p.holiday.paidOffDaysPerYear} required />
<Field label="Repos de substitution (%)" name="holidaySubstitutionRest" type="number" step="0.01" defaultValue={p.holiday.substitutionRestPercent} required />
</Group>
<Group title="Nuit et forfait jours">
<Field label="Début de nuit (min. depuis minuit)" name="nightStartMinutes" type="number" defaultValue={p.night.startMinutes} required />
<Field label="Fin de nuit" name="nightEndMinutes" type="number" defaultValue={p.night.endMinutes} required />
<Field label="Non imposable à partir de" name="nightNotEnforceableFromAge" type="number" defaultValue={p.night.notEnforceableFromAge} required />
<Field label="Jours de forfait maximum" name="forfaitMaxDaysPerYear" type="number" defaultValue={p.forfaitJours.maxDaysPerYear} required />
<Field label="Entretien de charge (mois)" name="forfaitReviewMonths" type="number" defaultValue={p.forfaitJours.workloadReviewIntervalMonths} required />
</Group>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Publier une nouvelle version</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">
Version enregistrée. Les semaines antérieures gardent la précédente.
</span>
) : null}
</div>
</PersistentForm>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import { AgreementForm } from '@/app/(app)/reglages/convention/AgreementForm';
import {
IDCC_1517_PROVENANCE,
type ParameterOrigin,
} from '@/domain/compliance/idcc1517';
import { listAgreementVersions } from '@/server/settings/agreement';
export const metadata = { title: 'Convention collective · PlanFlow' };
export const dynamic = 'force-dynamic';
const ORIGIN_TONE: Record<ParameterOrigin, 'danger' | 'info' | 'warn'> = {
OP: 'danger',
CCN: 'info',
ENT: 'warn',
};
const ORIGIN_LABEL: Record<ParameterOrigin, string> = {
OP: 'Ordre public',
CCN: 'Convention',
ENT: 'Accord d’entreprise',
};
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
export default async function ConventionPage() {
const versions = await listAgreementVersions();
const current = versions.find((version) => version.isCurrent) ?? versions[0];
return (
<PageBody>
<PageHeader
title="Convention collective"
subtitle={
current
? `IDCC ${current.idcc} · version ${current.version}`
: 'Aucune convention chargée'
}
/>
{!current ? (
<Card>
<EmptyState
title="Aucune convention"
description="Le moteur de règles lit ses seuils en base. Sans convention chargée, aucune règle horaire ne s’applique."
/>
</Card>
) : null}
<Card>
<CardHeader
title="Origine des valeurs"
badge={<Badge tone="neutral">{IDCC_1517_PROVENANCE.length}</Badge>}
/>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Paramètre
</th>
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Valeur
</th>
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Origine
</th>
<th className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Source
</th>
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{IDCC_1517_PROVENANCE.map((entry) => (
<tr key={entry.key}>
<td className="px-4 py-2">{entry.label}</td>
<td className="px-4 py-2 tnum">{entry.value}</td>
<td className="px-4 py-2">
<Badge tone={ORIGIN_TONE[entry.origin]}>
{ORIGIN_LABEL[entry.origin]}
</Badge>
</td>
<td className="px-4 py-2 text-ink-2">{entry.source}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="border-t border-line-1 px-4 py-3 text-xs leading-[var(--lh-prose)] text-ink-3">
Une valeur d’ordre public ne se négocie pas ; une valeur
conventionnelle change si la convention change ; une valeur d’accord
d’entreprise suppose un accord signé. Confondre les trois fait
apparaître comme discutable ce qui s’impose, et l’inverse.
</p>
</Card>
<Card>
<CardHeader
title="Versions"
badge={<Badge tone="neutral">{versions.length}</Badge>}
/>
{versions.length === 0 ? (
<EmptyState title="Aucune version enregistrée" />
) : (
<ul className="divide-y divide-line-1">
{versions.map((version) => (
<li key={version.id} className="flex flex-wrap items-center gap-2 px-4 py-3">
<Badge tone={version.isCurrent ? 'ok' : 'neutral'}>
v{version.version}
</Badge>
<span className="text-sm font-medium">{version.name}</span>
<span className="text-sm text-ink-2">
en vigueur au {dateFormat.format(version.effectiveFrom)}
</span>
{version.isCurrent ? <Badge tone="accent">Applicable</Badge> : null}
<span className="flex-1" />
<span className="text-xs text-ink-3">
{version.approvedBy
? `Approuvée par ${version.approvedBy}`
: 'Non approuvée'}
</span>
</li>
))}
</ul>
)}
</Card>
{current ? (
<Card>
<CardHeader
title="Éditer les paramètres"
badge={<Badge tone="warn">crée une version</Badge>}
/>
<AgreementForm
idcc={current.idcc}
name={current.name}
parameters={current.parameters}
/>
</Card>
) : null}
</PageBody>
);
}
@@ -0,0 +1,67 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
createJobTitleAction,
renameJobTitleAction,
type ActionState,
} from '@/server/settings/job-titles';
const empty: ActionState = {};
export function AddJobTitleForm() {
const [state, action] = useActionState(createJobTitleAction, empty);
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-3"
>
<div className="flex flex-wrap items-end gap-3">
<Field
label="Intitulé"
name="name"
required
maxLength={120}
placeholder="Hôte de caisse"
hint="L’intitulé figure au registre unique du personnel et sur le contrat."
/>
<SubmitButton>Ajouter l’emploi</SubmitButton>
</div>
<FormError>{state.error}</FormError>
{state.ok ? (
<p className="text-xs text-ok-soft-ink">Emploi enregistré.</p>
) : null}
</PersistentForm>
);
}
export function RenameJobTitleForm({
id,
name,
}: {
id: string;
name: string;
}) {
const [state, action] = useActionState(renameJobTitleAction, empty);
return (
<PersistentForm action={action} className="flex flex-1 items-end gap-2">
<input type="hidden" name="id" value={id} />
<Field
label="Intitulé"
name="name"
defaultValue={name}
required
maxLength={120}
className="h-8"
/>
<SubmitButton size="sm">Renommer</SubmitButton>
<FormError>{state.error}</FormError>
</PersistentForm>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import {
AddJobTitleForm,
RenameJobTitleForm,
} from '@/app/(app)/reglages/emplois/JobTitleForms';
import {
archiveJobTitleAction,
listJobTitles,
} from '@/server/settings/job-titles';
export const metadata = { title: 'Emplois · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function EmploisPage({
searchParams,
}: {
searchParams: Promise<{ archives?: string }>;
}) {
const { archives } = await searchParams;
const showArchived = archives === '1';
const titles = await listJobTitles(showArchived);
const active = titles.filter((title) => title.archivedAt === null);
return (
<PageBody>
<PageHeader
title="Emplois"
subtitle={`${active.length} emploi${active.length > 1 ? 's' : ''} en vigueur`}
/>
<Card>
<CardHeader
title="Référentiel"
badge={
<Badge tone="neutral">
{titles.length} affiché{titles.length > 1 ? 's' : ''}
</Badge>
}
action={
<a
href={showArchived ? '/reglages/emplois' : '/reglages/emplois?archives=1'}
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
>
{showArchived ? 'Masquer les archivés' : 'Voir les archivés'}
</a>
}
/>
{titles.length === 0 ? (
<EmptyState
title="Aucun emploi"
description="Un contrat porte une qualification ; créez d’abord les emplois de l’organisation."
/>
) : (
<ul className="divide-y divide-line-1">
{titles.map((title) => (
<li
key={title.id}
className="flex flex-wrap items-end gap-3 px-4 py-3"
>
<RenameJobTitleForm id={title.id} name={title.name} />
<div className="flex items-center gap-2">
<Badge tone="neutral">
{title.contractCount} contrat
{title.contractCount > 1 ? 's' : ''}
</Badge>
{title.archivedAt ? <Badge tone="warn">Archivé</Badge> : null}
</div>
<form action={archiveJobTitleAction}>
<input type="hidden" name="id" value={title.id} />
{title.archivedAt ? (
<input type="hidden" name="restore" value="1" />
) : null}
<Button size="sm" variant="ghost" type="submit">
{title.archivedAt ? 'Rétablir' : 'Archiver'}
</Button>
</form>
</li>
))}
</ul>
)}
</Card>
<Card>
<CardHeader title="Nouvel emploi" />
<div className="p-4">
<AddJobTitleForm />
</div>
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Un emploi porté par un contrat est archivé, jamais supprimé : le registre
unique du personnel doit rester lisible pendant toute la durée de
conservation, y compris pour un salarié parti.
</p>
</PageBody>
);
}
@@ -0,0 +1,85 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import { POSTE_CODES, POSTE_LABELS } from '@/lib/design/postes';
import {
createLabelAction,
updateLabelAction,
type ActionState,
} from '@/server/settings/labels';
const empty: ActionState = {};
function PaletteSelect({ defaultValue }: { defaultValue?: string }) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Teinte</span>
<select
name="paletteKey"
defaultValue={defaultValue ?? POSTE_CODES[0]}
className="h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
>
{POSTE_CODES.map((code) => (
<option key={code} value={code}>
{POSTE_LABELS[code]}
</option>
))}
</select>
</label>
);
}
export function AddLabelForm() {
const [state, action] = useActionState(createLabelAction, empty);
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-3"
>
<div className="flex flex-wrap items-end gap-3">
<Field
label="Code"
name="code"
required
maxLength={12}
placeholder="cai"
hint="Affiché sur le bloc de planning : la couleur ne porte jamais l’information seule."
/>
<Field label="Libellé" name="name" required maxLength={120} placeholder="Caisse" />
<PaletteSelect />
<SubmitButton>Ajouter l’étiquette</SubmitButton>
</div>
<FormError>{state.error}</FormError>
{state.ok ? (
<p className="text-xs text-ok-soft-ink">Étiquette enregistrée.</p>
) : null}
</PersistentForm>
);
}
export function EditLabelForm({
id,
name,
paletteKey,
}: {
id: string;
name: string;
paletteKey: string;
}) {
const [state, action] = useActionState(updateLabelAction, empty);
return (
<PersistentForm action={action} className="flex flex-1 flex-wrap items-end gap-2">
<input type="hidden" name="id" value={id} />
<Field label="Libellé" name="name" defaultValue={name} required maxLength={120} />
<PaletteSelect defaultValue={paletteKey} />
<SubmitButton size="sm">Enregistrer</SubmitButton>
<FormError>{state.error}</FormError>
</PersistentForm>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import {
AddLabelForm,
EditLabelForm,
} from '@/app/(app)/reglages/etiquettes/LabelForms';
import { isPosteCode, posteTokens } from '@/lib/design/postes';
import { archiveLabelAction, listLabels } from '@/server/settings/labels';
export const metadata = { title: 'Étiquettes · PlanFlow' };
export const dynamic = 'force-dynamic';
function Swatch({ paletteKey, code }: { paletteKey: string; code: string }) {
if (!isPosteCode(paletteKey)) {
return <Badge tone="warn">teinte inconnue</Badge>;
}
const tokens = posteTokens(paletteKey);
return (
<span
className="inline-flex h-6 shrink-0 items-center rounded-2 border px-2 text-micro font-semibold"
style={{
background: tokens.bg,
color: tokens.fg,
borderColor: tokens.edge,
}}
>
{code.toUpperCase()}
</span>
);
}
export default async function EtiquettesPage({
searchParams,
}: {
searchParams: Promise<{ archives?: string }>;
}) {
const { archives } = await searchParams;
const showArchived = archives === '1';
const labels = await listLabels(showArchived);
const active = labels.filter((label) => label.archivedAt === null);
return (
<PageBody>
<PageHeader
title="Étiquettes"
subtitle={`${active.length} étiquette${active.length > 1 ? 's' : ''} en vigueur`}
/>
<Card>
<CardHeader
title="Postes de la grille"
action={
<a
href={
showArchived
? '/reglages/etiquettes'
: '/reglages/etiquettes?archives=1'
}
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
>
{showArchived ? 'Masquer les archivées' : 'Voir les archivées'}
</a>
}
/>
{labels.length === 0 ? (
<EmptyState
title="Aucune étiquette"
description="Une étiquette colore le créneau et nomme le poste tenu. Créez-en une pour commencer."
/>
) : (
<ul className="divide-y divide-line-1">
{labels.map((label) => (
<li
key={label.id}
className="flex flex-wrap items-end gap-3 px-4 py-3"
>
<Swatch paletteKey={label.paletteKey} code={label.code} />
<EditLabelForm
id={label.id}
name={label.name}
paletteKey={label.paletteKey}
/>
<div className="flex items-center gap-2">
<Badge tone="neutral">
{label.shiftCount} créneau{label.shiftCount > 1 ? 'x' : ''}
</Badge>
{label.archivedAt ? <Badge tone="warn">Archivée</Badge> : null}
</div>
<form action={archiveLabelAction}>
<input type="hidden" name="id" value={label.id} />
{label.archivedAt ? (
<input type="hidden" name="restore" value="1" />
) : null}
<Button size="sm" variant="ghost" type="submit">
{label.archivedAt ? 'Rétablir' : 'Archiver'}
</Button>
</form>
</li>
))}
</ul>
)}
</Card>
<Card>
<CardHeader title="Nouvelle étiquette" />
<div className="p-4">
<AddLabelForm />
</div>
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Les douze teintes viennent d’une palette calculée pour rester
distinguables en vision daltonienne et à l’impression en noir et blanc.
C’est pourquoi le code du poste s’affiche toujours avec la couleur, et
pourquoi la teinte se choisit dans la liste plutôt qu’à la pipette.
</p>
</PageBody>
);
}
@@ -0,0 +1,102 @@
'use client';
import { useActionState } from 'react';
import { FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import { PRINT_DENSITIES, togglesOf } from '@/domain/settings/preferences';
import {
savePreferencesAction,
type ActionState,
type Preferences,
} from '@/server/settings/account';
const empty: ActionState = {};
export function PrintForm({ preferences }: { preferences: Preferences }) {
const [state, action] = useActionState(savePreferencesAction, empty);
const toggles = togglesOf('print');
return (
<PersistentForm action={action} className="flex flex-col gap-4 px-4 py-4">
{toggles.map((toggle) => (
<input key={`scope-${toggle.key}`} type="hidden" name="scope" value={toggle.key} />
))}
<fieldset className="flex flex-col gap-2">
<legend className="text-sm font-medium">Orientation</legend>
<p className="text-micro text-ink-3">
Une semaine se lit en largeur : le paysage évite de couper le
dimanche sur une seconde page.
</p>
{[
{ value: 'landscape', label: 'Paysage' },
{ value: 'portrait', label: 'Portrait' },
].map((option) => (
<label key={option.value} className="flex items-center gap-2 text-sm">
<input
type="radio"
name="printOrientation"
value={option.value}
defaultChecked={
(option.value === 'landscape') === preferences.printLandscape
}
/>
{option.label}
</label>
))}
</fieldset>
<label className="flex max-w-sm flex-col gap-1.5">
<span className="text-sm font-medium">Densité</span>
<select
name="printDensity"
defaultValue={preferences.printDensity}
className="h-9 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
>
{PRINT_DENSITIES.map((density) => (
<option key={density.key} value={density.key}>
{density.label} — {density.hint}
</option>
))}
</select>
</label>
{toggles.map((toggle) => (
<div key={toggle.key} className="flex flex-col gap-1">
<label className="flex items-start gap-2.5 text-sm">
<input
type="checkbox"
name={toggle.key}
defaultChecked={Boolean(
preferences[toggle.key as keyof Preferences],
)}
className="mt-0.5"
/>
<span>
<span className="font-medium">{toggle.label}</span>
<span className="block text-micro leading-[var(--lh-prose)] text-ink-3">
{toggle.hint}
</span>
</span>
</label>
{toggle.warning ? (
<p className="ml-6 rounded-2 border border-warn bg-warn-soft px-2.5 py-1.5 text-micro leading-[var(--lh-prose)] text-warn-soft-ink">
{toggle.warning}
</p>
) : null}
</div>
))}
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Enregistrer</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Réglages enregistrés.</span>
) : null}
</div>
</PersistentForm>
);
}
@@ -0,0 +1,37 @@
import Link from 'next/link';
import { PrintForm } from '@/app/(app)/reglages/impression/PrintForm';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Card, CardHeader } from '@/components/ui/Card';
import { getPreferences } from '@/server/settings/account';
export const metadata = { title: 'Impression · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function ImpressionReglagesPage() {
const preferences = await getPreferences();
return (
<PageBody>
<PageHeader
title="Impression"
subtitle="Mise en page du planning affiché en salle"
/>
<Card>
<CardHeader title="Planning imprimé" />
<PrintForm preferences={preferences} />
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Ces réglages gouvernent{' '}
<Link href="/planning/impression" className="underline">
le planning à afficher
</Link>
. Le rendu passe par la boîte d’impression du navigateur, qui sait aussi
enregistrer en PDF : un second moteur de rendu serait un second endroit
où la feuille peut diverger de l’écran.
</p>
</PageBody>
);
}
@@ -0,0 +1,89 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
createTemplateAction,
updateTemplateAction,
type ActionState,
} from '@/server/settings/templates';
const empty: ActionState = {};
function BodyField({ defaultValue }: { defaultValue?: string }) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Corps du document</span>
<textarea
name="bodyHtml"
rows={10}
required
defaultValue={defaultValue}
placeholder="<p>Je soussigné…, atteste que {{salarie.prenom}} {{salarie.nom}} est employé…</p>"
className="rounded-2 border border-line-2 bg-surface px-3 py-2 font-mono text-xs text-ink-1 outline-none focus-visible:border-focus"
/>
<span className="text-micro text-ink-3">
Les variables s’écrivent entre doubles accolades. Une variable inconnue
fait refuser l’enregistrement — mieux vaut la corriger ici que de
découvrir un trou dans l’attestation au moment de la remettre.
</span>
</label>
);
}
export function AddTemplateForm() {
const [state, action] = useActionState(createTemplateAction, empty);
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-3"
>
<Field
label="Nom du modèle"
name="name"
required
maxLength={160}
placeholder="Attestation d’emploi"
/>
<BodyField />
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Créer le modèle</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Modèle enregistré.</span>
) : null}
</div>
</PersistentForm>
);
}
export function EditTemplateForm({
id,
name,
bodyHtml,
}: {
id: string;
name: string;
bodyHtml: string;
}) {
const [state, action] = useActionState(updateTemplateAction, empty);
return (
<PersistentForm action={action} className="flex flex-col gap-3">
<input type="hidden" name="id" value={id} />
<Field label="Nom du modèle" name="name" defaultValue={name} required maxLength={160} />
<BodyField defaultValue={bodyHtml} />
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton size="sm">Enregistrer</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Modifications prises.</span>
) : null}
</div>
</PersistentForm>
);
}
@@ -0,0 +1,164 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import {
AddTemplateForm,
EditTemplateForm,
} from '@/app/(app)/reglages/modeles-documents/TemplateForms';
import { TEMPLATE_FIELDS } from '@/domain/documents/template';
import {
archiveTemplateAction,
listTemplates,
} from '@/server/settings/templates';
export const metadata = { title: 'Modèles de documents · PlanFlow' };
export const dynamic = 'force-dynamic';
const SCOPE_LABEL = {
employee: 'Salarié',
contract: 'Contrat',
location: 'Établissement',
} as const;
export default async function ModelesDocumentsPage({
searchParams,
}: {
searchParams: Promise<{ archives?: string }>;
}) {
const { archives } = await searchParams;
const showArchived = archives === '1';
const templates = await listTemplates(showArchived);
const active = templates.filter((template) => template.archivedAt === null);
return (
<PageBody>
<PageHeader
title="Modèles de documents"
subtitle={`${active.length} modèle${active.length > 1 ? 's' : ''} disponible${active.length > 1 ? 's' : ''}`}
/>
<div className="grid gap-5 lg:grid-cols-[1fr_18rem]">
<div className="flex min-w-0 flex-col gap-5">
<Card>
<CardHeader
title="Modèles"
action={
<a
href={
showArchived
? '/reglages/modeles-documents'
: '/reglages/modeles-documents?archives=1'
}
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
>
{showArchived ? 'Masquer les archivés' : 'Voir les archivés'}
</a>
}
/>
{templates.length === 0 ? (
<EmptyState
title="Aucun modèle"
description="Un modèle produit une attestation ou un courrier type en résolvant les variables du dossier."
/>
) : (
<ul className="divide-y divide-line-1">
{templates.map((template) => (
<li key={template.id} className="px-4 py-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">
{template.name}
</span>
<Badge tone="neutral">
{template.documentCount} pièce
{template.documentCount > 1 ? 's' : ''}
</Badge>
{template.archivedAt ? (
<Badge tone="warn">Archivé</Badge>
) : null}
<span className="flex-1" />
<form action={archiveTemplateAction}>
<input type="hidden" name="id" value={template.id} />
{template.archivedAt ? (
<input type="hidden" name="restore" value="1" />
) : null}
<Button size="sm" variant="ghost" type="submit">
{template.archivedAt ? 'Rétablir' : 'Archiver'}
</Button>
</form>
</div>
{template.fields.length > 0 ? (
<ul className="mb-3 flex flex-wrap gap-1.5">
{template.fields.map((field) => (
<li key={field}>
<Badge tone="info">{field}</Badge>
</li>
))}
</ul>
) : (
<p className="mb-3 text-xs text-ink-3">
Aucune variable : ce modèle produit toujours le même
texte.
</p>
)}
<details className="text-sm">
<summary className="cursor-pointer text-xs text-ink-2 underline underline-offset-2">
Modifier
</summary>
<div className="pt-3">
<EditTemplateForm
id={template.id}
name={template.name}
bodyHtml={template.bodyHtml}
/>
</div>
</details>
</li>
))}
</ul>
)}
</Card>
<Card>
<CardHeader title="Nouveau modèle" />
<div className="p-4">
<AddTemplateForm />
</div>
</Card>
</div>
<aside className="flex flex-col gap-3">
<Card>
<CardHeader title="Variables disponibles" />
<ul className="divide-y divide-line-1">
{TEMPLATE_FIELDS.map((field) => (
<li key={field.key} className="px-3 py-2">
<code className="text-xs text-ink-1">
{'{{'}
{field.key}
{'}}'}
</code>
<span className="mt-0.5 flex items-center gap-1.5">
<span className="text-micro text-ink-3">{field.label}</span>
<Badge tone="neutral" subtle>
{SCOPE_LABEL[field.scope]}
</Badge>
</span>
</li>
))}
</ul>
</Card>
<p className="text-xs leading-[var(--lh-prose)] text-ink-3">
Les variables d’établissement se résolvent dans le contexte de
l’établissement porté par le contrat du salarié : la même attestation
ne porte pas la même raison sociale d’un magasin à l’autre.
</p>
</aside>
</div>
</PageBody>
);
}
+38
View File
@@ -0,0 +1,38 @@
import Link from 'next/link';
import { PreferenceBlock } from '@/components/settings/PreferenceForms';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Card, CardHeader } from '@/components/ui/Card';
import { getPreferences } from '@/server/settings/account';
export const metadata = { title: 'Réglages de paie · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function ReglagesPaiePage() {
const preferences = await getPreferences();
return (
<PageBody>
<PageHeader
title="Réglages de paie"
subtitle="Décompte des heures et matricules"
/>
<Card>
<CardHeader title="Décompte" />
<PreferenceBlock group="payroll" preferences={preferences} />
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Ces trois réglages changent des chiffres, pas un affichage. Une période
déjà verrouillée n’est pas recalculée : le changement vaut pour les
périodes ouvertes, et les{' '}
<Link href="/paie/periodes" className="underline">
instantanés de paie
</Link>{' '}
antérieurs restent tels qu’ils ont été figés. C’est ce qui garde une paie
de mars reproductible après une modification en juin.
</p>
</PageBody>
);
}
@@ -0,0 +1,131 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
assignRttPolicyAction,
createRttPolicyAction,
type ActionState,
} from '@/server/settings/rtt-policies';
const empty: ActionState = {};
export function AddPolicyForm() {
const [state, action] = useActionState(createRttPolicyAction, empty);
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-3"
>
<div className="flex flex-wrap gap-3">
<Field
label="Nom"
name="name"
required
maxLength={160}
placeholder="RTT cadres"
/>
<Field
label="Jours par an"
name="daysPerYear"
type="number"
step="0.5"
min="0"
max="365"
required
defaultValue="0"
/>
<Field
label="Début de période"
name="periodStart"
required
placeholder="01-01"
pattern="(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])"
hint="Format MM-JJ : la période se reconduit d’une année sur l’autre, sans millésime à réécrire."
/>
</div>
<label className="flex items-start gap-2 text-sm">
<input type="checkbox" name="autoRenew" defaultChecked className="mt-0.5" />
<span>
Reconduction automatique
<span className="block text-micro text-ink-3">
La politique ouvre un nouveau droit à chaque début de période.
Archivée, elle cesse de le faire.
</span>
</span>
</label>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Créer la politique</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Politique enregistrée.</span>
) : null}
</div>
</PersistentForm>
);
}
export function AssignForm({
policyId,
candidates,
assigned,
}: {
policyId: string;
candidates: Array<{ membershipId: string; name: string }>;
assigned: string[];
}) {
const [state, action] = useActionState(assignRttPolicyAction, empty);
const available = candidates.filter(
(candidate) => !assigned.includes(candidate.membershipId),
);
if (available.length === 0) {
return (
<p className="text-xs text-ink-3">
Tous les salariés actifs sont déjà couverts par cette politique.
</p>
);
}
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-2"
>
<input type="hidden" name="policyId" value={policyId} />
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Salariés à assigner</span>
<select
name="membershipIds"
multiple
size={Math.min(8, available.length)}
className="rounded-2 border border-line-2 bg-surface px-2 py-1 text-sm text-ink-1"
>
{available.map((candidate) => (
<option key={candidate.membershipId} value={candidate.membershipId}>
{candidate.name}
</option>
))}
</select>
</label>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton size="sm">Assigner</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Affectation enregistrée.</span>
) : null}
</div>
</PersistentForm>
);
}
@@ -0,0 +1,185 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import {
AddPolicyForm,
AssignForm,
} from '@/app/(app)/reglages/politiques-rtt/PolicyForms';
import {
archiveRttPolicyAction,
getPolicyBoard,
unassignRttPolicyAction,
} from '@/server/settings/rtt-policies';
export const metadata = { title: 'Politiques de RTT · PlanFlow' };
export const dynamic = 'force-dynamic';
/** « 01-01 » → « 1er janvier ». */
function periodLabel(periodStart: string): string {
const [month, day] = periodStart.split('-');
if (!month || !day) return periodStart;
const date = new Date(Date.UTC(2001, Number(month) - 1, Number(day)));
const formatted = new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'long',
timeZone: 'UTC',
}).format(date);
return day === '01' ? formatted.replace(/^1 /, '1er ') : formatted;
}
export default async function PolitiquesRttPage({
searchParams,
}: {
searchParams: Promise<{ archives?: string }>;
}) {
const { archives } = await searchParams;
const showArchived = archives === '1';
const board = await getPolicyBoard(showArchived);
const active = board.policies.filter((policy) => policy.status === 'ACTIVE');
return (
<PageBody>
<PageHeader
title="Politiques de RTT"
subtitle={`${active.length} politique${active.length > 1 ? 's' : ''} active${active.length > 1 ? 's' : ''}`}
/>
<Card>
<CardHeader
title="Politiques"
action={
<a
href={
showArchived
? '/reglages/politiques-rtt'
: '/reglages/politiques-rtt?archives=1'
}
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
>
{showArchived ? 'Masquer les archivées' : 'Voir les archivées'}
</a>
}
/>
{board.policies.length === 0 ? (
<EmptyState
title="Aucune politique"
description="Une politique ouvre un droit à RTT et l’inscrit au registre des compteurs à chaque début de période."
/>
) : (
<ul className="divide-y divide-line-1">
{board.policies.map((policy) => (
<li key={policy.id} className="px-4 py-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">{policy.name}</span>
<Badge tone="accent" className="tnum">
{policy.daysPerYear} j/an
</Badge>
<Badge tone="neutral">
à partir du {periodLabel(policy.periodStart)}
</Badge>
{policy.autoRenew ? (
<Badge tone="ok">Reconduite</Badge>
) : (
<Badge tone="neutral">Période unique</Badge>
)}
{policy.status === 'ARCHIVED' ? (
<Badge tone="warn">Archivée</Badge>
) : null}
<span className="flex-1" />
<form action={archiveRttPolicyAction}>
<input type="hidden" name="id" value={policy.id} />
{policy.status === 'ARCHIVED' ? (
<input type="hidden" name="restore" value="1" />
) : null}
<Button size="sm" variant="ghost" type="submit">
{policy.status === 'ARCHIVED' ? 'Rétablir' : 'Archiver'}
</Button>
</form>
</div>
<p className="mb-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
{policy.assignedCount} salarié
{policy.assignedCount > 1 ? 's' : ''} couvert
{policy.assignedCount > 1 ? 's' : ''}
</p>
{policy.assignees.length > 0 ? (
<ul className="mb-3 flex flex-wrap gap-1.5">
{policy.assignees.map((assignee) => (
<li key={assignee.membershipId}>
<form
action={unassignRttPolicyAction}
className="inline-flex items-center gap-1 rounded-full border border-line-2 bg-surface-3 py-px pr-1 pl-2"
>
<input
type="hidden"
name="policyId"
value={policy.id}
/>
<input
type="hidden"
name="membershipId"
value={assignee.membershipId}
/>
<span className="text-micro font-semibold text-ink-2">
{assignee.name}
</span>
<button
type="submit"
aria-label={`Retirer ${assignee.name} de ${policy.name}`}
className="cursor-pointer rounded-full px-1 text-micro text-ink-3 hover:text-danger"
>
×
</button>
</form>
</li>
))}
</ul>
) : (
<p className="mb-3 text-xs text-ink-3">
Aucun salarié assigné : cette politique n’ouvre aucun droit.
</p>
)}
{policy.status === 'ACTIVE' ? (
<details className="text-sm">
<summary className="cursor-pointer text-xs text-ink-2 underline underline-offset-2">
Assigner des employés
</summary>
<div className="pt-3">
<AssignForm
policyId={policy.id}
candidates={board.candidates}
assigned={policy.assignees.map(
(assignee) => assignee.membershipId,
)}
/>
</div>
</details>
) : null}
</li>
))}
</ul>
)}
</Card>
<Card>
<CardHeader title="Nouvelle politique" />
<div className="p-4">
<AddPolicyForm />
</div>
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Archiver arrête la reconduction ; les jours déjà acquis restent au
registre des compteurs et les affectations en place. Une politique
n’efface pas ce qu’elle a produit — un solde de RTT est un droit ouvert,
pas un affichage.
</p>
</PageBody>
);
}
@@ -0,0 +1,40 @@
import { PreferenceBlock } from '@/components/settings/PreferenceForms';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Card, CardHeader } from '@/components/ui/Card';
import { getPreferences } from '@/server/settings/account';
export const metadata = { title: 'Préférences · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function PreferencesPage() {
const preferences = await getPreferences();
return (
<PageBody>
<PageHeader
title="Préférences"
subtitle="Comportement des plannings et droits ouverts aux salariés"
/>
<Card>
<CardHeader title="Plannings" />
<PreferenceBlock
group="planning"
preferences={preferences}
withEveningTime
/>
</Card>
<Card>
<CardHeader title="Droits" />
<PreferenceBlock group="rights" preferences={preferences} />
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Ces réglages valent pour tout le compte. Ils s’ajoutent aux capacités des
rôles sans les remplacer : ouvrir un droit ici ne donne rien à qui n’a
pas la capacité correspondante, et le fermer la retire à tout le monde.
</p>
</PageBody>
);
}
@@ -0,0 +1,42 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
saveProductivityTargetAction,
type ActionState,
} from '@/server/settings/account';
const empty: ActionState = {};
export function ProductivityForm({ target }: { target: string | null }) {
const [state, action] = useActionState(saveProductivityTargetAction, empty);
return (
<PersistentForm action={action} className="flex flex-col gap-3 p-4">
<div className="flex max-w-sm items-end gap-2">
<Field
label="Objectif de productivité"
name="target"
type="number"
step="0.01"
min="0"
defaultValue={target ?? ''}
hint="Chiffre d’affaires attendu par heure travaillée, en euros. Laisser vide pour n’en fixer aucun."
/>
<span className="pb-2.5 text-sm text-ink-2">€/h</span>
</div>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Enregistrer</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Objectif enregistré.</span>
) : null}
</div>
</PersistentForm>
);
}
@@ -0,0 +1,48 @@
import { ProductivityForm } from '@/app/(app)/reglages/productivite/ProductivityForm';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader } from '@/components/ui/Card';
import { getPreferences } from '@/server/settings/account';
export const metadata = { title: 'Productivité · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function ProductivitePage() {
const preferences = await getPreferences();
const target = preferences.productivityTargetPerHour;
return (
<PageBody>
<PageHeader
title="Productivité"
subtitle={
target
? `${target} € de chiffre d’affaires par heure travaillée`
: 'Aucun objectif fixé'
}
/>
<Card>
<CardHeader
title="Objectif global"
badge={
target ? null : <Badge tone="neutral">non fixé</Badge>
}
/>
<ProductivityForm target={target} />
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
L’objectif vaut pour l’ensemble des établissements. Vider le champ
l’efface plutôt que de le ramener à zéro : zéro serait un objectif
inatteignable, l’absence de valeur dit qu’aucun objectif n’a été fixé.
</p>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Un objectif de productivité rapporte un chiffre d’affaires à des heures
planifiées. Il éclaire une décision d’organisation ; il ne mesure pas le
travail d’une personne, et n’a pas à être opposé à un salarié.
</p>
</PageBody>
);
}
-21
View File
@@ -28,27 +28,6 @@ export default async function RegistrePage() {
subtitle={`${register.approvedCount} paramètre${register.approvedCount > 1 ? 's' : ''} approuvé${register.approvedCount > 1 ? 's' : ''} · ${register.pendingCount} en attente`}
/>
<Card>
<div className="p-4 text-sm leading-[var(--lh-prose)] text-ink-2">
<p>
Chaque paramètre appliqué par PlanFlow doit porter sa{' '}
<strong className="font-semibold text-ink-1">valeur</strong>, sa{' '}
<strong className="font-semibold text-ink-1">source</strong>, sa{' '}
<strong className="font-semibold text-ink-1">date d’effet</strong>,
la <strong className="font-semibold text-ink-1">population</strong>{' '}
concernée et un{' '}
<strong className="font-semibold text-ink-1">approbateur</strong>.
</p>
<p className="mt-2">
Recopier la configuration d’un autre logiciel ne suffit pas : sans
justification conservée, un paramètre n’est pas opposable en cas de
contrôle. Les valeurs de la convention IDCC 1517 déjà chargées dans
le moteur restent à recouper avec le texte consolidé et à faire
valider par le gestionnaire de paie.
</p>
</div>
</Card>
{register.missingDomains.length > 0 ? (
<Card className="border-warn">
<CardHeader
+184
View File
@@ -0,0 +1,184 @@
import Link from 'next/link';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader } from '@/components/ui/Card';
import {
DATA_DICTIONARY,
LEGAL_BASIS_LABELS,
} from '@/domain/legal/data-dictionary';
import { query } from '@/server/context';
export const metadata = { title: 'RGPD · PlanFlow' };
export const dynamic = 'force-dynamic';
export default async function RgpdPage() {
// Les durées ne sont pas écrites dans le dictionnaire : elles viennent des
// politiques de conservation, qui se paramètrent et s'auditent. Un artefact
// qui afficherait sa propre durée mentirait le jour où la politique change.
const policies = await query('settings.access', async (db) =>
db.retentionPolicy.findMany({
orderBy: [{ objectType: 'asc' }, { effectiveFrom: 'desc' }],
select: {
objectType: true,
durationMonths: true,
effectiveFrom: true,
},
}),
);
const durationFor = (objectTypes: string[]): string => {
const matches = objectTypes
.map((objectType) => {
// Une politique précise l'emporte sur une politique générale :
// « Document:SICK_NOTE » avant « Document ».
const exact = policies.find((policy) => policy.objectType === objectType);
if (exact) return exact;
const base = objectType.split(':')[0] as string;
return policies.find((policy) => policy.objectType === base);
})
.filter((policy) => policy !== undefined);
if (matches.length === 0) return 'Non déclarée';
const months = [...new Set(matches.map((policy) => policy.durationMonths))];
return months
.sort((a, b) => a - b)
.map((count) =>
count % 12 === 0 ? `${count / 12} an${count > 12 ? 's' : ''}` : `${count} mois`,
)
.join(' · ');
};
const undeclared = DATA_DICTIONARY.filter(
(entry) => durationFor(entry.objectTypes) === 'Non déclarée',
).length;
return (
<PageBody>
<PageHeader
title="RGPD"
subtitle="Dictionnaire des données : finalité, base légale, destinataires, durée"
/>
<p
role="note"
className="max-w-[70ch] rounded-2 border border-warn bg-warn-soft px-3 py-2 text-xs leading-[var(--lh-prose)] text-warn-soft-ink"
>
L’affectation d’une base légale est une qualification juridique. Ce
tableau porte la lecture usuelle d’un traitement de paie et de planning ;
il doit être confirmé par le responsable de traitement avant d’être
opposé à qui que ce soit.
</p>
{undeclared > 0 ? (
<p
role="status"
className="max-w-[70ch] rounded-2 border border-danger bg-danger-soft px-3 py-2 text-xs leading-[var(--lh-prose)] text-danger-soft-ink"
>
{undeclared} catégorie{undeclared > 1 ? 's' : ''} sans durée de
conservation déclarée. Le plan interdit d’appliquer une durée par
défaut : déclarez-la dans{' '}
<Link href="/reglages/conservation" className="underline">
Durées de conservation
</Link>
.
</p>
) : null}
<Card>
<CardHeader
title="Données traitées"
badge={<Badge tone="neutral">{DATA_DICTIONARY.length}</Badge>}
/>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
{[
'Catégorie',
'Finalité',
'Base légale',
'Destinataires',
'Durée',
].map((heading) => (
<th
key={heading}
className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{DATA_DICTIONARY.map((entry) => {
const duration = durationFor(entry.objectTypes);
return (
<tr key={entry.category}>
<td className="px-4 py-3 align-top">
<span className="font-medium">{entry.category}</span>
{entry.sensitive ? (
<Badge tone="danger" className="ml-1.5">
Catégorie particulière
</Badge>
) : null}
<span className="mt-1 block font-mono text-micro text-ink-3">
{entry.objectTypes.join(', ')}
</span>
</td>
<td className="max-w-[28ch] px-4 py-3 align-top text-ink-2">
{entry.purpose}
{entry.notes ? (
<span className="mt-1 block text-micro text-ink-3">
{entry.notes}
</span>
) : null}
</td>
<td className="px-4 py-3 align-top">
{LEGAL_BASIS_LABELS[entry.basis]}
</td>
<td className="px-4 py-3 align-top text-ink-2">
{entry.recipients.join(', ')}
</td>
<td className="px-4 py-3 align-top">
{duration === 'Non déclarée' ? (
<Badge tone="danger">Non déclarée</Badge>
) : (
<span className="tnum">{duration}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
<Card>
<CardHeader title="Droits des personnes" />
<div className="flex flex-col gap-2 px-4 py-3 text-sm leading-[var(--lh-prose)] text-ink-2">
<p>
L’accès, la rectification et l’effacement s’exercent sur le dossier
du salarié. L’effacement ne franchit pas les durées de conservation
légales : une pièce sous obligation de conservation ne s’efface pas
à la demande, et le refus doit être motivé.
</p>
<p>
La portabilité passe par le paquet de départ : export filtré des
données du salarié, accompagné de son manifeste et de son empreinte.
</p>
<p>
Toute lecture d’une pièce de santé est journalisée et consultable
dans le{' '}
<Link href="/rapports/activite" className="underline">
journal d’activité
</Link>
.
</p>
</div>
</Card>
</PageBody>
);
}
@@ -0,0 +1,156 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
createAbsenceTypeAction,
updateAbsenceTypeAction,
type AbsenceTypeRow,
type ActionState,
} from '@/server/settings/absence-types';
const empty: ActionState = {};
/** Drapeaux du type, avec ce que chacun décide réellement. */
const FLAGS = [
{
name: 'isPaid',
label: 'Rémunérée',
hint: 'L’absence est payée par l’employeur.',
},
{
name: 'countsAsWorkTime',
label: 'Compte comme temps de travail',
hint: 'Entre dans le décompte horaire, donc dans les seuils de convention.',
},
{
name: 'affectsPaidLeaveAccrual',
label: 'Acquiert des congés payés',
hint: 'La période continue d’ouvrir des droits à congés.',
},
{
name: 'isSocialSecurity',
label: 'Absence Sécurité sociale',
hint: 'Maladie, maternité, accident du travail. Donnée de santé : le motif est masqué en vue manager.',
},
{
name: 'requiresJustification',
label: 'Justificatif exigé',
hint: 'La demande est refusée sans pièce jointe.',
},
] as const;
function Flags({ row }: { row?: AbsenceTypeRow }) {
return (
<fieldset className="flex flex-col gap-2">
<legend className="text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Effets
</legend>
{FLAGS.map((flag) => (
<label key={flag.name} className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name={flag.name}
defaultChecked={row ? Boolean(row[flag.name]) : flag.name === 'isPaid'}
className="mt-0.5"
/>
<span>
{flag.label}
<span className="block text-micro text-ink-3">{flag.hint}</span>
</span>
</label>
))}
</fieldset>
);
}
function SharedFields({ row }: { row?: AbsenceTypeRow }) {
return (
<div className="flex flex-wrap gap-3">
<Field
label="Libellé"
name="name"
required
maxLength={120}
defaultValue={row?.name}
placeholder="Congé payé"
/>
<Field
label="Clé de couleur"
name="colorKey"
required
maxLength={40}
defaultValue={row?.colorKey ?? 'neutral'}
/>
<Field
label="Délai de prévenance"
name="minNoticeDays"
type="number"
min={0}
max={365}
defaultValue={row?.minNoticeDays ?? ''}
hint="En jours. Vide : aucun délai."
/>
<Field
label="Code Silae"
name="silaeCode"
maxLength={20}
defaultValue={row?.silaeCode ?? ''}
hint="Partie « code » de AB-<code>. Sans lui, l’export de la période échoue."
/>
</div>
);
}
export function AddAbsenceTypeForm() {
const [state, action] = useActionState(createAbsenceTypeAction, empty);
return (
<PersistentForm
action={action}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-4"
>
<div className="flex flex-wrap gap-3">
<Field
label="Code"
name="code"
required
maxLength={20}
placeholder="CP"
hint="Identifiant stable du type. Il ne se renomme pas."
/>
</div>
<SharedFields />
<Flags />
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Ajouter le type</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Type enregistré.</span>
) : null}
</div>
</PersistentForm>
);
}
export function EditAbsenceTypeForm({ row }: { row: AbsenceTypeRow }) {
const [state, action] = useActionState(updateAbsenceTypeAction, empty);
return (
<PersistentForm action={action} className="flex flex-col gap-4">
<input type="hidden" name="id" value={row.id} />
<SharedFields row={row} />
<Flags row={row} />
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton size="sm">Enregistrer</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Modifications prises.</span>
) : null}
</div>
</PersistentForm>
);
}
@@ -0,0 +1,149 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import {
AddAbsenceTypeForm,
EditAbsenceTypeForm,
} from '@/app/(app)/reglages/types-absence/AbsenceTypeForms';
import {
archiveAbsenceTypeAction,
listAbsenceTypes,
type AbsenceTypeRow,
} from '@/server/settings/absence-types';
export const metadata = { title: 'Types d’absence · PlanFlow' };
export const dynamic = 'force-dynamic';
function Summary({ row }: { row: AbsenceTypeRow }) {
return (
<div className="flex flex-wrap items-center gap-1.5">
<Badge tone={row.isPaid ? 'ok' : 'neutral'}>
{row.isPaid ? 'Rémunérée' : 'Non rémunérée'}
</Badge>
{row.isSocialSecurity ? <Badge tone="info">Sécurité sociale</Badge> : null}
{row.countsAsWorkTime ? <Badge tone="neutral">Temps de travail</Badge> : null}
{row.affectsPaidLeaveAccrual ? (
<Badge tone="neutral">Acquisition CP</Badge>
) : null}
{row.requiresJustification ? (
<Badge tone="warn">Justificatif</Badge>
) : null}
{row.silaeCode ? (
<Badge tone="neutral">AB-{row.silaeCode}</Badge>
) : (
<Badge tone="danger">Code Silae manquant</Badge>
)}
</div>
);
}
export default async function TypesAbsencePage({
searchParams,
}: {
searchParams: Promise<{ archives?: string }>;
}) {
const { archives } = await searchParams;
const showArchived = archives === '1';
const types = await listAbsenceTypes(showArchived);
const active = types.filter((type) => type.archivedAt === null);
const missingSilae = active.filter((type) => !type.silaeCode).length;
return (
<PageBody>
<PageHeader
title="Types d’absence"
subtitle={`${active.length} type${active.length > 1 ? 's' : ''} en vigueur`}
/>
{missingSilae > 0 ? (
<p
role="status"
className="rounded-2 border border-warn bg-warn-soft px-3 py-2 text-xs text-warn-soft-ink"
>
{missingSilae} type{missingSilae > 1 ? 's' : ''} sans code Silae.
L’export d’une période contenant ces absences échouera, avec la liste
des manques — aucun fichier partiel n’est produit.
</p>
) : null}
<Card>
<CardHeader
title="Référentiel"
action={
<a
href={
showArchived
? '/reglages/types-absence'
: '/reglages/types-absence?archives=1'
}
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
>
{showArchived ? 'Masquer les archivés' : 'Voir les archivés'}
</a>
}
/>
{types.length === 0 ? (
<EmptyState
title="Aucun type d’absence"
description="Sans type, aucune demande n’est saisissable. Commencez par le congé payé."
/>
) : (
<ul className="divide-y divide-line-1">
{types.map((type) => (
<li key={type.id} className="px-4 py-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
<Badge tone="accent">{type.code}</Badge>
<span className="text-sm font-semibold">{type.name}</span>
<Badge tone="neutral">
{type.timeOffCount} absence
{type.timeOffCount > 1 ? 's' : ''}
</Badge>
{type.archivedAt ? <Badge tone="warn">Archivé</Badge> : null}
<span className="flex-1" />
<form action={archiveAbsenceTypeAction}>
<input type="hidden" name="id" value={type.id} />
{type.archivedAt ? (
<input type="hidden" name="restore" value="1" />
) : null}
<Button size="sm" variant="ghost" type="submit">
{type.archivedAt ? 'Rétablir' : 'Archiver'}
</Button>
</form>
</div>
<div className="mb-3">
<Summary row={type} />
</div>
<details className="text-sm">
<summary className="cursor-pointer text-xs text-ink-2 underline underline-offset-2">
Modifier
</summary>
<div className="pt-3">
<EditAbsenceTypeForm row={type} />
</div>
</details>
</li>
))}
</ul>
)}
</Card>
<Card>
<CardHeader title="Nouveau type" />
<div className="p-4">
<AddAbsenceTypeForm />
</div>
</Card>
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
Le code d’un type ne se renomme pas : il est référencé par les absences
déjà saisies et par la correspondance de paie. Un type devenu inutile
s’archive — les absences passées gardent alors un libellé lisible et les
écritures de compteur restent justifiables.
</p>
</PageBody>
);
}
+105
View File
@@ -0,0 +1,105 @@
import Link from 'next/link';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Card, CardHeader } from '@/components/ui/Card';
import type { AnalyticsSeries } from '@/server/hr/analytics';
/**
* Cadre commun aux trois analyses.
*
* Même en-tête, même sélecteur d'établissement, mêmes onglets : trois écrans
* qui répondent à trois questions sur le même jeu de données n'ont pas de
* raison de se présenter différemment.
*/
export const ANALYSES = [
{ key: 'effectifs', href: '/rh/analyses/effectifs', label: 'Effectifs' },
{ key: 'heures', href: '/rh/analyses/heures', label: 'Heures' },
{ key: 'absences', href: '/rh/analyses/absences', label: 'Absences' },
] as const;
export type AnalysisKey = (typeof ANALYSES)[number]['key'];
export function AnalysisShell({
current,
title,
subtitle,
series,
children,
footnote,
}: {
current: AnalysisKey;
title: string;
subtitle: string;
series: AnalyticsSeries;
children: React.ReactNode;
footnote: string;
}) {
const withLocation = (href: string) =>
series.location ? `${href}?etablissement=${series.location.id}` : href;
return (
<PageBody>
<PageHeader
title={title}
subtitle={`${series.location?.name ?? 'Tous établissements'} · ${subtitle}`}
actions={
<nav aria-label="Analyses" className="flex gap-1">
{ANALYSES.map((analysis) => (
<Link
key={analysis.key}
href={withLocation(analysis.href)}
aria-current={analysis.key === current ? 'page' : undefined}
className={
analysis.key === current
? 'flex h-8 items-center rounded-2 border border-line-3 bg-surface-2 px-3 text-sm font-medium text-ink-1'
: 'flex h-8 items-center rounded-2 px-3 text-sm text-ink-2 hover:bg-surface-2'
}
>
{analysis.label}
</Link>
))}
</nav>
}
/>
{series.locations.length > 1 ? (
<Card>
<CardHeader title="Établissement" />
<div className="flex flex-wrap gap-1 px-4 py-3">
{series.locations.map((location) => (
<Link
key={location.id}
href={`${ANALYSES.find((a) => a.key === current)?.href}?etablissement=${location.id}`}
aria-current={
location.id === series.location?.id ? 'page' : undefined
}
className={
location.id === series.location?.id
? 'flex h-7 items-center rounded-2 border border-line-3 bg-surface-2 px-2.5 text-xs font-medium text-ink-1'
: 'flex h-7 items-center rounded-2 px-2.5 text-xs text-ink-2 hover:bg-surface-2'
}
>
{location.name}
</Link>
))}
</div>
</Card>
) : null}
{children}
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
{footnote}
</p>
</PageBody>
);
}
/** Mois abrégé, pour l'axe du graphique. « septembre 2026 » → « sept. 26 ». */
export function shortMonth(label: string): string {
const [name, year] = label.split(' ');
if (!name || !year) return label;
const abbreviated = name.length > 4 ? `${name.slice(0, 4)}.` : name;
return `${abbreviated} ${year.slice(2)}`;
}
+100
View File
@@ -0,0 +1,100 @@
import Link from 'next/link';
import {
AnalysisShell,
shortMonth,
} from '@/app/(app)/rh/analyses/AnalysisShell';
import { SeriesChart } from '@/components/hr/SeriesChart';
import { Card, CardHeader } from '@/components/ui/Card';
import { monthOf } from '@/domain/planning/month';
import { getAnalyticsSeries } from '@/server/hr/analytics';
export const metadata = { title: 'Analyse des absences · PlanFlow' };
export const dynamic = 'force-dynamic';
interface PageProps {
searchParams: Promise<{ etablissement?: string }>;
}
export default async function AbsencesAnalysePage({ searchParams }: PageProps) {
const { etablissement } = await searchParams;
const series = await getAnalyticsSeries(monthOf(new Date()), 12, etablissement);
const last = series.points[series.points.length - 1];
return (
<AnalysisShell
current="absences"
title="Absences"
subtitle={
last?.absenteeismRate === null || last === undefined
? 'Taux non calculable'
: `${last.absenteeismRate} % d’absentéisme ce mois`
}
series={series}
footnote="Le taux rapporte les jours d’absence aux jours théoriquement travaillés, pas aux jours calendaires : rapporter à trente donnerait un taux artificiellement bas et ferait passer un problème réel pour du bruit. La colonne « dont arrêts » agrège les absences relevant de la Sécurité sociale sans jamais nommer de salarié — un taux ne doit pas devenir un moyen d’identifier qui est malade."
>
<Card>
<CardHeader title="Taux d’absentéisme mensuel" />
<SeriesChart
tone="warn"
points={series.points.map((point) => ({
label: point.label,
short: shortMonth(point.label),
value: point.absenteeismRate,
}))}
format={(value) => `${value} %`}
/>
</Card>
<Card>
<CardHeader title="Détail mensuel" />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
{[
'Mois',
'Jours d’absence',
'dont arrêts',
'Jours théoriques',
'Taux',
].map((heading) => (
<th
key={heading}
className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{[...series.points].reverse().map((point) => (
<tr key={point.param}>
<td className="px-4 py-2">
<Link
href={`/?mois=${point.param}&liste=absences${series.location ? `&etablissement=${series.location.id}` : ''}`}
className="hover:underline"
>
{point.label}
</Link>
</td>
<td className="px-4 py-2 tnum">{point.absenceDays}</td>
<td className="px-4 py-2 tnum">{point.sickDays}</td>
<td className="px-4 py-2 tnum">
{point.theoreticalWorkedDays}
</td>
<td className="px-4 py-2 tnum">
{point.absenteeismRate === null
? '—'
: `${point.absenteeismRate} %`}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</AnalysisShell>
);
}
@@ -0,0 +1,93 @@
import Link from 'next/link';
import {
AnalysisShell,
shortMonth,
} from '@/app/(app)/rh/analyses/AnalysisShell';
import { SeriesChart } from '@/components/hr/SeriesChart';
import { Card, CardHeader } from '@/components/ui/Card';
import { monthOf } from '@/domain/planning/month';
import { getAnalyticsSeries } from '@/server/hr/analytics';
export const metadata = { title: 'Analyse des effectifs · PlanFlow' };
export const dynamic = 'force-dynamic';
interface PageProps {
searchParams: Promise<{ etablissement?: string }>;
}
export default async function EffectifsPage({ searchParams }: PageProps) {
const { etablissement } = await searchParams;
const series = await getAnalyticsSeries(monthOf(new Date()), 12, etablissement);
const last = series.points[series.points.length - 1];
return (
<AnalysisShell
current="effectifs"
title="Effectifs"
subtitle={`${last?.closingHeadcount ?? 0} salariés en fin de mois`}
series={series}
footnote="Le taux de rotation rapporte la moyenne des entrées et des sorties à l’effectif moyen : ne compter que les départs sous-estimerait une équipe qui recrute autant qu’elle perd. Sur un effectif nul il n’est pas calculé — « 0 % » y serait une affirmation fausse, pas une absence de mouvement."
>
<Card>
<CardHeader title="Effectif en fin de mois" />
<SeriesChart
points={series.points.map((point) => ({
label: point.label,
short: shortMonth(point.label),
value: point.closingHeadcount,
}))}
format={(value) => String(value)}
/>
</Card>
<Card>
<CardHeader title="Détail mensuel" />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
{['Mois', 'Fin de mois', 'Moyen', 'Entrées', 'Sorties', 'Rotation'].map(
(heading) => (
<th
key={heading}
className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
),
)}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{[...series.points].reverse().map((point) => (
<tr key={point.param}>
<td className="px-4 py-2">
{/* Chaque chiffre mène à ses lignes sources : un indicateur
qu'on ne peut pas ouvrir ne se corrige pas, il se
conteste. */}
<Link
href={`/?mois=${point.param}&liste=entrees${series.location ? `&etablissement=${series.location.id}` : ''}`}
className="hover:underline"
>
{point.label}
</Link>
</td>
<td className="px-4 py-2 tnum">{point.closingHeadcount}</td>
<td className="px-4 py-2 tnum">{point.averageHeadcount}</td>
<td className="px-4 py-2 tnum">{point.entries}</td>
<td className="px-4 py-2 tnum">{point.exits}</td>
<td className="px-4 py-2 tnum">
{point.turnoverRate === null
? '—'
: `${point.turnoverRate} %`}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</AnalysisShell>
);
}
+99
View File
@@ -0,0 +1,99 @@
import {
AnalysisShell,
shortMonth,
} from '@/app/(app)/rh/analyses/AnalysisShell';
import { SeriesChart } from '@/components/hr/SeriesChart';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader } from '@/components/ui/Card';
import { formatMinutes } from '@/domain/counters/week';
import { monthOf } from '@/domain/planning/month';
import { getAnalyticsSeries } from '@/server/hr/analytics';
export const metadata = { title: 'Analyse des heures · PlanFlow' };
export const dynamic = 'force-dynamic';
interface PageProps {
searchParams: Promise<{ etablissement?: string }>;
}
export default async function HeuresPage({ searchParams }: PageProps) {
const { etablissement } = await searchParams;
const series = await getAnalyticsSeries(monthOf(new Date()), 12, etablissement);
const last = series.points[series.points.length - 1];
return (
<AnalysisShell
current="heures"
title="Heures"
subtitle={
last ? `${formatMinutes(last.plannedMinutes)} planifiées ce mois` : '—'
}
series={series}
footnote="Le prévu et le réalisé sont deux états distincts du temps, et l’écart entre les deux est ce qu’un contrôle demande à justifier. Quand aucune heure réelle n’a été saisie sur un créneau, le prévu fait foi : laisser un zéro creuserait un écart qui n’existe pas."
>
<Card>
<CardHeader title="Heures planifiées par mois" />
<SeriesChart
points={series.points.map((point) => ({
label: point.label,
short: shortMonth(point.label),
value: Math.round(point.plannedMinutes / 60),
}))}
format={(value) => `${value} h`}
/>
</Card>
<Card>
<CardHeader
title="Prévu et réalisé"
badge={<Badge tone="neutral">12 mois</Badge>}
/>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1 text-left">
{['Mois', 'Planifié', 'Réalisé', 'Écart', 'Effectif moyen'].map(
(heading) => (
<th
key={heading}
className="px-4 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
),
)}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{[...series.points].reverse().map((point) => {
const delta = point.actualMinutes - point.plannedMinutes;
return (
<tr key={point.param}>
<td className="px-4 py-2">{point.label}</td>
<td className="px-4 py-2 tnum">
{formatMinutes(point.plannedMinutes)}
</td>
<td className="px-4 py-2 tnum">
{formatMinutes(point.actualMinutes)}
</td>
<td className="px-4 py-2 tnum">
{delta === 0 ? (
'—'
) : (
<Badge tone={delta > 0 ? 'warn' : 'info'}>
{delta > 0 ? '+' : '−'}
{formatMinutes(Math.abs(delta))}
</Badge>
)}
</td>
<td className="px-4 py-2 tnum">{point.averageHeadcount}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
</AnalysisShell>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { NextResponse, type NextRequest } from 'next/server';
import { asciiFileName, sanitiseFileName } from '@/domain/documents/rules';
import { recordAudit } from '@/server/audit';
import { query } from '@/server/context';
import { getRegisterData } from '@/server/employees/rup';
import { renderRegisterPdf } from '@/server/employees/rup-pdf';
/**
* Téléchargement du registre unique du personnel.
*
* Une route et non une action : le document est un fichier, pas un écran, et
* son édition doit pouvoir être rejouée par une simple adresse — c'est aussi
* ce qui permet de la présenter depuis un poste d'inspection.
*
* L'édition est journalisée. Le registre rassemble l'identité, la nationalité
* et le parcours de tout le personnel d'un établissement : savoir qui l'a
* extrait, et quand, fait partie de ce qu'on doit pouvoir répondre.
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
const locationId = request.nextUrl.searchParams.get('etablissement');
if (!locationId) {
return NextResponse.json(
{ error: 'Choisissez un établissement.' },
{ status: 400 },
);
}
const data = await getRegisterData(locationId);
if (!data) {
return NextResponse.json(
{ error: 'Établissement introuvable.' },
{ status: 404 },
);
}
await query('members.register.export', async (db, actor) =>
recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'members.register.export',
entityType: 'Location',
entityId: data.locationId,
after: { lines: data.people.length, incomplete: data.incomplete },
}),
);
const pdf = await renderRegisterPdf(data);
const stamp = new Date().toISOString().slice(0, 10).split('-').reverse().join('_');
const name = sanitiseFileName(`RUP - ${data.locationName} - ${stamp}.pdf`);
return new NextResponse(pdf as BodyInit, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${asciiFileName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`,
// Un registre se lit à l'instant où il est demandé : le mettre en cache
// ferait présenter un effectif périmé au contrôle suivant.
'Cache-Control': 'no-store',
},
});
}
+155
View File
@@ -0,0 +1,155 @@
import Link from 'next/link';
import { CancelButton, DecisionButtons } from '@/components/absences/DecisionButtons';
import { Badge, type Tone } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import {
statusLabel,
type AbsenceQueue as QueueKey,
type AbsenceQueueResult,
} from '@/server/absences/queries';
/**
* Files d'absences par état — PLAN.md §9.
*
* Les trois files partagent ce rendu. Une seule différence les sépare : la file
* à traiter porte les boutons de décision, les autres n'en portent pas — une
* absence déjà décidée ne se re-décide pas depuis une liste, elle s'annule
* depuis la fiche, avec sa contre-passe au registre.
*/
const STATUS_TONES: Record<string, Tone> = {
PENDING: 'warn',
ACCEPTED: 'ok',
DECLINED: 'danger',
CANCELLED: 'neutral',
EXPIRED: 'neutral',
};
export const QUEUE_TABS: Array<{ key: QueueKey; href: string; label: string }> = [
{ key: 'pending', href: '/absences/a-traiter', label: 'À traiter' },
{ key: 'treated', href: '/absences/traitees', label: 'Traitées' },
{ key: 'expired', href: '/absences/expirees', label: 'Expirées' },
];
const dateFormat = new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
function formatDay(iso: string): string {
return dateFormat.format(new Date(`${iso}T00:00:00Z`));
}
/**
* Période affichée d'une absence.
*
* `endDate` porte le **dernier jour d'absence**, pas la date de reprise. La
* confusion est la plus fréquente du domaine et se paie en un jour de congé de
* trop ou de trop peu ; l'écran l'écrit donc explicitement.
*/
function period(row: AbsenceQueueResult['rows'][number]): string {
const start = formatDay(row.startDate);
if (row.startDate === row.endDate) {
return row.startHalfDay || row.endHalfDay ? `${start} (demi-journée)` : start;
}
return `du ${start} au ${formatDay(row.endDate)} inclus`;
}
export function AbsenceQueueView({
result,
title,
description,
filterBar,
}: {
result: AbsenceQueueResult;
title: string;
description: string;
filterBar?: React.ReactNode;
}) {
return (
<Card>
<CardHeader
title={title}
badge={
<Badge tone={result.rows.length > 0 ? 'accent' : 'neutral'}>
{result.rows.length} ligne{result.rows.length > 1 ? 's' : ''}
</Badge>
}
action={
<nav aria-label="Files d’absences" className="flex gap-1">
{QUEUE_TABS.map((tab) => (
<Link
key={tab.key}
href={tab.href}
aria-current={tab.key === result.queue ? 'page' : undefined}
className={
tab.key === result.queue
? 'flex h-7 items-center rounded-2 border border-line-3 bg-surface-2 px-2.5 text-xs font-medium text-ink-1'
: 'flex h-7 items-center rounded-2 px-2.5 text-xs text-ink-2 hover:bg-surface-2'
}
>
{tab.label}
<span className="ml-1.5 tnum text-ink-3">
{result.counts[tab.key]}
</span>
</Link>
))}
</nav>
}
/>
{filterBar ? (
<div className="border-b border-line-1 px-4 py-2.5">{filterBar}</div>
) : null}
{result.rows.length === 0 ? (
<EmptyState title="Aucune absence" description={description} />
) : (
<ul className="divide-y divide-line-1">
{result.rows.map((row) => (
<li key={row.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
<div className="min-w-[14rem] flex-1">
<p className="text-sm font-medium">{row.name}</p>
<p className="text-xs text-ink-2">
{row.typeLabel} · {period(row)}
</p>
{row.comment ? (
<p className="mt-1 text-xs text-ink-3">« {row.comment} »</p>
) : null}
{row.decisionComment ? (
<p className="mt-1 text-xs text-ink-3">
Décision : {row.decisionComment}
</p>
) : null}
</div>
<Badge tone="neutral" className="tnum">
{row.days} j
</Badge>
<Badge tone={STATUS_TONES[row.status] ?? 'neutral'}>
{statusLabel(row.status)}
</Badge>
{result.queue === 'pending' && result.canDecide ? (
<DecisionButtons timeOffId={row.id} />
) : null}
{result.queue === 'treated' && row.status === 'ACCEPTED' ? (
<CancelButton timeOffId={row.id} />
) : null}
<Link
href={`/equipe/${row.membershipId}/absences`}
className="text-xs text-ink-2 underline underline-offset-2 hover:text-ink-1"
>
Fiche
</Link>
</li>
))}
</ul>
)}
</Card>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { cx } from '@/lib/cx';
/**
* Histogramme mensuel, en HTML.
*
* Pas de bibliothèque de graphiques : douze barres et une échelle ne justifient
* pas 90 ko de dépendance, et la CSP interdit de toute façon toute origine
* tierce. Le tableau reste la source — le graphique n'est qu'une lecture rapide
* posée devant lui.
*
* **La hauteur ne porte jamais l'information seule** : chaque barre affiche sa
* valeur, et l'ensemble est doublé d'un tableau lisible au lecteur d'écran.
*/
export interface SeriesPoint {
label: string;
value: number | null;
/** Libellé affiché sous la barre — le mois abrégé. */
short: string;
}
export function SeriesChart({
points,
format,
tone = 'accent',
}: {
points: SeriesPoint[];
format: (value: number) => string;
tone?: 'accent' | 'warn' | 'info';
}) {
const values = points
.map((point) => point.value)
.filter((value): value is number => value !== null);
const max = values.length > 0 ? Math.max(...values) : 0;
const bar = {
accent: 'bg-accent',
warn: 'bg-warn',
info: 'bg-info',
}[tone];
return (
<div
className="flex items-end gap-1.5 overflow-x-auto px-4 py-4"
role="presentation"
>
{points.map((point) => {
// Une valeur nulle n'est pas zéro : un taux non calculable sur un
// effectif vide ne doit pas se dessiner comme une absence de mouvement.
const height =
point.value === null || max === 0
? 0
: Math.max(2, Math.round((point.value / max) * 120));
return (
<div
key={point.label}
className="flex min-w-[3.25rem] flex-1 flex-col items-center gap-1"
>
<span className="tnum text-micro text-ink-2">
{point.value === null ? '—' : format(point.value)}
</span>
<div
className={cx(
'w-full rounded-t-2',
point.value === null ? 'bg-surface-3' : bar,
)}
style={{ height: `${height}px` }}
/>
<span className="text-micro text-ink-3">{point.short}</span>
</div>
);
})}
</div>
);
}
+15 -5
View File
@@ -27,9 +27,16 @@ function Cell({ label, value }: { label: string; value: string }) {
}
/**
* Les cinq compteurs d'une ligne salarié : contrat, planifié, absences, écart,
* dimanches et repos. Rendus depuis `computeWeekCounters`, jamais recalculés
* localement.
* Les cinq compteurs d'une ligne salarié — PLAN.md §7.4 : **heures
* contractuelles, planifié, absences, écart, repos compensateur**.
*
* Ce sont ces cinq-là et pas d'autres. Les dimanches travaillés et les jours de
* repos se lisent sur la grille ; le repos compensateur, lui, ne se lit nulle
* part ailleurs — c'est une contrepartie **due**, et l'omettre du bandeau
* masque la seule valeur qui dit qu'une dette existe.
*
* Rendus depuis `computeWeekCounters`, jamais recalculés localement : la même
* implémentation alimente la grille, le rapport d'heures et l'export de paie.
*/
export function CounterStrip({
counters,
@@ -57,7 +64,10 @@ export function CounterStrip({
<span className="rounded-full bg-accent-soft px-1.5 py-px font-medium text-accent-soft-ink">
Forfait jours
</span>
{/* Un forfait jours reste soumis aux repos : le RC s'affiche, l'écart
horaire non. */}
<Cell label="Rep." value={String(counters.restDays)} />
<Cell label="RC" value={counters.compensatoryRestLabel} />
</div>
);
}
@@ -71,6 +81,7 @@ export function CounterStrip({
·
</span>
<Cell label="Planif." value={counters.plannedLabel} />
<Cell label="Abs." value={counters.absenceLabel} />
<span
className={cx(
'tnum rounded-full px-1.5 py-px font-semibold',
@@ -80,8 +91,7 @@ export function CounterStrip({
>
{counters.deltaLabel}
</span>
<Cell label="Dim." value={String(counters.sundaysWorked)} />
<Cell label="Rep." value={String(counters.restDays)} />
<Cell label="RC" value={counters.compensatoryRestLabel} />
</div>
);
}
@@ -0,0 +1,90 @@
'use client';
import { useActionState } from 'react';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
saveAccountIdentityAction,
type AccountIdentity,
type ActionState,
} from '@/server/settings/account';
const empty: ActionState = {};
export function AccountIdentityForm({
account,
}: {
account: AccountIdentity;
}) {
const [state, action] = useActionState(saveAccountIdentityAction, empty);
return (
<PersistentForm action={action} className="flex flex-col gap-3 p-4">
<Field
label="Raison sociale"
name="name"
defaultValue={account.name}
required
maxLength={200}
/>
<div className="flex flex-wrap gap-3">
<Field
label="SIREN"
name="siren"
defaultValue={account.siren ?? ''}
inputMode="numeric"
placeholder="9 chiffres"
hint="Identifie l’entreprise. Le SIRET, propre à chaque établissement, se saisit sur l’établissement."
/>
<Field
label="Code APE"
name="apeCode"
defaultValue={account.apeCode ?? ''}
placeholder="4759B"
/>
</div>
<Field
label="Adresse du siège"
name="addressLine"
defaultValue={account.addressLine ?? ''}
maxLength={200}
hint="Figure sur les documents édités et au registre unique du personnel."
/>
<div className="flex flex-wrap gap-3">
<Field
label="Code postal"
name="postalCode"
defaultValue={account.postalCode ?? ''}
inputMode="numeric"
/>
<Field
label="Ville"
name="city"
defaultValue={account.city ?? ''}
maxLength={120}
/>
</div>
<Field
label="Fuseau horaire"
name="timezone"
defaultValue={account.timezone}
required
hint="Défaut du compte. Un établissement dans un autre fuseau porte le sien."
/>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Enregistrer</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Compte enregistré.</span>
) : null}
</div>
</PersistentForm>
);
}
+33 -3
View File
@@ -1,5 +1,6 @@
'use client';
import { useRouter } from 'next/navigation';
import { useActionState, useState, useTransition } from 'react';
import { Button } from '@/components/ui/Button';
@@ -28,8 +29,12 @@ export function MfaSettings({
enrolled: boolean;
required: boolean;
}) {
const router = useRouter();
const [offer, setOffer] = useState<OfferState | null>(null);
const [starting, startTransition] = useTransition();
// Les codes restent à l'écran tant que personne n'a dit les avoir notés : ce
// sont eux, et non l'horloge, qui décident du moment où l'on passe à la suite.
const [codesNoted, setCodesNoted] = useState(false);
const [confirmState, confirm, confirming] = useActionState(
confirmEnrolmentAction,
@@ -43,8 +48,22 @@ export function MfaSettings({
const codes = confirmState.recoveryCodes;
const active = (enrolled || confirmState.ok) && !disableState.ok;
if (codes && codes.length > 0) {
return <RecoveryCodes codes={codes} />;
if (codes && codes.length > 0 && !codesNoted) {
return (
<RecoveryCodes
codes={codes}
onNoted={() => {
setCodesNoted(true);
// Le layout applicatif décide de l'écran d'enrôlement au rendu, et un
// layout n'est pas rejoué par la navigation client : sans ce
// rafraîchissement, l'écran resterait posé sur toutes les pages, quel
// que soit le menu choisi. Il est déclenché ici, et non par l'action
// d'enrôlement, pour que les codes ne s'effacent pas avant d'avoir été
// notés.
router.refresh();
}}
/>
);
}
if (active) {
@@ -193,7 +212,13 @@ export function MfaSettings({
* Ils sont conservés hachés : ni le support ni un administrateur ne peuvent les
* relire. La seule issue, ensuite, est d'en régénérer une série.
*/
function RecoveryCodes({ codes }: { codes: string[] }) {
function RecoveryCodes({
codes,
onNoted,
}: {
codes: string[];
onNoted: () => void;
}) {
return (
<div className="flex flex-col gap-3 p-4">
<p className="text-sm text-ink-1">
@@ -214,6 +239,11 @@ function RecoveryCodes({ codes }: { codes: string[] }) {
Affichés une seule fois : ils ne sont pas conservés en clair. Chacun ne
sert qu’une fois.
</p>
<div>
<Button variant="primary" onClick={onNoted}>
J’ai noté mes codes, continuer
</Button>
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
'use client';
import { useActionState } from 'react';
import { FormError, SubmitButton } from '@/components/ui/Form';
import { PersistentForm } from '@/components/ui/PersistentForm';
import {
minutesToTime,
togglesOf,
type PreferenceGroup,
} from '@/domain/settings/preferences';
import {
savePreferencesAction,
type ActionState,
type Preferences,
} from '@/server/settings/account';
const empty: ActionState = {};
/**
* Un bloc de préférences.
*
* Chaque formulaire déclare les clés qu'il gouverne dans des champs `scope`.
* Sans eux, enregistrer « Plannings » remettrait à faux tout « Droits » : une
* case décochée n'arrive pas dans le `FormData`, et le serveur ne saurait pas
* distinguer « décoché » de « pas dans ce formulaire ».
*/
export function PreferenceBlock({
group,
preferences,
withEveningTime = false,
}: {
group: PreferenceGroup;
preferences: Preferences;
withEveningTime?: boolean;
}) {
const [state, action] = useActionState(savePreferencesAction, empty);
const toggles = togglesOf(group);
return (
<PersistentForm action={action} className="flex flex-col gap-4 px-4 py-4">
{toggles.map((toggle) => (
<input key={`scope-${toggle.key}`} type="hidden" name="scope" value={toggle.key} />
))}
{toggles.map((toggle) => (
<div key={toggle.key} className="flex flex-col gap-1">
<label className="flex items-start gap-2.5 text-sm">
<input
type="checkbox"
name={toggle.key}
defaultChecked={Boolean(
preferences[toggle.key as keyof Preferences],
)}
className="mt-0.5"
/>
<span>
<span className="font-medium">{toggle.label}</span>
<span className="block text-micro leading-[var(--lh-prose)] text-ink-3">
{toggle.hint}
</span>
</span>
</label>
{toggle.warning ? (
<p className="ml-6 rounded-2 border border-warn bg-warn-soft px-2.5 py-1.5 text-micro leading-[var(--lh-prose)] text-warn-soft-ink">
{toggle.warning}
</p>
) : null}
</div>
))}
{withEveningTime ? (
<label className="flex max-w-xs flex-col gap-1.5">
<span className="text-sm font-medium">
Un créneau est « du soir » à partir de
</span>
<input
type="time"
name="eveningShiftStart"
defaultValue={minutesToTime(preferences.eveningShiftStartMinutes)}
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1"
/>
<span className="text-micro text-ink-3">
Sert au repérage à l’écran et aux majorations de soirée, pas au
travail de nuit, qui relève de la convention.
</span>
</label>
) : null}
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Enregistrer</SubmitButton>
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Réglages enregistrés.</span>
) : null}
</div>
</PersistentForm>
);
}
+74 -9
View File
@@ -32,9 +32,29 @@ export const NAVIGATION: NavSection[] = [
{ id: 'entrees', label: 'Entrées et sorties', href: '/?liste=entrees' },
{ id: 'profils', label: 'Profils incomplets', href: '/?liste=profils' },
{ id: 'titres', label: 'Titres de séjour', href: '/?liste=titres' },
// Ancre plutôt que route : `/conges` appartient à la section Congés, et
// deux entrées allumées pour un même écran rendent la navigation muette.
{ id: 'soldes-cp', label: 'Compteurs de congés payés', href: '/conges#compteurs' },
// Ancre plutôt que route : le calendrier appartient à la section Congés,
// et deux entrées allumées pour un même écran rendent la navigation
// muette.
{
id: 'soldes-cp',
label: 'Compteurs de congés payés',
href: '/absences/calendrier#compteurs',
},
{
id: 'analyse-effectifs',
label: 'Analyse des effectifs',
href: '/rh/analyses/effectifs',
},
{
id: 'analyse-heures',
label: 'Analyse des heures',
href: '/rh/analyses/heures',
},
{
id: 'analyse-absences',
label: 'Analyse des absences',
href: '/rh/analyses/absences',
},
],
},
{
@@ -45,6 +65,12 @@ export const NAVIGATION: NavSection[] = [
{ 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: 'presences',
label: 'Présences et absences',
href: '/planning/presences',
},
{ id: 'impression', label: 'Planning à afficher', href: '/planning/impression' },
{ id: 'besoins', label: 'Besoins non couverts' },
{ id: 'modeles', label: "Modèles d'ouverture" },
],
@@ -61,9 +87,19 @@ export const NAVIGATION: NavSection[] = [
id: 'conges',
label: 'Congés',
items: [
{ id: 'calendrier', label: 'Calendrier des absences', href: '/conges' },
{ id: 'attente', label: 'Demandes en attente', href: '/conges#attente' },
{ id: 'politiques', label: 'Politiques de congés' },
{
id: 'calendrier',
label: 'Calendrier des absences',
href: '/absences/calendrier',
},
{ id: 'attente', label: 'Demandes à traiter', href: '/absences/a-traiter' },
{ id: 'traitees', label: 'Traitées', href: '/absences/traitees' },
{ id: 'expirees', label: 'Expirées', href: '/absences/expirees' },
{
id: 'politiques',
label: 'Politiques de RTT',
href: '/reglages/politiques-rtt',
},
],
},
{
@@ -74,14 +110,27 @@ export const NAVIGATION: NavSection[] = [
{ 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: 'activite',
label: "Journal d'activité",
href: '/rapports/activite',
},
],
},
{
id: 'reglages',
label: 'Réglages',
items: [
{ id: 'compte', label: 'Informations du compte', href: '/reglages/compte' },
{ id: 'sites', label: 'Établissements', href: '/reglages/etablissements' },
{ id: 'preferences', label: 'Préférences', href: '/reglages/preferences' },
{ id: 'paie-reglages', label: 'Réglages de paie', href: '/reglages/paie' },
{ id: 'impression', label: 'Impression', href: '/reglages/impression' },
{
id: 'productivite',
label: 'Productivité',
href: '/reglages/productivite',
},
{ id: 'registre', label: 'Registre de paramétrage', href: '/reglages/registre' },
{ id: 'email', label: 'Envoi de courrier', href: '/reglages/email' },
{ id: 'securite', label: 'Sécurité', href: '/reglages/securite' },
@@ -90,8 +139,24 @@ export const NAVIGATION: NavSection[] = [
label: 'Durées de conservation',
href: '/reglages/conservation',
},
{ id: 'convention', label: 'Convention collective' },
{ id: 'postes', label: 'Postes et étiquettes' },
{ id: 'rgpd', label: 'RGPD', href: '/reglages/rgpd' },
{
id: 'modeles',
label: 'Modèles de documents',
href: '/reglages/modeles-documents',
},
{
id: 'convention',
label: 'Convention collective',
href: '/reglages/convention',
},
{ id: 'emplois', label: 'Emplois', href: '/reglages/emplois' },
{ id: 'etiquettes', label: 'Étiquettes', href: '/reglages/etiquettes' },
{
id: 'types-absence',
label: 'Types d’absence',
href: '/reglages/types-absence',
},
{ id: 'roles', label: 'Rôles et permissions', href: '/reglages/roles' },
],
},
+11
View File
@@ -22,6 +22,15 @@ export interface WeekCounters {
sundaysWorked: number;
/** Jours de repos dans la semaine. */
restDays: number;
/**
* Repos compensateur posé sur la semaine, en minutes.
*
* Distinct de `restDays` : un jour sans créneau est un jour sans travail,
* tandis qu'un repos compensateur est une **contrepartie due** — au dimanche
* travaillé, au dépassement de contingent. Les confondre ferait disparaître
* du bandeau la seule valeur qui dit qu'une dette existe.
*/
compensatoryRestMinutes: number;
}
export interface WeekCountersView extends WeekCounters {
@@ -31,6 +40,7 @@ export interface WeekCountersView extends WeekCounters {
plannedLabel: string;
absenceLabel: string;
deltaLabel: string;
compensatoryRestLabel: string;
tone: DeltaTone;
}
@@ -74,6 +84,7 @@ export function computeWeekCounters(input: WeekCounters): WeekCountersView {
plannedLabel: formatMinutes(input.plannedMinutes),
absenceLabel: formatMinutes(input.absenceMinutes),
deltaLabel: formatDelta(deltaMinutes),
compensatoryRestLabel: formatMinutes(input.compensatoryRestMinutes),
tone: deltaTone(deltaMinutes),
};
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Modèles de documents — PLAN.md §4.7 et §9.
*
* Un modèle est un corps de texte avec des variables `{{cle}}`. Le rendu se
* fait ici, dans le domaine, pour trois raisons :
*
* 1. **Une variable inconnue n'est jamais silencieuse.** Un `{{salire}}` mal
* orthographié doit faire échouer le rendu, pas produire une attestation
* trouée. Une pièce remise à un salarié avec un blanc à la place de son
* salaire est un document faux, pas un document incomplet.
* 2. **Les variables sont scopées par établissement.** La même attestation ne
* porte pas la même raison sociale ni le même SIRET d'un magasin à l'autre ;
* le contexte de résolution est celui de l'établissement du contrat.
* 3. Le corps est de l'HTML rédigé par un administrateur du compte. Il est
* **échappé à l'insertion des valeurs**, jamais à l'écriture : c'est la
* valeur venue du dossier qui pourrait porter un chevron, pas le gabarit.
*/
export interface TemplateField {
key: string;
label: string;
/** D'où vient la valeur : salarié, contrat ou établissement. */
scope: 'employee' | 'contract' | 'location';
}
/**
* Variables offertes à la rédaction.
*
* Liste fermée : un modèle ne peut référencer que ce que l'application sait
* résoudre. Ouvrir la syntaxe à des expressions transformerait un écran de
* réglage en langage de programmation, avec la surface d'attaque qui va avec.
*/
export const TEMPLATE_FIELDS: TemplateField[] = [
{ key: 'salarie.nom', label: 'Nom du salarié', scope: 'employee' },
{ key: 'salarie.prenom', label: 'Prénom du salarié', scope: 'employee' },
{ key: 'salarie.matricule', label: 'Matricule', scope: 'employee' },
{ key: 'contrat.type', label: 'Type de contrat', scope: 'contract' },
{ key: 'contrat.debut', label: 'Date de début', scope: 'contract' },
{ key: 'contrat.fin', label: 'Date de fin', scope: 'contract' },
{ key: 'contrat.emploi', label: 'Emploi', scope: 'contract' },
{
key: 'contrat.heures',
label: 'Durée hebdomadaire',
scope: 'contract',
},
{ key: 'etablissement.nom', label: 'Établissement', scope: 'location' },
{ key: 'etablissement.siret', label: 'SIRET', scope: 'location' },
{ key: 'date.jour', label: 'Date du jour', scope: 'location' },
];
export const TEMPLATE_FIELD_KEYS: string[] = TEMPLATE_FIELDS.map(
(field) => field.key,
);
const PLACEHOLDER = /\{\{\s*([a-zA-Z0-9._-]+)\s*\}\}/g;
/** Variables citées par un corps, dans l'ordre, sans doublon. */
export function referencedFields(bodyHtml: string): string[] {
const found = new Set<string>();
for (const match of bodyHtml.matchAll(PLACEHOLDER)) {
const key = match[1];
if (key) found.add(key);
}
return [...found];
}
/** Variables citées mais que l'application ne sait pas résoudre. */
export function unknownFields(bodyHtml: string): string[] {
return referencedFields(bodyHtml).filter(
(key) => !TEMPLATE_FIELD_KEYS.includes(key),
);
}
/**
* Échappement HTML des valeurs insérées.
*
* Un nom de famille contenant `&` ou une apostrophe typographique doit sortir
* tel quel ; une valeur venue d'un import mal nettoyé ne doit pas pouvoir
* ouvrir une balise.
*/
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export class MissingTemplateValue extends Error {
constructor(public readonly keys: string[]) {
super(
`Le modèle cite des variables sans valeur : ${keys.join(', ')}. Complétez le dossier avant de générer la pièce.`,
);
this.name = 'MissingTemplateValue';
}
}
/**
* Rend un modèle avec les valeurs fournies.
*
* Lève dès qu'une variable citée n'a pas de valeur. Produire l'attestation avec
* un blanc serait pire que de refuser : le blanc se remarque à la relecture une
* fois sur deux, et la pièce part signée.
*/
export function renderTemplate(
bodyHtml: string,
values: Record<string, string | null | undefined>,
): string {
const missing = referencedFields(bodyHtml).filter(
(key) => values[key] === null || values[key] === undefined || values[key] === '',
);
if (missing.length > 0) throw new MissingTemplateValue(missing);
return bodyHtml.replace(PLACEHOLDER, (_match, key: string) =>
escapeHtml(String(values[key])),
);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Libellés d'état civil.
*
* Les codes vivent en base, les mots à l'écran. Les séparer évite qu'un
* changement de formulation devienne une migration, et qu'une valeur stockée
* dépende de la langue de l'interface.
*/
export const GENDERS = [
['FEMALE', 'Femme'],
['MALE', 'Homme'],
['UNSPECIFIED', 'Non précisé'],
] as const;
export const MARITAL_STATUSES = [
['SINGLE', 'Célibataire'],
['MARRIED', 'Marié(e)'],
['PACS', 'Pacsé(e)'],
['COHABITING', 'Concubinage'],
['DIVORCED', 'Divorcé(e)'],
['WIDOWED', 'Veuf(ve)'],
] as const;
export type GenderCode = (typeof GENDERS)[number][0];
export type MaritalStatusCode = (typeof MARITAL_STATUSES)[number][0];
const GENDER_LABELS = new Map<string, string>(GENDERS);
const MARITAL_LABELS = new Map<string, string>(MARITAL_STATUSES);
export function genderLabel(code: string | null): string | null {
return code ? (GENDER_LABELS.get(code) ?? code) : null;
}
export function maritalStatusLabel(code: string | null): string | null {
return code ? (MARITAL_LABELS.get(code) ?? code) : null;
}
/**
* Abrégé porté au registre du personnel.
*
* La colonne y est étroite et la mention se lit d'un coup d'œil ; « Non
* précisé » y devient un tiret, qui dit la même chose sans déborder.
*/
export function genderShort(code: string | null): string | null {
if (code === 'FEMALE') return 'F';
if (code === 'MALE') return 'M';
return null;
}
/**
* Matricule proposé à l'embauche.
*
* Une lettre et un rang, et non un horodatage : un matricule se dicte au
* téléphone et se recopie à la main. Le rang suit le dernier attribué de la
* même forme, ce qui laisse coexister les matricules repris d'un autre outil.
*/
export function nextEmployeeNumber(
existing: string[],
prefix = 'E',
): string {
const pattern = new RegExp(`^${prefix}(\\d+)$`);
const highest = existing.reduce((max, value) => {
const match = pattern.exec(value.trim());
if (!match) return max;
return Math.max(max, Number(match[1]));
}, 0);
return `${prefix}${String(highest + 1).padStart(4, '0')}`;
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Dictionnaire des données — matrice de conformité n° 14.
*
* « Donnée → finalité → base légale → destinataire → durée », livré comme
* artefact. Il décrit ce que **cette** application collecte, catégorie par
* catégorie ; il ne décrit pas le traitement RH de l'entreprise en général.
*
* Il vit dans le code et non en base : le contenu suit le schéma, et un
* dictionnaire tenu à la main dans une table divergerait au premier champ
* ajouté sans que rien ne le signale. La **durée**, elle, vient de
* `RetentionPolicy` : elle se paramètre, se justifie et s'audite.
*
* `À VALIDER` — l'affectation d'une base légale est une qualification
* juridique. Les lignes ci-dessous portent la lecture usuelle du traitement de
* paie et de planning ; elles doivent être confirmées par le responsable de
* traitement avant d'être opposées à qui que ce soit.
*/
export type LegalBasis =
| 'contract'
| 'legal_obligation'
| 'legitimate_interest'
| 'consent';
export const LEGAL_BASIS_LABELS: Record<LegalBasis, string> = {
contract: 'Exécution du contrat de travail',
legal_obligation: 'Obligation légale',
legitimate_interest: 'Intérêt légitime',
consent: 'Consentement',
};
export interface DictionaryEntry {
/** Catégorie de données, telle qu'un salarié la reconnaîtrait. */
category: string;
/** Objets du schéma qui la portent — le lien avec la politique de purge. */
objectTypes: string[];
purpose: string;
basis: LegalBasis;
recipients: string[];
/** Vrai pour une catégorie particulière au sens de l'article 9. */
sensitive: boolean;
notes?: string;
}
export const DATA_DICTIONARY: DictionaryEntry[] = [
{
category: 'État civil et coordonnées',
objectTypes: ['EmployeeProfile'],
purpose:
'Tenue du registre unique du personnel, déclaration préalable à l’embauche, édition des contrats.',
basis: 'legal_obligation',
recipients: ['Service RH', 'Cabinet de paie'],
sensitive: false,
},
{
category: 'Numéro de sécurité sociale',
objectTypes: ['EmployeeProfile'],
purpose: 'Déclarations sociales et transmission au logiciel de paie.',
basis: 'legal_obligation',
recipients: ['Cabinet de paie'],
sensitive: false,
notes:
'Chiffré au repos. Sa lecture n’est ouverte qu’aux capacités de paie.',
},
{
category: 'Coordonnées bancaires',
objectTypes: ['EmployeeProfile'],
purpose: 'Versement du salaire.',
basis: 'contract',
recipients: ['Cabinet de paie'],
sensitive: false,
notes: 'Chiffrées au repos.',
},
{
category: 'Contrat, rémunération et avenants',
objectTypes: ['UserContract', 'Amendment'],
purpose:
'Exécution et suivi du contrat, calcul des seuils de convention, préparation de la paie.',
basis: 'contract',
recipients: ['Service RH', 'Encadrement', 'Cabinet de paie'],
sensitive: false,
notes:
'La rémunération n’est visible qu’avec la capacité `members.salary.view`.',
},
{
category: 'Planning, heures prévues et réalisées',
objectTypes: ['Shift', 'WeeklySchedule', 'Rest', 'ForfaitDayEntry'],
purpose:
'Organisation du travail, contrôle des durées maximales et des repos, variables de paie.',
basis: 'legal_obligation',
recipients: ['Encadrement', 'Service RH', 'Cabinet de paie'],
sensitive: false,
},
{
category: 'Absences et compteurs',
objectTypes: ['TimeOff', 'Counter', 'LedgerOperation'],
purpose: 'Gestion des congés, décompte des droits, variables de paie.',
basis: 'contract',
recipients: ['Encadrement', 'Service RH', 'Cabinet de paie'],
sensitive: false,
},
{
category: 'Arrêts de travail et pièces de santé',
objectTypes: ['Document:SICK_NOTE'],
purpose:
'Justification de l’absence et déclaration à la sécurité sociale. Le motif médical n’est pas exploité.',
basis: 'legal_obligation',
recipients: ['Service RH'],
sensitive: true,
notes:
'Catégorie particulière (art. 9). Lecture journalisée ; le motif est masqué en vue encadrement.',
},
{
category: 'Titre de séjour et autorisation de travail',
objectTypes: ['WorkPermit'],
purpose:
'Vérification de l’autorisation de travail et suivi des échéances.',
basis: 'legal_obligation',
recipients: ['Service RH'],
sensitive: false,
notes:
'Le statut migratoire n’est ni affiché ni exploité au-delà de l’échéance.',
},
{
category: 'Comptes, sessions et second facteur',
objectTypes: ['User', 'Session', 'MfaRecoveryCode'],
purpose: 'Authentification, révocation d’accès, sécurité du compte.',
basis: 'legitimate_interest',
recipients: ['Administrateurs de l’instance'],
sensitive: false,
},
{
category: 'Journal d’audit',
objectTypes: ['AuditLog'],
purpose:
'Traçabilité des décisions opposables : contrat, absence, publication, période de paie, permissions.',
basis: 'legal_obligation',
recipients: ['Administrateurs', 'Contrôleurs habilités'],
sensitive: false,
notes: 'Append-only, imposé par un trigger de base.',
},
];
/** Catégories particulières, à traiter avec une vigilance distincte. */
export const SENSITIVE_ENTRIES = DATA_DICTIONARY.filter(
(entry) => entry.sensitive,
);
+73
View File
@@ -0,0 +1,73 @@
/**
* Registre unique du personnel — articles L1221-13 et D1221-23 du code du
* travail.
*
* Tenu par établissement et non par entreprise : c'est l'inspection d'un
* établissement donné qui le réclame, et un registre global ne répondrait pas
* à la demande.
*
* Les mentions sont fixées par le texte. Elles sont énumérées ici plutôt que
* dispersées dans le rendu, pour qu'une relecture juridique porte sur un seul
* endroit.
*/
export interface RupPerson {
lastName: string;
firstName: string;
/** Mention exigée. Non collectée par PlanFlow à ce jour. */
sex: string | null;
nationality: string | null;
birthDate: Date | null;
/** Emploi tenu, tel que porté par le contrat. */
jobTitle: string | null;
qualification: string | null;
contractLabel: string | null;
entryDate: Date | null;
exitDate: Date | null;
}
export interface RupColumn {
key: keyof RupPerson;
label: string;
/** Part de la largeur utile. La somme vaut 1. */
width: number;
}
/**
* L'ordre suit celui du texte réglementaire : identité, puis situation, puis
* dates. Un registre réordonné se relit mal en regard de l'article.
*/
export const RUP_COLUMNS: RupColumn[] = [
{ key: 'lastName', label: 'Nom', width: 0.13 },
{ key: 'firstName', label: 'Prénom', width: 0.11 },
{ key: 'sex', label: 'Sexe', width: 0.05 },
{ key: 'nationality', label: 'Nationalité', width: 0.1 },
{ key: 'birthDate', label: 'Date de naissance', width: 0.1 },
{ key: 'jobTitle', label: 'Emploi', width: 0.14 },
{ key: 'qualification', label: 'Qualification', width: 0.11 },
{ key: 'contractLabel', label: 'Contrat', width: 0.08 },
{ key: 'entryDate', label: 'Entrée', width: 0.09 },
{ key: 'exitDate', label: 'Sortie', width: 0.09 },
];
/**
* Mentions obligatoires manquantes pour une personne.
*
* La date de sortie est exclue : son absence signifie que le salarié est
* toujours présent, ce qui est une information et non un oubli.
*/
export function missingMentions(person: RupPerson): string[] {
return RUP_COLUMNS.filter((column) => {
if (column.key === 'exitDate') return false;
const value = person[column.key];
return value === null || value === '';
}).map((column) => column.label);
}
/**
* Une contravention par salarié concerné, et non une par registre : c'est ce
* qui justifie de compter les personnes plutôt que les champs.
*/
export function peopleWithGaps(people: RupPerson[]): number {
return people.filter((person) => missingMentions(person).length > 0).length;
}
+26
View File
@@ -76,6 +76,19 @@ export interface AbsenceInput {
colorKey: string;
}
/**
* Repos posé sur la semaine.
*
* Seul le repos **compensateur** alimente le compteur RC : le repos
* hebdomadaire est un droit déjà pris, tandis que le compensateur est une
* contrepartie due, et le bandeau doit dire laquelle des deux est en jeu.
*/
export interface RestInput {
membershipId: string;
restType: 'WEEKLY_REST' | 'COMPENSATORY_REST';
minutes: number | null;
}
/**
* Range les créneaux d'une semaine en lignes × sept colonnes.
*
@@ -91,9 +104,19 @@ export function buildRows(
timeZone: string,
isPublished: boolean,
absences: AbsenceInput[] = [],
rests: RestInput[] = [],
): { rows: BoardRow[]; unassignedRow: BoardRow | null } {
const columnOf = new Map(weekDates.map((date, index) => [date, index]));
const compensatoryByMember = new Map<string, number>();
for (const rest of rests) {
if (rest.restType !== 'COMPENSATORY_REST') continue;
compensatoryByMember.set(
rest.membershipId,
(compensatoryByMember.get(rest.membershipId) ?? 0) + (rest.minutes ?? 0),
);
}
const emptyDays = (): BoardShift[][] =>
Array.from({ length: 7 }, () => [] as BoardShift[]);
@@ -199,6 +222,8 @@ export function buildRows(
),
sundaysWorked: sundaysByMember.get(person.membershipId)?.size ?? 0,
restDays: 7 - worked,
compensatoryRestMinutes:
compensatoryByMember.get(person.membershipId) ?? 0,
},
} satisfies BoardRow;
});
@@ -221,6 +246,7 @@ export function buildRows(
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 0,
compensatoryRestMinutes: 0,
},
}
: null;
+70
View File
@@ -0,0 +1,70 @@
import type { BoardAbsence, BoardRow } from '@/domain/planning/board';
/**
* Lecture « présent / absent / repos » d'une ligne de planning.
*
* Vit dans le domaine et non dans l'écran : la vue des présences, la feuille
* imprimée et, demain, un export en tirent la même conclusion. Deux
* implémentations de cette règle finiraient par se contredire sur le seul cas
* qui compte — celui du salarié dont le congé a été validé après coup.
*/
export type DayState = 'present' | 'absent' | 'off';
/**
* Absence recouvrant la colonne demandée.
*
* Les absences d'une ligne sont déjà découpées sur la semaine affichée :
* `startDay` est un index de colonne, `span` un nombre de jours. Repartir des
* dates ici referait, moins bien, un travail déjà fait — et rouvrirait la
* question du fuseau au passage.
*/
export function absenceOnDay(
row: BoardRow,
dayIndex: number,
): BoardAbsence | undefined {
return row.absences.find(
(absence) =>
dayIndex >= absence.startDay && dayIndex < absence.startDay + absence.span,
);
}
/**
* État d'un salarié un jour donné.
*
* **L'absence l'emporte sur le créneau.** Un congé accepté après la
* construction du planning laisse les créneaux en base : afficher « présent »
* enverrait un responsable chercher en rayon quelqu'un qui est chez lui. Le
* planning a tort, pas l'absence.
*/
export function dayState(row: BoardRow, dayIndex: number): DayState {
if (absenceOnDay(row, dayIndex)) return 'absent';
return (row.days[dayIndex]?.length ?? 0) > 0 ? 'present' : 'off';
}
export interface DayHeadcount {
present: number;
absent: number;
off: number;
}
/**
* Effectif par jour sur un ensemble de lignes.
*
* Les lignes de besoins non couverts sont écartées : elles ne portent personne,
* et les compter en « repos » gonflerait un effectif qui n'existe pas.
*/
export function headcountByDay(
rows: BoardRow[],
dayCount: number,
): DayHeadcount[] {
const counted = rows.filter((row) => !row.unassigned);
return Array.from({ length: dayCount }, (_, index) => {
const tally: DayHeadcount = { present: 0, absent: 0, off: 0 };
for (const row of counted) {
tally[dayState(row, index)] += 1;
}
return tally;
});
}
+210
View File
@@ -0,0 +1,210 @@
/**
* Préférences du compte — PLAN.md §9.
*
* Le catalogue vit dans le domaine plutôt que dans l'écran : chaque préférence
* y porte son libellé, sa conséquence et, le cas échéant, son avertissement.
* Un interrupteur qui change un calcul de paie ne doit pas se présenter comme
* un réglage d'affichage, et la nuance ne tiendra pas si elle n'existe que dans
* le JSX.
*/
export type PreferenceGroup = 'planning' | 'rights' | 'payroll' | 'print';
/** Densités d'impression. Liste fermée : elle décide d'une mise en page. */
export const PRINT_DENSITIES = [
{
key: 'large',
label: 'Grande',
hint: 'Moins de salariés par page, lisible à distance sur un mur.',
},
{
key: 'compact',
label: 'Compacte',
hint: 'Tout l’effectif sur une page, au prix de la lisibilité de loin.',
},
] as const;
export type PrintDensity = (typeof PRINT_DENSITIES)[number]['key'];
export function isPrintDensity(value: string): value is PrintDensity {
return PRINT_DENSITIES.some((density) => density.key === value);
}
export interface PreferenceToggle {
key: string;
group: PreferenceGroup;
label: string;
/** Ce que l'interrupteur change réellement. */
hint: string;
/** Avertissement affiché en évidence — risque juridique ou de calcul. */
warning?: string;
/** Décalé sous la préférence dont il dépend. */
parent?: string;
}
export const PREFERENCE_TOGGLES: PreferenceToggle[] = [
// --- Plannings ---------------------------------------------------------
{
key: 'defaultMealPerShift',
group: 'planning',
label: 'Compter un repas par créneau',
hint: 'Chaque créneau ouvre un repas sans saisie. À décocher si le repas dépend de l’amplitude.',
},
{
key: 'paidBreaks',
group: 'planning',
label: 'Les pauses sont rémunérées',
hint: 'Les pauses cessent d’être déduites du temps de travail effectif — et entrent donc dans les seuils de convention.',
warning:
'Modifie le décompte des heures : les semaines déjà exportées ne sont pas recalculées.',
},
{
key: 'lockEmployeeMeals',
group: 'planning',
label: 'Les salariés ne peuvent pas modifier leur repas',
hint: 'Le repas devient une donnée de gestion, saisie par l’encadrement.',
},
{
key: 'employeesSeeOwnTotals',
group: 'planning',
label: 'Le salarié voit son total d’heures',
hint: 'Heures planifiées et réelles de sa semaine, sur son propre planning.',
},
{
key: 'employeesSeeTeamPlanning',
group: 'planning',
label: 'Le salarié voit le planning de son équipe',
hint: 'Les créneaux des autres membres de ses équipes, sans leurs compteurs.',
},
{
key: 'hideForfaitCounters',
group: 'planning',
label: 'Masquer les compteurs au forfait jours',
hint: 'Les alertes horaires de ces lignes sont masquées avec eux. Un forfait jours ne se compte pas en heures : un écart hebdomadaire y est un faux positif.',
},
// --- Droits ------------------------------------------------------------
{
key: 'employeesEditOwnProfile',
group: 'rights',
label: 'Le salarié modifie ses informations personnelles',
hint: 'Coordonnées et état civil. Le contrat et la rémunération restent hors de portée.',
},
{
key: 'employeesSeeOwnTimesheets',
group: 'rights',
label: 'Le salarié consulte ses feuilles de présence',
hint: 'Depuis son profil, en lecture seule.',
},
{
key: 'employeesSeeTeamContacts',
group: 'rights',
label: 'Le salarié voit les coordonnées de l’équipe',
hint: 'Téléphone et adresse e-mail de tous les membres.',
warning:
'Ouvre des données personnelles à l’ensemble de l’équipe : à n’activer que si la finalité le justifie.',
},
{
key: 'managerCreatedArePlannable',
group: 'rights',
label: 'Un salarié créé par un manager est planifiable aussitôt',
hint: 'Décoché, un administrateur ou un directeur doit le rendre planifiable depuis son profil.',
},
{
key: 'directorsSharePeople',
group: 'rights',
label: 'Les directeurs partagent leurs salariés',
hint: 'Un salarié devient disponible sur les plannings d’un autre établissement.',
},
{
key: 'directorsAccessHrDashboard',
group: 'rights',
label: 'Les directeurs accèdent au tableau de bord RH',
hint: 'Les agrégats restent bornés à leur périmètre d’établissement.',
},
{
key: 'directorsSeePaidLeave',
group: 'rights',
label: 'Compteurs de congés payés visibles des directeurs',
hint: 'Lecture seule. L’ajustement manuel reste une capacité distincte.',
},
{
key: 'managersSeePaidLeave',
group: 'rights',
label: 'Compteurs de congés payés visibles des managers',
hint: 'Lecture seule, même règle que pour les directeurs.',
},
// --- Paie --------------------------------------------------------------
{
key: 'smoothOvertimeMonthly',
group: 'payroll',
label: 'Lisser les heures supplémentaires au mois',
hint: 'Le décompte se fait sur le mois au lieu de la semaine.',
warning:
'Le décompte hebdomadaire est le principe (art. L3121-28). Lisser au mois suppose un accord d’aménagement du temps de travail ; sans accord, l’aménagement est inopposable et les heures restent dues à la semaine.',
},
{
key: 'includeRestInNormalHours',
group: 'payroll',
label: 'Inclure repos et absences dans les heures normales',
hint: 'Les heures travaillées au-delà du contractuel du fait d’une absence sont payées au taux normal plutôt qu’en heures supplémentaires.',
warning:
'Modifie la répartition entre heures normales et heures majorées : à confirmer avec le gestionnaire de paie avant tout export.',
},
{
key: 'autoEmployeeNumber',
group: 'payroll',
label: 'Générer le matricule automatiquement',
hint: 'Décoché, le matricule est saisi à l’embauche — utile quand il doit correspondre à celui du logiciel de paie.',
},
// --- Impression --------------------------------------------------------
{
key: 'printContractTotals',
group: 'print',
label: 'Imprimer les totaux contractuels et planifiés',
hint: 'Ajoute une colonne de totaux par salarié.',
},
{
key: 'printOtherTeams',
group: 'print',
label: 'Imprimer les créneaux des autres équipes',
hint: 'Situe la journée d’ensemble sur la feuille d’une équipe.',
},
{
key: 'printSunday',
group: 'print',
label: 'Imprimer le dimanche',
hint: 'À décocher pour un établissement qui n’ouvre jamais ce jour-là : la colonne gagnée profite aux six autres.',
},
{
key: 'printSignatureColumn',
group: 'print',
label: 'Imprimer la colonne « émargements »',
hint: 'Colonne vierge pour signature manuscrite.',
warning:
'Un émargement papier ne vaut pas décompte du temps de travail : le décompte reste celui de PlanFlow, pas celui de la feuille signée.',
},
];
export const PREFERENCE_KEYS: string[] = PREFERENCE_TOGGLES.map(
(toggle) => toggle.key,
);
export function togglesOf(group: PreferenceGroup): PreferenceToggle[] {
return PREFERENCE_TOGGLES.filter((toggle) => toggle.group === group);
}
/** « 20:00 » ↔ minutes depuis minuit, pour l'heure de bascule du soir. */
export function minutesToTime(minutes: number): string {
const hours = Math.floor(minutes / 60) % 24;
const rest = minutes % 60;
return `${String(hours).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
}
export function timeToMinutes(value: string): number | null {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value.trim());
if (!match) return null;
return Number(match[1]) * 60 + Number(match[2]);
}
+4 -4
View File
@@ -229,7 +229,7 @@ export async function requestTimeOffAction(
return toState(error, "Vous n'avez pas le droit de poser cette absence.");
}
revalidatePath('/conges');
revalidatePath('/absences/calendrier');
return warning ? { ok: true, warning } : { ok: true };
}
@@ -328,7 +328,7 @@ export async function decideTimeOffAction(
return toState(error, "Vous n'avez pas le droit de décider de cette demande.");
}
revalidatePath('/conges');
revalidatePath('/absences/calendrier');
return { ok: true };
}
@@ -384,7 +384,7 @@ export async function cancelTimeOffAction(
return toState(error, "Vous n'avez pas le droit d'annuler cette absence.");
}
revalidatePath('/conges');
revalidatePath('/absences/calendrier');
return { ok: true };
}
@@ -462,7 +462,7 @@ export async function adjustCounterAction(
return toState(error, "Vous n'avez pas le droit d'ajuster un compteur.");
}
revalidatePath('/conges');
revalidatePath('/absences/calendrier');
return { ok: true };
}
+235
View File
@@ -1,3 +1,5 @@
import { cache } from 'react';
import { can } from '@/domain/access/authorize';
import { counterView, type LedgerEntry } from '@/domain/absences/ledger';
import { monthDates, monthLabel, type Month } from '@/domain/planning/month';
@@ -71,6 +73,103 @@ export function statusLabel(status: AbsenceRequest['status']): string {
return STATUS_LABEL[status];
}
export interface MemberAbsences {
requests: AbsenceRequest[];
counters: CounterSummary[];
}
/**
* Absences d'un salarié — onglet de sa fiche.
*
* Le tableau mensuel répond à « qui est absent ce mois-ci » ; il ne répond pas
* à « qu'a pris ce salarié depuis son entrée ». D'où cette lecture, bornée à
* une personne et non à un mois.
*
* Rend `null` plutôt que de lever quand le dossier consulté n'est pas le sien
* et que la capacité manque : l'onglet se retire, le reste de la fiche tient.
*/
export const listMemberAbsences = cache(async function listMemberAbsences(
membershipId: string,
): Promise<MemberAbsences | null> {
return query('timeoff.view_own', async (db, actor) => {
if (
membershipId !== actor.membershipId &&
!can(actor, 'timeoff.view_others')
) {
return null;
}
const canSeeMedical = can(actor, 'members.documents.view');
const member = await db.membership.findUnique({
where: { id: membershipId },
select: {
employeeNumber: true,
profile: { select: { firstName: true, lastName: true } },
},
});
if (!member) return null;
const name =
`${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim();
const timeOffs = await db.timeOff.findMany({
where: { membershipId },
include: { absenceType: true },
orderBy: { startDate: 'desc' },
});
const counters = await db.counter.findMany({
where: { membershipId },
include: { operations: true },
});
return {
requests: timeOffs.map((entry) => ({
id: entry.id,
membershipId: entry.membershipId,
name,
typeLabel:
entry.absenceType.isSocialSecurity && !canSeeMedical
? 'Absence'
: entry.absenceType.name,
colorKey: entry.absenceType.colorKey,
isSocialSecurity: entry.absenceType.isSocialSecurity,
startDate: entry.startDate.toISOString().slice(0, 10),
endDate: entry.endDate.toISOString().slice(0, 10),
startHalfDay: entry.startHalfDay,
endHalfDay: entry.endHalfDay,
days: Number(entry.countedDays?.toString() ?? '0'),
status: entry.status,
comment:
entry.absenceType.isSocialSecurity && !canSeeMedical
? null
: entry.comment,
decisionComment: entry.decisionComment,
requestedAt: entry.requestedAt,
})),
counters: counters.map((counter) => {
const entries: LedgerEntry[] = counter.operations.map((operation) => ({
kind: operation.kind,
quantity: Number(operation.quantity.toString()),
unit: operation.unit,
effectiveDate: operation.effectiveDate.toISOString().slice(0, 10),
}));
const view = counterView(entries, 0);
return {
membershipId: counter.membershipId,
name,
counterType: counter.counterType,
accrued: view.accrued,
taken: view.taken,
balance: view.balance,
projected: view.projected,
};
}),
};
});
});
export async function getAbsenceBoard(month: Month): Promise<AbsenceBoard> {
return query('timeoff.view_own', async (db, actor) => {
const canSeeOthers = can(actor, 'timeoff.view_others');
@@ -223,6 +322,142 @@ export async function getAbsenceBoard(month: Month): Promise<AbsenceBoard> {
});
}
/**
* File d'absences filtrée par état — PLAN.md §9, écrans `/absences/*`.
*
* Les trois files partagent une requête plutôt qu'un écran chacune : la règle
* de masquage du motif médical vaut pour les trois, et la dupliquer trois fois
* garantirait qu'une des copies finisse par diverger. Une donnée de santé qui
* ne fuit que sur l'écran « traitées » reste une fuite.
*/
export type AbsenceQueue = 'pending' | 'treated' | 'expired';
export interface QueueFilters {
/** Restreint à un type d'absence. */
typeId?: string;
/** `true` : uniquement les absences Sécurité sociale (filtre relevé à l'audit). */
socialSecurityOnly?: boolean;
}
export interface AbsenceQueueResult {
queue: AbsenceQueue;
rows: AbsenceRequest[];
absenceTypes: Array<{ id: string; code: string; name: string }>;
canDecide: boolean;
ownMembershipId: string;
/** Décomptes des trois files, pour les onglets. */
counts: Record<AbsenceQueue, number>;
}
const QUEUE_STATUSES: Record<
AbsenceQueue,
Array<AbsenceRequest['status']>
> = {
pending: ['PENDING'],
treated: ['ACCEPTED', 'DECLINED', 'CANCELLED'],
expired: ['EXPIRED'],
};
export async function getAbsenceQueue(
queue: AbsenceQueue,
filters: QueueFilters = {},
): Promise<AbsenceQueueResult> {
return query('timeoff.view_own', async (db, actor) => {
const canSeeOthers = can(actor, 'timeoff.view_others');
const canDecide = can(actor, 'timeoff.decide');
const canSeeMedical = can(actor, 'members.documents.view');
const scope = canSeeOthers ? {} : { membershipId: actor.membershipId };
const rows = await db.timeOff.findMany({
where: {
...scope,
status: { in: QUEUE_STATUSES[queue] },
...(filters.typeId ? { absenceTypeId: filters.typeId } : {}),
...(filters.socialSecurityOnly
? { absenceType: { isSocialSecurity: true } }
: {}),
},
include: { absenceType: true },
// La file à traiter se lit par ancienneté — la plus vieille demande est
// celle qui coûte le plus au salarié qui attend. Les files closes se
// lisent à l'envers : on y cherche ce qui vient d'être décidé.
orderBy:
queue === 'pending'
? { requestedAt: 'asc' }
: { startDate: 'desc' },
take: 500,
});
const memberIds = [...new Set(rows.map((entry) => entry.membershipId))];
const members = await db.membership.findMany({
where: { id: { in: memberIds } },
include: { profile: { select: { firstName: true, lastName: true } } },
});
const nameById = new Map(
members.map((member) => [
member.id,
`${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim(),
]),
);
const counts = await db.timeOff.groupBy({
by: ['status'],
where: scope,
_count: { _all: true },
});
const byStatus = new Map(
counts.map((row) => [row.status, row._count._all] as const),
);
const total = (queueKey: AbsenceQueue) =>
QUEUE_STATUSES[queueKey].reduce(
(sum, status) => sum + (byStatus.get(status) ?? 0),
0,
);
const absenceTypes = await db.absenceType.findMany({
where: { archivedAt: null },
select: { id: true, code: true, name: true },
orderBy: { code: 'asc' },
});
return {
queue,
rows: rows.map((entry) => ({
id: entry.id,
membershipId: entry.membershipId,
name: nameById.get(entry.membershipId) ?? 'Salarié',
typeLabel:
entry.absenceType.isSocialSecurity && !canSeeMedical
? 'Absence'
: entry.absenceType.name,
colorKey: entry.absenceType.colorKey,
isSocialSecurity: entry.absenceType.isSocialSecurity,
startDate: entry.startDate.toISOString().slice(0, 10),
endDate: entry.endDate.toISOString().slice(0, 10),
startHalfDay: entry.startHalfDay,
endHalfDay: entry.endHalfDay,
days: Number(entry.countedDays?.toString() ?? '0'),
status: entry.status,
comment:
entry.absenceType.isSocialSecurity && !canSeeMedical
? null
: entry.comment,
decisionComment: entry.decisionComment,
requestedAt: entry.requestedAt,
})),
absenceTypes,
canDecide,
ownMembershipId: actor.membershipId,
counts: {
pending: total('pending'),
treated: total('treated'),
expired: total('expired'),
},
};
});
}
/** Absences acceptées d'une période, pour la grille de planning. */
export async function acceptedAbsencesBetween(
membershipIds: string[],
+314 -8
View File
@@ -3,8 +3,9 @@
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import { AuthorizationError, can } from '@/domain/access/authorize';
import { findOverlaps, validateContract } from '@/domain/contracts/rules';
import { nextEmployeeNumber } from '@/domain/hr/civil-status';
import { recordAudit } from '@/server/audit';
import { mutate } from '@/server/context';
@@ -27,18 +28,56 @@ const CONTRACT_TYPES = [
const employeeInput = z.object({
firstName: z.string().trim().min(1, 'Prénom requis').max(80),
lastName: z.string().trim().min(1, 'Nom requis').max(80),
employeeNumber: z.string().trim().min(1, 'Matricule requis').max(40),
/** Le nom d'usage vaut nom de naissance quand il n'est pas distinct. */
birthName: z.string().trim().max(80),
lastName: z.string().trim().min(1, 'Nom de famille requis').max(80),
/** Vide = proposé par l'application, à la suite du dernier attribué. */
employeeNumber: z.string().trim().max(40),
birthDate: z.string().trim().max(10),
/** Vide = salarié géré sans accès applicatif. */
email: z.string().trim().email('Adresse invalide').or(z.literal('')),
phone: z.string().trim().max(30),
landline: z.string().trim().max(30),
smsSchedules: z.boolean(),
});
/**
* Crée un salarié.
* Contrat posé au moment de l'embauche.
*
* Facultatif dans le formulaire, mais tout ou rien une fois entamé : un
* établissement sans date de début produirait un contrat qu'aucune règle ne
* sait valider.
*/
const hiringContractInput = z.object({
locationId: z.string().min(1, 'Établissement requis'),
teamId: z.string().trim(),
contractType: z.enum(CONTRACT_TYPES),
startDate: z.coerce.date(),
/** Heure de prise de poste : la DPAE la demande, la date seule n'y suffit pas. */
startTime: z
.string()
.trim()
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'Heure de début invalide'),
endDate: z.string().trim(),
workTimeArrangement: z.enum(['HOURLY', 'FORFAIT_JOURS']),
weeklyHours: z.coerce.number().min(0).max(60),
forfaitDaysPerYear: z.coerce.number().min(0).max(400).optional(),
forfaitAgreementRef: z.string().trim(),
forfaitAgreedAt: z.string().trim(),
lineManagerId: z.string().trim(),
rttPolicyId: z.string().trim(),
});
/**
* Crée un salarié, et l'embauche s'il y a lieu.
*
* Un salarié **sans compte utilisateur** doit rester créable : la plupart des
* équipes de vente ne se connectent jamais à l'outil, et exiger une adresse
* électronique les rendrait impossibles à planifier ou à déclarer.
*
* Le contrat et l'équipe sont posés dans la **même transaction** que le
* dossier. Créés séparément, un incident au milieu laisserait un salarié sans
* contrat — c'est-à-dire un dossier qu'aucun écran ne permettait de rattraper.
*/
export async function createEmployeeAction(
_previous: ActionState,
@@ -46,32 +85,130 @@ export async function createEmployeeAction(
): Promise<ActionState> {
const parsed = employeeInput.safeParse({
firstName: formData.get('firstName'),
birthName: formData.get('birthName') ?? '',
lastName: formData.get('lastName'),
employeeNumber: formData.get('employeeNumber'),
employeeNumber: formData.get('employeeNumber') ?? '',
birthDate: formData.get('birthDate') ?? '',
email: formData.get('email') ?? '',
phone: formData.get('phone') ?? '',
landline: formData.get('landline') ?? '',
smsSchedules: formData.get('smsSchedules') === 'on',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
let birthDate: Date | null = null;
if (parsed.data.birthDate) {
const candidate = new Date(`${parsed.data.birthDate}T00:00:00Z`);
if (Number.isNaN(candidate.getTime()) || candidate > new Date()) {
return { error: 'Date de naissance invalide.' };
}
birthDate = candidate;
}
// Le contrat n'est tenté que si le formulaire l'a ouvert : créer un salarié
// sans contrat reste légitime — un remplaçant se saisit avant que son
// établissement soit tranché.
const wantsContract = formData.get('withContract') === 'on';
let contract: z.infer<typeof hiringContractInput> | null = null;
let contractEnd: Date | null = null;
let forfaitAgreedAt: Date | null = null;
if (wantsContract) {
const parsedContract = hiringContractInput.safeParse({
locationId: formData.get('locationId') ?? '',
teamId: formData.get('teamId') ?? '',
contractType: formData.get('contractType') ?? 'CDI',
startDate: formData.get('startDate'),
startTime: formData.get('startTime') || '09:00',
lineManagerId: formData.get('lineManagerId') ?? '',
rttPolicyId: formData.get('rttPolicyId') ?? '',
endDate: formData.get('endDate') ?? '',
workTimeArrangement: formData.get('workTimeArrangement') ?? 'HOURLY',
weeklyHours: formData.get('weeklyHours') || 35,
forfaitDaysPerYear: formData.get('forfaitDaysPerYear') || undefined,
forfaitAgreementRef: formData.get('forfaitAgreementRef') ?? '',
forfaitAgreedAt: formData.get('forfaitAgreedAt') ?? '',
});
if (!parsedContract.success) {
return {
error:
parsedContract.error.issues[0]?.message ?? 'Contrat invalide',
};
}
contract = parsedContract.data;
contractEnd = contract.endDate ? new Date(contract.endDate) : null;
forfaitAgreedAt = contract.forfaitAgreedAt
? new Date(contract.forfaitAgreedAt)
: null;
const issues = validateContract({
startDate: contract.startDate,
endDate: contractEnd,
workTimeArrangement: contract.workTimeArrangement,
weeklyHours: contract.weeklyHours,
forfaitDaysPerYear: contract.forfaitDaysPerYear ?? null,
forfaitAgreementRef: contract.forfaitAgreementRef || null,
forfaitAgreedAt,
});
if (issues.length > 0) {
return { error: issues[0]?.message ?? 'Contrat invalide' };
}
}
try {
await mutate('members.create', async (db, actor) => {
// Poser un contrat est une capacité distincte de celle de créer un
// dossier : un gestionnaire d'annuaire n'engage pas l'entreprise.
if (contract && !can(actor, 'members.contract.create')) {
throw new AuthorizationError('members.contract.create');
}
const role = await db.role.findFirst({ where: { key: 'employee' } });
if (!role) throw new Error('Rôle « employee » introuvable.');
// Le matricule est proposé quand il n'est pas donné. Attribué **dans la
// transaction**, à la suite du dernier : deux embauches simultanées ne
// peuvent pas tomber sur le même rang, la seconde échouant sur l'unicité
// plutôt que d'écraser la première.
let employeeNumber = parsed.data.employeeNumber;
if (!employeeNumber) {
const taken = await db.membership.findMany({
select: { employeeNumber: true },
});
employeeNumber = nextEmployeeNumber(
taken.map((entry) => entry.employeeNumber),
);
}
const existing = await db.membership.findFirst({
where: { employeeNumber: parsed.data.employeeNumber },
where: { employeeNumber },
});
if (existing) {
throw new ValidationError('Ce matricule est déjà utilisé.');
}
if (contract?.lineManagerId) {
const manager = await db.membership.findUnique({
where: { id: contract.lineManagerId },
select: { id: true },
});
if (!manager) {
throw new ValidationError('Responsable hiérarchique introuvable.');
}
}
const created = await db.membership.create({
data: {
roleId: role.id,
employeeNumber: parsed.data.employeeNumber,
employeeNumber,
status: parsed.data.email ? 'INVITED' : 'ACTIVE',
lineManagerId: contract?.lineManagerId || null,
} as never,
});
@@ -80,10 +217,89 @@ export async function createEmployeeAction(
membershipId: created.id,
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
// Sans mention contraire, le nom de naissance est le nom d'usage :
// laisser le champ vide obligerait à le ressaisir pour l'immense
// majorité des dossiers, où les deux coïncident.
birthName: parsed.data.birthName || parsed.data.lastName,
birthDate: birthDate,
personalEmail: parsed.data.email || null,
phone: parsed.data.phone || null,
landline: parsed.data.landline || null,
smsSchedules: parsed.data.smsSchedules,
} as never,
});
if (contract) {
const location = await db.location.findUnique({
where: { id: contract.locationId },
});
if (!location) {
throw new ValidationError('Établissement introuvable.');
}
const openedContract = await db.userContract.create({
data: {
membershipId: created.id,
locationId: location.id,
contractType: contract.contractType,
startDate: contract.startDate,
startTime: contract.startTime,
endDate: contractEnd,
workTimeArrangement: contract.workTimeArrangement,
weeklyHours: contract.weeklyHours,
forfaitDaysPerYear: contract.forfaitDaysPerYear ?? null,
forfaitAgreementRef: contract.forfaitAgreementRef || null,
forfaitAgreedAt,
} as never,
});
// Sans équipe, le salarié n'apparaît sur aucune grille : le planning
// s'ordonne par équipe, et un rattachement omis se paie à la première
// semaine à couvrir.
if (contract.teamId) {
const team = await db.team.findUnique({
where: { id: contract.teamId },
select: { id: true, locationId: true },
});
if (!team || team.locationId !== location.id) {
throw new ValidationError(
'Cette équipe n’appartient pas à l’établissement choisi.',
);
}
await db.teamMember.create({
data: { teamId: team.id, membershipId: created.id } as never,
});
}
if (contract.rttPolicyId) {
const policy = await db.rttPolicy.findUnique({
where: { id: contract.rttPolicyId },
select: { id: true, status: true },
});
if (!policy || policy.status !== 'ACTIVE') {
throw new ValidationError('Politique RTT introuvable ou archivée.');
}
await db.rttPolicyAssignment.create({
data: {
rttPolicyId: policy.id,
membershipId: created.id,
} as never,
});
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'contract.create',
entityType: 'UserContract',
entityId: openedContract.id,
after: {
contractType: openedContract.contractType,
startDate: openedContract.startDate.toISOString(),
workTimeArrangement: openedContract.workTimeArrangement,
},
});
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'membership.create',
@@ -92,13 +308,19 @@ export async function createEmployeeAction(
after: {
employeeNumber: created.employeeNumber,
hasAccount: Boolean(parsed.data.email),
withContract: Boolean(contract),
},
});
});
} catch (error) {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de créer un salarié." };
return {
error:
error.permission === 'members.contract.create'
? "Vous n'avez pas le droit d'ouvrir un contrat. Créez le salarié sans contrat, ou faites-le poser."
: "Vous n'avez pas le droit de créer un salarié.",
};
}
throw error;
}
@@ -329,3 +551,87 @@ export async function createAmendmentAction(
if (membershipId) revalidatePath(`/equipe/${membershipId}`);
return { ok: true };
}
const endContractInput = z.object({
contractId: z.string().min(1),
endDate: z.coerce.date(),
endReason: z.string().trim().min(1, 'Motif de fin requis').max(300),
});
/**
* Termine un contrat.
*
* Le contrat n'est pas supprimé : il est **daté et clos**. Un contrôle porte
* sur une période révolue, et effacer un contrat terminé effacerait la preuve
* que le salarié a travaillé — ainsi que la base de son solde de tout compte.
*/
export async function endContractAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = endContractInput.safeParse({
contractId: formData.get('contractId'),
endDate: formData.get('endDate'),
endReason: formData.get('endReason'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
let membershipId = '';
try {
await mutate('members.contract.edit', async (db, actor) => {
const contract = await db.userContract.findUnique({
where: { id: parsed.data.contractId },
});
if (!contract) throw new AuthorizationError('members.contract.edit');
membershipId = contract.membershipId;
if (contract.status === 'ENDED') {
throw new ValidationError('Ce contrat est déjà terminé.');
}
if (parsed.data.endDate < contract.startDate) {
throw new ValidationError(
'La fin du contrat précède son début.',
);
}
await db.userContract.update({
where: { id: contract.id },
data: {
endDate: parsed.data.endDate,
endReason: parsed.data.endReason,
status: 'ENDED',
version: { increment: 1 },
},
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'contract.end',
entityType: 'UserContract',
entityId: contract.id,
before: { status: contract.status, endDate: contract.endDate?.toISOString() ?? null },
after: {
status: 'ENDED',
endDate: parsed.data.endDate.toISOString(),
},
reason: parsed.data.endReason,
});
});
} catch (error) {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de terminer un contrat." };
}
throw error;
}
if (membershipId) {
revalidatePath(`/equipe/${membershipId}`);
revalidatePath('/equipe');
}
return { ok: true };
}
+202
View File
@@ -0,0 +1,202 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import { recordAudit } from '@/server/audit';
import { mutate } from '@/server/context';
/**
* Rattachement et périmètre.
*
* Deux capacités distinctes, parce que ce sont deux décisions distinctes :
*
* - le **périmètre** dit ce qu'une personne voit du compte. L'élargir revient à
* lui donner accès à des dossiers et des plannings qu'elle n'avait pas :
* c'est distribuer des droits, donc `settings.roles.manage` ;
* - le **rattachement** dit sur quelle grille elle apparaît. C'est de
* l'organisation du travail, donc `settings.teams.manage`.
*
* L'établissement du contrat n'est modifiable ni par l'une ni par l'autre : il
* est porté par le contrat, et ne se change que par avenant.
*/
export interface PlacementActionState {
error?: string;
ok?: boolean;
}
class ValidationError extends Error {}
const scopeInput = z.object({
membershipId: z.string().min(1),
allLocations: z.boolean(),
locationIds: z.array(z.string().min(1)),
});
export async function updateScopeAction(
_previous: PlacementActionState,
formData: FormData,
): Promise<PlacementActionState> {
const parsed = scopeInput.safeParse({
membershipId: formData.get('membershipId') ?? '',
allLocations: formData.get('allLocations') === 'on',
locationIds: formData.getAll('locationIds').map(String),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const { membershipId, allLocations, locationIds } = parsed.data;
if (!allLocations && locationIds.length === 0) {
return {
error:
'Sans accès généralisé, désignez au moins un établissement — sinon cette personne ne verrait plus rien.',
};
}
try {
await mutate('settings.roles.manage', async (db, actor) => {
const membership = await db.membership.findUnique({
where: { id: membershipId },
select: { id: true },
});
if (!membership) throw new ValidationError('Salarié introuvable.');
const known = await db.location.findMany({
where: { id: { in: locationIds } },
select: { id: true },
});
if (known.length !== locationIds.length) {
throw new ValidationError('Établissement inconnu.');
}
const before = await db.membershipScope.findMany({
where: { membershipId },
select: { allLocations: true, locationId: true },
});
// Remplacement et non fusion : un périmètre qui ne fait que grandir ne
// se réduit jamais, et le retrait d'un établissement resterait sans
// effet — l'exact contraire de ce que l'écran promet.
await db.membershipScope.deleteMany({ where: { membershipId } });
if (allLocations) {
await db.membershipScope.create({
data: { membershipId, allLocations: true } as never,
});
} else {
for (const locationId of locationIds) {
await db.membershipScope.create({
data: { membershipId, allLocations: false, locationId } as never,
});
}
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'membership.scope.update',
entityType: 'Membership',
entityId: membershipId,
before: { scopes: before },
after: { allLocations, locationIds },
});
});
} catch (error) {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof AuthorizationError) {
return {
error:
'Modifier un périmètre revient à distribuer des droits : la capacité « Gérer les rôles » est requise.',
};
}
throw error;
}
revalidatePath(`/equipe/${membershipId}/planification`);
return { ok: true };
}
const teamsInput = z.object({
membershipId: z.string().min(1),
teamIds: z.array(z.string().min(1)),
primaryTeamId: z.string().trim(),
});
export async function updateTeamsAction(
_previous: PlacementActionState,
formData: FormData,
): Promise<PlacementActionState> {
const parsed = teamsInput.safeParse({
membershipId: formData.get('membershipId') ?? '',
teamIds: formData.getAll('teamIds').map(String),
primaryTeamId: formData.get('primaryTeamId') ?? '',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const { membershipId, teamIds, primaryTeamId } = parsed.data;
if (primaryTeamId && !teamIds.includes(primaryTeamId)) {
return {
error: 'L’équipe principale doit figurer parmi les équipes cochées.',
};
}
try {
await mutate('settings.teams.manage', async (db, actor) => {
const known = await db.team.findMany({
where: { id: { in: teamIds } },
select: { id: true },
});
if (known.length !== teamIds.length) {
throw new ValidationError('Équipe inconnue.');
}
const before = await db.teamMember.findMany({
where: { membershipId },
select: { teamId: true, isPrimary: true },
});
await db.teamMember.deleteMany({ where: { membershipId } });
for (const teamId of teamIds) {
await db.teamMember.create({
data: {
teamId,
membershipId,
// Une seule principale : c'est elle qui décide de la colonne où la
// personne apparaît quand la grille n'en montre qu'une.
isPrimary: teamId === (primaryTeamId || teamIds[0]),
} as never,
});
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'membership.teams.update',
entityType: 'Membership',
entityId: membershipId,
before: { teams: before },
after: { teamIds, primaryTeamId: primaryTeamId || teamIds[0] || null },
});
});
} catch (error) {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof AuthorizationError) {
return {
error: 'La capacité « Gérer les équipes » est requise.',
};
}
throw error;
}
revalidatePath(`/equipe/${membershipId}/planification`);
revalidatePath('/equipe');
return { ok: true };
}
+268
View File
@@ -0,0 +1,268 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError, can } from '@/domain/access/authorize';
import { recordAudit } from '@/server/audit';
import { mutate } from '@/server/context';
import { encryptOptional } from '@/server/crypto';
/**
* Saisie du dossier personnel — matrice n° 1 et registre unique du personnel.
*
* Deux points d'écriture et non un seul. L'état civil et les coordonnées se
* modifient avec `members.edit` ; le NIR, l'IBAN et le BIC exigent en plus de
* pouvoir les **lire**. Les fondre dans une seule action laisserait un profil
* habilité à modifier mais pas à lire renvoyer des champs vides — et effacer
* ainsi ce qui ne lui avait jamais été montré.
*/
export interface ProfileActionState {
error?: string;
ok?: boolean;
}
class ValidationError extends Error {}
/** Un champ laissé vide vaut « non renseigné », pas la chaîne vide. */
const optionalText = (max: number) =>
z
.string()
.trim()
.max(max)
.transform((value) => (value === '' ? null : value));
/** Une valeur vide vaut « non renseigné » ; toute autre doit être connue. */
const optionalEnum = <T extends readonly string[]>(values: T) =>
z
.string()
.trim()
.refine(
(value) => value === '' || (values as readonly string[]).includes(value),
'Valeur inconnue',
)
.transform((value) => (value === '' ? null : value));
const profileInput = z.object({
membershipId: z.string().min(1),
gender: optionalEnum(['FEMALE', 'MALE', 'UNSPECIFIED'] as const),
firstName: z.string().trim().min(1, 'Prénom requis').max(80),
birthName: optionalText(80),
lastName: z.string().trim().min(1, 'Nom requis').max(80),
birthDate: optionalText(10),
birthPlace: optionalText(120),
birthCountry: optionalText(80),
birthDepartment: optionalText(80),
nationality: optionalText(80),
maritalStatus: optionalEnum([
'SINGLE',
'MARRIED',
'PACS',
'COHABITING',
'DIVORCED',
'WIDOWED',
] as const),
dependents: z
.string()
.trim()
.refine(
(value) => value === '' || /^\d{1,2}$/.test(value),
'Nombre de personnes à charge invalide',
)
.transform((value) => (value === '' ? null : Number(value))),
personalEmail: z
.string()
.trim()
.max(180)
.refine(
(value) => value === '' || z.string().email().safeParse(value).success,
'Adresse électronique invalide',
)
.transform((value) => (value === '' ? null : value)),
phone: optionalText(30),
landline: optionalText(30),
smsSchedules: z.boolean(),
addressLine1: optionalText(180),
addressLine2: optionalText(180),
postalCode: optionalText(12),
city: optionalText(120),
country: optionalText(80),
emergencyContactName: optionalText(120),
emergencyContactPhone: optionalText(30),
});
export async function updateProfileAction(
_previous: ProfileActionState,
formData: FormData,
): Promise<ProfileActionState> {
const parsed = profileInput.safeParse({
...Object.fromEntries(
[
'membershipId',
'gender',
'firstName',
'birthName',
'lastName',
'birthDate',
'birthPlace',
'birthCountry',
'birthDepartment',
'nationality',
'maritalStatus',
'dependents',
'personalEmail',
'phone',
'landline',
'addressLine1',
'addressLine2',
'postalCode',
'city',
'country',
'emergencyContactName',
'emergencyContactPhone',
].map((key) => [key, formData.get(key) ?? '']),
),
// Une case décochée n'est pas envoyée : son absence vaut « non », et la
// lire comme une chaîne vide en ferait une valeur inconnue.
smsSchedules: formData.get('smsSchedules') === 'on',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const { membershipId, birthDate, ...rest } = parsed.data;
// Une date de naissance postérieure à aujourd'hui n'est pas une faute de
// frappe anodine : elle fausse l'âge, et l'âge commande l'emploi des mineurs.
let parsedBirthDate: Date | null = null;
if (birthDate) {
const candidate = new Date(`${birthDate}T00:00:00Z`);
if (Number.isNaN(candidate.getTime()) || candidate > new Date()) {
return { error: 'Date de naissance invalide.' };
}
parsedBirthDate = candidate;
}
try {
await mutate('members.edit', async (db, actor) => {
const existing = await db.employeeProfile.findUnique({
where: { membershipId },
select: { firstName: true, lastName: true },
});
if (!existing) throw new ValidationError('Dossier introuvable.');
await db.employeeProfile.update({
where: { membershipId },
data: { ...rest, birthDate: parsedBirthDate } as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'membership.profile.update',
entityType: 'EmployeeProfile',
entityId: membershipId,
before: existing,
after: { firstName: rest.firstName, lastName: rest.lastName },
});
});
} catch (error) {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof AuthorizationError) {
return { error: 'Vous n’avez pas le droit de modifier ce dossier.' };
}
throw error;
}
revalidatePath('/equipe');
revalidatePath(`/equipe/${membershipId}`);
return { ok: true };
}
const sensitiveInput = z.object({
membershipId: z.string().min(1),
socialSecurityNumber: optionalText(20),
iban: optionalText(40),
bic: optionalText(15),
});
/**
* Écrit les trois champs chiffrés au repos.
*
* Le NIR est contrôlé sur sa forme — quinze chiffres — et non sur sa clé : un
* refus fondé sur un calcul que le saisisseur ne peut pas refaire à la main
* bloquerait des dossiers valides sans jamais dire lesquels.
*/
export async function updateSensitiveAction(
_previous: ProfileActionState,
formData: FormData,
): Promise<ProfileActionState> {
const parsed = sensitiveInput.safeParse({
membershipId: formData.get('membershipId') ?? '',
socialSecurityNumber: formData.get('socialSecurityNumber') ?? '',
iban: formData.get('iban') ?? '',
bic: formData.get('bic') ?? '',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const { membershipId } = parsed.data;
const nir = parsed.data.socialSecurityNumber?.replace(/\s/g, '') ?? null;
const iban = parsed.data.iban?.replace(/\s/g, '').toUpperCase() ?? null;
const bic = parsed.data.bic?.replace(/\s/g, '').toUpperCase() ?? null;
if (nir && !/^\d{15}$/.test(nir)) {
return { error: 'Le numéro de sécurité sociale compte quinze chiffres.' };
}
if (iban && !/^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/.test(iban)) {
return { error: 'IBAN invalide.' };
}
try {
await mutate('members.edit', async (db, actor) => {
// La capacité de lecture est exigée pour écrire : sans elle, le
// formulaire n'aurait pas pu afficher la valeur en place, et l'envoyer
// reviendrait à écraser à l'aveugle.
if (!can(actor, 'members.documents.view')) {
throw new AuthorizationError('members.documents.view');
}
await db.employeeProfile.update({
where: { membershipId },
data: {
socialSecurityNumberEnc: encryptOptional(nir),
ibanEnc: encryptOptional(iban),
bicEnc: encryptOptional(bic),
} as never,
});
// Le journal ne retient que **ce qui a été renseigné**, jamais la valeur :
// il se relit, s'exporte et se conserve longtemps.
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'membership.profile.sensitive.update',
entityType: 'EmployeeProfile',
entityId: membershipId,
after: {
socialSecurityNumber: nir ? 'renseigné' : 'effacé',
iban: iban ? 'renseigné' : 'effacé',
bic: bic ? 'renseigné' : 'effacé',
},
});
});
} catch (error) {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof AuthorizationError) {
return {
error: 'Vous n’avez pas le droit de modifier ces données protégées.',
};
}
throw error;
}
revalidatePath(`/equipe/${membershipId}`);
return { ok: true };
}
+520 -34
View File
@@ -1,3 +1,5 @@
import { cache } from 'react';
import { can } from '@/domain/access/authorize';
import {
invitationState,
@@ -21,6 +23,8 @@ export interface EmployeeListRow {
firstName: string;
lastName: string;
email: string | null;
/** Téléphone du dossier : la colonne « Mobile » de l'annuaire. */
phone: string | null;
status: string;
hasAccount: boolean;
roleName: string;
@@ -32,6 +36,36 @@ export interface EmployeeListRow {
forfaitJours: boolean;
} | null;
locationName: string | null;
teamName: string | null;
invitationState: InvitationState | null;
}
/**
* Critères de l'annuaire.
*
* Tous facultatifs, et tous appliqués **en base** plutôt qu'après lecture :
* un effectif de plusieurs centaines de dossiers ne se filtre pas en mémoire à
* chaque affichage.
*/
export interface EmployeeFilters {
/** Prénom, nom ou matricule. */
q?: string;
locationId?: string;
roleId?: string;
contractType?: string;
/** `active` par défaut : un archivé n'a rien à faire dans l'effectif courant. */
presence?: 'active' | 'archived' | 'all';
sort?: 'name' | 'number';
}
export interface EmployeeDirectory {
rows: EmployeeListRow[];
/** De quoi peupler les listes déroulantes, sans seconde requête à l'écran. */
locations: Array<{ id: string; name: string }>;
roles: Array<{ id: string; name: string }>;
contractTypes: Array<{ value: string; label: string }>;
/** Effectif total, avant filtres : « 12 sur 87 » se lit autrement que « 12 ». */
total: number;
}
const CONTRACT_LABELS: Record<string, string> = {
@@ -50,22 +84,104 @@ export function contractLabel(type: string): string {
return CONTRACT_LABELS[type] ?? type;
}
export async function listEmployees(): Promise<EmployeeListRow[]> {
export async function listEmployees(
filters: EmployeeFilters = {},
): Promise<EmployeeDirectory> {
return query('members.view', async (db) => {
const memberships = await db.membership.findMany({
where: { archivedAt: null },
include: {
profile: { select: { firstName: true, lastName: true } },
user: { select: { email: true } },
role: { select: { name: true } },
contracts: {
where: { status: 'ACTIVE' },
orderBy: { startDate: 'desc' },
take: 1,
const now = new Date();
const search = filters.q?.trim();
// Un archivé n'appartient plus à l'effectif : il faut le demander.
const presence =
filters.presence === 'all'
? {}
: filters.presence === 'archived'
? { archivedAt: { not: null } }
: { archivedAt: null };
// L'établissement et le type de contrat sont portés par le contrat en
// cours, pas par le membership : le filtre passe donc par la relation.
const contractFilter =
filters.locationId || filters.contractType
? {
contracts: {
some: {
status: 'ACTIVE' as const,
...(filters.locationId
? { locationId: filters.locationId }
: {}),
...(filters.contractType
? { contractType: filters.contractType as never }
: {}),
},
},
}
: {};
const searchFilter = search
? {
OR: [
{ employeeNumber: { contains: search, mode: 'insensitive' as const } },
{
profile: {
is: {
OR: [
{ firstName: { contains: search, mode: 'insensitive' as const } },
{ lastName: { contains: search, mode: 'insensitive' as const } },
],
},
},
},
],
}
: {};
const where = {
...presence,
...contractFilter,
...searchFilter,
...(filters.roleId ? { roleId: filters.roleId } : {}),
};
const [memberships, total, locations, roles] = await Promise.all([
db.membership.findMany({
where,
include: {
profile: {
select: { firstName: true, lastName: true, phone: true },
},
user: { select: { email: true } },
role: { select: { name: true } },
teams: {
select: {
isPrimary: true,
team: { select: { name: true } },
},
},
contracts: {
where: { status: 'ACTIVE' },
orderBy: { startDate: 'desc' },
take: 1,
},
invitations: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { expiresAt: true, acceptedAt: true, revokedAt: true },
},
},
},
orderBy: { employeeNumber: 'asc' },
});
orderBy: { employeeNumber: 'asc' },
}),
db.membership.count({ where: { archivedAt: null } }),
db.location.findMany({
where: { archivedAt: null },
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
db.role.findMany({
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
]);
const locationIds = [
...new Set(
@@ -74,20 +190,28 @@ export async function listEmployees(): Promise<EmployeeListRow[]> {
.map((contract) => contract.locationId),
),
];
const locations = await db.location.findMany({
const contractLocations = await db.location.findMany({
where: { id: { in: locationIds } },
select: { id: true, name: true },
});
const locationNames = new Map(locations.map((l) => [l.id, l.name]));
const locationNames = new Map(
contractLocations.map((entry) => [entry.id, entry.name]),
);
return memberships.map((membership) => {
const rows: EmployeeListRow[] = memberships.map((membership) => {
const contract = membership.contracts[0];
const team =
membership.teams.find((entry) => entry.isPrimary) ??
membership.teams[0];
const invitation = membership.invitations[0];
return {
id: membership.id,
employeeNumber: membership.employeeNumber,
firstName: membership.profile?.firstName ?? '',
lastName: membership.profile?.lastName ?? membership.employeeNumber,
email: membership.user?.email ?? null,
phone: membership.profile?.phone ?? null,
status: membership.status,
hasAccount: membership.userId !== null,
roleName: membership.role.name,
@@ -103,37 +227,113 @@ export async function listEmployees(): Promise<EmployeeListRow[]> {
locationName: contract
? (locationNames.get(contract.locationId) ?? null)
: null,
teamName: team?.team.name ?? null,
invitationState: invitation ? invitationState(invitation, now) : null,
};
});
// Le tri par nom se fait ici : trier en base sur le nom du dossier
// demanderait une jointure ordonnée, et « Étienne » se range après
// « Etchegaray » pour Postgres, avant pour un lecteur français.
if (filters.sort !== 'number') {
rows.sort((a, b) =>
`${a.lastName} ${a.firstName}`.localeCompare(
`${b.lastName} ${b.firstName}`,
'fr',
),
);
}
return {
rows,
locations,
roles,
contractTypes: Object.entries(CONTRACT_LABELS).map(([value, label]) => ({
value,
label,
})),
total,
};
});
}
export interface EmployeeDetail extends EmployeeListRow {
profile: {
birthDate: Date | null;
city: string | null;
phone: string | null;
personalEmail: string | null;
/** Chiffré au repos, déchiffré seulement pour qui a le droit de le lire. */
/**
* Dossier personnel tel qu'il est saisi.
*
* Les trois champs chiffrés sont regroupés à part : ils ne sont pas seulement
* masqués à l'affichage quand la capacité manque, ils sont **absents** de la
* réponse, et l'écran de saisie qui les porte n'est alors pas rendu du tout.
* Un formulaire affiché vide renverrait des champs vides, et effacerait ce
* qu'il n'avait pas le droit de lire.
*/
export interface EmployeeProfileDetail {
gender: string | null;
firstName: string;
birthName: string | null;
lastName: string;
birthDate: Date | null;
birthPlace: string | null;
birthCountry: string | null;
birthDepartment: string | null;
nationality: string | null;
maritalStatus: string | null;
dependents: number | null;
addressLine1: string | null;
addressLine2: string | null;
postalCode: string | null;
city: string | null;
country: string | null;
phone: string | null;
landline: string | null;
smsSchedules: boolean;
personalEmail: string | null;
emergencyContactName: string | null;
emergencyContactPhone: string | null;
/** Chiffrés au repos, déchiffrés seulement pour qui a le droit de les lire. */
sensitive: {
socialSecurityNumber: string | null;
iban: string | null;
bic: string | null;
} | null;
}
export interface EmployeeDetail extends EmployeeListRow {
/** Ce que porte le bandeau de la fiche, au-dessus des onglets. */
headline: {
jobTitle: string | null;
lineManagerName: string | null;
contractEnd: Date | null;
};
profile: EmployeeProfileDetail | null;
contracts: Array<{
id: string;
type: string;
label: string;
startDate: Date;
endDate: Date | null;
trialEndDate: Date | null;
weeklyHours: string;
forfaitJours: boolean;
forfaitDaysPerYear: string | null;
forfaitAgreementRef: string | null;
isModulated: boolean;
classification: string | null;
coefficient: string | null;
locationName: string | null;
status: string;
endReason: string | null;
/** Absent quand `members.salary.view` manque. */
monthlySalary: string | null;
amendments: Array<{ id: string; effectiveDate: Date; reason: string | null }>;
amendments: Array<{
id: string;
effectiveDate: Date;
reason: string | null;
changes: unknown;
}>;
}>;
canSeeSalary: boolean;
canInvite: boolean;
canEdit: boolean;
/** Dernière invitation émise, quel que soit son sort. */
invitation: {
state: InvitationState;
@@ -143,7 +343,14 @@ export interface EmployeeDetail extends EmployeeListRow {
} | null;
}
export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
/**
* Mémorisé le temps d'une requête : la fiche est désormais un gabarit à
* onglets, et le gabarit comme l'onglet ont besoin du même dossier. Sans cela,
* chaque affichage le lirait deux fois.
*/
export const getEmployee = cache(async function getEmployee(
id: string,
): Promise<EmployeeDetail | null> {
return query('members.view', async (db, actor) => {
const canSeeSalary = can(actor, 'members.salary.view');
const canSeeDocuments = can(actor, 'members.documents.view');
@@ -154,6 +361,9 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
user: { select: { email: true } },
role: { select: { name: true } },
profile: true,
teams: {
select: { isPrimary: true, team: { select: { name: true } } },
},
contracts: {
orderBy: { startDate: 'desc' },
include: {
@@ -168,13 +378,45 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
const active = membership.contracts.find(
(contract) => contract.status === 'ACTIVE',
);
// Tous les établissements portés par l'historique, et pas seulement celui
// du contrat en cours : un contrat terminé ailleurs reste à lire.
const contractLocations = await db.location.findMany({
where: {
id: {
in: [
...new Set(membership.contracts.map((entry) => entry.locationId)),
],
},
},
select: { id: true, name: true },
});
const locationNames = new Map(
contractLocations.map((entry) => [entry.id, entry.name]),
);
const location = active
? await db.location.findUnique({
where: { id: active.locationId },
? { name: locationNames.get(active.locationId) ?? null }
: null;
// L'emploi est porté par le contrat, pas par le dossier : il change par
// avenant, et c'est l'avenant qui fait foi devant l'inspection.
const jobTitle = active?.jobTitleId
? await db.jobTitle.findUnique({
where: { id: active.jobTitleId },
select: { name: true },
})
: null;
const lineManager = membership.lineManagerId
? await db.membership.findUnique({
where: { id: membership.lineManagerId },
select: {
employeeNumber: true,
profile: { select: { firstName: true, lastName: true } },
},
})
: null;
// La plus récente, pas la seule en attente : « invitation expirée le 3 »
// est une information utile, et la masquer laisserait croire qu'aucune
// n'a jamais été envoyée.
@@ -196,10 +438,19 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
firstName: membership.profile?.firstName ?? '',
lastName: membership.profile?.lastName ?? membership.employeeNumber,
email: membership.user?.email ?? null,
phone: membership.profile?.phone ?? null,
status: membership.status,
hasAccount: membership.userId !== null,
roleName: membership.role.name,
locationName: location?.name ?? null,
teamName:
(
membership.teams.find((entry) => entry.isPrimary) ??
membership.teams[0]
)?.team.name ?? null,
invitationState: lastInvitation
? invitationState(lastInvitation, new Date())
: null,
contract: active
? {
type: active.contractType,
@@ -209,18 +460,47 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
forfaitJours: active.workTimeArrangement === 'FORFAIT_JOURS',
}
: null,
headline: {
jobTitle: jobTitle?.name ?? null,
lineManagerName: lineManager
? `${lineManager.profile?.firstName ?? ''} ${lineManager.profile?.lastName ?? lineManager.employeeNumber}`.trim()
: null,
contractEnd: active?.endDate ?? null,
},
profile: membership.profile
? {
gender: membership.profile.gender,
firstName: membership.profile.firstName,
birthName: membership.profile.birthName,
lastName: membership.profile.lastName,
birthDate: membership.profile.birthDate,
birthPlace: membership.profile.birthPlace,
birthCountry: membership.profile.birthCountry,
birthDepartment: membership.profile.birthDepartment,
nationality: membership.profile.nationality,
maritalStatus: membership.profile.maritalStatus,
dependents: membership.profile.dependents,
addressLine1: membership.profile.addressLine1,
addressLine2: membership.profile.addressLine2,
postalCode: membership.profile.postalCode,
city: membership.profile.city,
country: membership.profile.country,
phone: membership.profile.phone,
landline: membership.profile.landline,
smsSchedules: membership.profile.smsSchedules,
personalEmail: membership.profile.personalEmail,
// Le NIR et l'IBAN ne sont déchiffrés que pour un profil habilité.
socialSecurityNumber: canSeeDocuments
? decryptOptional(membership.profile.socialSecurityNumberEnc)
: null,
iban: canSeeDocuments
? decryptOptional(membership.profile.ibanEnc)
emergencyContactName: membership.profile.emergencyContactName,
emergencyContactPhone: membership.profile.emergencyContactPhone,
// Le NIR, l'IBAN et le BIC ne sont déchiffrés que pour un profil
// habilité — sinon le bloc entier reste absent de la réponse.
sensitive: canSeeDocuments
? {
socialSecurityNumber: decryptOptional(
membership.profile.socialSecurityNumberEnc,
),
iban: decryptOptional(membership.profile.ibanEnc),
bic: decryptOptional(membership.profile.bicEnc),
}
: null,
}
: null,
@@ -230,10 +510,17 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
label: contractLabel(contract.contractType),
startDate: contract.startDate,
endDate: contract.endDate,
trialEndDate: contract.trialEndDate,
weeklyHours: contract.weeklyHours.toString(),
forfaitJours: contract.workTimeArrangement === 'FORFAIT_JOURS',
forfaitDaysPerYear: contract.forfaitDaysPerYear?.toString() ?? null,
forfaitAgreementRef: contract.forfaitAgreementRef,
isModulated: contract.isModulated,
classification: contract.classification,
coefficient: contract.coefficient,
locationName: locationNames.get(contract.locationId) ?? null,
status: contract.status,
endReason: contract.endReason,
monthlySalary: canSeeSalary
? (contract.monthlySalary?.toString() ?? null)
: null,
@@ -241,10 +528,12 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
id: amendment.id,
effectiveDate: amendment.effectiveDate,
reason: amendment.reason,
changes: amendment.changes,
})),
})),
canSeeSalary,
canInvite: can(actor, 'members.invite'),
canEdit: can(actor, 'members.edit'),
invitation: lastInvitation
? {
state: invitationState(lastInvitation, new Date()),
@@ -255,4 +544,201 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
: null,
};
});
});
/**
* Établissements où poser un contrat.
*
* Une liste à part de celle des réglages : ouvrir un contrat n'exige pas
* d'administrer les établissements, et emprunter `settings.access` fermerait
* la création à un gestionnaire de paie qui n'a rien à y administrer.
*/
export interface ContractLocation {
id: string;
name: string;
teams: Array<{ id: string; name: string }>;
}
export const listContractLocations = cache(
async function listContractLocations(): Promise<ContractLocation[]> {
return query('members.contract.create', async (db) =>
db.location.findMany({
where: { archivedAt: null },
select: {
id: true,
name: true,
teams: {
where: { archivedAt: null },
select: { id: true, name: true },
orderBy: { position: 'asc' },
},
},
orderBy: { name: 'asc' },
}),
);
},
);
/**
* Ce que le panneau d'embauche propose en plus des établissements.
*
* Lu avec `members.view` : ce sont des noms déjà affichés par l'annuaire, et
* une politique RTT n'est pas une donnée sensible.
*/
export interface HiringOptions {
managers: Array<{ id: string; name: string }>;
rttPolicies: Array<{ id: string; name: string }>;
}
export const listHiringOptions = cache(
async function listHiringOptions(): Promise<HiringOptions> {
return query('members.view', async (db) => {
const [managers, rttPolicies] = await Promise.all([
db.membership.findMany({
where: { archivedAt: null },
select: {
id: true,
employeeNumber: true,
profile: { select: { firstName: true, lastName: true } },
},
}),
db.rttPolicy.findMany({
where: { status: 'ACTIVE' },
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
]);
return {
managers: managers
.map((manager) => ({
id: manager.id,
name: `${manager.profile?.firstName ?? ''} ${manager.profile?.lastName ?? manager.employeeNumber}`.trim(),
}))
.sort((a, b) => a.name.localeCompare(b.name, 'fr')),
rttPolicies,
};
});
},
);
/** Rattachement et périmètre — onglet « Planification et accès ». */
export interface MemberPlacement {
/** Établissement porté par le contrat en cours. Il ne se change qu'en avenant. */
contractLocationName: string | null;
teams: Array<{ id: string; name: string; locationName: string; isPrimary: boolean }>;
/** Vrai quand le périmètre couvre tout le compte, ouvertures futures comprises. */
allLocations: boolean;
scopedLocations: string[];
scopedTeams: string[];
/** Les mêmes, par identifiant : ce que les formulaires cochent. */
scopedLocationIds: string[];
teamIds: string[];
primaryTeamId: string | null;
}
export const getMemberPlacement = cache(async function getMemberPlacement(
id: string,
): Promise<MemberPlacement | null> {
return query('members.view', async (db) => {
const membership = await db.membership.findUnique({
where: { id },
select: {
contracts: {
where: { status: 'ACTIVE' },
orderBy: { startDate: 'desc' },
take: 1,
select: { locationId: true },
},
teams: {
select: {
isPrimary: true,
team: {
select: {
id: true,
name: true,
location: { select: { name: true } },
},
},
},
},
scopes: {
select: {
allLocations: true,
locationId: true,
location: { select: { name: true } },
team: { select: { name: true } },
},
},
},
});
if (!membership) return null;
const contractLocationId = membership.contracts[0]?.locationId;
const contractLocation = contractLocationId
? await db.location.findUnique({
where: { id: contractLocationId },
select: { name: true },
})
: null;
return {
contractLocationName: contractLocation?.name ?? null,
teams: membership.teams.map((member) => ({
id: member.team.id,
name: member.team.name,
locationName: member.team.location.name,
isPrimary: member.isPrimary,
})),
allLocations: membership.scopes.some((scope) => scope.allLocations),
scopedLocations: [
...new Set(
membership.scopes
.map((scope) => scope.location?.name)
.filter((name): name is string => Boolean(name)),
),
],
scopedTeams: [
...new Set(
membership.scopes
.map((scope) => scope.team?.name)
.filter((name): name is string => Boolean(name)),
),
],
scopedLocationIds: [
...new Set(
membership.scopes
.map((scope) => scope.locationId)
.filter((id): id is string => Boolean(id)),
),
],
teamIds: membership.teams.map((member) => member.team.id),
primaryTeamId:
membership.teams.find((member) => member.isPrimary)?.team.id ?? null,
};
});
});
/** Établissements et équipes où rattacher quelqu'un. */
export const listPlacementOptions = cache(
async function listPlacementOptions(): Promise<ContractLocation[]> {
// `members.view` suffit : ces noms sont déjà affichés par l'annuaire, et
// exiger davantage fermerait l'écran à qui a le droit de le lire.
return query('members.view', async (db) =>
db.location.findMany({
where: { archivedAt: null },
select: {
id: true,
name: true,
teams: {
where: { archivedAt: null },
select: { id: true, name: true },
orderBy: { position: 'asc' },
},
},
orderBy: { name: 'asc' },
}),
);
},
);
+164
View File
@@ -0,0 +1,164 @@
import 'server-only';
import { PDFDocument, StandardFonts, rgb, type PDFFont } from 'pdf-lib';
import { RUP_COLUMNS, type RupPerson } from '@/domain/legal/rup';
import type { RupData } from '@/server/employees/rup';
/**
* Rendu du registre unique du personnel.
*
* Aucune forme n'est imposée par la loi : ce qui compte est que les mentions y
* soient, lisibles, et que le document puisse être présenté tel quel en cas de
* contrôle. D'où un tableau paysage sobre plutôt qu'une mise en page.
*
* Helvetica et l'encodage WinAnsi : les accents français y sont, et une police
* embarquée alourdirait le fichier sans rien apporter à un tableau.
*/
const A4_LANDSCAPE: [number, number] = [841.89, 595.28];
const MARGIN = 32;
const ROW_HEIGHT = 18;
const FONT_SIZE = 8;
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'short' });
function cell(person: RupPerson, key: keyof RupPerson): string {
const value = person[key];
if (value === null || value === '') return '—';
if (value instanceof Date) return dateFormat.format(value);
return String(value);
}
/** Coupe au lieu de déborder : une colonne qui empiète sur la suivante rend
* les deux illisibles. */
function fit(text: string, font: PDFFont, size: number, width: number): string {
if (font.widthOfTextAtSize(text, size) <= width) return text;
let cut = text;
while (cut.length > 1 && font.widthOfTextAtSize(`${cut}…`, size) > width) {
cut = cut.slice(0, -1);
}
return `${cut}…`;
}
export async function renderRegisterPdf(data: RupData): Promise<Uint8Array> {
const pdf = await PDFDocument.create();
pdf.setTitle(`Registre unique du personnel — ${data.locationName}`);
pdf.setCreator('PlanFlow');
const regular = await pdf.embedFont(StandardFonts.Helvetica);
const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
const usable = A4_LANDSCAPE[0] - MARGIN * 2;
const widths = RUP_COLUMNS.map((column) => column.width * usable);
const ink = rgb(0.11, 0.11, 0.11);
const faint = rgb(0.62, 0.62, 0.62);
const rule = rgb(0.85, 0.85, 0.85);
const printedOn = dateFormat.format(new Date());
let page = pdf.addPage(A4_LANDSCAPE);
let y = A4_LANDSCAPE[1] - MARGIN;
const header = () => {
page.drawText('Registre unique du personnel', {
x: MARGIN,
y: y - 12,
size: 14,
font: bold,
color: ink,
});
page.drawText(
[
data.locationName,
data.siret ? `SIRET ${data.siret}` : null,
`édité le ${printedOn}`,
]
.filter(Boolean)
.join(' · '),
{ x: MARGIN, y: y - 28, size: 9, font: regular, color: faint },
);
y -= 48;
let x = MARGIN;
RUP_COLUMNS.forEach((column, index) => {
page.drawText(column.label, {
x,
y: y - 10,
size: FONT_SIZE,
font: bold,
color: ink,
});
x += widths[index] as number;
});
y -= 16;
page.drawLine({
start: { x: MARGIN, y },
end: { x: MARGIN + usable, y },
thickness: 0.7,
color: rule,
});
y -= 4;
};
header();
for (const person of data.people) {
// Nouvelle page avant d'écrire, pas après : une ligne à moitié sous la
// marge est pire qu'une page de plus.
if (y - ROW_HEIGHT < MARGIN + 24) {
page = pdf.addPage(A4_LANDSCAPE);
y = A4_LANDSCAPE[1] - MARGIN;
header();
}
let x = MARGIN;
RUP_COLUMNS.forEach((column, index) => {
const width = widths[index] as number;
const value = cell(person, column.key);
page.drawText(fit(value, regular, FONT_SIZE, width - 6), {
x,
y: y - 12,
size: FONT_SIZE,
font: regular,
color: value === '—' ? faint : ink,
});
x += width;
});
y -= ROW_HEIGHT;
page.drawLine({
start: { x: MARGIN, y: y + 4 },
end: { x: MARGIN + usable, y: y + 4 },
thickness: 0.4,
color: rule,
});
}
if (data.people.length === 0) {
page.drawText('Aucun salarié rattaché à cet établissement.', {
x: MARGIN,
y: y - 12,
size: 9,
font: regular,
color: faint,
});
}
// Mention de pied : un registre incomplet reste un registre, mais celui qui
// le présente doit savoir ce qui lui manque avant l'inspection.
const pages = pdf.getPages();
pages.forEach((current, index) => {
current.drawText(
[
`Page ${index + 1} sur ${pages.length}`,
`${data.people.length} ligne${data.people.length > 1 ? 's' : ''}`,
data.incomplete > 0
? `${data.incomplete} dossier${data.incomplete > 1 ? 's' : ''} incomplet${data.incomplete > 1 ? 's' : ''}`
: 'Mentions complètes',
].join(' · '),
{ x: MARGIN, y: MARGIN - 12, size: 7, font: regular, color: faint },
);
});
return pdf.save();
}
+174
View File
@@ -0,0 +1,174 @@
import 'server-only';
import { cache } from 'react';
import { genderShort } from '@/domain/hr/civil-status';
import type { RupPerson } from '@/domain/legal/rup';
import { peopleWithGaps } from '@/domain/legal/rup';
import { query } from '@/server/context';
import { contractLabel } from '@/server/employees/queries';
/**
* Registre unique du personnel — lecture.
*
* Le registre porte **tous** ceux qui ont été employés dans l'établissement,
* y compris les partis : c'est précisément l'historique que l'inspection vient
* chercher. Filtrer sur les présents en ferait un trombinoscope.
*/
export interface RupData {
locationId: string;
locationName: string;
siret: string | null;
people: RupPerson[];
/** Nombre de salariés à qui il manque au moins une mention obligatoire. */
incomplete: number;
}
export const getRegisterData = cache(async function getRegisterData(
locationId: string,
): Promise<RupData | null> {
return query('members.register.export', async (db) => {
const location = await db.location.findUnique({
where: { id: locationId },
select: { id: true, name: true, siret: true },
});
if (!location) return null;
const contracts = await db.userContract.findMany({
where: { locationId },
orderBy: { startDate: 'asc' },
include: {
membership: {
select: {
profile: {
select: {
firstName: true,
lastName: true,
birthName: true,
gender: true,
nationality: true,
birthDate: true,
},
},
},
},
},
});
const jobTitleIds = [
...new Set(
contracts
.map((contract) => contract.jobTitleId)
.filter((id): id is string => Boolean(id)),
),
];
const jobTitles = await db.jobTitle.findMany({
where: { id: { in: jobTitleIds } },
select: { id: true, name: true },
});
const jobTitleNames = new Map(
jobTitles.map((entry) => [entry.id, entry.name]),
);
// Une ligne par contrat, pas par personne : un salarié réembauché a deux
// entrées et deux sorties, et les fondre effacerait l'interruption.
const people: RupPerson[] = contracts.map((contract) => ({
// Le registre se tient au nom de naissance : c'est celui de l'état civil,
// et le nom d'usage peut changer sans que la personne change.
lastName:
contract.membership.profile?.birthName ??
contract.membership.profile?.lastName ??
'',
firstName: contract.membership.profile?.firstName ?? '',
sex: genderShort(contract.membership.profile?.gender ?? null),
nationality: contract.membership.profile?.nationality ?? null,
birthDate: contract.membership.profile?.birthDate ?? null,
jobTitle: contract.jobTitleId
? (jobTitleNames.get(contract.jobTitleId) ?? null)
: null,
qualification: contract.classification,
contractLabel: contractLabel(contract.contractType),
entryDate: contract.startDate,
exitDate: contract.endDate,
}));
return {
locationId: location.id,
locationName: location.name,
siret: location.siret,
people,
incomplete: peopleWithGaps(people),
};
});
});
/** Établissements pour lesquels un registre peut être tiré. */
export const listRegisterLocations = cache(
async function listRegisterLocations(): Promise<
Array<{ id: string; name: string; incomplete: number; total: number }>
> {
return query('members.register.export', async (db) => {
const locations = await db.location.findMany({
where: { archivedAt: null },
select: { id: true, name: true },
orderBy: { name: 'asc' },
});
// Le décompte des manques est calculé ici pour que l'écran puisse
// avertir **avant** le téléchargement : découvrir un registre incomplet
// en l'ouvrant, c'est le découvrir devant l'inspection.
return Promise.all(
locations.map(async (location) => {
const contracts = await db.userContract.findMany({
where: { locationId: location.id },
select: {
classification: true,
jobTitleId: true,
contractType: true,
startDate: true,
endDate: true,
membership: {
select: {
profile: {
select: {
firstName: true,
lastName: true,
birthName: true,
gender: true,
nationality: true,
birthDate: true,
},
},
},
},
},
});
const people: RupPerson[] = contracts.map((contract) => ({
lastName:
contract.membership.profile?.birthName ??
contract.membership.profile?.lastName ??
'',
firstName: contract.membership.profile?.firstName ?? '',
sex: genderShort(contract.membership.profile?.gender ?? null),
nationality: contract.membership.profile?.nationality ?? null,
birthDate: contract.membership.profile?.birthDate ?? null,
jobTitle: contract.jobTitleId ? 'x' : null,
qualification: contract.classification,
contractLabel: contractLabel(contract.contractType),
entryDate: contract.startDate,
exitDate: contract.endDate,
}));
return {
id: location.id,
name: location.name,
incomplete: peopleWithGaps(people),
total: people.length,
};
}),
);
});
},
);
+232
View File
@@ -0,0 +1,232 @@
import { inScope } from '@/domain/access/authorize';
import { shiftMinutes } from '@/domain/counters/week';
import {
absenteeism,
headcount,
turnoverRate,
type AbsenceInput,
type HeadcountInput,
} from '@/domain/hr/indicators';
import {
formatMonthParam,
monthDates,
monthLabel,
previousMonth,
type Month,
} from '@/domain/planning/month';
import { query } from '@/server/context';
/**
* Analyses RH — PLAN.md §9, `/rh/analyses/{effectifs,heures,absences}`.
*
* Une série mensuelle, pas trois écrans qui recalculent chacun leur version.
* Les trois analyses lisent le même tableau : c'est ce qui garantit qu'un
* effectif moyen affiché en « effectifs » soit le même que le dénominateur du
* taux d'absentéisme affiché en « absences ». Deux calculs séparés finiraient
* par diverger d'un arrondi, et l'écart se plaide mal devant un CSE.
*
* Les indicateurs eux-mêmes viennent du domaine, testés aux bornes. Ce module
* ne fait que rassembler les données et boucler sur les mois.
*/
export interface MonthPoint {
month: Month;
label: string;
param: string;
closingHeadcount: number;
averageHeadcount: number;
entries: number;
exits: number;
turnoverRate: number | null;
absenceDays: number;
sickDays: number;
absenteeismRate: number | null;
/** Minutes planifiées, pauses déduites. */
plannedMinutes: number;
/** Minutes réellement travaillées quand elles ont été saisies. */
actualMinutes: number;
theoreticalWorkedDays: number;
}
export interface AnalyticsSeries {
location: { id: string; name: string } | null;
locations: Array<{ id: string; name: string }>;
/** Du plus ancien au plus récent : un graphique se lit dans ce sens. */
points: MonthPoint[];
}
/** Jours ouvrables d'un mois — base théorique de l'absentéisme (dimanche exclu). */
function workingDaysIn(dates: string[]): number {
return dates.filter(
(date) => new Date(`${date}T00:00:00Z`).getUTCDay() !== 0,
).length;
}
function monthsEndingWith(last: Month, count: number): Month[] {
const months: Month[] = [last];
for (let index = 1; index < count; index += 1) {
months.unshift(previousMonth(months[0] as Month));
}
return months;
}
export async function getAnalyticsSeries(
lastMonth: Month,
monthCount = 12,
locationId?: string,
): Promise<AnalyticsSeries> {
return query('members.view', async (db, actor) => {
const allLocations = await db.location.findMany({
where: { archivedAt: null },
select: { id: true, name: true },
orderBy: { name: 'asc' },
});
// Le périmètre s'applique avant l'agrégation : un total consolidé ne doit
// pas laisser deviner l'effectif d'un établissement qu'on n'a pas le droit
// de lire.
const visible = allLocations.filter((location) =>
inScope(actor, { locationId: location.id }),
);
const location =
visible.find((candidate) => candidate.id === locationId) ??
visible[0] ??
null;
const months = monthsEndingWith(lastMonth, monthCount);
const firstDate = monthDates(months[0] as Month)[0] as string;
const lastDates = monthDates(months[months.length - 1] as Month);
const lastDate = lastDates[lastDates.length - 1] as string;
const locationFilter = location ? { locationId: location.id } : {};
// Une seule lecture par nature, puis un découpage en mémoire. Douze
// requêtes par indicateur donneraient le même résultat en douze fois plus
// de temps, et rendraient l'écran inutilisable sur une année glissante.
const contracts = await db.userContract.findMany({
where: locationFilter,
select: {
membershipId: true,
startDate: true,
endDate: true,
},
});
const contractInputs: HeadcountInput[] = contracts.map((contract) => ({
membershipId: contract.membershipId,
startDate: contract.startDate.toISOString().slice(0, 10),
endDate: contract.endDate?.toISOString().slice(0, 10) ?? null,
}));
const timeOffs = await db.timeOff.findMany({
where: {
status: 'ACCEPTED',
startDate: { lte: new Date(`${lastDate}T00:00:00Z`) },
endDate: { gte: new Date(`${firstDate}T00:00:00Z`) },
},
select: {
id: true,
startDate: true,
endDate: true,
countedDays: true,
absenceType: { select: { isSocialSecurity: true } },
},
});
const shifts = await db.shift.findMany({
where: {
localDate: {
gte: new Date(`${firstDate}T00:00:00Z`),
lte: new Date(`${lastDate}T00:00:00Z`),
},
},
select: {
localDate: true,
startAt: true,
endAt: true,
breakMinutes: true,
actualStartAt: true,
actualEndAt: true,
actualBreakMinutes: true,
},
});
const points = months.map((month) => {
const dates = monthDates(month);
const from = dates[0] as string;
const to = dates[dates.length - 1] as string;
const counts = headcount(contractInputs, from, to);
// Une absence à cheval sur deux mois est rattachée au mois de son début :
// `countedDays` a été figé à la décision et ne se redécoupe pas sans
// refaire le décompte des fériés, ce qui produirait un autre chiffre que
// celui écrit au registre des compteurs.
const monthAbsences: AbsenceInput[] = timeOffs
.filter((entry) => {
const start = entry.startDate.toISOString().slice(0, 10);
return start >= from && start <= to;
})
.map((entry) => ({
membershipId: entry.id,
timeOffId: entry.id,
days: Number(entry.countedDays?.toString() ?? '0'),
isSocialSecurity: entry.absenceType.isSocialSecurity,
}));
const theoreticalWorkedDays =
workingDaysIn(dates) * Math.round(counts.average);
const absence = absenteeism(monthAbsences, theoreticalWorkedDays);
const monthShifts = shifts.filter((shift) => {
const day = shift.localDate.toISOString().slice(0, 10);
return day >= from && day <= to;
});
const plannedMinutes = monthShifts.reduce(
(sum, shift) =>
sum + shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes),
0,
);
const actualMinutes = monthShifts.reduce((sum, shift) => {
if (!shift.actualStartAt || !shift.actualEndAt) {
// Sans heures réelles saisies, le prévu fait foi (PLAN.md §7.3) :
// laisser un zéro creuserait un écart qui n'existe pas.
return sum + shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes);
}
return (
sum +
shiftMinutes(
shift.actualStartAt,
shift.actualEndAt,
shift.actualBreakMinutes ?? shift.breakMinutes,
)
);
}, 0);
return {
month,
label: monthLabel(month),
param: formatMonthParam(month),
closingHeadcount: counts.closing,
averageHeadcount: counts.average,
entries: counts.entries.length,
exits: counts.exits.length,
turnoverRate: turnoverRate(counts),
absenceDays: absence.totalDays,
sickDays: absence.sickDays,
absenteeismRate: absence.rate,
plannedMinutes,
actualMinutes,
theoreticalWorkedDays,
};
});
return {
location: location ? { id: location.id, name: location.name } : null,
locations: visible,
points,
};
}, locationId ? { locationId } : undefined);
}
+23
View File
@@ -147,6 +147,22 @@ export async function getWeekBoard(
schedules.map((schedule) => [schedule.teamId, schedule]),
);
// Repos posés sur la semaine. Seul le compensateur alimente le compteur
// RC du bandeau ; le repos hebdomadaire est chargé avec, parce que les
// deux se posent sur la même table et que filtrer ici coûterait une
// seconde requête pour une distinction que le domaine fait déjà.
const rests = await db.rest.findMany({
where: {
weeklyScheduleId: { in: schedules.map((schedule) => schedule.id) },
},
select: {
weeklyScheduleId: true,
membershipId: true,
restType: true,
minutes: true,
},
});
const shifts = await db.shift.findMany({
where: {
weeklyScheduleId: { in: schedules.map((schedule) => schedule.id) },
@@ -236,6 +252,13 @@ export async function getWeekBoard(
location.timezone,
isPublished,
absenceInputs,
rests
.filter((rest) => rest.weeklyScheduleId === schedule?.id)
.map((rest) => ({
membershipId: rest.membershipId,
restType: rest.restType,
minutes: rest.minutes,
})),
);
const teamAlerts = alerts
+147
View File
@@ -0,0 +1,147 @@
import { query } from '@/server/context';
/**
* Journal d'activité — PLAN.md §9, `/rapports/activite`.
*
* Le journal d'audit est append-only, imposé par un trigger. Cet écran ne fait
* que le lire : aucune action ne s'y trouve, et c'est le point. Une piste que
* l'on peut trier, filtrer et exporter, mais jamais corriger, est la seule qui
* prouve quelque chose.
*
* L'accès passe par `audit.view`, capacité 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.
*/
export interface ActivityEntry {
id: string;
occurredAt: Date;
action: string;
entityType: string;
entityId: string;
actorName: string;
reason: string | null;
ip: string | null;
/** Champs dont la valeur a changé, sans leur contenu. */
changedFields: string[];
}
export interface ActivityFilters {
/** Préfixe d'action, par exemple `planning` ou `payroll.period`. */
domain?: string;
entityType?: string;
from?: string;
to?: string;
}
export interface ActivityPage {
entries: ActivityEntry[];
/** Domaines présents dans le journal, pour le filtre. */
domains: string[];
entityTypes: string[];
total: number;
truncated: boolean;
}
const PAGE_SIZE = 200;
/**
* Noms des champs modifiés, sans leur valeur.
*
* La piste enregistre l'avant et l'après en clair — c'est ce qui la rend
* opposable. Les afficher dans une liste, en revanche, exposerait un salaire ou
* une donnée de santé à quiconque ouvre l'écran. Le journal dit **ce qui** a
* changé ; le détail se lit sur la fiche, sous ses propres autorisations.
*/
function changedFields(before: unknown, after: unknown): string[] {
const keys = new Set<string>();
for (const value of [before, after]) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
for (const key of Object.keys(value)) keys.add(key);
}
}
return [...keys].sort();
}
export async function getActivity(
filters: ActivityFilters = {},
): Promise<ActivityPage> {
return query('audit.view', async (db) => {
const where = {
...(filters.domain ? { action: { startsWith: filters.domain } } : {}),
...(filters.entityType ? { entityType: filters.entityType } : {}),
...(filters.from || filters.to
? {
occurredAt: {
...(filters.from
? { gte: new Date(`${filters.from}T00:00:00Z`) }
: {}),
// Borne haute inclusive : filtrer « jusqu'au 31 » doit contenir
// le 31, pas s'arrêter à son premier instant.
...(filters.to
? { lte: new Date(`${filters.to}T23:59:59.999Z`) }
: {}),
},
}
: {}),
};
const [rows, total] = await Promise.all([
db.auditLog.findMany({
where,
orderBy: { occurredAt: 'desc' },
take: PAGE_SIZE,
include: {
actor: {
include: {
profile: { select: { firstName: true, lastName: true } },
},
},
},
}),
db.auditLog.count({ where }),
]);
// Les listes de filtres viennent du journal lui-même : une action ajoutée
// par un lot futur y apparaît sans qu'on ait à tenir une énumération à
// jour, et une énumération figée finirait par masquer ce qu'elle omet.
const [actions, types] = await Promise.all([
db.auditLog.findMany({
distinct: ['action'],
select: { action: true },
orderBy: { action: 'asc' },
take: 500,
}),
db.auditLog.findMany({
distinct: ['entityType'],
select: { entityType: true },
orderBy: { entityType: 'asc' },
take: 200,
}),
]);
return {
entries: rows.map((row) => ({
id: row.id,
occurredAt: row.occurredAt,
action: row.action,
entityType: row.entityType,
entityId: row.entityId,
actorName: row.actor
? `${row.actor.profile?.firstName ?? ''} ${row.actor.profile?.lastName ?? row.actor.employeeNumber}`.trim()
: 'Système',
reason: row.reason,
ip: row.ip,
changedFields: changedFields(row.before, row.after),
})),
domains: [
...new Set(
actions.map((row) => row.action.split('.')[0] ?? row.action),
),
].sort(),
entityTypes: types.map((row) => row.entityType),
total,
truncated: total > rows.length,
};
});
}
+266
View File
@@ -0,0 +1,266 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import { recordAudit } from '@/server/audit';
import { mutate, query } from '@/server/context';
/**
* Types d'absence — PLAN.md §9, réglage « /reglages/types-absence ».
*
* Ce référentiel n'est pas décoratif : chaque drapeau porte une conséquence de
* paie ou de droit.
*
* - `isSocialSecurity` isole maladie, maternité et accident du travail. Le
* journal des absences les filtre séparément, et ce sont des **données de
* santé** : la vue manager ne doit pas en montrer le motif (matrice n° 9).
* - `affectsPaidLeaveAccrual` décide si la période acquiert des congés payés.
* - `countsAsWorkTime` décide si la durée entre dans le décompte horaire, donc
* dans les seuils de convention.
* - `silaeCode` est la partie `<code>` de `AB-<code>` à l'export (§8.2). Sans
* lui, l'export de la période **échoue** plutôt que de produire un fichier
* partiel — c'est voulu.
*
* **Autorisation.** Les mutations passent par `settings.agreement.manage` et
* non par un code dédié : le catalogue de §5 n'en déclare pas, et ces drapeaux
* relèvent du paramétrage conventionnel et légal, exactement ce que cette
* capacité gouverne. Créer un code sans mandat aurait été inventer une règle.
*/
export interface AbsenceTypeRow {
id: string;
code: string;
name: string;
colorKey: string;
isPaid: boolean;
countsAsWorkTime: boolean;
affectsPaidLeaveAccrual: boolean;
isSocialSecurity: boolean;
requiresJustification: boolean;
minNoticeDays: number | null;
silaeCode: string | null;
archivedAt: Date | null;
/** Absences saisies avec ce type — un type porté ne se supprime pas. */
timeOffCount: number;
}
export async function listAbsenceTypes(
includeArchived = false,
): Promise<AbsenceTypeRow[]> {
return query('settings.access', async (db) => {
const types = await db.absenceType.findMany({
where: includeArchived ? {} : { archivedAt: null },
orderBy: { code: 'asc' },
});
const counts = await db.timeOff.groupBy({
by: ['absenceTypeId'],
_count: { _all: true },
});
const byId = new Map(
counts.map((row) => [row.absenceTypeId, row._count._all] as const),
);
return types.map((type) => ({
id: type.id,
code: type.code,
name: type.name,
colorKey: type.colorKey,
isPaid: type.isPaid,
countsAsWorkTime: type.countsAsWorkTime,
affectsPaidLeaveAccrual: type.affectsPaidLeaveAccrual,
isSocialSecurity: type.isSocialSecurity,
requiresJustification: type.requiresJustification,
minNoticeDays: type.minNoticeDays,
silaeCode: type.silaeCode,
archivedAt: type.archivedAt,
timeOffCount: byId.get(type.id) ?? 0,
}));
});
}
export interface ActionState {
error?: string;
ok?: boolean;
}
class DuplicateAbsenceType extends Error {}
const flags = {
isPaid: z.coerce.boolean(),
countsAsWorkTime: z.coerce.boolean(),
affectsPaidLeaveAccrual: z.coerce.boolean(),
isSocialSecurity: z.coerce.boolean(),
requiresJustification: z.coerce.boolean(),
minNoticeDays: z
.union([z.literal(''), z.coerce.number().int().min(0).max(365)])
.transform((value) => (value === '' ? null : value)),
silaeCode: z
.string()
.trim()
.max(20)
.transform((value) => (value === '' ? null : value)),
};
const createInput = z.object({
code: z
.string()
.trim()
.min(1, 'Code requis')
.max(20)
.regex(/^[A-Za-z0-9_-]+$/, 'Lettres, chiffres, tirets et soulignés'),
name: z.string().trim().min(1, 'Nom requis').max(120),
colorKey: z.string().trim().min(1).max(40),
...flags,
});
function readForm(formData: FormData) {
return {
code: formData.get('code'),
name: formData.get('name'),
colorKey: formData.get('colorKey') || 'neutral',
isPaid: formData.get('isPaid') === 'on',
countsAsWorkTime: formData.get('countsAsWorkTime') === 'on',
affectsPaidLeaveAccrual: formData.get('affectsPaidLeaveAccrual') === 'on',
isSocialSecurity: formData.get('isSocialSecurity') === 'on',
requiresJustification: formData.get('requiresJustification') === 'on',
minNoticeDays: (formData.get('minNoticeDays') ?? '') as string,
silaeCode: (formData.get('silaeCode') ?? '') as string,
};
}
export async function createAbsenceTypeAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = createInput.safeParse(readForm(formData));
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const code = parsed.data.code.toUpperCase();
try {
await mutate('settings.agreement.manage', async (db, actor) => {
const existing = await db.absenceType.findFirst({ where: { code } });
if (existing) throw new DuplicateAbsenceType();
const { code: _ignored, ...rest } = parsed.data;
const created = await db.absenceType.create({
data: { ...rest, code } as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'absence_type.create',
entityType: 'AbsenceType',
entityId: created.id,
after: { code: created.code, name: created.name },
});
});
} catch (error) {
if (error instanceof DuplicateAbsenceType) {
return { error: 'Ce code de type d’absence est déjà pris.' };
}
if (error instanceof AuthorizationError) {
return {
error: "Vous n'avez pas le droit de gérer les types d'absence.",
};
}
throw error;
}
revalidatePath('/reglages/types-absence');
return { ok: true };
}
const updateInput = z.object({
id: z.string().min(1),
name: z.string().trim().min(1, 'Nom requis').max(120),
colorKey: z.string().trim().min(1).max(40),
...flags,
});
export async function updateAbsenceTypeAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = updateInput.safeParse({
...readForm(formData),
id: formData.get('id'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('settings.agreement.manage', async (db, actor) => {
const { id, ...data } = parsed.data;
const before = await db.absenceType.findUnique({ where: { id } });
if (!before) throw new AuthorizationError('settings.agreement.manage');
await db.absenceType.update({ where: { id }, data });
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'absence_type.update',
entityType: 'AbsenceType',
entityId: id,
before: {
name: before.name,
isPaid: before.isPaid,
countsAsWorkTime: before.countsAsWorkTime,
affectsPaidLeaveAccrual: before.affectsPaidLeaveAccrual,
isSocialSecurity: before.isSocialSecurity,
requiresJustification: before.requiresJustification,
minNoticeDays: before.minNoticeDays,
silaeCode: before.silaeCode,
},
after: data,
});
});
} catch (error) {
if (error instanceof AuthorizationError) {
return {
error: "Vous n'avez pas le droit de gérer les types d'absence.",
};
}
throw error;
}
revalidatePath('/reglages/types-absence');
revalidatePath('/absences/calendrier');
return { ok: true };
}
export async function archiveAbsenceTypeAction(
formData: FormData,
): Promise<void> {
const id = String(formData.get('id') ?? '');
const restore = formData.get('restore') === '1';
if (!id) return;
await mutate('settings.agreement.manage', async (db, actor) => {
const before = await db.absenceType.findUnique({ where: { id } });
if (!before) return;
// Archiver, jamais supprimer : les absences passées gardent leur type, et
// les compteurs de congés s'appuient dessus pour justifier une écriture.
const archivedAt = restore ? null : new Date();
await db.absenceType.update({ where: { id }, data: { archivedAt } });
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: restore ? 'absence_type.restore' : 'absence_type.archive',
entityType: 'AbsenceType',
entityId: id,
before: { archivedAt: before.archivedAt },
after: { archivedAt: archivedAt?.toISOString() ?? null },
});
});
revalidatePath('/reglages/types-absence');
revalidatePath('/absences/calendrier');
}
+385
View File
@@ -0,0 +1,385 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import {
isPrintDensity,
PREFERENCE_KEYS,
timeToMinutes,
} from '@/domain/settings/preferences';
import { recordAudit } from '@/server/audit';
import { mutate, query } from '@/server/context';
/**
* Compte et préférences — PLAN.md §9, `/reglages/compte` et
* `/reglages/preferences`.
*
* Les préférences sont une ligne créée à la demande : l'absence de ligne vaut
* « tous les défauts ». Semer une ligne à l'installation obligerait à migrer
* chaque compte au premier réglage ajouté ; la lire avec un repli sur les
* défauts du modèle ne coûte rien et ne se périme pas.
*/
export interface AccountIdentity {
id: string;
name: string;
siren: string | null;
apeCode: string | null;
addressLine: string | null;
postalCode: string | null;
city: string | null;
country: string;
timezone: string;
}
export interface Preferences {
defaultMealPerShift: boolean;
paidBreaks: boolean;
lockEmployeeMeals: boolean;
employeesSeeOwnTotals: boolean;
employeesSeeTeamPlanning: boolean;
hideForfaitCounters: boolean;
eveningShiftStartMinutes: number;
employeesEditOwnProfile: boolean;
employeesSeeOwnTimesheets: boolean;
employeesSeeTeamContacts: boolean;
managerCreatedArePlannable: boolean;
directorsSharePeople: boolean;
directorsAccessHrDashboard: boolean;
directorsSeePaidLeave: boolean;
managersSeePaidLeave: boolean;
smoothOvertimeMonthly: boolean;
includeRestInNormalHours: boolean;
autoEmployeeNumber: boolean;
printLandscape: boolean;
printDensity: string;
printContractTotals: boolean;
printOtherTeams: boolean;
printSunday: boolean;
printSignatureColumn: boolean;
/** Chaîne décimale, ou `null` quand aucun objectif n'est fixé. */
productivityTargetPerHour: string | null;
}
const DEFAULTS: Preferences = {
defaultMealPerShift: false,
paidBreaks: false,
lockEmployeeMeals: false,
employeesSeeOwnTotals: true,
employeesSeeTeamPlanning: false,
hideForfaitCounters: true,
eveningShiftStartMinutes: 1200,
employeesEditOwnProfile: true,
employeesSeeOwnTimesheets: true,
employeesSeeTeamContacts: false,
managerCreatedArePlannable: false,
directorsSharePeople: false,
directorsAccessHrDashboard: true,
directorsSeePaidLeave: true,
managersSeePaidLeave: false,
smoothOvertimeMonthly: false,
includeRestInNormalHours: false,
autoEmployeeNumber: false,
printLandscape: true,
printDensity: 'large',
printContractTotals: true,
printOtherTeams: false,
printSunday: true,
printSignatureColumn: true,
productivityTargetPerHour: null,
};
export async function getAccountIdentity(): Promise<AccountIdentity | null> {
return query('settings.access', async (db, actor) => {
const account = await db.account.findUnique({
where: { id: actor.accountId },
});
if (!account) return null;
return {
id: account.id,
name: account.name,
siren: account.siren,
apeCode: account.apeCode,
addressLine: account.addressLine,
postalCode: account.postalCode,
city: account.city,
country: account.country,
timezone: account.timezone,
};
});
}
export async function getPreferences(): Promise<Preferences> {
return query('settings.access', async (db, actor) => {
const row = await db.accountPreferences.findUnique({
where: { accountId: actor.accountId },
});
if (!row) return DEFAULTS;
const {
accountId: _ignored,
updatedAt: _stamp,
productivityTargetPerHour,
...values
} = row;
return {
...values,
// Un Decimal Prisma ne traverse pas la frontière serveur/client. Une
// chaîne le fait sans perdre de décimale, ce qu'un `number` ne garantit
// pas sur un montant.
productivityTargetPerHour: productivityTargetPerHour?.toString() ?? null,
};
});
}
export interface ActionState {
error?: string;
ok?: boolean;
}
const identityInput = z.object({
name: z.string().trim().min(1, 'Raison sociale requise').max(200),
siren: z
.string()
.trim()
.regex(/^\d{9}$/, 'Le SIREN compte 9 chiffres')
.or(z.literal('')),
apeCode: z
.string()
.trim()
.regex(/^\d{4}[A-Z]$/, 'Le code APE s’écrit « 4759B »')
.or(z.literal('')),
addressLine: z.string().trim().max(200),
postalCode: z
.string()
.trim()
.regex(/^\d{5}$/, 'Le code postal compte 5 chiffres')
.or(z.literal('')),
city: z.string().trim().max(120),
timezone: z.string().trim().min(1).max(64),
});
export async function saveAccountIdentityAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = identityInput.safeParse({
name: formData.get('name'),
siren: formData.get('siren') ?? '',
apeCode: formData.get('apeCode') ?? '',
addressLine: formData.get('addressLine') ?? '',
postalCode: formData.get('postalCode') ?? '',
city: formData.get('city') ?? '',
timezone: formData.get('timezone') || 'Europe/Paris',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('settings.locations.manage', async (db, actor) => {
const before = await db.account.findUnique({
where: { id: actor.accountId },
});
if (!before) throw new AuthorizationError('settings.locations.manage');
const data = {
name: parsed.data.name,
siren: parsed.data.siren || null,
apeCode: parsed.data.apeCode || null,
addressLine: parsed.data.addressLine || null,
postalCode: parsed.data.postalCode || null,
city: parsed.data.city || null,
timezone: parsed.data.timezone,
};
await db.account.update({ where: { id: actor.accountId }, data });
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'account.update',
entityType: 'Account',
entityId: actor.accountId,
before: {
name: before.name,
siren: before.siren,
apeCode: before.apeCode,
city: before.city,
timezone: before.timezone,
},
after: data,
});
});
} catch (error) {
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de modifier le compte." };
}
throw error;
}
revalidatePath('/reglages/compte');
return { ok: true };
}
/**
* Enregistre un bloc de préférences.
*
* Les cases non cochées n'arrivent pas dans le `FormData` : le formulaire porte
* donc la liste des clés qu'il gouverne, et seules celles-là sont réécrites.
* Sans cette liste, enregistrer le bloc « Plannings » remettrait à faux tout le
* bloc « Droits » — un formulaire partiel effacerait ce qu'il n'affiche pas.
*/
export async function savePreferencesAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const scope = formData
.getAll('scope')
.map(String)
.filter((key) => PREFERENCE_KEYS.includes(key));
if (scope.length === 0) {
return { error: 'Formulaire incomplet : aucun réglage à enregistrer.' };
}
const values: Record<string, boolean | number | string> = {};
for (const key of scope) {
values[key] = formData.get(key) === 'on';
}
// L'orientation et la densité vivent dans le même formulaire que les
// interrupteurs d'impression : elles ne sont lues que si le formulaire les
// porte, sinon un autre bloc les remettrait à leur défaut.
const orientation = formData.get('printOrientation');
if (typeof orientation === 'string' && orientation !== '') {
values.printLandscape = orientation === 'landscape';
}
const density = formData.get('printDensity');
if (typeof density === 'string' && density !== '') {
if (!isPrintDensity(density)) {
return { error: 'Densité d’impression inconnue.' };
}
values.printDensity = density;
}
const eveningRaw = formData.get('eveningShiftStart');
if (typeof eveningRaw === 'string' && eveningRaw !== '') {
const minutes = timeToMinutes(eveningRaw);
if (minutes === null) {
return { error: 'Heure de bascule du soir invalide (format HH:MM).' };
}
values.eveningShiftStartMinutes = minutes;
}
try {
await mutate('settings.access', async (db, actor) => {
const before = await db.accountPreferences.findUnique({
where: { accountId: actor.accountId },
});
await db.accountPreferences.upsert({
where: { accountId: actor.accountId },
create: values as never,
update: values as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'account.preferences.update',
entityType: 'AccountPreferences',
entityId: actor.accountId,
before: before
? Object.fromEntries(
Object.keys(values).map((key) => [
key,
(before as Record<string, unknown>)[key],
]),
)
: null,
after: values,
});
});
} catch (error) {
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de modifier les réglages." };
}
throw error;
}
revalidatePath('/reglages/preferences');
revalidatePath('/reglages/paie');
revalidatePath('/reglages/impression');
return { ok: true };
}
const productivityInput = z.object({
target: z
.union([
z.literal(''),
z.coerce
.number()
.min(0, 'Un objectif négatif n’a pas de sens')
.max(100_000),
])
.transform((value) => (value === '' ? null : value)),
});
/**
* Objectif de productivité, en euros de chiffre d'affaires par heure.
*
* Vider le champ **efface** l'objectif au lieu de le mettre à zéro : zéro est
* un objectif inatteignable, l'absence de valeur dit qu'aucun objectif n'a été
* fixé. Un écran qui afficherait « 0 €/h attendu » pousserait à corriger un
* réglage qui n'existe pas.
*/
export async function saveProductivityTargetAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = productivityInput.safeParse({
target: formData.get('target') ?? '',
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('settings.access', async (db, actor) => {
const before = await db.accountPreferences.findUnique({
where: { accountId: actor.accountId },
});
await db.accountPreferences.upsert({
where: { accountId: actor.accountId },
create: { productivityTargetPerHour: parsed.data.target } as never,
update: { productivityTargetPerHour: parsed.data.target },
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'account.productivity.update',
entityType: 'AccountPreferences',
entityId: actor.accountId,
before: {
productivityTargetPerHour:
before?.productivityTargetPerHour?.toString() ?? null,
},
after: { productivityTargetPerHour: parsed.data.target },
});
});
} catch (error) {
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de modifier les réglages." };
}
throw error;
}
revalidatePath('/reglages/productivite');
return { ok: true };
}
+306
View File
@@ -0,0 +1,306 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import {
agreementParametersSchema,
parseAgreementParameters,
type AgreementParameters,
} from '@/domain/compliance/parameters';
import { recordAudit } from '@/server/audit';
import { mutate, query } from '@/server/context';
/**
* Convention collective — PLAN.md §9, réglage « /reglages/convention ».
*
* **Une convention ne se modifie pas : elle se réédite.** Chaque enregistrement
* crée une version datée, laissant intacte la précédente. C'est l'exigence
* effectif-datée de §12.2 : une paie de mars doit rester reproductible après un
* changement de règle en juin, ce qu'une mise à jour en place rendrait
* impossible — le moteur relirait les nouveaux seuils et produirait un autre
* résultat sur les mêmes données.
*
* L'origine de chaque valeur est portée par le registre de paramétrage
* (`LegalConfigEntry`, §12.7) : ordre public, disposition conventionnelle ou
* accord d'entreprise ne se corrigent pas de la même main.
*/
export interface AgreementVersion {
id: string;
idcc: string;
name: string;
version: number;
effectiveFrom: Date;
source: string | null;
approvedBy: string | null;
approvedAt: Date | null;
createdAt: Date;
parameters: AgreementParameters;
/** Vraie pour la version applicable aujourd'hui. */
isCurrent: boolean;
}
export async function listAgreementVersions(): Promise<AgreementVersion[]> {
return query('settings.access', async (db) => {
const rows = await db.collectiveAgreement.findMany({
orderBy: [{ effectiveFrom: 'desc' }, { version: 'desc' }],
});
const today = new Date();
// La version courante est la plus récente **déjà en vigueur** : une version
// datée du mois prochain existe en base sans gouverner le calcul du jour.
const currentId = rows.find((row) => row.effectiveFrom <= today)?.id ?? null;
return rows.map((row) => ({
id: row.id,
idcc: row.idcc,
name: row.name,
version: row.version,
effectiveFrom: row.effectiveFrom,
source: row.source,
approvedBy: row.approvedBy,
approvedAt: row.approvedAt,
createdAt: row.createdAt,
parameters: parseAgreementParameters(row.parameters),
isCurrent: row.id === currentId,
}));
});
}
export interface ActionState {
error?: string;
ok?: boolean;
}
/**
* Tranches d'heures supplémentaires, saisies une par ligne : `début;fin;taux`.
*
* Un tableau de longueur variable ne se saisit pas en champs fixes, et un JSON
* brut exposerait la forme interne. Le format compact reste vérifiable à l'œil,
* ce qui compte pour une valeur que le gestionnaire de paie doit relire.
* `fin` vide signifie « jusqu'à l'infini ».
*/
function parseTiers(raw: string) {
const lines = raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
return lines.map((line, index) => {
const parts = line.split(';').map((part) => part.trim());
if (parts.length !== 3) {
throw new ParameterFormatError(
`Tranche ${index + 1} : trois valeurs attendues « début;fin;taux ».`,
);
}
const [from, to, rate] = parts as [string, string, string];
const fromMinutes = Number(from);
const toMinutes = to === '' ? null : Number(to);
const ratePercent = Number(rate);
if (
!Number.isFinite(fromMinutes) ||
(toMinutes !== null && !Number.isFinite(toMinutes)) ||
!Number.isFinite(ratePercent)
) {
throw new ParameterFormatError(
`Tranche ${index + 1} : valeurs numériques attendues.`,
);
}
return { fromMinutes, toMinutes, ratePercent };
});
}
/** Dérogations de temps partiel : `code;libellé;minutes` par ligne. */
function parseDerogations(raw: string) {
return raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line, index) => {
const parts = line.split(';').map((part) => part.trim());
if (parts.length !== 3) {
throw new ParameterFormatError(
`Dérogation ${index + 1} : « code;libellé;minutes » attendu.`,
);
}
const [code, label, min] = parts as [string, string, string];
const minWeeklyMinutes = Number(min);
if (!code || !label || !Number.isFinite(minWeeklyMinutes)) {
throw new ParameterFormatError(
`Dérogation ${index + 1} : code, libellé et minutes requis.`,
);
}
return { code, label, minWeeklyMinutes };
});
}
class ParameterFormatError extends Error {}
const metaInput = z.object({
idcc: z.string().trim().min(1, 'IDCC requis').max(10),
name: z.string().trim().min(1, 'Intitulé requis').max(160),
effectiveFrom: z.coerce.date(),
source: z.string().trim().max(400),
approvedBy: z.string().trim().max(160),
});
function num(formData: FormData, key: string): number {
return Number(formData.get(key) ?? Number.NaN);
}
/**
* Reconstruit le jeu complet depuis le formulaire, puis le valide.
*
* La validation passe par le **même** schéma que le chargement du moteur : un
* jeu accepté ici est donc, par construction, un jeu que le moteur saura lire.
*/
function readParameters(formData: FormData): AgreementParameters {
const candidate = {
weeklyReferenceMinutes: num(formData, 'weeklyReferenceMinutes'),
maxDailyWorkMinutes: num(formData, 'maxDailyWorkMinutes'),
maxDailyAmplitudeMinutes:
String(formData.get('maxDailyAmplitudeMinutes') ?? '') === ''
? null
: num(formData, 'maxDailyAmplitudeMinutes'),
minDailyRestMinutes: num(formData, 'minDailyRestMinutes'),
minWeeklyRestMinutes: num(formData, 'minWeeklyRestMinutes'),
maxWeeklyWorkAbsoluteMinutes: num(formData, 'maxWeeklyWorkAbsoluteMinutes'),
averagedWeeklyWork: {
windowWeeks: num(formData, 'averagedWindowWeeks'),
maxAverageMinutes: num(formData, 'averagedMaxMinutes'),
},
maxConsecutiveWorkDays: num(formData, 'maxConsecutiveWorkDays'),
breakAfterThreshold: {
thresholdMinutes: num(formData, 'breakThresholdMinutes'),
minBreakMinutes: num(formData, 'breakMinMinutes'),
},
partTime: {
minWeeklyMinutes: num(formData, 'partTimeMinWeeklyMinutes'),
derogations: parseDerogations(String(formData.get('derogations') ?? '')),
},
contractDeviationToleranceMinutes: num(formData, 'deviationToleranceMinutes'),
overtime: {
tiers: parseTiers(String(formData.get('overtimeTiers') ?? '')),
annualQuotaMinutes: num(formData, 'overtimeQuotaMinutes'),
beyondQuotaRestPercent: num(formData, 'overtimeBeyondRestPercent'),
},
complementaryHours: {
firstTierRatePercent: num(formData, 'complementaryFirstRate'),
beyondRatePercent: num(formData, 'complementaryBeyondRate'),
firstTierFraction: num(formData, 'complementaryFirstFraction'),
capFraction: num(formData, 'complementaryCapFraction'),
},
sunday: {
premiumPercent: num(formData, 'sundayPremiumPercent'),
compensatoryRestPercent: num(formData, 'sundayRestPercent'),
mayorQuotaPerYear: num(formData, 'sundayMayorQuota'),
},
holiday: {
workedPremiumPercent: num(formData, 'holidayWorkedPremium'),
labourDayPremiumPercent: num(formData, 'holidayLabourDayPremium'),
paidOffDaysPerYear: num(formData, 'holidayPaidOffDays'),
substitutionRestPercent: num(formData, 'holidaySubstitutionRest'),
},
night: {
startMinutes: num(formData, 'nightStartMinutes'),
endMinutes: num(formData, 'nightEndMinutes'),
notEnforceableFromAge: num(formData, 'nightNotEnforceableFromAge'),
},
forfaitJours: {
maxDaysPerYear: num(formData, 'forfaitMaxDaysPerYear'),
workloadReviewIntervalMonths: num(formData, 'forfaitReviewMonths'),
},
};
const parsed = agreementParametersSchema.safeParse(candidate);
if (!parsed.success) {
const issue = parsed.error.issues[0];
throw new ParameterFormatError(
`${issue?.path.join('.') ?? 'paramètre'} : ${issue?.message ?? 'valeur refusée'}`,
);
}
return parsed.data;
}
export async function publishAgreementVersionAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const meta = metaInput.safeParse({
idcc: formData.get('idcc'),
name: formData.get('name'),
effectiveFrom: formData.get('effectiveFrom'),
source: formData.get('source') ?? '',
approvedBy: formData.get('approvedBy') ?? '',
});
if (!meta.success) {
return { error: meta.error.issues[0]?.message ?? 'Formulaire invalide' };
}
let parameters: AgreementParameters;
try {
parameters = readParameters(formData);
} catch (error) {
if (error instanceof ParameterFormatError) return { error: error.message };
throw error;
}
if (!meta.data.source) {
// Une valeur sans source n'est pas opposable : le registre de paramétrage
// exige de dire d'où elle vient avant de l'appliquer (§12.7).
return {
error:
'Indiquez la source du texte : sans elle, le paramètre n’est pas opposable.',
};
}
try {
await mutate('settings.agreement.manage', async (db, actor) => {
const last = await db.collectiveAgreement.findFirst({
where: { idcc: meta.data.idcc },
orderBy: { version: 'desc' },
});
const created = await db.collectiveAgreement.create({
data: {
idcc: meta.data.idcc,
name: meta.data.name,
parameters: parameters as never,
version: (last?.version ?? 0) + 1,
effectiveFrom: meta.data.effectiveFrom,
source: meta.data.source,
approvedBy: meta.data.approvedBy || null,
approvedAt: meta.data.approvedBy ? new Date() : null,
} as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'agreement.publish_version',
entityType: 'CollectiveAgreement',
entityId: created.id,
before: last
? { version: last.version, effectiveFrom: last.effectiveFrom }
: null,
after: {
version: created.version,
effectiveFrom: created.effectiveFrom,
source: created.source,
},
});
});
} catch (error) {
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer la convention." };
}
throw error;
}
revalidatePath('/reglages/convention');
return { ok: true };
}
+208
View File
@@ -0,0 +1,208 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import { recordAudit } from '@/server/audit';
import { mutate, query } from '@/server/context';
/**
* Emplois — PLAN.md §9, réglage « /reglages/emplois ».
*
* L'emploi est la qualification portée par le contrat, et il figure au registre
* unique du personnel. Il est donc **archivé, jamais supprimé** : un emploi
* effacé rendrait illisible un contrat clos dont la conservation court encore.
*/
export interface JobTitleRow {
id: string;
name: string;
archivedAt: Date | null;
/** Contrats qui s'y rattachent — un emploi porté n'est pas un doublon à purger. */
contractCount: number;
}
export async function listJobTitles(
includeArchived = false,
): Promise<JobTitleRow[]> {
return query('settings.access', async (db) => {
const titles = await db.jobTitle.findMany({
where: includeArchived ? {} : { archivedAt: null },
orderBy: { name: 'asc' },
});
const counts = await db.userContract.groupBy({
by: ['jobTitleId'],
_count: { _all: true },
});
const byId = new Map(
counts.map((row) => [row.jobTitleId, row._count._all] as const),
);
return titles.map((title) => ({
id: title.id,
name: title.name,
archivedAt: title.archivedAt,
contractCount: byId.get(title.id) ?? 0,
}));
});
}
export interface ActionState {
error?: string;
ok?: boolean;
}
const nameInput = z.object({
name: z.string().trim().min(1, 'Nom requis').max(120),
});
export async function createJobTitleAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = nameInput.safeParse({ name: formData.get('name') });
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('settings.jobtitles.manage', async (db, actor) => {
// Un emploi archivé porte le même nom qu'un emploi à recréer : on le
// réveille plutôt que d'échouer sur la contrainte d'unicité, sans quoi le
// nom deviendrait définitivement inutilisable.
const existing = await db.jobTitle.findFirst({
where: { name: parsed.data.name },
});
if (existing && existing.archivedAt === null) {
throw new DuplicateJobTitle();
}
if (existing) {
await db.jobTitle.update({
where: { id: existing.id },
data: { archivedAt: null },
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'job_title.restore',
entityType: 'JobTitle',
entityId: existing.id,
before: { archivedAt: existing.archivedAt },
after: { archivedAt: null },
});
return;
}
const created = await db.jobTitle.create({
data: { name: parsed.data.name } as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'job_title.create',
entityType: 'JobTitle',
entityId: created.id,
after: { name: created.name },
});
});
} catch (error) {
if (error instanceof DuplicateJobTitle) {
return { error: 'Cet emploi existe déjà.' };
}
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer les emplois." };
}
throw error;
}
revalidatePath('/reglages/emplois');
return { ok: true };
}
class DuplicateJobTitle extends Error {}
const renameInput = z.object({
id: z.string().min(1),
name: z.string().trim().min(1, 'Nom requis').max(120),
});
export async function renameJobTitleAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = renameInput.safeParse({
id: formData.get('id'),
name: formData.get('name'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('settings.jobtitles.manage', async (db, actor) => {
const before = await db.jobTitle.findUnique({
where: { id: parsed.data.id },
});
if (!before) throw new AuthorizationError('settings.jobtitles.manage');
const clash = await db.jobTitle.findFirst({
where: { name: parsed.data.name, id: { not: before.id } },
});
if (clash) throw new DuplicateJobTitle();
await db.jobTitle.update({
where: { id: before.id },
data: { name: parsed.data.name },
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'job_title.rename',
entityType: 'JobTitle',
entityId: before.id,
before: { name: before.name },
after: { name: parsed.data.name },
});
});
} catch (error) {
if (error instanceof DuplicateJobTitle) {
return { error: 'Un autre emploi porte déjà ce nom.' };
}
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer les emplois." };
}
throw error;
}
revalidatePath('/reglages/emplois');
return { ok: true };
}
export async function archiveJobTitleAction(formData: FormData): Promise<void> {
const id = String(formData.get('id') ?? '');
const restore = formData.get('restore') === '1';
if (!id) return;
await mutate('settings.jobtitles.manage', async (db, actor) => {
const before = await db.jobTitle.findUnique({ where: { id } });
if (!before) return;
const archivedAt = restore ? null : new Date();
await db.jobTitle.update({ where: { id }, data: { archivedAt } });
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: restore ? 'job_title.restore' : 'job_title.archive',
entityType: 'JobTitle',
entityId: id,
before: { archivedAt: before.archivedAt },
after: { archivedAt: archivedAt?.toISOString() ?? null },
});
});
revalidatePath('/reglages/emplois');
}
+230
View File
@@ -0,0 +1,230 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import { isPosteCode } from '@/lib/design/postes';
import { recordAudit } from '@/server/audit';
import { mutate, query } from '@/server/context';
/**
* Étiquettes de planning — PLAN.md §9, réglage « /reglages/etiquettes ».
*
* L'étiquette est le poste coloré de la grille. Sa teinte vient de la palette
* catégorielle, jamais d'une couleur libre : douze teintes calculées pour
* rester distinguables en vision daltonienne, et un code affiché avec le bloc
* pour que la couleur ne porte jamais l'information seule.
*/
export interface LabelRow {
id: string;
code: string;
name: string;
paletteKey: string;
position: number;
archivedAt: Date | null;
/** Créneaux qui la portent — une étiquette utilisée ne se supprime pas. */
shiftCount: number;
}
export async function listLabels(includeArchived = false): Promise<LabelRow[]> {
return query('settings.access', async (db) => {
const labels = await db.label.findMany({
where: includeArchived ? {} : { archivedAt: null },
orderBy: [{ position: 'asc' }, { code: 'asc' }],
});
const counts = await db.shift.groupBy({
by: ['labelId'],
_count: { _all: true },
});
const byId = new Map(
counts.map((row) => [row.labelId, row._count._all] as const),
);
return labels.map((label) => ({
id: label.id,
code: label.code,
name: label.name,
paletteKey: label.paletteKey,
position: label.position,
archivedAt: label.archivedAt,
shiftCount: byId.get(label.id) ?? 0,
}));
});
}
export interface ActionState {
error?: string;
ok?: boolean;
}
class DuplicateLabel extends Error {}
const labelInput = z.object({
code: z
.string()
.trim()
.min(1, 'Code requis')
.max(12)
.regex(/^[A-Za-z0-9-]+$/, 'Lettres, chiffres et tirets seulement'),
name: z.string().trim().min(1, 'Nom requis').max(120),
paletteKey: z.string().refine(isPosteCode, 'Teinte inconnue'),
});
export async function createLabelAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = labelInput.safeParse({
code: formData.get('code'),
name: formData.get('name'),
paletteKey: formData.get('paletteKey'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const code = parsed.data.code.toLowerCase();
try {
await mutate('planning.labels.manage', async (db, actor) => {
const existing = await db.label.findFirst({ where: { code } });
if (existing && existing.archivedAt === null) throw new DuplicateLabel();
if (existing) {
await db.label.update({
where: { id: existing.id },
data: {
archivedAt: null,
name: parsed.data.name,
paletteKey: parsed.data.paletteKey,
},
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'label.restore',
entityType: 'Label',
entityId: existing.id,
before: { archivedAt: existing.archivedAt, name: existing.name },
after: { archivedAt: null, name: parsed.data.name },
});
return;
}
const position = await db.label.count();
const created = await db.label.create({
data: {
code,
name: parsed.data.name,
paletteKey: parsed.data.paletteKey,
position,
} as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'label.create',
entityType: 'Label',
entityId: created.id,
after: {
code: created.code,
name: created.name,
paletteKey: created.paletteKey,
},
});
});
} catch (error) {
if (error instanceof DuplicateLabel) {
return { error: 'Ce code d’étiquette est déjà pris.' };
}
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer les étiquettes." };
}
throw error;
}
revalidatePath('/reglages/etiquettes');
revalidatePath('/planning/etiquettes');
return { ok: true };
}
const updateInput = z.object({
id: z.string().min(1),
name: z.string().trim().min(1, 'Nom requis').max(120),
paletteKey: z.string().refine(isPosteCode, 'Teinte inconnue'),
});
export async function updateLabelAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = updateInput.safeParse({
id: formData.get('id'),
name: formData.get('name'),
paletteKey: formData.get('paletteKey'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('planning.labels.manage', async (db, actor) => {
const before = await db.label.findUnique({ where: { id: parsed.data.id } });
if (!before) throw new AuthorizationError('planning.labels.manage');
await db.label.update({
where: { id: before.id },
data: { name: parsed.data.name, paletteKey: parsed.data.paletteKey },
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'label.update',
entityType: 'Label',
entityId: before.id,
before: { name: before.name, paletteKey: before.paletteKey },
after: { name: parsed.data.name, paletteKey: parsed.data.paletteKey },
});
});
} catch (error) {
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer les étiquettes." };
}
throw error;
}
revalidatePath('/reglages/etiquettes');
revalidatePath('/planning/etiquettes');
return { ok: true };
}
export async function archiveLabelAction(formData: FormData): Promise<void> {
const id = String(formData.get('id') ?? '');
const restore = formData.get('restore') === '1';
if (!id) return;
await mutate('planning.labels.manage', async (db, actor) => {
const before = await db.label.findUnique({ where: { id } });
if (!before) return;
// Archiver, pas supprimer : les créneaux passés portent cette étiquette et
// les rapports d'heures s'y adossent. Une suppression laisserait des
// semaines closes sans poste lisible.
const archivedAt = restore ? null : new Date();
await db.label.update({ where: { id }, data: { archivedAt } });
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: restore ? 'label.restore' : 'label.archive',
entityType: 'Label',
entityId: id,
before: { archivedAt: before.archivedAt },
after: { archivedAt: archivedAt?.toISOString() ?? null },
});
});
revalidatePath('/reglages/etiquettes');
revalidatePath('/planning/etiquettes');
}
+300
View File
@@ -0,0 +1,300 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import { recordAudit } from '@/server/audit';
import { mutate, query } from '@/server/context';
/**
* Politiques de RTT — PLAN.md §9, réglage « /reglages/politiques-rtt ».
*
* Une politique porte un nombre de jours par an et une date de début de
* période. Elle **se reconduit automatiquement** tant qu'elle est active ;
* archivée, elle cesse de le faire — c'est le critère d'acceptation de WP-06.
*
* Les deux actions de ligne relevées à l'audit sont « Assigner des employés » et
* « Archiver ». Pas de suppression : une politique a produit des acquisitions au
* registre des compteurs, et l'effacer rendrait ces écritures inexplicables.
*
* **Autorisation.** `settings.agreement.manage`, comme les types d'absence : le
* catalogue §5 ne déclare pas de code dédié, et un droit à RTT relève du
* paramétrage conventionnel. Inventer une capacité aurait créé un droit sans
* rien protéger de neuf.
*/
export interface RttPolicyRow {
id: string;
name: string;
daysPerYear: string;
/** « MM-JJ ». */
periodStart: string;
autoRenew: boolean;
status: 'ACTIVE' | 'ARCHIVED';
assignedCount: number;
assignees: Array<{ membershipId: string; name: string }>;
}
export interface PolicyBoard {
policies: RttPolicyRow[];
/** Salariés assignables, pour le formulaire d'affectation. */
candidates: Array<{ membershipId: string; name: string }>;
}
export async function getPolicyBoard(
includeArchived = false,
): Promise<PolicyBoard> {
return query('settings.access', async (db) => {
const policies = await db.rttPolicy.findMany({
where: includeArchived ? {} : { status: 'ACTIVE' },
orderBy: { name: 'asc' },
include: { assignments: true },
});
const members = await db.membership.findMany({
where: { archivedAt: null },
select: {
id: true,
employeeNumber: true,
profile: { select: { firstName: true, lastName: true } },
},
});
const nameById = new Map(
members.map((member) => [
member.id,
`${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim(),
]),
);
return {
policies: policies.map((policy) => ({
id: policy.id,
name: policy.name,
daysPerYear: policy.daysPerYear.toString(),
periodStart: policy.periodStart,
autoRenew: policy.autoRenew,
status: policy.status,
assignedCount: policy.assignments.length,
assignees: policy.assignments
.map((assignment) => ({
membershipId: assignment.membershipId,
name: nameById.get(assignment.membershipId) ?? 'Salarié',
}))
.sort((a, b) => a.name.localeCompare(b.name, 'fr')),
})),
candidates: [...nameById.entries()]
.map(([membershipId, name]) => ({ membershipId, name }))
.sort((a, b) => a.name.localeCompare(b.name, 'fr')),
};
});
}
export interface ActionState {
error?: string;
ok?: boolean;
}
class DuplicatePolicy extends Error {}
const policyInput = z.object({
name: z.string().trim().min(1, 'Nom requis').max(160),
daysPerYear: z.coerce
.number()
.min(0, 'Un droit négatif n’existe pas')
.max(365, 'Un droit annuel ne dépasse pas 365 jours'),
// « MM-JJ » plutôt qu'une date complète : la période se reconduit d'une année
// sur l'autre, et porter un millésime obligerait à réécrire la politique
// chaque janvier.
periodStart: z
.string()
.trim()
.regex(/^(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/, 'Format attendu : MM-JJ'),
autoRenew: z.boolean(),
});
function readForm(formData: FormData) {
return {
name: formData.get('name'),
daysPerYear: formData.get('daysPerYear') ?? 0,
periodStart: formData.get('periodStart') ?? '',
autoRenew: formData.get('autoRenew') === 'on',
};
}
export async function createRttPolicyAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = policyInput.safeParse(readForm(formData));
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('settings.agreement.manage', async (db, actor) => {
const existing = await db.rttPolicy.findFirst({
where: { name: parsed.data.name },
});
if (existing) throw new DuplicatePolicy();
const created = await db.rttPolicy.create({
data: parsed.data as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'rtt_policy.create',
entityType: 'RttPolicy',
entityId: created.id,
after: {
name: created.name,
daysPerYear: created.daysPerYear.toString(),
periodStart: created.periodStart,
autoRenew: created.autoRenew,
},
});
});
} catch (error) {
if (error instanceof DuplicatePolicy) {
return { error: 'Une politique porte déjà ce nom.' };
}
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer les politiques." };
}
throw error;
}
revalidatePath('/reglages/politiques-rtt');
return { ok: true };
}
export async function archiveRttPolicyAction(
formData: FormData,
): Promise<void> {
const id = String(formData.get('id') ?? '');
const restore = formData.get('restore') === '1';
if (!id) return;
await mutate('settings.agreement.manage', async (db, actor) => {
const before = await db.rttPolicy.findUnique({ where: { id } });
if (!before) return;
// Archiver arrête la reconduction ; les affectations restent en place, et
// les jours déjà acquis restent au registre. Une politique n'efface pas ce
// qu'elle a produit.
const status = restore ? 'ACTIVE' : 'ARCHIVED';
await db.rttPolicy.update({ where: { id }, data: { status } });
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: restore ? 'rtt_policy.restore' : 'rtt_policy.archive',
entityType: 'RttPolicy',
entityId: id,
before: { status: before.status },
after: { status },
});
});
revalidatePath('/reglages/politiques-rtt');
}
const assignInput = z.object({
policyId: z.string().min(1),
membershipIds: z.array(z.string().min(1)).min(1, 'Sélectionnez un salarié'),
});
export async function assignRttPolicyAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = assignInput.safeParse({
policyId: formData.get('policyId'),
membershipIds: formData.getAll('membershipIds').map(String),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('settings.agreement.manage', async (db, actor) => {
const policy = await db.rttPolicy.findUnique({
where: { id: parsed.data.policyId },
});
if (!policy) throw new AuthorizationError('settings.agreement.manage');
if (policy.status === 'ARCHIVED') throw new ArchivedPolicy();
// Le périmètre est revérifié en base : un identifiant soumis depuis le
// formulaire pourrait désigner un salarié d'un autre compte.
const members = await db.membership.findMany({
where: { id: { in: parsed.data.membershipIds } },
select: { id: true },
});
for (const member of members) {
// `upsert` sur la clé composée : réassigner un salarié déjà couvert ne
// doit ni échouer ni créer un doublon d'acquisition.
await db.rttPolicyAssignment.upsert({
where: {
rttPolicyId_membershipId: {
rttPolicyId: policy.id,
membershipId: member.id,
},
},
create: { rttPolicyId: policy.id, membershipId: member.id } as never,
update: {},
});
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'rtt_policy.assign',
entityType: 'RttPolicy',
entityId: policy.id,
after: { membershipIds: members.map((member) => member.id) },
});
});
} catch (error) {
if (error instanceof ArchivedPolicy) {
return {
error:
'Cette politique est archivée : elle ne produit plus d’acquisition.',
};
}
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer les politiques." };
}
throw error;
}
revalidatePath('/reglages/politiques-rtt');
return { ok: true };
}
class ArchivedPolicy extends Error {}
export async function unassignRttPolicyAction(
formData: FormData,
): Promise<void> {
const policyId = String(formData.get('policyId') ?? '');
const membershipId = String(formData.get('membershipId') ?? '');
if (!policyId || !membershipId) return;
await mutate('settings.agreement.manage', async (db, actor) => {
const removed = await db.rttPolicyAssignment.deleteMany({
where: { rttPolicyId: policyId, membershipId },
});
if (removed.count === 0) return;
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'rtt_policy.unassign',
entityType: 'RttPolicy',
entityId: policyId,
before: { membershipId },
});
});
revalidatePath('/reglages/politiques-rtt');
}
+217
View File
@@ -0,0 +1,217 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError } from '@/domain/access/authorize';
import { referencedFields, unknownFields } from '@/domain/documents/template';
import { recordAudit } from '@/server/audit';
import { mutate, query } from '@/server/context';
/**
* Modèles de documents — PLAN.md §9, réglage « /reglages/modeles-documents ».
*
* L'écran valide les variables **à l'enregistrement**, pas à la génération. Un
* modèle qui cite `{{salarie.salire}}` doit être refusé par l'administrateur
* qui l'écrit, pas découvert par le gestionnaire qui tente d'éditer une
* attestation devant le salarié qui attend.
*/
export interface TemplateRow {
id: string;
name: string;
bodyHtml: string;
fields: string[];
archivedAt: Date | null;
createdAt: Date;
/** Pièces déjà générées depuis ce modèle. */
documentCount: number;
}
export async function listTemplates(
includeArchived = false,
): Promise<TemplateRow[]> {
return query('settings.access', async (db) => {
const templates = await db.documentTemplate.findMany({
where: includeArchived ? {} : { archivedAt: null },
orderBy: { name: 'asc' },
});
const counts = await db.document.groupBy({
by: ['templateId'],
_count: { _all: true },
});
const byId = new Map(
counts.map((row) => [row.templateId, row._count._all] as const),
);
return templates.map((template) => ({
id: template.id,
name: template.name,
bodyHtml: template.bodyHtml,
fields: Array.isArray(template.availableFields)
? (template.availableFields as string[])
: [],
archivedAt: template.archivedAt,
createdAt: template.createdAt,
documentCount: byId.get(template.id) ?? 0,
}));
});
}
export interface ActionState {
error?: string;
ok?: boolean;
}
class DuplicateTemplate extends Error {}
const templateInput = z.object({
name: z.string().trim().min(1, 'Nom requis').max(160),
bodyHtml: z.string().trim().min(1, 'Corps requis').max(50_000),
});
/** Refuse un corps citant une variable que l'application ne sait pas résoudre. */
function rejectUnknownFields(bodyHtml: string): string | null {
const unknown = unknownFields(bodyHtml);
if (unknown.length === 0) return null;
return `Variables inconnues : ${unknown.join(', ')}. Utilisez la liste ci-contre.`;
}
export async function createTemplateAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = templateInput.safeParse({
name: formData.get('name'),
bodyHtml: formData.get('bodyHtml'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const rejected = rejectUnknownFields(parsed.data.bodyHtml);
if (rejected) return { error: rejected };
try {
await mutate('settings.templates.manage', async (db, actor) => {
const existing = await db.documentTemplate.findFirst({
where: { name: parsed.data.name },
});
if (existing) throw new DuplicateTemplate();
const created = await db.documentTemplate.create({
data: {
name: parsed.data.name,
bodyHtml: parsed.data.bodyHtml,
// Les variables citées sont figées à l'enregistrement : elles
// documentent ce que le modèle exige du dossier, et servent à
// prévenir avant génération plutôt qu'à échouer pendant.
availableFields: referencedFields(parsed.data.bodyHtml) as never,
} as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'document_template.create',
entityType: 'DocumentTemplate',
entityId: created.id,
after: { name: created.name },
});
});
} catch (error) {
if (error instanceof DuplicateTemplate) {
return { error: 'Un modèle porte déjà ce nom.' };
}
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer les modèles." };
}
throw error;
}
revalidatePath('/reglages/modeles-documents');
return { ok: true };
}
const updateInput = templateInput.extend({ id: z.string().min(1) });
export async function updateTemplateAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = updateInput.safeParse({
id: formData.get('id'),
name: formData.get('name'),
bodyHtml: formData.get('bodyHtml'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const rejected = rejectUnknownFields(parsed.data.bodyHtml);
if (rejected) return { error: rejected };
try {
await mutate('settings.templates.manage', async (db, actor) => {
const before = await db.documentTemplate.findUnique({
where: { id: parsed.data.id },
});
if (!before) throw new AuthorizationError('settings.templates.manage');
await db.documentTemplate.update({
where: { id: before.id },
data: {
name: parsed.data.name,
bodyHtml: parsed.data.bodyHtml,
availableFields: referencedFields(parsed.data.bodyHtml) as never,
},
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'document_template.update',
entityType: 'DocumentTemplate',
entityId: before.id,
before: { name: before.name },
after: { name: parsed.data.name },
});
});
} catch (error) {
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de gérer les modèles." };
}
throw error;
}
revalidatePath('/reglages/modeles-documents');
return { ok: true };
}
export async function archiveTemplateAction(formData: FormData): Promise<void> {
const id = String(formData.get('id') ?? '');
const restore = formData.get('restore') === '1';
if (!id) return;
await mutate('settings.templates.manage', async (db, actor) => {
const before = await db.documentTemplate.findUnique({ where: { id } });
if (!before) return;
// Archiver, pas supprimer : une pièce déjà remise garde son `templateId`,
// et perdre le modèle rendrait inexplicable la forme d'un acte signé.
const archivedAt = restore ? null : new Date();
await db.documentTemplate.update({ where: { id }, data: { archivedAt } });
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: restore
? 'document_template.restore'
: 'document_template.archive',
entityType: 'DocumentTemplate',
entityId: id,
before: { archivedAt: before.archivedAt },
after: { archivedAt: archivedAt?.toISOString() ?? null },
});
});
revalidatePath('/reglages/modeles-documents');
}
+9 -9
View File
@@ -55,7 +55,7 @@ function nextWeekday(weekday: number): string {
* attente » et « acceptée » bloquent.
*/
async function release(page: Page, marker: string, month?: string) {
await page.goto(month ? `/conges?mois=${month}` : '/conges');
await page.goto(month ? `/absences/calendrier?mois=${month}` : '/absences/calendrier');
const pending = page
.locator('section')
@@ -117,7 +117,7 @@ async function request(
test('le formulaire nomme le dernier jour d’absence, pas la date de fin', async ({
page,
}) => {
await page.goto('/conges');
await page.goto('/absences/calendrier');
// Le libellé exact coûte moins cher qu'une régularisation de solde.
await expect(
page.getByText('Dernier jour d’absence', { exact: true }),
@@ -128,7 +128,7 @@ test('le formulaire nomme le dernier jour d’absence, pas la date de fin', asyn
});
test('une demande apparaît dans la file, puis se décide', async ({ page }) => {
await page.goto('/conges');
await page.goto('/absences/calendrier');
const from = isoDate(0);
const to = isoDate(4);
@@ -160,7 +160,7 @@ test('une demande apparaît dans la file, puis se décide', async ({ page }) =>
// La ligne est visée par **ses** dates, pas par « la première de ce
// salarié » : les passages précédents en laissent d'autres dans le même
// mois, et certaines n'offrent plus de bouton d'annulation.
await page.goto(`/conges?mois=${from.slice(0, 7)}`);
await page.goto(`/absences/calendrier?mois=${from.slice(0, 7)}`);
const accepted = page
.locator('section')
.filter({ hasText: 'Absences du mois' })
@@ -173,7 +173,7 @@ test('une demande apparaît dans la file, puis se décide', async ({ page }) =>
});
test('deux absences qui se recouvrent sont refusées', async ({ page }) => {
await page.goto('/conges');
await page.goto('/absences/calendrier');
await release(page, 'Chevauchement ');
@@ -208,7 +208,7 @@ test('deux absences qui se recouvrent sont refusées', async ({ page }) => {
});
test('une période sans jour décomptable est refusée', async ({ page }) => {
await page.goto('/conges');
await page.goto('/absences/calendrier');
// Un dimanche seul : jamais ouvrable, donc rien à décompter.
const sunday = nextWeekday(0);
@@ -222,7 +222,7 @@ test('une période sans jour décomptable est refusée', async ({ page }) => {
});
test('un jour férié dans la période n’est pas décompté', async ({ page }) => {
await page.goto('/conges');
await page.goto('/absences/calendrier');
// Un jour férié pris au hasard parmi ceux de l'année : la période qui
// l'englobe ne doit pas le décompter, et l'utilisateur n'a pas eu à scinder
@@ -263,7 +263,7 @@ test('un jour férié dans la période n’est pas décompté', async ({ page })
test('une date de fin antérieure au début rappelle la règle', async ({
page,
}) => {
await page.goto('/conges');
await page.goto('/absences/calendrier');
const form = await request(page, {
from: isoDate(22),
@@ -277,7 +277,7 @@ test('une date de fin antérieure au début rappelle la règle', async ({
});
test('le solde revient à son niveau après un aller-retour', async ({ page }) => {
await page.goto('/conges');
await page.goto('/absences/calendrier');
const counters = page.locator('section').filter({ hasText: 'Compteurs' });
await expect(counters).toBeVisible();
+22 -6
View File
@@ -113,14 +113,22 @@ test('une pièce sans politique n’est jamais purgée', async ({ page }) => {
// compte, et une pièce partagée verrait son sort décidé par un autre test.
const suffix = `${Date.now()}-garde`;
await page.goto('/equipe');
const create = page.locator('form').filter({ hasText: 'Ajouter' });
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
// en modale, à la demande.
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
const create = page.locator('form').filter({ hasText: 'Informations salarié' });
await create.getByLabel('Prénom').fill('Garde');
await create.getByLabel('Nom', { exact: true }).fill(`Garde${suffix}`);
await create.getByLabel('Nom de famille').fill(`Garde${suffix}`);
await create.getByRole('button', { name: 'Saisir un matricule' }).click();
await create.getByLabel('Matricule').fill(`RET${suffix}`);
await create.getByRole('button', { name: 'Ajouter' }).click();
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await create.getByLabel('Ouvrir un contrat maintenant').uncheck();
await create.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(`Garde${suffix}`) }).click();
await page.goto(`${page.url()}/documents`);
const upload = page.locator('form').filter({ hasText: 'Déposer' });
await upload.getByLabel('Catégorie').selectOption('OTHER');
await upload
@@ -163,14 +171,22 @@ test('une pièce échue est effectivement effacée', async ({ page }) => {
await expect(page.getByText('Durée enregistrée.')).toBeVisible();
await page.goto('/equipe');
const create = page.locator('form').filter({ hasText: 'Ajouter' });
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
// en modale, à la demande.
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
const create = page.locator('form').filter({ hasText: 'Informations salarié' });
await create.getByLabel('Prénom').fill('Purge');
await create.getByLabel('Nom', { exact: true }).fill(`Purge${suffix}`);
await create.getByLabel('Nom de famille').fill(`Purge${suffix}`);
await create.getByRole('button', { name: 'Saisir un matricule' }).click();
await create.getByLabel('Matricule').fill(`PUR${suffix}`);
await create.getByRole('button', { name: 'Ajouter' }).click();
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await create.getByLabel('Ouvrir un contrat maintenant').uncheck();
await create.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(`Purge${suffix}`) }).click();
await page.goto(`${page.url()}/documents`);
const dossier = page.url();
const upload = page.locator('form').filter({ hasText: 'Déposer' });
await upload.getByLabel('Catégorie').selectOption('REGISTER');
+14 -3
View File
@@ -27,15 +27,26 @@ async function createEmployee(page: Page, tag: string) {
const lastName = `Dossier${suffix}`;
await page.goto('/equipe');
const form = page.locator('form').filter({ hasText: 'Ajouter' });
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
// en modale, à la demande.
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
const form = page.locator('form').filter({ hasText: 'Informations salarié' });
await form.getByLabel('Prénom').fill('Inès');
await form.getByLabel('Nom', { exact: true }).fill(lastName);
await form.getByLabel('Nom de famille').fill(lastName);
await form.getByRole('button', { name: 'Saisir un matricule' }).click();
await form.getByLabel('Matricule').fill(`DOC${suffix}`);
await form.getByRole('button', { name: 'Ajouter' }).click();
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
await form.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(lastName) }).click();
await expect(page.getByRole('heading', { name: new RegExp(lastName) })).toBeVisible();
// La fiche s'ouvre sur les informations personnelles : les pièces sont un
// onglet, et une route à part entière.
await page.goto(`${page.url()}/documents`);
return { lastName, url: page.url() };
}
+136
View File
@@ -0,0 +1,136 @@
import { expect, test, type Page } from '@playwright/test';
/**
* Fiche salarié — onglets et saisie du dossier.
*
* Le dossier personnel est la première chose qu'on remplit après une embauche,
* et la dernière qu'on relit avant une déclaration. Ce parcours éprouve donc ce
* que voit un gestionnaire : les onglets tiennent leur route, et ce qui est
* saisi est encore là au rechargement.
*/
async function createEmployee(page: Page) {
const suffix = `${Date.now()}-fiche`;
const lastName = `Fiche${suffix}`;
await page.goto('/equipe');
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
// en modale, à la demande.
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
const form = page.locator('form').filter({ hasText: 'Informations salarié' });
await form.getByLabel('Prénom').fill('Awa');
await form.getByLabel('Nom de famille').fill(lastName);
await form.getByRole('button', { name: 'Saisir un matricule' }).click();
await form.getByLabel('Matricule').fill(`FIC${suffix}`);
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
await form.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(lastName) }).click();
await expect(
page.getByRole('heading', { name: new RegExp(lastName) }),
).toBeVisible();
return { lastName, url: page.url() };
}
test('les onglets de la fiche sont des routes', async ({ page }) => {
const employee = await createEmployee(page);
// Le bandeau ne bouge pas d'un onglet à l'autre : c'est ce qui dit de qui on
// parle pendant qu'on navigue.
for (const [label, segment] of [
['Contrats', '/contrats'],
['Planification et accès', '/planification'],
['Congés et Absences', '/absences'],
['Documents', '/documents'],
] as const) {
await page.getByRole('link', { name: label, exact: true }).click();
await expect(page).toHaveURL(new RegExp(`${segment}$`));
await expect(
page.getByRole('heading', { name: new RegExp(employee.lastName) }),
).toBeVisible();
}
// Rouvrir l'onglet directement par son adresse doit donner le même écran :
// une fiche s'envoie par lien.
await page.goto(`${employee.url}/contrats`);
await expect(page.getByText('Tous les contrats et avenants')).toBeVisible();
});
test('le dossier personnel se saisit et se conserve', async ({ page }) => {
const employee = await createEmployee(page);
await expect(page.getByText('Non renseigné').first()).toBeVisible();
await page
.getByRole('button', { name: 'Modifier les informations personnelles' })
.click();
await page.getByLabel('Date de naissance').fill('1988-03-12');
await page.getByLabel('Lieu de naissance').fill('Nancy');
await page.getByLabel('Nationalité').fill('France');
await page.getByLabel('Téléphone mobile').fill('+33 6 12 34 56 78');
await page.getByLabel('Ville').fill('Frouard');
await page.getByRole('button', { name: 'Enregistrer les modifications' }).click();
await expect(page.getByText('Nancy')).toBeVisible();
// Le rechargement est la seule preuve qui vaille : un écran qui affiche ce
// qu'on vient de taper ne dit rien de ce qui a été écrit.
await page.goto(employee.url);
await expect(page.getByText('Nancy')).toBeVisible();
await expect(page.getByText('Frouard')).toBeVisible();
});
test('une date de naissance à venir est refusée', async ({ page }) => {
await createEmployee(page);
await page
.getByRole('button', { name: 'Modifier les informations personnelles' })
.click();
await page.getByLabel('Date de naissance').fill('2999-01-01');
await page.getByRole('button', { name: 'Enregistrer les modifications' }).click();
await expect(page.getByText('Date de naissance invalide.')).toBeVisible();
});
test('une embauche pose le contrat en même temps que le dossier', async ({
page,
}) => {
const suffix = `${Date.now()}-embauche`;
const lastName = `Embauche${suffix}`;
await page.goto('/equipe');
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
// en modale, à la demande.
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
const form = page.locator('form').filter({ hasText: 'Informations salarié' });
await form.getByLabel('Prénom').fill('Sofia');
await form.getByLabel('Nom de famille').fill(lastName);
await form.getByRole('button', { name: 'Saisir un matricule' }).click();
await form.getByLabel('Matricule').fill(`EMB${suffix}`);
// Le contrat est proposé coché : un dossier créé sans lui n'apparaît sur
// aucune grille et ne se déclare pas.
await expect(
form.getByLabel('Ouvrir un contrat maintenant'),
).toBeChecked();
await form.getByLabel('Début du contrat').fill('2026-01-05');
await form.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
// Le rattachement paraît dans l'annuaire : un dossier sans contrat y porte
// « Sans contrat », celui-ci porte son établissement.
await expect(
page.getByRole('row', { name: new RegExp(lastName) }),
).not.toContainText('Sans contrat');
await page.getByRole('link', { name: new RegExp(lastName) }).click();
// Le bandeau porte le contrat : c'est ce qui distingue un dossier embauché
// d'un dossier ouvert.
await expect(page.getByText('Type de contrat')).toBeVisible();
await page.getByRole('link', { name: 'Contrats', exact: true }).click();
await expect(page.getByText('35 heures hebdomadaires')).toBeVisible();
});
+14 -3
View File
@@ -21,12 +21,19 @@ async function createEmployee(page: Page, tag: string) {
const email = `sacha.${suffix}@exemple.test`;
await page.goto('/equipe');
const form = page.locator('form').filter({ hasText: 'Ajouter' });
// Le formulaire d’embauche n’est plus posé au bas de la liste : il s’ouvre
// en modale, à la demande.
await page.getByRole('button', { name: 'Ajouter un collaborateur' }).click();
const form = page.locator('form').filter({ hasText: 'Informations salarié' });
await form.getByLabel('Prénom').fill(firstName);
await form.getByLabel('Nom', { exact: true }).fill(lastName);
await form.getByLabel('Nom de famille').fill(lastName);
await form.getByRole('button', { name: 'Saisir un matricule' }).click();
await form.getByLabel('Matricule').fill(`E2E${suffix}`);
await form.getByLabel('Adresse électronique').fill(email);
await form.getByRole('button', { name: 'Ajouter' }).click();
// Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut
// pas, et un salarié sans contrat doit rester créable.
await form.getByLabel('Ouvrir un contrat maintenant').uncheck();
await form.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
const row = page.getByRole('link', { name: new RegExp(lastName) });
@@ -37,6 +44,10 @@ async function createEmployee(page: Page, tag: string) {
page.getByRole('heading', { name: `${firstName} ${lastName}` }),
).toBeVisible();
// L'accès applicatif est porté par l'onglet « Documents », avec les pièces du
// dossier : la fiche s'ouvre ailleurs.
await page.goto(`${page.url()}/documents`);
return { firstName, lastName, email, url: page.url() };
}
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more