Skip to content

Commit b0484f8

Browse files
feat(admin): K2 — campaign progression chart (stats) (#3286)
1 parent c53e2c3 commit b0484f8

30 files changed

Lines changed: 1512 additions & 1 deletion
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- Track the first moment a declaration transitions to `status = 'submitted'`.
2+
-- Resubmissions after correction leave the value intact (the submit mutation
3+
-- uses `COALESCE(submitted_at, now)` to preserve the first submission date).
4+
-- Feeds the campaign progression chart (K2) which plots cumulative submissions
5+
-- per calendar day.
6+
ALTER TABLE "app_declaration"
7+
ADD COLUMN IF NOT EXISTS "submitted_at" timestamp with time zone;
8+
--> statement-breakpoint
9+
10+
-- Backfill historic submissions from `updated_at`. This is an approximation —
11+
-- `updated_at` is touched on every write, so for declarations resubmitted
12+
-- after corrections the value will be later than the true first submission.
13+
-- Accepting that drift for the historical curve; future submissions record
14+
-- the exact date via the submit mutation.
15+
UPDATE "app_declaration"
16+
SET "submitted_at" = "updated_at"
17+
WHERE "status" = 'submitted' AND "submitted_at" IS NULL;
18+
--> statement-breakpoint
19+
20+
CREATE INDEX IF NOT EXISTS "declaration_submitted_at_idx"
21+
ON "app_declaration" ("submitted_at");

packages/app/drizzle/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,13 @@
204204
"when": 1776000000000,
205205
"tag": "0028_add_cse_opinion_completed_at",
206206
"breakpoints": true
207+
},
208+
{
209+
"idx": 29,
210+
"version": "7",
211+
"when": 1776100000000,
212+
"tag": "0029_add_declaration_submitted_at",
213+
"breakpoints": true
207214
}
208215
]
209216
}

packages/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"react": "^19.2.5",
5454
"react-dom": "^19.2.5",
5555
"react-hook-form": "^7.72.1",
56+
"recharts": "^3.8.1",
5657
"redis": "^5.11.0",
5758
"server-only": "^0.0.1",
5859
"superjson": "^2.2.6",
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { CampaignStatsPage } from "~/modules/admin/stats";
2+
import { FIRST_DECLARATION_YEAR, getCurrentYear } from "~/modules/domain";
3+
4+
export default function Page() {
5+
const currentYear = getCurrentYear();
6+
const availableYears: number[] = [];
7+
for (let year = currentYear; year >= FIRST_DECLARATION_YEAR; year--) {
8+
availableYears.push(year);
9+
}
10+
return (
11+
<CampaignStatsPage
12+
availableYears={availableYears}
13+
currentYear={currentYear}
14+
/>
15+
);
16+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
import {
4+
deleteSeededCampaignDeclarations,
5+
seedSubmittedDeclarationsForStats,
6+
} from "./helpers/db";
7+
8+
// 9-digit SIRENs reserved for this test only — chosen outside any legitimate
9+
// range so we don't collide with real data.
10+
const SIRENS = {
11+
smallA: "999300001",
12+
smallB: "999300002",
13+
largeA: "999300003",
14+
} as const;
15+
16+
test.describe("admin campaign progression stats", () => {
17+
test.beforeAll(async () => {
18+
await seedSubmittedDeclarationsForStats([
19+
{
20+
siren: SIRENS.smallA,
21+
year: 2024,
22+
submittedAt: "2024-01-15T10:00:00Z",
23+
workforce: 30,
24+
},
25+
{
26+
siren: SIRENS.smallB,
27+
year: 2025,
28+
submittedAt: "2025-01-20T10:00:00Z",
29+
workforce: 30,
30+
},
31+
{
32+
siren: SIRENS.largeA,
33+
year: 2026,
34+
submittedAt: "2026-01-10T10:00:00Z",
35+
workforce: 250,
36+
},
37+
]);
38+
});
39+
40+
test.afterAll(async () => {
41+
await deleteSeededCampaignDeclarations(Object.values(SIRENS));
42+
});
43+
44+
test("admin can open the campaign stats page and sees the chart", async ({
45+
page,
46+
}) => {
47+
await page.goto("/admin/stats/campagne");
48+
49+
await expect(
50+
page.getByRole("heading", { name: "Statistiques campagne", level: 1 }),
51+
).toBeVisible();
52+
await expect(
53+
page.getByRole("heading", {
54+
name: "Progression dans le temps",
55+
level: 2,
56+
}),
57+
).toBeVisible();
58+
await expect(
59+
page.getByRole("figure", {
60+
name: /courbe de progression cumulative/i,
61+
}),
62+
).toBeVisible();
63+
});
64+
65+
test("accessible alternative table lists the same data", async ({ page }) => {
66+
await page.goto("/admin/stats/campagne");
67+
68+
// Wait for the chart to be rendered — the table renders next to it only
69+
// once the tRPC query resolves.
70+
await expect(
71+
page.getByRole("figure", { name: /courbe de progression cumulative/i }),
72+
).toBeVisible();
73+
74+
// The table lives inside a <details> disclosure — use the summary text
75+
// directly rather than a role match (varies across browsers).
76+
await page
77+
.locator("summary", {
78+
hasText: /consulter les données du graphique sous forme de tableau/i,
79+
})
80+
.click();
81+
82+
await expect(
83+
page.getByRole("columnheader", { name: "2024" }),
84+
).toBeVisible();
85+
});
86+
87+
test("filtering by size range keeps only matching companies", async ({
88+
page,
89+
}) => {
90+
await page.goto("/admin/stats/campagne");
91+
await page.getByLabel("Tranche d'effectif").selectOption("250+");
92+
93+
// The small-company seeds should disappear; the large one (workforce=250)
94+
// still contributes to the 2026 curve.
95+
await expect(
96+
page.getByRole("figure", { name: /courbe de progression cumulative/i }),
97+
).toBeVisible();
98+
});
99+
});
100+
101+
test("non-admin users are redirected away from the stats page", async ({
102+
browser,
103+
}) => {
104+
const anonCtx = await browser.newContext({ storageState: undefined });
105+
try {
106+
const page = await anonCtx.newPage();
107+
await page.goto("/admin/stats/campagne");
108+
// Redirected to /login (anonymous).
109+
await expect(page).toHaveURL(/\/login/);
110+
} finally {
111+
await anonCtx.close();
112+
}
113+
});

packages/app/src/e2e/helpers/db.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,74 @@ export async function deleteReferents(ids: string[]) {
426426
}
427427
}
428428

