diff --git a/src/app/(app)/reglages/conservation/page.tsx b/src/app/(app)/reglages/conservation/page.tsx
index f9a2de1..5a03545 100644
--- a/src/app/(app)/reglages/conservation/page.tsx
+++ b/src/app/(app)/reglages/conservation/page.tsx
@@ -34,6 +34,18 @@ export default async function RetentionPage() {
const due = candidates.filter((entry) => entry.verdict === 'DUE');
const unpolicied = candidates.filter((entry) => entry.verdict === 'NO_POLICY');
+ // Ce qui appelle une décision d'abord. Trier par date de dépôt puis tronquer
+ // ferait disparaître les pièces échues derrière les plus anciennes, qui sont
+ // justement celles dont il n'y a rien à dire.
+ const PRIORITY: Record
+ {candidates.length - shown.length} pièce(s) de plus, non affichées. + Les pièces échues et suspendues figurent toujours en tête. +
+ ) : null} + {unpolicied.length > 0 ? (
{unpolicied.length} pièce(s) sans politique déclarée : elles ne
diff --git a/src/app/(app)/reglages/roles/page.tsx b/src/app/(app)/reglages/roles/page.tsx
new file mode 100644
index 0000000..708db7a
--- /dev/null
+++ b/src/app/(app)/reglages/roles/page.tsx
@@ -0,0 +1,104 @@
+import {
+ CreateRoleForm,
+ DeleteRoleForm,
+ RolePermissionsForm,
+ type CapabilityView,
+ type RoleView,
+} from '@/components/settings/RoleEditor';
+import { PageBody, PageHeader } from '@/components/shell/PageHeader';
+import { Badge } from '@/components/ui/Badge';
+import { Card, CardHeader } from '@/components/ui/Card';
+import { query } from '@/server/context';
+
+export const metadata = { title: 'Rôles et permissions · PlanFlow' };
+export const dynamic = 'force-dynamic';
+
+/**
+ * Rôles et capacités.
+ *
+ * Critère d'acceptation de WP-01 : un rôle personnalisé créé par un client doit
+ * modifier effectivement l'accès, sans changement de code. Le code ne teste
+ * jamais un nom de rôle, seulement des capacités — c'est ce qui rend cet écran
+ * possible.
+ */
+export default async function RolesPage() {
+ const { roles, capabilities } = await query(
+ 'settings.roles.manage',
+ async (db, actor) => {
+ const [rows, permissions] = await Promise.all([
+ db.role.findMany({
+ orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
+ include: {
+ permissions: { include: { permission: true } },
+ _count: { select: { memberships: true } },
+ },
+ }),
+ db.permission.findMany({ orderBy: [{ category: 'asc' }, { code: 'asc' }] }),
+ ]);
+
+ return {
+ roles: rows.map(
+ (role): RoleView => ({
+ id: role.id,
+ key: role.key,
+ name: role.name,
+ isSystem: role.isSystem,
+ memberCount: role._count.memberships,
+ granted: role.permissions.map((entry) => entry.permission.code),
+ }),
+ ),
+ capabilities: permissions.map(
+ (permission): CapabilityView => ({
+ code: permission.code,
+ category: permission.category,
+ label: permission.label,
+ // Calculé côté serveur depuis l'acteur : le client n'a pas à
+ // décider ce qu'il a le droit d'accorder.
+ grantable: actor.permissions.has(permission.code),
+ }),
+ ),
+ };
+ },
+ );
+
+ return (
+
+ Vous ne pouvez accorder que les capacités que vous détenez vous-même — + sinon cet écran suffirait à s’octroyer l’accès aux rémunérations. + Retirer reste possible : réduire un droit n’a jamais élargi le sien. +
++ Au moins un rôle doit conserver « Gérer les rôles », faute de quoi + l’organisation se fermerait dehors et le seul recours serait une + intervention en base. +
+