Skip to content

Commit 694a7b3

Browse files
committed
feat(admin): add referent management with CRUD, import/export, and public API
- Add referents table to Drizzle schema with referent_type enum - Create adminReferents tRPC router (search, create, update, delete, import, exportAll) - Build AdminReferentsPage with paginated table, search, sort, batch delete - Add create/edit modals with region-dependent county select - Add JSON import modal with validation - Add JSON/CSV export buttons - Create public API route /api/public/referents-egalite-professionnelle - Add Zod schemas, types, constants for the referents module - Add unit tests for schemas, constants, and page component - Add E2E test for non-admin redirect Closes #3182
1 parent 532e4af commit 694a7b3

20 files changed

Lines changed: 2046 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { AdminReferentsPage } from "~/modules/admin";
2+
3+
export default function Page() {
4+
return <AdminReferentsPage />;
5+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { asc } from "drizzle-orm";
2+
import { NextResponse } from "next/server";
3+
import type { CountyCode, RegionCode } from "~/modules/domain";
4+
import { COUNTIES, REGIONS } from "~/modules/domain";
5+
import { db } from "~/server/db";
6+
import { referents } from "~/server/db/schema";
7+
8+
async function getAllReferents() {
9+
return db
10+
.select({
11+
region: referents.region,
12+
county: referents.county,
13+
name: referents.name,
14+
type: referents.type,
15+
value: referents.value,
16+
principal: referents.principal,
17+
substituteName: referents.substituteName,
18+
substituteEmail: referents.substituteEmail,
19+
})
20+
.from(referents)
21+
.orderBy(asc(referents.region), asc(referents.county));
22+
}
23+
24+
function formatCsv(rows: Awaited<ReturnType<typeof getAllReferents>>): string {
25+
const headers = [
26+
"Région",
27+
"Département",
28+
"Nom",
29+
"Type",
30+
"Valeur",
31+
"Principal",
32+
"Nom suppléant",
33+
"Email suppléant",
34+
];
35+
36+
const csvRows = [
37+
headers.join(";"),
38+
...rows.map((r) =>
39+
[
40+
REGIONS[r.region as RegionCode] ?? r.region,
41+
r.county ? (COUNTIES[r.county as CountyCode] ?? r.county) : "",
42+
r.name,
43+
r.type,
44+
r.value,
45+
r.principal ? "Oui" : "Non",
46+
r.substituteName ?? "",
47+
r.substituteEmail ?? "",
48+
]
49+
.map((val) => `"${String(val).replace(/"/g, '""')}"`)
50+
.join(";"),
51+
),
52+
];
53+
54+
return csvRows.join("\n");
55+
}
56+
57+
export async function GET(request: Request) {
58+
const { searchParams } = new URL(request.url);
59+
const format = searchParams.get("format") ?? "json";
60+
61+
const rows = await getAllReferents();
62+
63+
if (format === "csv") {
64+
const csv = formatCsv(rows);
65+
return new NextResponse(csv, {
66+
headers: {
67+
"Content-Type": "text/csv; charset=utf-8",
68+
"Content-Disposition":
69+
'attachment; filename="referents_egalite_professionnelle.csv"',
70+
},
71+
});
72+
}
73+
74+
return NextResponse.json(rows);
75+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
test("non-admin user visiting /admin/liste-referents is redirected to /mon-espace", async ({
4+
page,
5+
}) => {
6+
await page.goto("/admin/liste-referents");
7+
await page.waitForURL("**/mon-espace");
8+
expect(page.url()).toContain("/mon-espace");
9+
});
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
"use client";
2+
3+
import { useSearchParams } from "next/navigation";
4+
import { Suspense, useCallback, useState } from "react";
5+
6+
import type { CountyCode, RegionCode } from "~/modules/domain";
7+
import { api } from "~/trpc/react";
8+
import { DeleteModal, useDeleteModal } from "./DeleteConfirmationModal";
9+
import { ImportReferentsModal, useImportModal } from "./ImportReferentsModal";
10+
import { ReferentFormModal, useReferentFormModal } from "./ReferentFormModal";
11+
import { ReferentTable } from "./ReferentTable";
12+
import { SearchForm } from "./SearchForm";
13+
import type { ReferentFormValues, SortColumn } from "./schemas";
14+
import { DEFAULT_PAGE_SIZE } from "./schemas";
15+
import type { ReferentSearchRow } from "./types";
16+
17+
function ExportButton() {
18+
const { data, refetch, isFetching } = api.adminReferents.exportAll.useQuery(
19+
undefined,
20+
{ enabled: false },
21+
);
22+
23+
const handleExport = useCallback(
24+
async (format: "json" | "csv") => {
25+
const result = data ?? (await refetch()).data;
26+
if (!result) return;
27+
28+
let content: string;
29+
let mimeType: string;
30+
let extension: string;
31+
32+
if (format === "csv") {
33+
const headers = [
34+
"region",
35+
"county",
36+
"name",
37+
"type",
38+
"value",
39+
"principal",
40+
"substituteName",
41+
"substituteEmail",
42+
];
43+
const csvRows = [
44+
headers.join(";"),
45+
...result.map((r) =>
46+
headers
47+
.map((h) => {
48+
const val = r[h as keyof typeof r] ?? "";
49+
return `"${String(val).replace(/"/g, '""')}"`;
50+
})
51+
.join(";"),
52+
),
53+
];
54+
content = csvRows.join("\n");
55+
mimeType = "text/csv;charset=utf-8";
56+
extension = "csv";
57+
} else {
58+
content = JSON.stringify(result, null, 2);
59+
mimeType = "application/json";
60+
extension = "json";
61+
}
62+
63+
const blob = new Blob([content], { type: mimeType });
64+
const url = URL.createObjectURL(blob);
65+
const link = document.createElement("a");
66+
link.href = url;
67+
link.download = `referents.${extension}`;
68+
link.click();
69+
URL.revokeObjectURL(url);
70+
},
71+
[data, refetch],
72+
);
73+
74+
return (
75+
<div className="fr-btns-group fr-btns-group--inline fr-btns-group--sm">
76+
<button
77+
className="fr-btn fr-btn--secondary fr-btn--sm fr-btn--icon-left fr-icon-download-line"
78+
disabled={isFetching}
79+
onClick={() => handleExport("json")}
80+
type="button"
81+
>
82+
Export JSON
83+
</button>
84+
<button
85+
className="fr-btn fr-btn--secondary fr-btn--sm fr-btn--icon-left fr-icon-download-line"
86+
disabled={isFetching}
87+
onClick={() => handleExport("csv")}
88+
type="button"
89+
>
90+
Export CSV
91+
</button>
92+
</div>
93+
);
94+
}
95+
96+
function ReferentsContent() {
97+
const searchParams = useSearchParams();
98+
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
99+
const [deleteError, setDeleteError] = useState<string | null>(null);
100+
const [editingReferent, setEditingReferent] =
101+
useState<ReferentSearchRow | null>(null);
102+
const {
103+
modalRef: deleteModalRef,
104+
open: openDeleteModal,
105+
close: closeDeleteModal,
106+
} = useDeleteModal();
107+
const { createRef, editRef, openCreate, closeCreate, openEdit, closeEdit } =
108+
useReferentFormModal();
109+
const {
110+
modalRef: importModalRef,
111+
open: openImport,
112+
close: closeImport,
113+
} = useImportModal();
114+
115+
const input = {
116+
query: searchParams.get("query") ?? undefined,
117+
region: (searchParams.get("region") as RegionCode) || undefined,
118+
county: (searchParams.get("county") as CountyCode) || undefined,
119+
page: Number(searchParams.get("page") ?? "1"),
120+
pageSize: Number(searchParams.get("pageSize") ?? String(DEFAULT_PAGE_SIZE)),
121+
sortBy: (searchParams.get("sortBy") as SortColumn) ?? "region",
122+
sortOrder: (searchParams.get("sortOrder") as "asc" | "desc") ?? "asc",
123+
};
124+
125+
const { data, isLoading, refetch } =
126+
api.adminReferents.search.useQuery(input);
127+
const deleteMutation = api.adminReferents.delete.useMutation({
128+
onSuccess: () => {
129+
setSelectedIds(new Set());
130+
setDeleteError(null);
131+
refetch();
132+
},
133+
onError: () => {
134+
setDeleteError("La suppression a échoué. Veuillez réessayer.");
135+
},
136+
});
137+
const createMutation = api.adminReferents.create.useMutation({
138+
onSuccess: () => refetch(),
139+
});
140+
const updateMutation = api.adminReferents.update.useMutation({
141+
onSuccess: () => refetch(),
142+
});
143+
144+
const handleDelete = useCallback(() => {
145+
if (selectedIds.size === 0) return;
146+
deleteMutation.mutate({ ids: [...selectedIds] });
147+
}, [selectedIds, deleteMutation]);
148+
149+
const handleEdit = useCallback(
150+
(row: ReferentSearchRow) => {
151+
setEditingReferent(row);
152+
openEdit();
153+
},
154+
[openEdit],
155+
);
156+
157+
const handleCreateSubmit = useCallback(
158+
(data: ReferentFormValues & { id?: string }) => {
159+
createMutation.mutate(
160+
data as Parameters<typeof createMutation.mutate>[0],
161+
);
162+
},
163+
[createMutation],
164+
);
165+
166+
const handleEditSubmit = useCallback(
167+
(data: ReferentFormValues & { id?: string }) => {
168+
if (!data.id) return;
169+
updateMutation.mutate(
170+
data as Parameters<typeof updateMutation.mutate>[0],
171+
);
172+
},
173+
[updateMutation],
174+
);
175+
176+
if (isLoading) {
177+
return <p>Chargement...</p>;
178+
}
179+
180+
return (
181+
<>
182+
<SearchForm />
183+
<div className="fr-grid-row fr-grid-row--middle fr-mb-2w">
184+
<div className="fr-col">
185+
<ul className="fr-btns-group fr-btns-group--inline">
186+
<li>
187+
<button
188+
className="fr-btn fr-btn--icon-left fr-icon-add-circle-line"
189+
onClick={openCreate}
190+
type="button"
191+
>
192+
Ajouter
193+
</button>
194+
</li>
195+
<li>
196+
<button
197+
className="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-upload-line"
198+
onClick={openImport}
199+
type="button"
200+
>
201+
Importer
202+
</button>
203+
</li>
204+
</ul>
205+
</div>
206+
<div className="fr-col-auto">
207+
<ExportButton />
208+
</div>
209+
</div>
210+
{deleteError && (
211+
<div
212+
aria-live="polite"
213+
className="fr-alert fr-alert--error fr-alert--sm fr-mb-2w"
214+
>
215+
<p>{deleteError}</p>
216+
</div>
217+
)}
218+
{selectedIds.size > 0 && (
219+
<div className="fr-mb-2w">
220+
<button
221+
className="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-delete-line"
222+
onClick={openDeleteModal}
223+
type="button"
224+
>
225+
Supprimer la sélection ({selectedIds.size})
226+
</button>
227+
</div>
228+
)}
229+
{data && (
230+
<ReferentTable
231+
onEdit={handleEdit}
232+
onSelectionChange={setSelectedIds}
233+
page={data.page}
234+
rows={data.rows}
235+
selectedIds={selectedIds}
236+
sortBy={input.sortBy}
237+
sortOrder={input.sortOrder}
238+
total={data.total}
239+
totalPages={data.totalPages}
240+
/>
241+
)}
242+
<DeleteModal
243+
count={selectedIds.size}
244+
modalRef={deleteModalRef}
245+
onClose={closeDeleteModal}
246+
onConfirm={handleDelete}
247+
/>
248+
<ReferentFormModal
249+
modalRef={createRef}
250+
mode="create"
251+
onClose={closeCreate}
252+
onSubmit={handleCreateSubmit}
253+
/>
254+
<ReferentFormModal
255+
modalRef={editRef}
256+
mode="edit"
257+
onClose={closeEdit}
258+
onSubmit={handleEditSubmit}
259+
referent={editingReferent}
260+
/>
261+
<ImportReferentsModal
262+
modalRef={importModalRef}
263+
onClose={closeImport}
264+
onSuccess={() => refetch()}
265+
/>
266+
</>
267+
);
268+
}
269+
270+
export function AdminReferentsPage() {
271+
return (
272+
<>
273+
<h1 className="fr-h3 fr-mb-4w">Liste des référents Egapro</h1>
274+
<Suspense fallback={<p>Chargement...</p>}>
275+
<ReferentsContent />
276+
</Suspense>
277+
</>
278+
);
279+
}

0 commit comments

Comments
 (0)