429+
type SeededCampaignDeclaration = {
430+
/** Unique 9-digit SIREN. Must not collide with existing data. */
431+
siren: string;
432+
year: number;
433+
/** Campaign-day the declaration was submitted (ISO string). */
434+
submittedAt: string;
435+
/** Workforce stored on the synthetic company. */
436+
workforce: number;
437+
};
438+
439+
/**
440+
* Insert synthetic submitted declarations for the campaign progression E2E
441+
* tests (K2). Each entry creates a company row if needed and a matching
442+
* declaration with the specified `submitted_at`. Uses the real E2E declarant
443+
* attached to TEST_SIREN to satisfy the FK on `declarant_id`.
444+
*/
445+
export async function seedSubmittedDeclarationsForStats(
446+
rows: SeededCampaignDeclaration[],
447+
) {
448+
if (rows.length === 0) return;
449+
const sql = createConnection();
450+
try {
451+
const users = await sql`
452+
SELECT user_id FROM app_user_company WHERE siren = ${TEST_SIREN} LIMIT 1
453+
`;
454+
const declarantId = users[0]?.user_id as string | undefined;
455+
if (!declarantId) {
456+
throw new Error(
457+
"seedSubmittedDeclarationsForStats: no declarant found for TEST_SIREN",
458+
);
459+
}
460+
for (const row of rows) {
461+
await sql`
462+
INSERT INTO app_company (siren, name, workforce, created_at, updated_at)
463+
VALUES (${row.siren}, ${`E2E Stats Co. ${row.siren}`}, ${row.workforce}, NOW(), NOW())
464+
ON CONFLICT (siren) DO UPDATE SET workforce = EXCLUDED.workforce
465+
`;
466+
await sql`
467+
INSERT INTO app_declaration (
468+
id, siren, year, declarant_id, current_step, status,
469+
submitted_at, created_at, updated_at
470+
)
471+
VALUES (
472+
gen_random_uuid(), ${row.siren}, ${row.year}, ${declarantId}, 6,
473+
'submitted', ${row.submittedAt}, NOW(), NOW()
474+
)
475+
ON CONFLICT ON CONSTRAINT declaration_siren_year_idx DO UPDATE SET
476+
status = 'submitted',
477+
submitted_at = EXCLUDED.submitted_at
478+
`;
479+
}
480+
} finally {
481+
await sql.end();
482+
}
483+
}
484+
485+
/** Remove the synthetic company+declaration rows seeded for the stats E2E. */
486+
export async function deleteSeededCampaignDeclarations(sirens: string[]) {
487+
if (sirens.length === 0) return;
488+
const sql = createConnection();
489+
try {
490+
await sql`DELETE FROM app_declaration WHERE siren = ANY(${sirens})`;
491+
await sql`DELETE FROM app_company WHERE siren = ANY(${sirens})`;
492+
} finally {
493+
await sql.end();
494+
}
495+
}
496+
429497
/** Clear or set the phone number for the test user (identified via user_company link to TEST_SIREN). */
430498
export async function setUserPhone(phone: string | null) {
431499
const sql = createConnection();

packages/app/src/modules/admin/AdminNavigation.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const adminLinks = [
88
{ href: "/admin/declarations", label: "Déclarations" },
99
{ href: "/admin/impersonate", label: "Mimoquer un Siren" },
1010
{ href: "/admin/liste-referents", label: "Référents" },
11+
{ href: "/admin/stats/campagne", label: "Statistiques campagne" },
1112
{ href: "/admin/parametres", label: "Paramètres" },
1213
] as const;
1314

packages/app/src/modules/admin/__tests__/AdminNavigation.test.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@ describe("AdminNavigation", () => {
2626
screen.getByRole("link", { name: "Mimoquer un Siren" }),
2727
).toBeInTheDocument();
2828
expect(screen.getByRole("link", { name: "Référents" })).toBeInTheDocument();
29+
expect(
30+
screen.getByRole("link", { name: "Statistiques campagne" }),
31+
).toBeInTheDocument();
32+
});
33+
34+
it("marks /admin/stats/campagne as active on that page", () => {
35+
(usePathname as Mock).mockReturnValue("/admin/stats/campagne");
36+
render(<AdminNavigation />);
37+
expect(
38+
screen.getByRole("link", { name: "Statistiques campagne" }),
39+
).toHaveAttribute("aria-current", "page");
40+
expect(screen.getByRole("link", { name: "Accueil" })).not.toHaveAttribute(
41+
"aria-current",
42+
);
2943
});
3044

3145
it("marks /admin as active when on /admin", () => {
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
.chartWrapper {
2+
width: 100%;
3+
height: 360px;
4+
}
5+
6+
.tooltipList {
7+
margin: 0;
8+
padding: 0;
9+
list-style: none;
10+
}
11+
12+
.tooltipItem {
13+
font-size: 0.875rem;
14+
}

0 commit comments

Comments
 (0)