diff --git a/prisma/migrations/20260811130000_account_preferences/migration.sql b/prisma/migrations/20260811130000_account_preferences/migration.sql new file mode 100644 index 0000000..7b96f74 --- /dev/null +++ b/prisma/migrations/20260811130000_account_preferences/migration.sql @@ -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()); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bc4076b..81f61f8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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. @@ -37,6 +45,69 @@ model Account { 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) + + updatedAt DateTime @updatedAt + + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) } /// Marqueur d'installation de l'instance — une ligne, jamais deux. diff --git a/src/app/(app)/reglages/compte/page.tsx b/src/app/(app)/reglages/compte/page.tsx new file mode 100644 index 0000000..f878c2a --- /dev/null +++ b/src/app/(app)/reglages/compte/page.tsx @@ -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 ( + + + + {account === null ? ( + + + + ) : ( + + + + + )} + +

+ Le siège n’est pas un établissement : il porte l’identité juridique, là + où{' '} + + les établissements + {' '} + 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. +

+
+ ); +} diff --git a/src/app/(app)/reglages/paie/page.tsx b/src/app/(app)/reglages/paie/page.tsx new file mode 100644 index 0000000..d13138a --- /dev/null +++ b/src/app/(app)/reglages/paie/page.tsx @@ -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 ( + + + + + + + + +

+ 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{' '} + + instantanés de paie + {' '} + 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. +

+
+ ); +} diff --git a/src/app/(app)/reglages/preferences/page.tsx b/src/app/(app)/reglages/preferences/page.tsx new file mode 100644 index 0000000..bb9e2b0 --- /dev/null +++ b/src/app/(app)/reglages/preferences/page.tsx @@ -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 ( + + + + + + + + + + + + + +

+ 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. +

+
+ ); +} diff --git a/src/components/settings/AccountIdentityForm.tsx b/src/components/settings/AccountIdentityForm.tsx new file mode 100644 index 0000000..99ca424 --- /dev/null +++ b/src/components/settings/AccountIdentityForm.tsx @@ -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 ( + + + +
+ + +
+ + + +
+ + +
+ + + + {state.error} + +
+ Enregistrer + {state.ok ? ( + Compte enregistré. + ) : null} +
+
+ ); +} diff --git a/src/components/settings/PreferenceForms.tsx b/src/components/settings/PreferenceForms.tsx new file mode 100644 index 0000000..cc92ae3 --- /dev/null +++ b/src/components/settings/PreferenceForms.tsx @@ -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 ( + + {toggles.map((toggle) => ( + + ))} + + {toggles.map((toggle) => ( +
+ + + {toggle.warning ? ( +

+ {toggle.warning} +

+ ) : null} +
+ ))} + + {withEveningTime ? ( + + ) : null} + + {state.error} + +
+ Enregistrer + {state.ok ? ( + Réglages enregistrés. + ) : null} +
+
+ ); +} diff --git a/src/components/shell/navigation.ts b/src/components/shell/navigation.ts index eb5ca42..ee143ec 100644 --- a/src/components/shell/navigation.ts +++ b/src/components/shell/navigation.ts @@ -121,7 +121,10 @@ export const NAVIGATION: NavSection[] = [ 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: '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' }, diff --git a/src/domain/settings/preferences.ts b/src/domain/settings/preferences.ts new file mode 100644 index 0000000..6a976aa --- /dev/null +++ b/src/domain/settings/preferences.ts @@ -0,0 +1,162 @@ +/** + * 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'; + +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.', + }, +]; + +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]); +} diff --git a/src/server/settings/account.ts b/src/server/settings/account.ts new file mode 100644 index 0000000..2745215 --- /dev/null +++ b/src/server/settings/account.ts @@ -0,0 +1,274 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; + +import { AuthorizationError } from '@/domain/access/authorize'; +import { + 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; +} + +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, +}; + +export async function getAccountIdentity(): Promise { + 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 { + 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, ...values } = row; + return values; + }); +} + +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 { + 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 { + 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 = {}; + for (const key of scope) { + values[key] = formData.get(key) === 'on'; + } + + 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)[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'); + return { ok: true }; +} diff --git a/tests/unit/preferences.test.ts b/tests/unit/preferences.test.ts new file mode 100644 index 0000000..eb6bcf7 --- /dev/null +++ b/tests/unit/preferences.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { + minutesToTime, + PREFERENCE_KEYS, + PREFERENCE_TOGGLES, + timeToMinutes, + togglesOf, +} from '@/domain/settings/preferences'; + +describe('catalogue de préférences', () => { + it('n’a pas deux fois la même clé', () => { + expect(new Set(PREFERENCE_KEYS).size).toBe(PREFERENCE_KEYS.length); + }); + + it('range chaque préférence dans un seul groupe', () => { + const grouped = [ + ...togglesOf('planning'), + ...togglesOf('rights'), + ...togglesOf('payroll'), + ]; + expect(grouped).toHaveLength(PREFERENCE_TOGGLES.length); + }); + + it('avertit sur les réglages qui changent un calcul', () => { + // Un interrupteur qui déplace des heures entre normales et majorées ne doit + // pas se présenter comme un réglage d'affichage. + for (const key of [ + 'smoothOvertimeMonthly', + 'includeRestInNormalHours', + 'paidBreaks', + ]) { + const toggle = PREFERENCE_TOGGLES.find((entry) => entry.key === key); + expect(toggle?.warning, `${key} sans avertissement`).toBeTruthy(); + } + }); +}); + +describe('heure de bascule du soir', () => { + it('convertit dans les deux sens', () => { + expect(minutesToTime(1200)).toBe('20:00'); + expect(timeToMinutes('20:00')).toBe(1200); + expect(minutesToTime(0)).toBe('00:00'); + expect(timeToMinutes('00:00')).toBe(0); + }); + + it('refuse une heure invalide plutôt que de la ramener à zéro', () => { + // Silencieusement ramenée à minuit, une saisie fautive ferait passer tous + // les créneaux pour des créneaux du soir. + expect(timeToMinutes('24:00')).toBeNull(); + expect(timeToMinutes('20:60')).toBeNull(); + expect(timeToMinutes('8:00')).toBeNull(); + expect(timeToMinutes('')).toBeNull(); + }); +});