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>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-11 12:59:53 +02:00
1 parent f0ef6f12a1
commit d8060eb4a9
11 files changed
+945

No files matched your search

@@ -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());
+71
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.
@@ -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.
+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>
);
}
+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,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,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>
);
}
+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>
);
}
+3
View File
@@ -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' },
+162
View File
@@ -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]);
}
+274
View File
@@ -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<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, ...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<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> = {};
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<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');
return { ok: true };
}
+55
View File
@@ -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();
});
});