WP-02: locations, teams and the legal configuration register
Adds the referential models, the first database-backed settings screens, and the register the compliance matrix requires before any parameter is enforceable. The register is the point of the lot. The matrix is explicit that copying another product's configuration is not enough — each parameter must carry its value, source, effective date, population and an approver. Approval records the session's actor, never a form field: a signature you can type yourself is worth nothing. The screen names the domains that have no approved parameter yet, so the gap is visible rather than assumed closed. Two bugs of the same family, both now structurally impossible: - The Prisma scoping extension read a hand-written list of models carrying accountId. The four models added here were missing from it, so writes failed with an opaque Prisma error — and a read would have silently returned every account's rows. The list is now derived from the schema itself. - The RLS policies were likewise per-table. A new integration test fails if any table with an accountId column lacks forced RLS and both policies, which is the failure mode that hides best: nobody writes a wrong rule, someone forgets to write one. An end-to-end test signs in as a manager and confirms the settings screens refuse to render — the sidebar hiding them is a convenience, the server check is the control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
20 files changed
+1475
-17
No files matched your search
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import {
|
||||
createLocationAction,
|
||||
type ActionState,
|
||||
} from '@/server/settings/locations';
|
||||
|
||||
export function AddLocationForm() {
|
||||
const [state, formAction] = useActionState<ActionState, FormData>(
|
||||
createLocationAction,
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field label="Nom" name="name" required placeholder="Nantes Atlantis" />
|
||||
<Field
|
||||
label="SIRET"
|
||||
name="siret"
|
||||
inputMode="numeric"
|
||||
placeholder="14 chiffres"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Fuseau horaire"
|
||||
name="timezone"
|
||||
defaultValue="Europe/Paris"
|
||||
hint="Les durées se calculent depuis les instants ; le fuseau ne sert qu'à l'affichage et au regroupement par jour."
|
||||
/>
|
||||
<Field
|
||||
label="Cotisations patronales"
|
||||
name="employerContributionRate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
defaultValue="42"
|
||||
hint="Taux moyen, utilisé pour le coût prévisionnel du planning."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Créer l’établissement</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">Établissement créé.</span>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import {
|
||||
createTeamAction,
|
||||
type ActionState,
|
||||
} from '@/server/settings/locations';
|
||||
|
||||
export function AddTeamForm({ locationId }: { locationId: string }) {
|
||||
const [state, formAction] = useActionState<ActionState, FormData>(
|
||||
createTeamAction,
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-wrap items-end gap-2">
|
||||
<input type="hidden" name="locationId" value={locationId} />
|
||||
<Field label="Nouvelle équipe" name="name" placeholder="Caisse" required />
|
||||
<SubmitButton size="md">Ajouter</SubmitButton>
|
||||
<div className="w-full">
|
||||
<FormError>{state.error}</FormError>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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 { AddLocationForm } from '@/app/(app)/reglages/etablissements/AddLocationForm';
|
||||
import { AddTeamForm } from '@/app/(app)/reglages/etablissements/AddTeamForm';
|
||||
import { archiveLocationAction, listLocations } from '@/server/settings/locations';
|
||||
|
||||
export const metadata = { title: 'Établissements · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function EtablissementsPage() {
|
||||
const locations = await listLocations();
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Établissements"
|
||||
subtitle={`${locations.length} établissement${locations.length > 1 ? 's' : ''} actif${locations.length > 1 ? 's' : ''}`}
|
||||
/>
|
||||
|
||||
{locations.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title="Aucun établissement"
|
||||
description="Créez le premier établissement pour commencer à planifier."
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{locations.map((location) => (
|
||||
<Card key={location.id}>
|
||||
<CardHeader
|
||||
title={location.name}
|
||||
badge={
|
||||
<Badge tone="neutral">
|
||||
{location.teams.length} équipe
|
||||
{location.teams.length > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
<form action={archiveLocationAction}>
|
||||
<input type="hidden" name="id" value={location.id} />
|
||||
<Button size="sm" variant="ghost" type="submit">
|
||||
Archiver
|
||||
</Button>
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
|
||||
<dl className="grid gap-x-8 gap-y-2 px-4 py-3 text-sm [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]">
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">SIRET</dt>
|
||||
<dd className="tnum">{location.siret ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">Fuseau horaire</dt>
|
||||
<dd>{location.timezone}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-micro text-ink-3">
|
||||
Cotisations patronales
|
||||
</dt>
|
||||
<dd className="tnum">{location.employerContributionRate} %</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="border-t border-line-1 px-4 py-3">
|
||||
<p className="mb-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
Équipes
|
||||
</p>
|
||||
<ul className="mb-3 flex flex-wrap gap-1.5">
|
||||
{location.teams.length === 0 ? (
|
||||
<li className="text-sm text-ink-3">Aucune équipe</li>
|
||||
) : (
|
||||
location.teams.map((team) => (
|
||||
<li key={team.id}>
|
||||
<Badge tone="neutral">{team.name}</Badge>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
<AddTeamForm locationId={location.id} />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Nouvel établissement" />
|
||||
<div className="p-4">
|
||||
<AddLocationForm />
|
||||
</div>
|
||||
</Card>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||
import { LEGAL_DOMAINS } from '@/domain/legal/domains';
|
||||
import {
|
||||
addLegalEntryAction,
|
||||
type ActionState,
|
||||
} from '@/server/settings/legal-register';
|
||||
|
||||
export function AddEntryForm() {
|
||||
const [state, formAction] = useActionState<ActionState, FormData>(
|
||||
addLegalEntryAction,
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-3">
|
||||
<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">Domaine</span>
|
||||
<select
|
||||
name="domain"
|
||||
required
|
||||
className="h-9 rounded-2 border border-line-2 bg-surface px-3 text-sm text-ink-1 outline-none focus-visible:border-focus"
|
||||
>
|
||||
{LEGAL_DOMAINS.map((domain) => (
|
||||
<option key={domain.key} value={domain.key}>
|
||||
{domain.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Field
|
||||
label="Paramètre"
|
||||
name="key"
|
||||
required
|
||||
placeholder="Durée quotidienne maximale"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field label="Valeur" name="value" required placeholder="10 h" />
|
||||
<Field
|
||||
label="Source"
|
||||
name="source"
|
||||
required
|
||||
placeholder="IDCC 1517, texte consolidé Legifrance du…"
|
||||
hint="Texte, article, ou référence de l’accord. Une valeur sans source n’est pas opposable."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field
|
||||
label="Date d’effet"
|
||||
name="effectiveFrom"
|
||||
type="date"
|
||||
required
|
||||
defaultValue="2026-01-01"
|
||||
/>
|
||||
<Field
|
||||
label="Population"
|
||||
name="population"
|
||||
required
|
||||
defaultValue="Tous les salariés"
|
||||
placeholder="Cadres autonomes niveaux VII à IX"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Consigner</SubmitButton>
|
||||
{state.ok ? (
|
||||
<span className="text-xs text-ok-soft-ink">
|
||||
Paramètre consigné — il reste à approuver.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { AddEntryForm } from '@/app/(app)/reglages/registre/AddEntryForm';
|
||||
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 { LEGAL_DOMAINS } from '@/domain/legal/domains';
|
||||
import {
|
||||
approveLegalEntryAction,
|
||||
readLegalRegister,
|
||||
} from '@/server/settings/legal-register';
|
||||
|
||||
export const metadata = { title: 'Registre de paramétrage · PlanFlow' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const DOMAIN_LABELS = new Map<string, string>(
|
||||
LEGAL_DOMAINS.map((domain) => [domain.key, domain.label]),
|
||||
);
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
|
||||
|
||||
export default async function RegistrePage() {
|
||||
const register = await readLegalRegister();
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Registre de paramétrage juridique"
|
||||
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
|
||||
title="Domaines sans paramètre approuvé"
|
||||
badge={<Badge tone="warn">{register.missingDomains.length}</Badge>}
|
||||
/>
|
||||
<ul className="flex flex-wrap gap-1.5 p-4">
|
||||
{register.missingDomains.map((domain) => (
|
||||
<li key={domain}>
|
||||
<Badge tone="warn">{domain}</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Paramètres enregistrés" />
|
||||
{register.entries.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Registre vide"
|
||||
description="Aucun paramètre n’a encore été consigné. Tant que le registre est vide, aucune valeur appliquée par l’application n’est justifiée."
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] 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">Domaine</th>
|
||||
<th className="px-4 py-2.5">Paramètre</th>
|
||||
<th className="px-4 py-2.5">Valeur</th>
|
||||
<th className="px-4 py-2.5">Source</th>
|
||||
<th className="px-4 py-2.5">Effet</th>
|
||||
<th className="px-4 py-2.5">Population</th>
|
||||
<th className="px-4 py-2.5">Approbation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{register.entries.map((entry) => (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className="border-b border-line-1 last:border-b-0"
|
||||
>
|
||||
<td className="px-4 py-2.5 text-ink-2">
|
||||
{DOMAIN_LABELS.get(entry.domain) ?? entry.domain}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-medium">{entry.key}</td>
|
||||
<td className="tnum px-4 py-2.5">{entry.value}</td>
|
||||
<td className="px-4 py-2.5 text-ink-2">{entry.source}</td>
|
||||
<td className="tnum px-4 py-2.5 text-ink-2">
|
||||
{dateFormat.format(entry.effectiveFrom)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-2">
|
||||
{entry.population}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{entry.approvedAt ? (
|
||||
<Badge tone="ok">
|
||||
Approuvé le {dateFormat.format(entry.approvedAt)}
|
||||
</Badge>
|
||||
) : (
|
||||
<form action={approveLegalEntryAction}>
|
||||
<input type="hidden" name="id" value={entry.id} />
|
||||
<Button size="sm" type="submit">
|
||||
Approuver
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Consigner un paramètre" />
|
||||
<div className="p-4">
|
||||
<AddEntryForm />
|
||||
</div>
|
||||
</Card>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user