diff --git a/packages/app/.env.example b/packages/app/.env.example
index b9b4796f6..59389ce90 100644
--- a/packages/app/.env.example
+++ b/packages/app/.env.example
@@ -23,3 +23,4 @@ S3_BUCKET_NAME="egapro-dev-app"
CLAMAV_HOST="localhost"
CLAMAV_PORT="3310"
ADMIN_EMAILS="test@fia1.fr"
+EGAPRO_AUDIT_CLEANUP_TOKEN="change-me-to-a-32-chars-minimum-token"
diff --git a/packages/app/drizzle/0026_add_referents_table.sql b/packages/app/drizzle/0026_add_referents_table.sql
new file mode 100644
index 000000000..779a9f0ac
--- /dev/null
+++ b/packages/app/drizzle/0026_add_referents_table.sql
@@ -0,0 +1,22 @@
+-- Create referent type enum and referents table for admin referent management (issue #3182)
+DO $$ BEGIN
+ CREATE TYPE "public"."referent_type" AS ENUM('email', 'url');
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
+
+CREATE TABLE IF NOT EXISTS "app_referent" (
+ "id" varchar(255) PRIMARY KEY NOT NULL,
+ "region" varchar(3) NOT NULL,
+ "county" varchar(3),
+ "name" varchar(255) NOT NULL,
+ "type" "referent_type" NOT NULL,
+ "value" varchar(500) NOT NULL,
+ "principal" boolean DEFAULT false NOT NULL,
+ "substitute_name" varchar(255),
+ "substitute_email" varchar(255),
+ "created_at" timestamp with time zone,
+ "updated_at" timestamp with time zone
+);
+
+CREATE INDEX IF NOT EXISTS "referent_region_idx" ON "app_referent" USING btree ("region");
diff --git a/packages/app/drizzle/meta/_journal.json b/packages/app/drizzle/meta/_journal.json
index e54c6ec70..9770dadd0 100644
--- a/packages/app/drizzle/meta/_journal.json
+++ b/packages/app/drizzle/meta/_journal.json
@@ -183,6 +183,13 @@
"when": 1775700000000,
"tag": "0025_add_user_is_admin",
"breakpoints": true
+ },
+ {
+ "idx": 26,
+ "version": "7",
+ "when": 1775800000000,
+ "tag": "0026_add_referents_table",
+ "breakpoints": true
}
]
}
diff --git a/packages/app/src/app/admin/liste-referents/page.tsx b/packages/app/src/app/admin/liste-referents/page.tsx
new file mode 100644
index 000000000..b221c17bc
--- /dev/null
+++ b/packages/app/src/app/admin/liste-referents/page.tsx
@@ -0,0 +1,5 @@
+import { AdminReferentsPage } from "~/modules/admin";
+
+export default function Page() {
+ return ;
+}
diff --git a/packages/app/src/app/api/public/referents-egalite-professionnelle/__tests__/route.test.ts b/packages/app/src/app/api/public/referents-egalite-professionnelle/__tests__/route.test.ts
new file mode 100644
index 000000000..2c67922bb
--- /dev/null
+++ b/packages/app/src/app/api/public/referents-egalite-professionnelle/__tests__/route.test.ts
@@ -0,0 +1,116 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ dbSelect: vi.fn(),
+}));
+
+vi.mock("~/server/db", () => ({
+ db: { select: mocks.dbSelect },
+}));
+
+vi.mock("~/server/db/schema", () => ({
+ referents: {
+ region: "region",
+ county: "county",
+ name: "name",
+ type: "type",
+ value: "value",
+ principal: "principal",
+ substituteName: "substituteName",
+ substituteEmail: "substituteEmail",
+ },
+}));
+
+vi.mock("drizzle-orm", () => ({
+ asc: (col: unknown) => ({ asc: col }),
+}));
+
+function setRows(rows: unknown[]) {
+ mocks.dbSelect.mockReturnValue({
+ from: () => ({
+ orderBy: () => Promise.resolve(rows),
+ }),
+ });
+}
+
+describe("/api/public/referents-egalite-professionnelle", () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it("returns JSON by default", async () => {
+ setRows([
+ {
+ region: "11",
+ county: "75",
+ name: "Jean",
+ type: "email",
+ value: "j@gouv.fr",
+ principal: true,
+ substituteName: null,
+ substituteEmail: null,
+ },
+ ]);
+
+ const { GET } = await import("../route");
+ const response = await GET(
+ new Request(
+ "http://localhost/api/public/referents-egalite-professionnelle",
+ ),
+ );
+
+ expect(response.headers.get("Content-Type")).toMatch(/application\/json/);
+ const body = await response.json();
+ expect(body).toHaveLength(1);
+ expect(body[0]).toMatchObject({ name: "Jean", region: "11" });
+ });
+
+ it("returns CSV with headers and region/county labels when format=csv", async () => {
+ setRows([
+ {
+ region: "11",
+ county: "75",
+ name: "Jean",
+ type: "email",
+ value: 'j"@gouv.fr',
+ principal: true,
+ substituteName: "Marie",
+ substituteEmail: "m@gouv.fr",
+ },
+ {
+ region: "11",
+ county: null,
+ name: "Sans département",
+ type: "url",
+ value: "https://gouv.fr",
+ principal: false,
+ substituteName: null,
+ substituteEmail: null,
+ },
+ ]);
+
+ const { GET } = await import("../route");
+ const response = await GET(
+ new Request(
+ "http://localhost/api/public/referents-egalite-professionnelle?format=csv",
+ ),
+ );
+
+ expect(response.headers.get("Content-Type")).toContain("text/csv");
+ expect(response.headers.get("Content-Disposition")).toContain(
+ "referents_egalite_professionnelle.csv",
+ );
+
+ const csv = await response.text();
+ const lines = csv.split("\n");
+ expect(lines[0]).toBe(
+ "Région;Département;Nom;Type;Valeur;Principal;Nom suppléant;Email suppléant",
+ );
+ expect(lines[1]).toContain('"Jean"');
+ expect(lines[1]).toContain('"j""@gouv.fr"');
+ expect(lines[1]).toContain('"Oui"');
+ expect(lines[2]).toContain('"Sans département"');
+ expect(lines[2]).toContain('"Non"');
+ expect(lines[2]).toContain('""');
+ });
+});
diff --git a/packages/app/src/app/api/public/referents-egalite-professionnelle/route.ts b/packages/app/src/app/api/public/referents-egalite-professionnelle/route.ts
new file mode 100644
index 000000000..f9f8da7e7
--- /dev/null
+++ b/packages/app/src/app/api/public/referents-egalite-professionnelle/route.ts
@@ -0,0 +1,75 @@
+import { asc } from "drizzle-orm";
+import { NextResponse } from "next/server";
+import type { CountyCode, RegionCode } from "~/modules/domain";
+import { COUNTIES, REGIONS } from "~/modules/domain";
+import { db } from "~/server/db";
+import { referents } from "~/server/db/schema";
+
+async function getAllReferents() {
+ return db
+ .select({
+ region: referents.region,
+ county: referents.county,
+ name: referents.name,
+ type: referents.type,
+ value: referents.value,
+ principal: referents.principal,
+ substituteName: referents.substituteName,
+ substituteEmail: referents.substituteEmail,
+ })
+ .from(referents)
+ .orderBy(asc(referents.region), asc(referents.county));
+}
+
+function formatCsv(rows: Awaited>): string {
+ const headers = [
+ "Région",
+ "Département",
+ "Nom",
+ "Type",
+ "Valeur",
+ "Principal",
+ "Nom suppléant",
+ "Email suppléant",
+ ];
+
+ const csvRows = [
+ headers.join(";"),
+ ...rows.map((r) =>
+ [
+ REGIONS[r.region as RegionCode] ?? r.region,
+ r.county ? (COUNTIES[r.county as CountyCode] ?? r.county) : "",
+ r.name,
+ r.type,
+ r.value,
+ r.principal ? "Oui" : "Non",
+ r.substituteName ?? "",
+ r.substituteEmail ?? "",
+ ]
+ .map((val) => `"${String(val).replace(/"/g, '""')}"`)
+ .join(";"),
+ ),
+ ];
+
+ return csvRows.join("\n");
+}
+
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const format = searchParams.get("format") ?? "json";
+
+ const rows = await getAllReferents();
+
+ if (format === "csv") {
+ const csv = formatCsv(rows);
+ return new NextResponse(csv, {
+ headers: {
+ "Content-Type": "text/csv; charset=utf-8",
+ "Content-Disposition":
+ 'attachment; filename="referents_egalite_professionnelle.csv"',
+ },
+ });
+ }
+
+ return NextResponse.json(rows);
+}
diff --git a/packages/app/src/e2e/admin-referents.e2e.ts b/packages/app/src/e2e/admin-referents.e2e.ts
new file mode 100644
index 000000000..c6a158c0d
--- /dev/null
+++ b/packages/app/src/e2e/admin-referents.e2e.ts
@@ -0,0 +1,22 @@
+import { expect, test } from "@playwright/test";
+
+test("admin user can access /admin/liste-referents", async ({ page }) => {
+ await page.goto("/admin/liste-referents");
+ await expect(
+ page.getByRole("heading", { name: "Liste des référents Egapro", level: 1 }),
+ ).toBeVisible();
+});
+
+test("unauthenticated user visiting /admin/liste-referents is redirected to /login", async ({
+ browser,
+}) => {
+ const anonCtx = await browser.newContext({ storageState: undefined });
+ try {
+ const page = await anonCtx.newPage();
+ await page.goto("/admin/liste-referents");
+ await page.waitForURL("**/login**");
+ expect(page.url()).toContain("/login");
+ } finally {
+ await anonCtx.close();
+ }
+});
diff --git a/packages/app/src/modules/admin/AdminNavigation.tsx b/packages/app/src/modules/admin/AdminNavigation.tsx
index 88fb03bc5..64008c22e 100644
--- a/packages/app/src/modules/admin/AdminNavigation.tsx
+++ b/packages/app/src/modules/admin/AdminNavigation.tsx
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
const adminLinks = [
{ href: "/admin", label: "Accueil" },
{ href: "/admin/impersonate", label: "Mimoquer un Siren" },
+ { href: "/admin/liste-referents", label: "Référents" },
] as const;
export function AdminNavigation() {
diff --git a/packages/app/src/modules/admin/__tests__/AdminNavigation.test.tsx b/packages/app/src/modules/admin/__tests__/AdminNavigation.test.tsx
index 9a953d1dc..50f28e2fe 100644
--- a/packages/app/src/modules/admin/__tests__/AdminNavigation.test.tsx
+++ b/packages/app/src/modules/admin/__tests__/AdminNavigation.test.tsx
@@ -22,6 +22,7 @@ describe("AdminNavigation", () => {
expect(
screen.getByRole("link", { name: "Mimoquer un Siren" }),
).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Référents" })).toBeInTheDocument();
});
it("marks /admin as active when on /admin", () => {
@@ -48,4 +49,16 @@ describe("AdminNavigation", () => {
"aria-current",
);
});
+
+ it("marks /admin/liste-referents as active when on that page", () => {
+ (usePathname as Mock).mockReturnValue("/admin/liste-referents");
+ render();
+ expect(screen.getByRole("link", { name: "Référents" })).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ expect(screen.getByRole("link", { name: "Accueil" })).not.toHaveAttribute(
+ "aria-current",
+ );
+ });
});
diff --git a/packages/app/src/modules/admin/index.ts b/packages/app/src/modules/admin/index.ts
index 5f1abad1b..4da810051 100644
--- a/packages/app/src/modules/admin/index.ts
+++ b/packages/app/src/modules/admin/index.ts
@@ -5,6 +5,7 @@ export {
AdminDeclarationsPage,
} from "./declarations";
export { ImpersonatePage } from "./impersonate/ImpersonatePage";
+export { AdminReferentsPage } from "./referents";
export {
type ImpersonateSearchInput,
impersonateSearchSchema,
diff --git a/packages/app/src/modules/admin/referents/AdminReferentsPage.tsx b/packages/app/src/modules/admin/referents/AdminReferentsPage.tsx
new file mode 100644
index 000000000..21dee5c5d
--- /dev/null
+++ b/packages/app/src/modules/admin/referents/AdminReferentsPage.tsx
@@ -0,0 +1,279 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+import { Suspense, useCallback, useState } from "react";
+
+import type { CountyCode, RegionCode } from "~/modules/domain";
+import { api } from "~/trpc/react";
+import { DeleteModal, useDeleteModal } from "./DeleteConfirmationModal";
+import { ImportReferentsModal, useImportModal } from "./ImportReferentsModal";
+import { ReferentFormModal, useReferentFormModal } from "./ReferentFormModal";
+import { ReferentTable } from "./ReferentTable";
+import { SearchForm } from "./SearchForm";
+import type { ReferentFormValues, SortColumn } from "./schemas";
+import { DEFAULT_PAGE_SIZE } from "./schemas";
+import type { ReferentSearchRow } from "./types";
+
+function ExportButton() {
+ const { data, refetch, isFetching } = api.adminReferents.exportAll.useQuery(
+ undefined,
+ { enabled: false },
+ );
+
+ const handleExport = useCallback(
+ async (format: "json" | "csv") => {
+ const result = data ?? (await refetch()).data;
+ if (!result) return;
+
+ let content: string;
+ let mimeType: string;
+ let extension: string;
+
+ if (format === "csv") {
+ const headers = [
+ "region",
+ "county",
+ "name",
+ "type",
+ "value",
+ "principal",
+ "substituteName",
+ "substituteEmail",
+ ];
+ const csvRows = [
+ headers.join(";"),
+ ...result.map((r) =>
+ headers
+ .map((h) => {
+ const val = r[h as keyof typeof r] ?? "";
+ return `"${String(val).replace(/"/g, '""')}"`;
+ })
+ .join(";"),
+ ),
+ ];
+ content = csvRows.join("\n");
+ mimeType = "text/csv;charset=utf-8";
+ extension = "csv";
+ } else {
+ content = JSON.stringify(result, null, 2);
+ mimeType = "application/json";
+ extension = "json";
+ }
+
+ const blob = new Blob([content], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `referents.${extension}`;
+ link.click();
+ URL.revokeObjectURL(url);
+ },
+ [data, refetch],
+ );
+
+ return (
+
+
+
+
+ );
+}
+
+function ReferentsContent() {
+ const searchParams = useSearchParams();
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+ const [deleteError, setDeleteError] = useState(null);
+ const [editingReferent, setEditingReferent] =
+ useState(null);
+ const {
+ modalRef: deleteModalRef,
+ open: openDeleteModal,
+ close: closeDeleteModal,
+ } = useDeleteModal();
+ const { createRef, editRef, openCreate, closeCreate, openEdit, closeEdit } =
+ useReferentFormModal();
+ const {
+ modalRef: importModalRef,
+ open: openImport,
+ close: closeImport,
+ } = useImportModal();
+
+ const input = {
+ query: searchParams.get("query") ?? undefined,
+ region: (searchParams.get("region") as RegionCode) || undefined,
+ county: (searchParams.get("county") as CountyCode) || undefined,
+ page: Number(searchParams.get("page") ?? "1"),
+ pageSize: Number(searchParams.get("pageSize") ?? String(DEFAULT_PAGE_SIZE)),
+ sortBy: (searchParams.get("sortBy") as SortColumn) ?? "region",
+ sortOrder: (searchParams.get("sortOrder") as "asc" | "desc") ?? "asc",
+ };
+
+ const { data, isLoading, refetch } =
+ api.adminReferents.search.useQuery(input);
+ const deleteMutation = api.adminReferents.delete.useMutation({
+ onSuccess: () => {
+ setSelectedIds(new Set());
+ setDeleteError(null);
+ refetch();
+ },
+ onError: () => {
+ setDeleteError("La suppression a échoué. Veuillez réessayer.");
+ },
+ });
+ const createMutation = api.adminReferents.create.useMutation({
+ onSuccess: () => refetch(),
+ });
+ const updateMutation = api.adminReferents.update.useMutation({
+ onSuccess: () => refetch(),
+ });
+
+ const handleDelete = useCallback(() => {
+ if (selectedIds.size === 0) return;
+ deleteMutation.mutate({ ids: [...selectedIds] });
+ }, [selectedIds, deleteMutation]);
+
+ const handleEdit = useCallback(
+ (row: ReferentSearchRow) => {
+ setEditingReferent(row);
+ openEdit();
+ },
+ [openEdit],
+ );
+
+ const handleCreateSubmit = useCallback(
+ (data: ReferentFormValues & { id?: string }) => {
+ createMutation.mutate(
+ data as Parameters[0],
+ );
+ },
+ [createMutation],
+ );
+
+ const handleEditSubmit = useCallback(
+ (data: ReferentFormValues & { id?: string }) => {
+ if (!data.id) return;
+ updateMutation.mutate(
+ data as Parameters[0],
+ );
+ },
+ [updateMutation],
+ );
+
+ if (isLoading) {
+ return Chargement...
;
+ }
+
+ return (
+ <>
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+
+
+
+
+ {deleteError && (
+
+ )}
+ {selectedIds.size > 0 && (
+
+
+
+ )}
+ {data && (
+
+ )}
+
+
+
+ refetch()}
+ />
+ >
+ );
+}
+
+export function AdminReferentsPage() {
+ return (
+ <>
+ Liste des référents Egapro
+ Chargement...
}>
+
+
+ >
+ );
+}
diff --git a/packages/app/src/modules/admin/referents/DeleteConfirmationModal.tsx b/packages/app/src/modules/admin/referents/DeleteConfirmationModal.tsx
new file mode 100644
index 000000000..8fea7e7d3
--- /dev/null
+++ b/packages/app/src/modules/admin/referents/DeleteConfirmationModal.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import { useCallback } from "react";
+
+import { useDsfrModal } from "~/modules/shared";
+
+const MODAL_ID = "admin-delete-referents-modal";
+
+export function useDeleteModal() {
+ return useDsfrModal();
+}
+
+type ModalProps = {
+ count: number;
+ onConfirm: () => void;
+ modalRef: React.RefObject;
+ onClose: () => void;
+};
+
+export function DeleteModal({
+ count,
+ onConfirm,
+ modalRef,
+ onClose,
+}: ModalProps) {
+ const handleConfirm = useCallback(() => {
+ onClose();
+ onConfirm();
+ }, [onClose, onConfirm]);
+
+ return (
+
+ );
+}
diff --git a/packages/app/src/modules/admin/referents/ImportReferentsModal.tsx b/packages/app/src/modules/admin/referents/ImportReferentsModal.tsx
new file mode 100644
index 000000000..3e41aafda
--- /dev/null
+++ b/packages/app/src/modules/admin/referents/ImportReferentsModal.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+import { useCallback, useState } from "react";
+
+import { useDsfrModal } from "~/modules/shared";
+import { api } from "~/trpc/react";
+
+import { importReferentsSchema } from "./schemas";
+
+const MODAL_ID = "admin-import-referents-modal";
+
+export function useImportModal() {
+ return useDsfrModal();
+}
+
+type Props = {
+ modalRef: React.RefObject;
+ onClose: () => void;
+ onSuccess: () => void;
+};
+
+export function ImportReferentsModal({ modalRef, onClose, onSuccess }: Props) {
+ const [file, setFile] = useState(null);
+ const [error, setError] = useState("");
+ const importMutation = api.adminReferents.import.useMutation({
+ onSuccess: () => {
+ setFile(null);
+ setError("");
+ onClose();
+ onSuccess();
+ },
+ onError: (err) => {
+ setError(err.message);
+ },
+ });
+
+ const handleFileChange = useCallback(
+ (e: React.ChangeEvent) => {
+ setFile(e.target.files?.[0] ?? null);
+ setError("");
+ },
+ [],
+ );
+
+ const handleImport = useCallback(async () => {
+ if (!file) return;
+ setError("");
+
+ try {
+ const text = await file.text();
+ const json: unknown = JSON.parse(text);
+ const parsed = importReferentsSchema.safeParse(json);
+
+ if (!parsed.success) {
+ setError(
+ `Format invalide : ${parsed.error.issues.map((i) => i.message).join(", ")}`,
+ );
+ return;
+ }
+
+ importMutation.mutate(parsed.data);
+ } catch {
+ setError("Le fichier n'est pas un JSON valide.");
+ }
+ }, [file, importMutation]);
+
+ return (
+
+ );
+}
diff --git a/packages/app/src/modules/admin/referents/ReferentFormFields.tsx b/packages/app/src/modules/admin/referents/ReferentFormFields.tsx
new file mode 100644
index 000000000..e4cf295fc
--- /dev/null
+++ b/packages/app/src/modules/admin/referents/ReferentFormFields.tsx
@@ -0,0 +1,191 @@
+"use client";
+
+import type {
+ FieldErrors,
+ UseFormRegister,
+ UseFormWatch,
+} from "react-hook-form";
+import type { RegionCode } from "~/modules/domain";
+import { COUNTIES, REGIONS, REGIONS_TO_COUNTIES } from "~/modules/domain";
+
+import type { ReferentFormValues } from "./schemas";
+
+type Props = {
+ modalId: string;
+ register: UseFormRegister;
+ watch: UseFormWatch;
+ errors: FieldErrors;
+};
+
+const sortedRegions = (Object.entries(REGIONS) as [RegionCode, string][]).sort(
+ (a, b) => a[1].localeCompare(b[1], "fr"),
+);
+
+export function ReferentFormFields({
+ modalId,
+ register,
+ watch,
+ errors,
+}: Props) {
+ const selectedRegion = watch("region") as RegionCode | "";
+ const selectedType = watch("type");
+ const countyOptions = selectedRegion
+ ? REGIONS_TO_COUNTIES[selectedRegion as RegionCode]
+ : [];
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {errors.name &&
{errors.name.message}
}
+
+
+
+
+
+ {errors.region && (
+
{errors.region.message}
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {errors.value && (
+
{errors.value.message}
+ )}
+
+
+
+ >
+ );
+}
diff --git a/packages/app/src/modules/admin/referents/ReferentFormModal.tsx b/packages/app/src/modules/admin/referents/ReferentFormModal.tsx
new file mode 100644
index 000000000..379eda1d6
--- /dev/null
+++ b/packages/app/src/modules/admin/referents/ReferentFormModal.tsx
@@ -0,0 +1,170 @@
+"use client";
+
+import { useCallback, useEffect } from "react";
+import type { CountyCode, RegionCode } from "~/modules/domain";
+import { useDsfrModal, useZodForm } from "~/modules/shared";
+
+import { ReferentFormFields } from "./ReferentFormFields";
+import type { ReferentFormValues } from "./schemas";
+import { referentFormSchema } from "./schemas";
+import type { ReferentSearchRow } from "./types";
+
+type Props = {
+ mode: "create" | "edit";
+ referent?: ReferentSearchRow | null;
+ onSubmit: (data: ReferentFormValues & { id?: string }) => void;
+};
+
+const CREATE_MODAL_ID = "admin-create-referent-modal";
+const EDIT_MODAL_ID = "admin-edit-referent-modal";
+
+export function useReferentFormModal() {
+ const create = useDsfrModal();
+ const edit = useDsfrModal();
+ return {
+ createRef: create.modalRef,
+ editRef: edit.modalRef,
+ openCreate: create.open,
+ closeCreate: create.close,
+ openEdit: edit.open,
+ closeEdit: edit.close,
+ };
+}
+
+export function ReferentFormModal({
+ mode,
+ referent,
+ onSubmit,
+ modalRef,
+ onClose,
+}: Props & {
+ modalRef: React.RefObject;
+ onClose: () => void;
+}) {
+ const modalId = mode === "create" ? CREATE_MODAL_ID : EDIT_MODAL_ID;
+
+ const {
+ register,
+ handleSubmit,
+ reset,
+ watch,
+ formState: { errors, isValid },
+ } = useZodForm(referentFormSchema, {
+ defaultValues: {
+ region: "" as RegionCode,
+ county: "",
+ name: "",
+ type: "email" as const,
+ value: "",
+ principal: false,
+ substituteName: "",
+ substituteEmail: "",
+ },
+ mode: "onChange",
+ });
+
+ useEffect(() => {
+ if (mode === "edit" && referent) {
+ reset({
+ region: referent.region as RegionCode,
+ county: (referent.county as CountyCode) ?? "",
+ name: referent.name,
+ type: referent.type,
+ value: referent.value,
+ principal: referent.principal,
+ substituteName: referent.substituteName ?? "",
+ substituteEmail: referent.substituteEmail ?? "",
+ });
+ } else if (mode === "create") {
+ reset({
+ region: "" as RegionCode,
+ county: "",
+ name: "",
+ type: "email",
+ value: "",
+ principal: false,
+ substituteName: "",
+ substituteEmail: "",
+ });
+ }
+ }, [mode, referent, reset]);
+
+ const doSubmit = useCallback(
+ (data: ReferentFormValues) => {
+ onSubmit(
+ mode === "edit" && referent ? { ...data, id: referent.id } : data,
+ );
+ onClose();
+ reset();
+ },
+ [mode, referent, onSubmit, onClose, reset],
+ );
+
+ return (
+
+ );
+}
diff --git a/packages/app/src/modules/admin/referents/ReferentTable.tsx b/packages/app/src/modules/admin/referents/ReferentTable.tsx
new file mode 100644
index 000000000..7aded2339
--- /dev/null
+++ b/packages/app/src/modules/admin/referents/ReferentTable.tsx
@@ -0,0 +1,239 @@
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import { useCallback } from "react";
+import type { CountyCode, RegionCode } from "~/modules/domain";
+import { COUNTIES, REGIONS } from "~/modules/domain";
+import { DsfrTable } from "~/modules/shared/DsfrTable";
+
+import type { SortColumn } from "./schemas";
+import { SORT_COLUMNS } from "./schemas";
+import { COLUMN_LABELS } from "./shared/constants";
+import type { ReferentSearchRow } from "./types";
+
+type Props = {
+ rows: ReferentSearchRow[];
+ total: number;
+ page: number;
+ totalPages: number;
+ sortBy: string;
+ sortOrder: string;
+ selectedIds: Set;
+ onSelectionChange: (ids: Set) => void;
+ onEdit: (row: ReferentSearchRow) => void;
+};
+
+export function ReferentTable({
+ rows,
+ total,
+ page,
+ totalPages,
+ sortBy,
+ sortOrder,
+ selectedIds,
+ onSelectionChange,
+ onEdit,
+}: Props) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const toggleOne = useCallback(
+ (id: string) => {
+ const next = new Set(selectedIds);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ }
+ onSelectionChange(next);
+ },
+ [selectedIds, onSelectionChange],
+ );
+
+ const handleSort = useCallback(
+ (column: SortColumn) => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (sortBy === column) {
+ params.set("sortOrder", sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ params.set("sortBy", column);
+ params.set("sortOrder", "asc");
+ }
+ params.set("page", "1");
+ router.push(`/admin/liste-referents?${params.toString()}`);
+ },
+ [searchParams, sortBy, sortOrder, router],
+ );
+
+ const handlePageChange = useCallback(
+ (newPage: number) => {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set("page", String(newPage));
+ router.push(`/admin/liste-referents?${params.toString()}`);
+ },
+ [searchParams, router],
+ );
+
+ const ariaSort = (column: SortColumn) => {
+ if (sortBy !== column) return undefined;
+ return sortOrder === "asc"
+ ? ("ascending" as const)
+ : ("descending" as const);
+ };
+
+ const sortIcon = (column: SortColumn) => {
+ if (sortBy !== column) return null;
+ return {sortOrder === "asc" ? " ▲" : " ▼"};
+ };
+
+ return (
+ <>
+
+ {total} résultat{total > 1 ? "s" : ""}
+
+
+
+
+ |
+ Sélectionner
+ |
+ {SORT_COLUMNS.map((col) => (
+
+
+ |
+ ))}
+ Actions |
+
+
+
+ {rows.map((row) => (
+
+ |
+
+ toggleOne(row.id)}
+ type="checkbox"
+ />
+
+
+ |
+
+ {REGIONS[row.region as RegionCode] ?? row.region} ({row.region})
+ |
+
+ {row.county
+ ? `${COUNTIES[row.county as CountyCode] ?? row.county} (${row.county})`
+ : "—"}
+ |
+
+ {row.name}
+ {row.substituteName && (
+ <>
+
+
+ {row.substituteName}
+
+ >
+ )}
+ |
+
+
+
+ {row.value}
+
+ {row.substituteEmail && (
+ <>
+
+
+ {row.substituteEmail}
+
+ >
+ )}
+ |
+
+
+
+ {row.principal ? "Oui" : "Non"}
+
+ |
+
+
+ |
+
+ ))}
+ {rows.length === 0 && (
+
+ | Aucun référent trouvé. |
+
+ )}
+
+
+ {totalPages > 1 && (
+
+ Page {page} sur {totalPages} —{" "}
+ {page > 1 && (
+
+ )}
+ {page < totalPages && (
+
+ )}
+
+ )}
+ >
+ );
+}
diff --git a/packages/app/src/modules/admin/referents/SearchForm.tsx b/packages/app/src/modules/admin/referents/SearchForm.tsx
new file mode 100644
index 000000000..72c9485cc
--- /dev/null
+++ b/packages/app/src/modules/admin/referents/SearchForm.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import { useCallback } from "react";
+import type { RegionCode } from "~/modules/domain";
+import { COUNTIES, REGIONS, REGIONS_TO_COUNTIES } from "~/modules/domain";
+import { useZodForm } from "~/modules/shared";
+
+import type { SearchReferentsFormValues } from "./schemas";
+import { searchReferentsFormSchema } from "./schemas";
+
+const sortedRegions = (Object.entries(REGIONS) as [RegionCode, string][]).sort(
+ (a, b) => a[1].localeCompare(b[1], "fr"),
+);
+
+export function SearchForm() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const { register, handleSubmit, reset, watch } = useZodForm(
+ searchReferentsFormSchema,
+ {
+ defaultValues: {
+ query: searchParams.get("query") ?? "",
+ region: searchParams.get("region") ?? "",
+ county: searchParams.get("county") ?? "",
+ },
+ },
+ );
+
+ const selectedRegion = watch("region") as RegionCode | "" | undefined;
+ const countyOptions = selectedRegion
+ ? REGIONS_TO_COUNTIES[selectedRegion as RegionCode]
+ : [];
+
+ const onSubmit = useCallback(
+ (data: SearchReferentsFormValues) => {
+ const params = new URLSearchParams();
+ for (const [key, value] of Object.entries(data)) {
+ if (value !== undefined && value !== "") {
+ params.set(key, String(value));
+ }
+ }
+ params.set("page", "1");
+ router.push(`/admin/liste-referents?${params.toString()}`);
+ },
+ [router],
+ );
+
+ const handleReset = useCallback(() => {
+ reset({ query: "", region: "", county: "" });
+ router.push("/admin/liste-referents");
+ }, [reset, router]);
+
+ return (
+
+ );
+}
diff --git a/packages/app/src/modules/admin/referents/__tests__/AdminReferentsPage.test.tsx b/packages/app/src/modules/admin/referents/__tests__/AdminReferentsPage.test.tsx
new file mode 100644
index 000000000..79e4eed25
--- /dev/null
+++ b/packages/app/src/modules/admin/referents/__tests__/AdminReferentsPage.test.tsx
@@ -0,0 +1,121 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("next/navigation", async () => {
+ const actual =
+ await vi.importActual("next/navigation");
+ return {
+ ...actual,
+ useRouter: () => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ back: vi.fn(),
+ refresh: vi.fn(),
+ }),
+ useSearchParams: vi.fn().mockReturnValue(new URLSearchParams()),
+ };
+});
+
+vi.mock("~/trpc/react", () => ({
+ api: {
+ adminReferents: {
+ search: {
+ useQuery: vi.fn().mockReturnValue({
+ data: {
+ rows: [
+ {
+ id: "ref-1",
+ region: "11",
+ county: "75",
+ name: "Jean DUPONT",
+ type: "email",
+ value: "jean@gouv.fr",
+ principal: true,
+ substituteName: "Marie MARTIN",
+ substituteEmail: "marie@gouv.fr",
+ createdAt: new Date("2024-06-15T10:00:00Z"),
+ },
+ ],
+ total: 1,
+ page: 1,
+ pageSize: 20,
+ totalPages: 1,
+ },
+ isLoading: false,
+ refetch: vi.fn(),
+ }),
+ },
+ delete: {
+ useMutation: vi.fn().mockReturnValue({
+ mutate: vi.fn(),
+ isPending: false,
+ }),
+ },
+ create: {
+ useMutation: vi.fn().mockReturnValue({
+ mutate: vi.fn(),
+ isPending: false,
+ }),
+ },
+ update: {
+ useMutation: vi.fn().mockReturnValue({
+ mutate: vi.fn(),
+ isPending: false,
+ }),
+ },
+ import: {
+ useMutation: vi.fn().mockReturnValue({
+ mutate: vi.fn(),
+ isPending: false,
+ }),
+ },
+ exportAll: {
+ useQuery: vi.fn().mockReturnValue({
+ data: null,
+ refetch: vi.fn(),
+ isFetching: false,
+ }),
+ },
+ },
+ },
+}));
+
+import { AdminReferentsPage } from "../AdminReferentsPage";
+
+describe("AdminReferentsPage", () => {
+ it("renders page title", () => {
+ render();
+ expect(
+ screen.getByRole("heading", {
+ level: 1,
+ name: "Liste des référents Egapro",
+ }),
+ ).toBeInTheDocument();
+ });
+
+ it("renders search form", () => {
+ render();
+ expect(
+ screen.getByRole("button", { name: "Rechercher" }),
+ ).toBeInTheDocument();
+ });
+
+ it("renders table with data", () => {
+ render();
+ expect(screen.getByText("Jean DUPONT")).toBeInTheDocument();
+ expect(screen.getByText("jean@gouv.fr")).toBeInTheDocument();
+ });
+
+ it("shows result count", () => {
+ render();
+ expect(screen.getByText("1 résultat")).toBeInTheDocument();
+ });
+
+ it("renders action buttons", () => {
+ render();
+ expect(screen.getByRole("button", { name: "Ajouter" })).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Importer" }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/packages/app/src/modules/admin/referents/__tests__/DeleteConfirmationModal.test.tsx b/packages/app/src/modules/admin/referents/__tests__/DeleteConfirmationModal.test.tsx
new file mode 100644
index 000000000..7bef9909a
--- /dev/null
+++ b/packages/app/src/modules/admin/referents/__tests__/DeleteConfirmationModal.test.tsx
@@ -0,0 +1,69 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { createRef } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import { DeleteModal } from "../DeleteConfirmationModal";
+
+function renderModal(count = 3) {
+ const onClose = vi.fn();
+ const onConfirm = vi.fn();
+ const modalRef = createRef();
+ render(
+ ,
+ );
+ return { onClose, onConfirm };
+}
+
+describe("DeleteModal", () => {
+ it("renders the confirmation title", () => {
+ renderModal();
+ expect(
+ screen.getByRole("heading", {
+ level: 2,
+ name: "Confirmer la suppression",
+ hidden: true,
+ }),
+ ).toBeInTheDocument();
+ });
+
+ it("pluralizes the count when greater than 1", () => {
+ renderModal(3);
+ expect(screen.getByText("3 référents")).toBeInTheDocument();
+ });
+
+ it("uses the singular form when count is 1", () => {
+ renderModal(1);
+ expect(screen.getByText("1 référent")).toBeInTheDocument();
+ });
+
+ it("calls onClose then onConfirm when confirming", () => {
+ const { onClose, onConfirm } = renderModal(2);
+ fireEvent.click(
+ screen.getByRole("button", { name: "Supprimer", hidden: true }),
+ );
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onClose on Annuler", () => {
+ const { onClose, onConfirm } = renderModal(2);
+ fireEvent.click(
+ screen.getByRole("button", { name: "Annuler", hidden: true }),
+ );
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onConfirm).not.toHaveBeenCalled();
+ });
+
+ it("calls onClose on the Fermer button", () => {
+ const { onClose } = renderModal(2);
+ fireEvent.click(
+ screen.getByRole("button", { name: "Fermer", hidden: true }),
+ );
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/app/src/modules/admin/referents/__tests__/ImportReferentsModal.test.tsx b/packages/app/src/modules/admin/referents/__tests__/ImportReferentsModal.test.tsx
new file mode 100644
index 000000000..b0c953661
--- /dev/null
+++ b/packages/app/src/modules/admin/referents/__tests__/ImportReferentsModal.test.tsx
@@ -0,0 +1,145 @@
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { createRef } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const importMutate = vi.fn();
+let mutationOnError: ((err: { message: string }) => void) | undefined;
+let mutationOnSuccess: (() => void) | undefined;
+
+vi.mock("~/trpc/react", () => ({
+ api: {
+ adminReferents: {
+ import: {
+ useMutation: (opts: {
+ onSuccess?: () => void;
+ onError?: (err: { message: string }) => void;
+ }) => {
+ mutationOnSuccess = opts?.onSuccess;
+ mutationOnError = opts?.onError;
+ return { mutate: importMutate, isPending: false };
+ },
+ },
+ },
+ },
+}));
+
+import { ImportReferentsModal } from "../ImportReferentsModal";
+
+function renderModal() {
+ const onClose = vi.fn();
+ const onSuccess = vi.fn();
+ const modalRef = createRef();
+ render(
+ ,
+ );
+ return { onClose, onSuccess };
+}
+
+function selectFile(name: string, content: string) {
+ const input = screen.getByLabelText(/Fichier JSON/, {
+ selector: "input",
+ }) as HTMLInputElement;
+ const file = new File([content], name, { type: "application/json" });
+ fireEvent.change(input, { target: { files: [file] } });
+ return file;
+}
+
+describe("ImportReferentsModal", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mutationOnError = undefined;
+ mutationOnSuccess = undefined;
+ });
+
+ it("renders warning and title", () => {
+ renderModal();
+ expect(
+ screen.getByText(/cette opération remplacera toutes les données/),
+ ).toBeInTheDocument();
+ });
+
+ it("disables Importer until a file is selected", () => {
+ renderModal();
+ const importBtn = screen.getByRole("button", {
+ name: "Importer",
+ hidden: true,
+ });
+ expect(importBtn).toBeDisabled();
+ });
+
+ it("shows an error when the file is not valid JSON", async () => {
+ renderModal();
+ selectFile("bad.json", "not-json");
+ fireEvent.click(
+ screen.getByRole("button", { name: "Importer", hidden: true }),
+ );
+ await waitFor(() => {
+ expect(
+ screen.getByText("Le fichier n'est pas un JSON valide."),
+ ).toBeInTheDocument();
+ });
+ expect(importMutate).not.toHaveBeenCalled();
+ });
+
+ it("shows an error when the JSON fails schema validation", async () => {
+ renderModal();
+ selectFile("wrong.json", JSON.stringify([{ region: "bad" }]));
+ fireEvent.click(
+ screen.getByRole("button", { name: "Importer", hidden: true }),
+ );
+ await waitFor(() => {
+ expect(screen.getByText(/Format invalide/)).toBeInTheDocument();
+ });
+ expect(importMutate).not.toHaveBeenCalled();
+ });
+
+ it("calls the mutation when the payload is valid", async () => {
+ renderModal();
+ const valid = [
+ {
+ region: "11",
+ county: "75",
+ name: "Jean",
+ type: "email",
+ value: "jean@gouv.fr",
+ principal: true,
+ substituteName: "",
+ substituteEmail: "",
+ },
+ ];
+ selectFile("valid.json", JSON.stringify(valid));
+ fireEvent.click(
+ screen.getByRole("button", { name: "Importer", hidden: true }),
+ );
+ await waitFor(() => {
+ expect(importMutate).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it("surfaces a server error through the mutation onError hook", () => {
+ renderModal();
+ act(() => {
+ mutationOnError?.({ message: "boom" });
+ });
+ expect(screen.getByText("boom")).toBeInTheDocument();
+ });
+
+ it("closes and calls onSuccess when the mutation resolves", () => {
+ const { onClose, onSuccess } = renderModal();
+ act(() => {
+ mutationOnSuccess?.();
+ });
+ expect(onClose).toHaveBeenCalled();
+ expect(onSuccess).toHaveBeenCalled();
+ });
+});
diff --git a/packages/app/src/modules/admin/referents/__tests__/ReferentTable.test.tsx b/packages/app/src/modules/admin/referents/__tests__/ReferentTable.test.tsx
new file mode 100644
index 000000000..98ab12fa9
--- /dev/null
+++ b/packages/app/src/modules/admin/referents/__tests__/ReferentTable.test.tsx
@@ -0,0 +1,144 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const routerPush = vi.fn();
+let currentSearchParams = new URLSearchParams();
+
+vi.mock("next/navigation", async () => {
+ const actual =
+ await vi.importActual("next/navigation");
+ return {
+ ...actual,
+ useRouter: () => ({ push: routerPush }),
+ useSearchParams: () => currentSearchParams,
+ };
+});
+
+import { ReferentTable } from "../ReferentTable";
+import type { ReferentSearchRow } from "../types";
+
+const baseRow: ReferentSearchRow = {
+ id: "ref-1",
+ region: "11",
+ county: "75",
+ name: "Jean DUPONT",
+ type: "email",
+ value: "jean@gouv.fr",
+ principal: true,
+ substituteName: "Marie MARTIN",
+ substituteEmail: "marie@gouv.fr",
+ createdAt: new Date("2024-06-15T10:00:00Z"),
+};
+
+function renderTable(
+ overrides: Partial[0]> = {},
+) {
+ const onEdit = vi.fn();
+ const onSelectionChange = vi.fn();
+ render(
+ ,
+ );
+ return { onEdit, onSelectionChange };
+}
+
+describe("ReferentTable", () => {
+ beforeEach(() => {
+ routerPush.mockClear();
+ currentSearchParams = new URLSearchParams();
+ });
+
+ it("renders rows with region, county and value", () => {
+ renderTable();
+ expect(screen.getByText("Jean DUPONT")).toBeInTheDocument();
+ expect(screen.getByText("jean@gouv.fr")).toBeInTheDocument();
+ expect(screen.getByText("Marie MARTIN")).toBeInTheDocument();
+ });
+
+ it("renders an empty state when there are no rows", () => {
+ renderTable({ rows: [], total: 0 });
+ expect(screen.getByText("Aucun référent trouvé.")).toBeInTheDocument();
+ });
+
+ it("toggles a row selection when its checkbox is clicked", () => {
+ const { onSelectionChange } = renderTable();
+ fireEvent.click(screen.getByLabelText(/Sélectionner Jean DUPONT/));
+ const selected = onSelectionChange.mock.calls[0]?.[0] as Set;
+ expect(selected.has("ref-1")).toBe(true);
+ });
+
+ it("unselects when clicking an already-selected row", () => {
+ const { onSelectionChange } = renderTable({
+ selectedIds: new Set(["ref-1"]),
+ });
+ fireEvent.click(screen.getByLabelText(/Sélectionner Jean DUPONT/));
+ const selected = onSelectionChange.mock.calls[0]?.[0] as Set;
+ expect(selected.has("ref-1")).toBe(false);
+ });
+
+ it("flips sort order when clicking the active sort column", () => {
+ renderTable({ sortBy: "region", sortOrder: "asc" });
+ fireEvent.click(screen.getByRole("button", { name: /Région/ }));
+ expect(routerPush).toHaveBeenCalledWith(
+ expect.stringMatching(/sortOrder=desc/),
+ );
+ });
+
+ it("switches sort column on inactive column click", () => {
+ renderTable({ sortBy: "region", sortOrder: "asc" });
+ fireEvent.click(screen.getByRole("button", { name: "Nom" }));
+ expect(routerPush).toHaveBeenCalledWith(
+ expect.stringMatching(/sortBy=name/),
+ );
+ expect(routerPush).toHaveBeenCalledWith(
+ expect.stringMatching(/sortOrder=asc/),
+ );
+ });
+
+ it("shows pagination when totalPages > 1 and navigates", () => {
+ renderTable({ page: 2, totalPages: 3 });
+ fireEvent.click(screen.getByRole("button", { name: "Suivant" }));
+ expect(routerPush).toHaveBeenCalledWith(expect.stringMatching(/page=3/));
+ fireEvent.click(screen.getByRole("button", { name: "Précédent" }));
+ expect(routerPush).toHaveBeenCalledWith(expect.stringMatching(/page=1/));
+ });
+
+ it("calls onEdit when clicking Modifier", () => {
+ const { onEdit } = renderTable();
+ fireEvent.click(screen.getByRole("button", { name: "Modifier" }));
+ expect(onEdit).toHaveBeenCalledWith(baseRow);
+ });
+
+ it("handles rows without a county", () => {
+ renderTable({
+ rows: [{ ...baseRow, county: null }],
+ });
+ expect(screen.getByText("—")).toBeInTheDocument();
+ });
+
+ it("handles URL type referents", () => {
+ renderTable({
+ rows: [
+ {
+ ...baseRow,
+ type: "url",
+ value: "https://gouv.fr",
+ substituteName: null,
+ substituteEmail: null,
+ },
+ ],
+ });
+ const link = screen.getByRole("link", { name: /https:\/\/gouv\.fr/ });
+ expect(link).toHaveAttribute("href", "https://gouv.fr");
+ });
+});
diff --git a/packages/app/src/modules/admin/referents/__tests__/SearchForm.test.tsx b/packages/app/src/modules/admin/referents/__tests__/SearchForm.test.tsx
new file mode 100644
index 000000000..20f2c7f2c
--- /dev/null
+++ b/packages/app/src/modules/admin/referents/__tests__/SearchForm.test.tsx
@@ -0,0 +1,62 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const routerPush = vi.fn();
+
+vi.mock("next/navigation", async () => {
+ const actual =
+ await vi.importActual("next/navigation");
+ return {
+ ...actual,
+ useRouter: () => ({ push: routerPush }),
+ useSearchParams: () => new URLSearchParams(),
+ };
+});
+
+import { SearchForm } from "../SearchForm";
+
+describe("SearchForm", () => {
+ beforeEach(() => {
+ routerPush.mockClear();
+ });
+
+ it("disables the county select until a region is chosen", () => {
+ render();
+ const county = screen.getByLabelText("Département") as HTMLSelectElement;
+ expect(county).toBeDisabled();
+ expect(county).toHaveDisplayValue("Choisir une région d'abord");
+ });
+
+ it("enables the county select when a region is selected", () => {
+ render();
+ const region = screen.getByLabelText("Région") as HTMLSelectElement;
+ fireEvent.change(region, { target: { value: "11" } });
+ const county = screen.getByLabelText("Département") as HTMLSelectElement;
+ expect(county).not.toBeDisabled();
+ });
+
+ it("pushes a URL with the submitted filters on search", async () => {
+ render();
+ fireEvent.input(screen.getByLabelText("Nom du référent"), {
+ target: { value: "Jean" },
+ });
+ fireEvent.change(screen.getByLabelText("Région"), {
+ target: { value: "11" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Rechercher" }));
+
+ await waitFor(() => {
+ expect(routerPush).toHaveBeenCalledWith(
+ expect.stringMatching(/\/admin\/liste-referents\?.*query=Jean/),
+ );
+ });
+ expect(routerPush).toHaveBeenCalledWith(expect.stringMatching(/region=11/));
+ expect(routerPush).toHaveBeenCalledWith(expect.stringMatching(/page=1/));
+ });
+
+ it("resets and pushes the base URL on Réinitialiser", () => {
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "Réinitialiser" }));
+ expect(routerPush).toHaveBeenCalledWith("/admin/liste-referents");
+ });
+});
diff --git a/packages/app/src/modules/admin/referents/__tests__/constants.test.ts b/packages/app/src/modules/admin/referents/__tests__/constants.test.ts
new file mode 100644
index 000000000..dbd9e408a
--- /dev/null
+++ b/packages/app/src/modules/admin/referents/__tests__/constants.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vitest";
+
+import { COLUMN_LABELS, REFERENT_TYPE_LABELS } from "../shared/constants";
+
+describe("REFERENT_TYPE_LABELS", () => {
+ it("has labels for email and url", () => {
+ expect(REFERENT_TYPE_LABELS.email).toBe("Email");
+ expect(REFERENT_TYPE_LABELS.url).toBe("URL");
+ });
+});
+
+describe("COLUMN_LABELS", () => {
+ it("has labels for all columns", () => {
+ expect(COLUMN_LABELS.region).toBe("Région");
+ expect(COLUMN_LABELS.county).toBe("Département");
+ expect(COLUMN_LABELS.name).toBe("Nom");
+ expect(COLUMN_LABELS.value).toBe("Valeur");
+ expect(COLUMN_LABELS.principal).toBe("Principal");
+ expect(COLUMN_LABELS.createdAt).toBe("Date de création");
+ });
+});
diff --git a/packages/app/src/modules/admin/referents/__tests__/schemas.test.ts b/packages/app/src/modules/admin/referents/__tests__/schemas.test.ts
new file mode 100644
index 000000000..3a0eb455d
--- /dev/null
+++ b/packages/app/src/modules/admin/referents/__tests__/schemas.test.ts
@@ -0,0 +1,196 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ createReferentSchema,
+ deleteReferentsSchema,
+ editReferentSchema,
+ importReferentsSchema,
+ referentFormSchema,
+ searchReferentsSchema,
+} from "../schemas";
+
+describe("searchReferentsSchema", () => {
+ it("accepts minimal input with defaults", () => {
+ const result = searchReferentsSchema.parse({});
+ expect(result).toEqual({
+ page: 1,
+ pageSize: 20,
+ sortBy: "region",
+ sortOrder: "asc",
+ });
+ });
+
+ it("parses all fields", () => {
+ const result = searchReferentsSchema.parse({
+ query: "Dupont",
+ region: "11",
+ county: "75",
+ page: "2",
+ pageSize: "50",
+ sortBy: "name",
+ sortOrder: "desc",
+ });
+ expect(result).toEqual({
+ query: "Dupont",
+ region: "11",
+ county: "75",
+ page: 2,
+ pageSize: 50,
+ sortBy: "name",
+ sortOrder: "desc",
+ });
+ });
+
+ it("accepts empty region/county strings", () => {
+ const result = searchReferentsSchema.parse({
+ region: "",
+ county: "",
+ });
+ expect(result.region).toBe("");
+ expect(result.county).toBe("");
+ });
+
+ it("rejects invalid region", () => {
+ expect(() => searchReferentsSchema.parse({ region: "ZZ" })).toThrow();
+ });
+
+ it("rejects invalid sort column", () => {
+ expect(() =>
+ searchReferentsSchema.parse({ sortBy: "nonexistent" }),
+ ).toThrow();
+ });
+});
+
+describe("createReferentSchema", () => {
+ it("validates an email referent", () => {
+ const result = createReferentSchema.parse({
+ region: "11",
+ name: "Jean DUPONT",
+ type: "email",
+ value: "jean@gouv.fr",
+ principal: true,
+ });
+ expect(result.type).toBe("email");
+ expect(result.value).toBe("jean@gouv.fr");
+ });
+
+ it("validates a URL referent", () => {
+ const result = createReferentSchema.parse({
+ region: "84",
+ county: "69",
+ name: "DREETS ARA",
+ type: "url",
+ value: "https://dreets.gouv.fr",
+ });
+ expect(result.type).toBe("url");
+ });
+
+ it("rejects invalid email", () => {
+ expect(() =>
+ createReferentSchema.parse({
+ region: "11",
+ name: "Test",
+ type: "email",
+ value: "not-an-email",
+ }),
+ ).toThrow();
+ });
+
+ it("rejects invalid URL", () => {
+ expect(() =>
+ createReferentSchema.parse({
+ region: "11",
+ name: "Test",
+ type: "url",
+ value: "not-a-url",
+ }),
+ ).toThrow();
+ });
+
+ it("rejects empty name", () => {
+ expect(() =>
+ createReferentSchema.parse({
+ region: "11",
+ name: "",
+ type: "email",
+ value: "test@gouv.fr",
+ }),
+ ).toThrow();
+ });
+});
+
+describe("editReferentSchema", () => {
+ it("requires an id", () => {
+ const result = editReferentSchema.parse({
+ id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ region: "11",
+ name: "Jean DUPONT",
+ type: "email",
+ value: "jean@gouv.fr",
+ });
+ expect(result.id).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+ });
+
+ it("rejects missing id", () => {
+ expect(() =>
+ editReferentSchema.parse({
+ region: "11",
+ name: "Test",
+ type: "email",
+ value: "test@gouv.fr",
+ }),
+ ).toThrow();
+ });
+});
+
+describe("deleteReferentsSchema", () => {
+ it("accepts an array of UUIDs", () => {
+ const ids = [
+ "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ "b2c3d4e5-f6a7-8901-bcde-f12345678901",
+ ];
+ const result = deleteReferentsSchema.parse({ ids });
+ expect(result.ids).toEqual(ids);
+ });
+
+ it("rejects empty array", () => {
+ expect(() => deleteReferentsSchema.parse({ ids: [] })).toThrow();
+ });
+});
+
+describe("importReferentsSchema", () => {
+ it("validates an array of referents", () => {
+ const result = importReferentsSchema.parse([
+ {
+ region: "11",
+ name: "Jean DUPONT",
+ type: "email",
+ value: "jean@gouv.fr",
+ },
+ {
+ region: "84",
+ name: "DREETS ARA",
+ type: "url",
+ value: "https://dreets.gouv.fr",
+ },
+ ]);
+ expect(result).toHaveLength(2);
+ });
+
+ it("rejects empty array", () => {
+ expect(() => importReferentsSchema.parse([])).toThrow();
+ });
+});
+
+describe("referentFormSchema", () => {
+ it("validates form data", () => {
+ const result = referentFormSchema.parse({
+ region: "11",
+ name: "Test",
+ type: "email",
+ value: "test@gouv.fr",
+ principal: false,
+ });
+ expect(result.principal).toBe(false);
+ });
+});
diff --git a/packages/app/src/modules/admin/referents/index.ts b/packages/app/src/modules/admin/referents/index.ts
new file mode 100644
index 000000000..438797cc1
--- /dev/null
+++ b/packages/app/src/modules/admin/referents/index.ts
@@ -0,0 +1,15 @@
+export { AdminReferentsPage } from "./AdminReferentsPage";
+export type {
+ CreateReferentInput,
+ EditReferentInput,
+ ImportReferentsInput,
+ SearchReferentsInput,
+ SearchReferentsOutput,
+} from "./schemas";
+export {
+ createReferentSchema,
+ deleteReferentsSchema,
+ editReferentSchema,
+ importReferentsSchema,
+ searchReferentsSchema,
+} from "./schemas";
diff --git a/packages/app/src/modules/admin/referents/schemas.ts b/packages/app/src/modules/admin/referents/schemas.ts
new file mode 100644
index 000000000..607dd7ac0
--- /dev/null
+++ b/packages/app/src/modules/admin/referents/schemas.ts
@@ -0,0 +1,109 @@
+import { z } from "zod";
+
+import { COUNTY_CODES, REGION_CODES } from "~/modules/domain";
+
+export const SORT_COLUMNS = [
+ "region",
+ "county",
+ "name",
+ "value",
+ "principal",
+ "createdAt",
+] as const;
+
+export type SortColumn = (typeof SORT_COLUMNS)[number];
+
+export const DEFAULT_PAGE_SIZE = 20;
+
+export const searchReferentsSchema = z.object({
+ query: z.string().optional(),
+ region: z.enum(REGION_CODES).optional().or(z.literal("")),
+ county: z.enum(COUNTY_CODES).optional().or(z.literal("")),
+ page: z.coerce.number().int().min(1).default(1),
+ pageSize: z.coerce.number().int().min(10).max(100).default(DEFAULT_PAGE_SIZE),
+ sortBy: z.enum(SORT_COLUMNS).default("region"),
+ sortOrder: z.enum(["asc", "desc"]).default("asc"),
+});
+
+export type SearchReferentsInput = z.input;
+export type SearchReferentsOutput = z.output;
+
+export const searchReferentsFormSchema = z.object({
+ query: z.string().optional(),
+ region: z.string().optional(),
+ county: z.string().optional(),
+});
+
+export type SearchReferentsFormValues = z.infer<
+ typeof searchReferentsFormSchema
+>;
+
+const baseReferentFields = {
+ region: z.enum(REGION_CODES),
+ county: z.enum(COUNTY_CODES).optional().or(z.literal("")),
+ name: z.string().min(1, "Le nom est obligatoire"),
+ principal: z.boolean().default(false),
+ substituteName: z.string().optional().or(z.literal("")),
+ substituteEmail: z
+ .string()
+ .email("L'email du suppléant est invalide")
+ .optional()
+ .or(z.literal("")),
+};
+
+export const createReferentSchema = z.discriminatedUnion("type", [
+ z.object({
+ ...baseReferentFields,
+ type: z.literal("email"),
+ value: z.string().email("L'email est invalide"),
+ }),
+ z.object({
+ ...baseReferentFields,
+ type: z.literal("url"),
+ value: z.string().url("L'URL est invalide"),
+ }),
+]);
+
+export type CreateReferentInput = z.infer;
+
+export const editReferentSchema = z.discriminatedUnion("type", [
+ z.object({
+ id: z.string().uuid(),
+ ...baseReferentFields,
+ type: z.literal("email"),
+ value: z.string().email("L'email est invalide"),
+ }),
+ z.object({
+ id: z.string().uuid(),
+ ...baseReferentFields,
+ type: z.literal("url"),
+ value: z.string().url("L'URL est invalide"),
+ }),
+]);
+
+export type EditReferentInput = z.infer;
+
+export const deleteReferentsSchema = z.object({
+ ids: z.array(z.string().uuid()).min(1).max(100),
+});
+
+export const importReferentsSchema = z.array(createReferentSchema).min(1);
+
+export type ImportReferentsInput = z.infer;
+
+export const referentFormSchema = z.object({
+ region: z.enum(REGION_CODES),
+ county: z.enum(COUNTY_CODES).optional().or(z.literal("")),
+ name: z.string().min(1, "Le nom est obligatoire"),
+ principal: z.boolean(),
+ substituteName: z.string().optional().or(z.literal("")),
+ substituteEmail: z
+ .string()
+ .email("L'email du suppléant est invalide")
+ .optional()
+ .or(z.literal("")),
+ type: z.enum(["email", "url"]),
+ value: z.string().min(1, "La valeur est obligatoire"),
+});
+
+export type ReferentFormValues = z.infer;
diff --git a/packages/app/src/modules/admin/referents/shared/constants.ts b/packages/app/src/modules/admin/referents/shared/constants.ts
new file mode 100644
index 000000000..4aa709efc
--- /dev/null
+++ b/packages/app/src/modules/admin/referents/shared/constants.ts
@@ -0,0 +1,13 @@
+export const REFERENT_TYPE_LABELS: Record = {
+ email: "Email",
+ url: "URL",
+};
+
+export const COLUMN_LABELS: Record = {
+ region: "Région",
+ county: "Département",
+ name: "Nom",
+ value: "Valeur",
+ principal: "Principal",
+ createdAt: "Date de création",
+};
diff --git a/packages/app/src/modules/admin/referents/types.ts b/packages/app/src/modules/admin/referents/types.ts
new file mode 100644
index 000000000..569f871e9
--- /dev/null
+++ b/packages/app/src/modules/admin/referents/types.ts
@@ -0,0 +1,4 @@
+import type { RouterOutputs } from "~/trpc/react";
+
+export type ReferentSearchRow =
+ RouterOutputs["adminReferents"]["search"]["rows"][number];
diff --git a/packages/app/src/modules/domain/__tests__/regions.test.ts b/packages/app/src/modules/domain/__tests__/regions.test.ts
new file mode 100644
index 000000000..8a0c1f50f
--- /dev/null
+++ b/packages/app/src/modules/domain/__tests__/regions.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ COUNTIES,
+ COUNTY_CODES,
+ REGION_CODES,
+ REGIONS,
+ REGIONS_TO_COUNTIES,
+} from "../shared/regions";
+
+describe("REGIONS", () => {
+ it("has 18 regions", () => {
+ expect(Object.keys(REGIONS)).toHaveLength(18);
+ });
+
+ it("REGION_CODES matches REGIONS keys", () => {
+ expect(REGION_CODES).toEqual(Object.keys(REGIONS));
+ });
+
+ it("includes Île-de-France", () => {
+ expect(REGIONS["11"]).toBe("Île-de-France");
+ });
+});
+
+describe("COUNTIES", () => {
+ it("has 101 counties", () => {
+ expect(Object.keys(COUNTIES)).toHaveLength(101);
+ });
+
+ it("COUNTY_CODES matches COUNTIES keys", () => {
+ expect(COUNTY_CODES).toEqual(Object.keys(COUNTIES));
+ });
+
+ it("includes Paris", () => {
+ expect(COUNTIES["75"]).toBe("Paris");
+ });
+
+ it("includes overseas departments", () => {
+ expect(COUNTIES["971"]).toBe("Guadeloupe");
+ expect(COUNTIES["976"]).toBe("Mayotte");
+ });
+});
+
+describe("REGIONS_TO_COUNTIES", () => {
+ it("maps all 18 regions", () => {
+ expect(Object.keys(REGIONS_TO_COUNTIES)).toHaveLength(18);
+ });
+
+ it("Île-de-France contains Paris", () => {
+ expect(REGIONS_TO_COUNTIES["11"]).toContain("75");
+ });
+
+ it("every county in the mapping exists in COUNTIES", () => {
+ for (const counties of Object.values(REGIONS_TO_COUNTIES)) {
+ for (const code of counties) {
+ expect(COUNTIES).toHaveProperty(code);
+ }
+ }
+ });
+});
diff --git a/packages/app/src/modules/domain/index.ts b/packages/app/src/modules/domain/index.ts
index 1eaa2f566..e658b832f 100644
--- a/packages/app/src/modules/domain/index.ts
+++ b/packages/app/src/modules/domain/index.ts
@@ -53,6 +53,15 @@ export {
normalizeDecimalInput,
parseNumber,
} from "./shared/number";
+export type { CountyCode, RegionCode } from "./shared/regions";
+// Regions & counties
+export {
+ COUNTIES,
+ COUNTY_CODES,
+ REGION_CODES,
+ REGIONS,
+ REGIONS_TO_COUNTIES,
+} from "./shared/regions";
// SIREN utilities
export { extractSiren, formatSiren, parseSiren } from "./shared/siren";
export type {
diff --git a/packages/app/src/modules/domain/shared/regions.ts b/packages/app/src/modules/domain/shared/regions.ts
new file mode 100644
index 000000000..2a88ebcc4
--- /dev/null
+++ b/packages/app/src/modules/domain/shared/regions.ts
@@ -0,0 +1,199 @@
+export const REGIONS = {
+ "01": "Guadeloupe",
+ "02": "Martinique",
+ "03": "Guyane",
+ "04": "La Réunion",
+ "06": "Mayotte",
+ "11": "Île-de-France",
+ "24": "Centre-Val de Loire",
+ "27": "Bourgogne-Franche-Comté",
+ "28": "Normandie",
+ "32": "Hauts-de-France",
+ "44": "Grand-Est",
+ "52": "Pays de la Loire",
+ "53": "Bretagne",
+ "75": "Nouvelle-Aquitaine",
+ "76": "Occitanie",
+ "84": "Auvergne-Rhône-Alpes",
+ "93": "Provence-Alpes-Côte d'Azur",
+ "94": "Corse",
+} as const;
+
+export type RegionCode = keyof typeof REGIONS;
+
+export const REGION_CODES = Object.keys(REGIONS) as [
+ RegionCode,
+ ...RegionCode[],
+];
+
+export const COUNTIES = {
+ "01": "Ain",
+ "02": "Aisne",
+ "03": "Allier",
+ "04": "Alpes-de-Haute-Provence",
+ "05": "Hautes-Alpes",
+ "06": "Alpes-Maritimes",
+ "07": "Ardèche",
+ "08": "Ardennes",
+ "09": "Ariège",
+ "10": "Aube",
+ "11": "Aude",
+ "12": "Aveyron",
+ "13": "Bouches-du-Rhône",
+ "14": "Calvados",
+ "15": "Cantal",
+ "16": "Charente",
+ "17": "Charente-Maritime",
+ "18": "Cher",
+ "19": "Corrèze",
+ "2A": "Corse-du-Sud",
+ "2B": "Haute-Corse",
+ "21": "Côte-d'Or",
+ "22": "Côtes-d'Armor",
+ "23": "Creuse",
+ "24": "Dordogne",
+ "25": "Doubs",
+ "26": "Drôme",
+ "27": "Eure",
+ "28": "Eure-et-Loir",
+ "29": "Finistère",
+ "30": "Gard",
+ "31": "Haute-Garonne",
+ "32": "Gers",
+ "33": "Gironde",
+ "34": "Hérault",
+ "35": "Ille-et-Vilaine",
+ "36": "Indre",
+ "37": "Indre-et-Loire",
+ "38": "Isère",
+ "39": "Jura",
+ "40": "Landes",
+ "41": "Loir-et-Cher",
+ "42": "Loire",
+ "43": "Haute-Loire",
+ "44": "Loire-Atlantique",
+ "45": "Loiret",
+ "46": "Lot",
+ "47": "Lot-et-Garonne",
+ "48": "Lozère",
+ "49": "Maine-et-Loire",
+ "50": "Manche",
+ "51": "Marne",
+ "52": "Haute-Marne",
+ "53": "Mayenne",
+ "54": "Meurthe-et-Moselle",
+ "55": "Meuse",
+ "56": "Morbihan",
+ "57": "Moselle",
+ "58": "Nièvre",
+ "59": "Nord",
+ "60": "Oise",
+ "61": "Orne",
+ "62": "Pas-de-Calais",
+ "63": "Puy-de-Dôme",
+ "64": "Pyrénées-Atlantiques",
+ "65": "Hautes-Pyrénées",
+ "66": "Pyrénées-Orientales",
+ "67": "Bas-Rhin",
+ "68": "Haut-Rhin",
+ "69": "Rhône",
+ "70": "Haute-Saône",
+ "71": "Saône-et-Loire",
+ "72": "Sarthe",
+ "73": "Savoie",
+ "74": "Haute-Savoie",
+ "75": "Paris",
+ "76": "Seine-Maritime",
+ "77": "Seine-et-Marne",
+ "78": "Yvelines",
+ "79": "Deux-Sèvres",
+ "80": "Somme",
+ "81": "Tarn",
+ "82": "Tarn-et-Garonne",
+ "83": "Var",
+ "84": "Vaucluse",
+ "85": "Vendée",
+ "86": "Vienne",
+ "87": "Haute-Vienne",
+ "88": "Vosges",
+ "89": "Yonne",
+ "90": "Territoire de Belfort",
+ "91": "Essonne",
+ "92": "Hauts-de-Seine",
+ "93": "Seine-Saint-Denis",
+ "94": "Val-de-Marne",
+ "95": "Val-d'Oise",
+ "971": "Guadeloupe",
+ "972": "Martinique",
+ "973": "Guyane",
+ "974": "La Réunion",
+ "976": "Mayotte",
+} as const;
+
+export type CountyCode = keyof typeof COUNTIES;
+
+export const COUNTY_CODES = Object.keys(COUNTIES) as [
+ CountyCode,
+ ...CountyCode[],
+];
+
+export const REGIONS_TO_COUNTIES: Record = {
+ "84": [
+ "01",
+ "03",
+ "07",
+ "15",
+ "26",
+ "38",
+ "42",
+ "43",
+ "63",
+ "69",
+ "73",
+ "74",
+ ],
+ "27": ["21", "25", "39", "58", "70", "71", "89", "90"],
+ "53": ["35", "22", "56", "29"],
+ "24": ["18", "28", "36", "37", "41", "45"],
+ "94": ["2A", "2B"],
+ "44": ["08", "10", "51", "52", "54", "55", "57", "67", "68", "88"],
+ "01": ["971"],
+ "03": ["973"],
+ "32": ["02", "59", "60", "62", "80"],
+ "11": ["75", "77", "78", "91", "92", "93", "94", "95"],
+ "04": ["974"],
+ "06": ["976"],
+ "02": ["972"],
+ "28": ["14", "27", "50", "61", "76"],
+ "75": [
+ "16",
+ "17",
+ "19",
+ "23",
+ "24",
+ "33",
+ "40",
+ "47",
+ "64",
+ "79",
+ "86",
+ "87",
+ ],
+ "52": ["44", "49", "53", "72", "85"],
+ "93": ["04", "05", "06", "13", "83", "84"],
+ "76": [
+ "09",
+ "11",
+ "12",
+ "30",
+ "31",
+ "32",
+ "34",
+ "46",
+ "48",
+ "65",
+ "66",
+ "81",
+ "82",
+ ],
+};
diff --git a/packages/app/src/modules/my-space/DeclarationsSection.tsx b/packages/app/src/modules/my-space/DeclarationsSection.tsx
index d038950b0..a693196aa 100644
--- a/packages/app/src/modules/my-space/DeclarationsSection.tsx
+++ b/packages/app/src/modules/my-space/DeclarationsSection.tsx
@@ -3,7 +3,7 @@
import type { ReactNode } from "react";
import { useState } from "react";
-import { formatShortDate, getCurrentYear } from "~/modules/domain";
+import { getCurrentYear } from "~/modules/domain";
import { Pagination } from "~/modules/shared/Pagination";
diff --git a/packages/app/src/modules/shared/DsfrTable.tsx b/packages/app/src/modules/shared/DsfrTable.tsx
new file mode 100644
index 000000000..02e246b07
--- /dev/null
+++ b/packages/app/src/modules/shared/DsfrTable.tsx
@@ -0,0 +1,28 @@
+import type { ReactNode } from "react";
+
+type Props = {
+ caption: string;
+ children: ReactNode;
+ className?: string;
+};
+
+export function DsfrTable({
+ caption,
+ children,
+ className = "fr-mb-4w",
+}: Props) {
+ return (
+
+
+
+
+
+ {caption}
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/packages/app/src/modules/shared/index.ts b/packages/app/src/modules/shared/index.ts
index 34fb5c6d6..9bed90cd4 100644
--- a/packages/app/src/modules/shared/index.ts
+++ b/packages/app/src/modules/shared/index.ts
@@ -10,4 +10,6 @@ export {
SCAN_TIMEOUT_MS,
} from "./uploadConfig";
export { uploadFile } from "./uploadFile";
+export { useDsfrModal } from "./useDsfrModal";
export { useFileUploadForm } from "./useFileUploadForm";
+export { useZodForm } from "./useZodForm";
diff --git a/packages/app/src/modules/shared/useDsfrModal.ts b/packages/app/src/modules/shared/useDsfrModal.ts
new file mode 100644
index 000000000..1850c9231
--- /dev/null
+++ b/packages/app/src/modules/shared/useDsfrModal.ts
@@ -0,0 +1,21 @@
+import { useCallback, useRef } from "react";
+
+import { getDsfrModal } from "./getDsfrModal";
+
+export function useDsfrModal() {
+ const modalRef = useRef(null);
+
+ const open = useCallback(() => {
+ if (modalRef.current) {
+ getDsfrModal(modalRef.current)?.disclose();
+ }
+ }, []);
+
+ const close = useCallback(() => {
+ if (modalRef.current) {
+ getDsfrModal(modalRef.current)?.conceal();
+ }
+ }, []);
+
+ return { modalRef, open, close };
+}
diff --git a/packages/app/src/server/api/root.ts b/packages/app/src/server/api/root.ts
index ca0a885d0..69e1edb15 100644
--- a/packages/app/src/server/api/root.ts
+++ b/packages/app/src/server/api/root.ts
@@ -1,5 +1,6 @@
import { adminRouter } from "~/server/api/routers/admin";
import { adminDeclarationsRouter } from "~/server/api/routers/adminDeclarations";
+import { adminReferentsRouter } from "~/server/api/routers/adminReferents";
import { companyRouter } from "~/server/api/routers/company";
import { cseOpinionRouter } from "~/server/api/routers/cseOpinion";
import { declarationRouter } from "~/server/api/routers/declaration";
@@ -16,6 +17,7 @@ import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
export const appRouter = createTRPCRouter({
admin: adminRouter,
adminDeclarations: adminDeclarationsRouter,
+ adminReferents: adminReferentsRouter,
company: companyRouter,
cseOpinion: cseOpinionRouter,
declaration: declarationRouter,
diff --git a/packages/app/src/server/api/routers/__tests__/adminReferents.test.ts b/packages/app/src/server/api/routers/__tests__/adminReferents.test.ts
new file mode 100644
index 000000000..062ebad02
--- /dev/null
+++ b/packages/app/src/server/api/routers/__tests__/adminReferents.test.ts
@@ -0,0 +1,291 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("~/server/auth", () => ({ auth: vi.fn() }));
+vi.mock("~/server/db", () => ({ db: {} }));
+vi.mock("~/server/db/schema", () => ({
+ referents: {
+ id: "id",
+ region: "region",
+ county: "county",
+ name: "name",
+ type: "type",
+ value: "value",
+ principal: "principal",
+ substituteName: "substituteName",
+ substituteEmail: "substituteEmail",
+ createdAt: "createdAt",
+ },
+}));
+
+const mockInsertValues = vi.fn();
+const mockUpdateSet = vi.fn();
+const mockDeleteCalled = vi.fn();
+const mockTransaction = vi.fn();
+
+type SelectQueue = unknown[];
+let selectQueue: SelectQueue = [];
+
+const CHAIN_METHODS = new Set(["from", "where", "orderBy", "limit", "offset"]);
+
+function wrapChain(results: unknown): unknown {
+ const promise = Promise.resolve(results);
+ const chain = new Proxy(
+ {},
+ {
+ get(_target, prop) {
+ if (prop === "then" || prop === "catch" || prop === "finally") {
+ return (promise[prop] as (...args: unknown[]) => unknown).bind(
+ promise,
+ );
+ }
+ if (typeof prop === "string" && CHAIN_METHODS.has(prop)) {
+ return () => chain;
+ }
+ return undefined;
+ },
+ },
+ );
+ return chain;
+}
+
+function makeChain(): unknown {
+ return wrapChain(selectQueue.shift() ?? []);
+}
+
+function createMockDb() {
+ const db = {
+ select: vi.fn(() => makeChain()),
+ insert: vi.fn(() => ({
+ values: (v: unknown) => {
+ mockInsertValues(v);
+ return {
+ returning: () => Promise.resolve([{ id: "new-id" }]),
+ };
+ },
+ })),
+ update: vi.fn(() => ({
+ set: (v: unknown) => {
+ mockUpdateSet(v);
+ return { where: () => Promise.resolve(undefined) };
+ },
+ })),
+ delete: vi.fn(() => {
+ mockDeleteCalled();
+ return { where: () => Promise.resolve(undefined) };
+ }),
+ transaction: mockTransaction.mockImplementation(async (fn) => fn(db)),
+ };
+ return db;
+}
+
+async function createCaller(mockDb: unknown, isAdmin = true) {
+ const { adminReferentsRouter } = await import("../adminReferents");
+ return adminReferentsRouter.createCaller({
+ db: mockDb,
+ session: {
+ user: { id: "admin-1", email: "admin@gov.fr", isAdmin },
+ expires: "",
+ },
+ headers: new Headers(),
+ } as never);
+}
+
+describe("adminReferentsRouter", () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ selectQueue = [];
+ });
+
+ describe("authorization", () => {
+ it("rejects non-admin users", async () => {
+ const caller = await createCaller(createMockDb(), false);
+ await expect(
+ caller.search({
+ page: 1,
+ pageSize: 20,
+ sortBy: "region",
+ sortOrder: "asc",
+ }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+ });
+ });
+
+ describe("search", () => {
+ it("returns rows and pagination metadata", async () => {
+ const rows = [
+ {
+ id: "r-1",
+ region: "11",
+ county: "75",
+ name: "Jean",
+ type: "email",
+ value: "j@gouv.fr",
+ principal: true,
+ substituteName: null,
+ substituteEmail: null,
+ createdAt: new Date(),
+ },
+ ];
+ selectQueue = [rows, [{ total: 1 }]];
+ const caller = await createCaller(createMockDb());
+
+ const result = await caller.search({
+ page: 1,
+ pageSize: 20,
+ sortBy: "region",
+ sortOrder: "asc",
+ });
+
+ expect(result).toMatchObject({
+ rows,
+ total: 1,
+ page: 1,
+ pageSize: 20,
+ totalPages: 1,
+ });
+ });
+
+ it("applies filters on query, region and county", async () => {
+ selectQueue = [[], [{ total: 0 }]];
+ const caller = await createCaller(createMockDb());
+
+ const result = await caller.search({
+ query: "Jean",
+ region: "11",
+ county: "75",
+ page: 2,
+ pageSize: 10,
+ sortBy: "name",
+ sortOrder: "desc",
+ });
+
+ expect(result.rows).toEqual([]);
+ expect(result.total).toBe(0);
+ expect(result.totalPages).toBe(1);
+ });
+ });
+
+ describe("create", () => {
+ it("inserts a referent and strips empty optional strings", async () => {
+ const caller = await createCaller(createMockDb());
+
+ const result = await caller.create({
+ region: "11",
+ county: "75",
+ name: "Jean",
+ type: "email",
+ value: "jean@gouv.fr",
+ principal: true,
+ substituteName: " ",
+ substituteEmail: "",
+ });
+
+ expect(result).toEqual({ id: "new-id" });
+ expect(mockInsertValues).toHaveBeenCalledWith(
+ expect.objectContaining({
+ region: "11",
+ county: "75",
+ name: "Jean",
+ substituteName: undefined,
+ substituteEmail: undefined,
+ }),
+ );
+ });
+ });
+
+ describe("update", () => {
+ it("updates a referent and returns its id", async () => {
+ const caller = await createCaller(createMockDb());
+
+ const result = await caller.update({
+ id: "11111111-1111-4111-8111-111111111111",
+ region: "11",
+ county: "",
+ name: "Jean",
+ type: "url",
+ value: "https://gouv.fr/contact",
+ principal: false,
+ substituteName: "",
+ substituteEmail: "",
+ });
+
+ expect(result).toEqual({ id: "11111111-1111-4111-8111-111111111111" });
+ expect(mockUpdateSet).toHaveBeenCalledWith(
+ expect.objectContaining({ county: undefined }),
+ );
+ });
+ });
+
+ describe("delete", () => {
+ it("deletes the given ids", async () => {
+ const caller = await createCaller(createMockDb());
+
+ const result = await caller.delete({
+ ids: [
+ "11111111-1111-4111-8111-111111111111",
+ "22222222-2222-4222-8222-222222222222",
+ ],
+ });
+
+ expect(result).toEqual({ deleted: 2 });
+ expect(mockDeleteCalled).toHaveBeenCalled();
+ });
+ });
+
+ describe("import", () => {
+ it("clears and re-inserts all referents in a transaction", async () => {
+ const caller = await createCaller(createMockDb());
+
+ const result = await caller.import([
+ {
+ region: "11",
+ county: "75",
+ name: "Jean",
+ type: "email",
+ value: "jean@gouv.fr",
+ principal: true,
+ substituteName: "",
+ substituteEmail: "",
+ },
+ {
+ region: "11",
+ county: "",
+ name: "Marie",
+ type: "url",
+ value: "https://gouv.fr",
+ principal: false,
+ substituteName: "",
+ substituteEmail: "",
+ },
+ ]);
+
+ expect(result).toEqual({ imported: 2 });
+ expect(mockTransaction).toHaveBeenCalled();
+ expect(mockDeleteCalled).toHaveBeenCalled();
+ expect(mockInsertValues).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe("exportAll", () => {
+ it("returns all referents ordered by region then county", async () => {
+ const rows = [
+ {
+ region: "11",
+ county: "75",
+ name: "Jean",
+ type: "email" as const,
+ value: "j@gouv.fr",
+ principal: true,
+ substituteName: null,
+ substituteEmail: null,
+ },
+ ];
+ selectQueue = [rows];
+ const caller = await createCaller(createMockDb());
+
+ const result = await caller.exportAll();
+
+ expect(result).toEqual(rows);
+ });
+ });
+});
diff --git a/packages/app/src/server/api/routers/adminReferents.ts b/packages/app/src/server/api/routers/adminReferents.ts
new file mode 100644
index 000000000..ad9b85ff4
--- /dev/null
+++ b/packages/app/src/server/api/routers/adminReferents.ts
@@ -0,0 +1,180 @@
+import {
+ and,
+ asc,
+ count,
+ desc,
+ eq,
+ ilike,
+ inArray,
+ type SQL,
+} from "drizzle-orm";
+
+import {
+ createReferentSchema,
+ deleteReferentsSchema,
+ editReferentSchema,
+ importReferentsSchema,
+ searchReferentsSchema,
+} from "~/modules/admin/referents/schemas";
+import { adminProcedure, createTRPCRouter } from "~/server/api/trpc";
+import { referents } from "~/server/db/schema";
+
+const sortColumnMap = {
+ region: referents.region,
+ county: referents.county,
+ name: referents.name,
+ value: referents.value,
+ principal: referents.principal,
+ createdAt: referents.createdAt,
+} as const;
+
+function cleanOptionalString(val: string | undefined): string | undefined {
+ return val && val.trim() !== "" ? val.trim() : undefined;
+}
+
+export const adminReferentsRouter = createTRPCRouter({
+ search: adminProcedure
+ .input(searchReferentsSchema)
+ .query(async ({ ctx, input }) => {
+ const filters: SQL[] = [];
+
+ if (input.query) {
+ const term = `%${input.query}%`;
+ filters.push(ilike(referents.name, term));
+ }
+
+ if (input.region) {
+ filters.push(eq(referents.region, input.region));
+ }
+
+ if (input.county) {
+ filters.push(eq(referents.county, input.county));
+ }
+
+ const where = filters.length > 0 ? and(...filters) : undefined;
+ const orderDir = input.sortOrder === "asc" ? asc : desc;
+ const orderColumn =
+ sortColumnMap[input.sortBy as keyof typeof sortColumnMap];
+ const offset = (input.page - 1) * input.pageSize;
+
+ const [rows, totalResult] = await Promise.all([
+ ctx.db
+ .select({
+ id: referents.id,
+ region: referents.region,
+ county: referents.county,
+ name: referents.name,
+ type: referents.type,
+ value: referents.value,
+ principal: referents.principal,
+ substituteName: referents.substituteName,
+ substituteEmail: referents.substituteEmail,
+ createdAt: referents.createdAt,
+ })
+ .from(referents)
+ .where(where)
+ .orderBy(orderDir(orderColumn))
+ .limit(input.pageSize)
+ .offset(offset),
+ ctx.db.select({ total: count() }).from(referents).where(where),
+ ]);
+
+ const total = totalResult[0]?.total ?? 0;
+
+ return {
+ rows,
+ total,
+ page: input.page,
+ pageSize: input.pageSize,
+ totalPages: Math.max(1, Math.ceil(total / input.pageSize)),
+ };
+ }),
+
+ create: adminProcedure
+ .input(createReferentSchema)
+ .mutation(async ({ ctx, input }) => {
+ const [created] = await ctx.db
+ .insert(referents)
+ .values({
+ region: input.region,
+ county: cleanOptionalString(input.county),
+ name: input.name,
+ type: input.type,
+ value: input.value,
+ principal: input.principal,
+ substituteName: cleanOptionalString(input.substituteName),
+ substituteEmail: cleanOptionalString(input.substituteEmail),
+ })
+ .returning({ id: referents.id });
+
+ return { id: created?.id };
+ }),
+
+ update: adminProcedure
+ .input(editReferentSchema)
+ .mutation(async ({ ctx, input }) => {
+ await ctx.db
+ .update(referents)
+ .set({
+ region: input.region,
+ county: cleanOptionalString(input.county),
+ name: input.name,
+ type: input.type,
+ value: input.value,
+ principal: input.principal,
+ substituteName: cleanOptionalString(input.substituteName),
+ substituteEmail: cleanOptionalString(input.substituteEmail),
+ updatedAt: new Date(),
+ })
+ .where(eq(referents.id, input.id));
+
+ return { id: input.id };
+ }),
+
+ delete: adminProcedure
+ .input(deleteReferentsSchema)
+ .mutation(async ({ ctx, input }) => {
+ await ctx.db.delete(referents).where(inArray(referents.id, input.ids));
+
+ return { deleted: input.ids.length };
+ }),
+
+ import: adminProcedure
+ .input(importReferentsSchema)
+ .mutation(async ({ ctx, input }) => {
+ await ctx.db.transaction(async (tx) => {
+ await tx.delete(referents);
+
+ for (const item of input) {
+ await tx.insert(referents).values({
+ region: item.region,
+ county: cleanOptionalString(item.county),
+ name: item.name,
+ type: item.type,
+ value: item.value,
+ principal: item.principal,
+ substituteName: cleanOptionalString(item.substituteName),
+ substituteEmail: cleanOptionalString(item.substituteEmail),
+ });
+ }
+ });
+
+ return { imported: input.length };
+ }),
+
+ exportAll: adminProcedure.query(async ({ ctx }) => {
+ return ctx.db
+ .select({
+ region: referents.region,
+ county: referents.county,
+ name: referents.name,
+ type: referents.type,
+ value: referents.value,
+ principal: referents.principal,
+ substituteName: referents.substituteName,
+ substituteEmail: referents.substituteEmail,
+ })
+ .from(referents)
+ .orderBy(asc(referents.region), asc(referents.county));
+ }),
+});
diff --git a/packages/app/src/server/db/schema.ts b/packages/app/src/server/db/schema.ts
index 915aded9c..6f87c430c 100644
--- a/packages/app/src/server/db/schema.ts
+++ b/packages/app/src/server/db/schema.ts
@@ -528,6 +528,32 @@ export const adminImpersonationEvents = createTable(
],
);
+// ── Referent tables ───────────────────────────────────────────────
+
+export const referentTypeEnum = pgEnum("referent_type", ["email", "url"]);
+
+export const referents = createTable(
+ "referent",
+ (d) => ({
+ id: d
+ .varchar({ length: 255 })
+ .notNull()
+ .primaryKey()
+ .$defaultFn(() => crypto.randomUUID()),
+ region: d.varchar({ length: 3 }).notNull(),
+ county: d.varchar({ length: 3 }),
+ name: d.varchar({ length: 255 }).notNull(),
+ type: referentTypeEnum().notNull(),
+ value: d.varchar({ length: 500 }).notNull(),
+ principal: d.boolean().notNull().default(false),
+ substituteName: d.varchar({ length: 255 }),
+ substituteEmail: d.varchar({ length: 255 }),
+ createdAt: d.timestamp({ withTimezone: true }).$defaultFn(() => new Date()),
+ updatedAt: d.timestamp({ withTimezone: true }).$defaultFn(() => new Date()),
+ }),
+ (t) => [index("referent_region_idx").on(t.region)],
+);
+
// ── Export tables ───────────────────────────────────────────────────
export const exports = createTable(
diff --git a/packages/app/src/server/services/clamav.ts b/packages/app/src/server/services/clamav.ts
index 7118857f8..9ef40b6b6 100644
--- a/packages/app/src/server/services/clamav.ts
+++ b/packages/app/src/server/services/clamav.ts
@@ -4,7 +4,7 @@ import net from "node:net";
import { env } from "~/env";
-import { SCAN_TIMEOUT_MS } from "~/modules/shared";
+import { SCAN_TIMEOUT_MS } from "~/modules/shared/uploadConfig";
export type ScanResult = { clean: true } | { clean: false; virus: string };
diff --git a/packages/app/src/server/services/fileUpload.ts b/packages/app/src/server/services/fileUpload.ts
index 7ce721e3c..db653653c 100644
--- a/packages/app/src/server/services/fileUpload.ts
+++ b/packages/app/src/server/services/fileUpload.ts
@@ -5,7 +5,10 @@ import path from "node:path";
import { env } from "~/env";
-import { FILE_TOO_LARGE_ERROR, MAX_FILE_SIZE } from "~/modules/shared";
+import {
+ FILE_TOO_LARGE_ERROR,
+ MAX_FILE_SIZE,
+} from "~/modules/shared/uploadConfig";
import { createClamdStream, type ScanResult } from "./clamav";
import { MIN_SIGNATURE_BYTES, validateFileSignature } from "./fileValidation";
diff --git a/packages/app/src/server/services/s3.ts b/packages/app/src/server/services/s3.ts
index 0b2abec7f..2591a7882 100644
--- a/packages/app/src/server/services/s3.ts
+++ b/packages/app/src/server/services/s3.ts
@@ -112,7 +112,7 @@ export async function deleteFile(key: string): Promise {
);
}
-import { S3_PART_MIN_SIZE } from "~/modules/shared";
+import { S3_PART_MIN_SIZE } from "~/modules/shared/uploadConfig";
/**
* Creates an incremental multipart upload to S3.