Skip to content

Commit f23ccef

Browse files
feat: add declaration PDF generation and download (#2904) (#2910)
Co-authored-by: Gary van Woerkens <gary.van-woerkens@sg.social.gouv.fr>
1 parent 92ffd1e commit f23ccef

21 files changed

Lines changed: 1404 additions & 3 deletions

.claude/hooks/block-bad-patterns.sh

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,11 @@ check_pattern '\.(ts|tsx|js|jsx)$' \
4242
'biome-ignore|eslint-disable|@ts-ignore|@ts-expect-error' \
4343
'Suppression comments are forbidden. Fix the underlying issue instead.'
4444

45-
# Inline styles — JSX files only
45+
# Inline styles — JSX files only (exclude @react-pdf/renderer components which require style={})
4646
check_pattern '\.(tsx|jsx)$' \
4747
'style=\{' \
48-
'Inline style={{}} is forbidden. Use DSFR classes or a scoped SCSS module.'
48+
'Inline style={{}} is forbidden. Use DSFR classes or a scoped SCSS module.' \
49+
'declarationPdf/'
4950

5051
# Inline SVG — JSX files only (DsfrPictogram is the only allowed SVG wrapper)
5152
check_pattern '\.(tsx|jsx)$' \

packages/app/next.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { withSentryConfig } from "@sentry/nextjs";
99
/** @type {import("next").NextConfig} */
1010
const config = {
1111
output: "standalone",
12+
serverExternalPackages: ["@react-pdf/renderer"],
1213
sassOptions: {
1314
additionalData: `
1415
@import "@gouvfr/dsfr/src/dsfr/core/style/selector/setting/breakpoint";

packages/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"dependencies": {
3232
"@auth/drizzle-adapter": "^1.11.1",
3333
"@gouvfr/dsfr": "^1.14.3",
34+
"@react-pdf/renderer": "^4.3.2",
3435
"@sentry/nextjs": "^10.39.0",
3536
"@socialgouv/matomo-next": "^1.11.0",
3637
"@t3-oss/env-nextjs": "^0.13.10",
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { renderToBuffer } from "@react-pdf/renderer";
2+
import { buildPdfData } from "~/modules/declarationPdf/buildPdfData";
3+
import { DeclarationPdfDocument } from "~/modules/declarationPdf/DeclarationPdfDocument";
4+
import { auth } from "~/server/auth";
5+
6+
export async function GET() {
7+
const session = await auth();
8+
if (!session?.user?.siret) {
9+
return new Response("Non autorisé", { status: 401 });
10+
}
11+
12+
const siren = session.user.siret.slice(0, 9);
13+
const year = new Date().getFullYear();
14+
15+
try {
16+
const data = await buildPdfData(siren, year, new Date());
17+
const buffer = await renderToBuffer(DeclarationPdfDocument({ data }));
18+
const filename = `declaration-remuneration-${siren}-${year}.pdf`;
19+
20+
return new Response(new Uint8Array(buffer), {
21+
headers: {
22+
"Content-Type": "application/pdf",
23+
"Content-Disposition": `attachment; filename="${filename}"`,
24+
},
25+
});
26+
} catch (error) {
27+
console.error("[declaration-pdf]", error);
28+
return new Response("Impossible de générer le PDF", { status: 400 });
29+
}
30+
}

packages/app/src/modules/declaration-remuneration/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,20 @@ export { DeclarationIntro } from "./DeclarationIntro";
22
export { DeclarationLayout } from "./DeclarationLayout";
33
export { MissingSiret } from "./MissingSiret";
44
export { StepPageClient } from "./StepPageClient";
5+
export {
6+
computeGap,
7+
computePercentage,
8+
formatCurrency,
9+
formatGap,
10+
gapLevel,
11+
} from "./shared/gapUtils";
512
export { Step1Workforce } from "./steps/Step1Workforce";
613
export { Step2PayGap } from "./steps/Step2PayGap";
714
export { Step3VariablePay } from "./steps/Step3VariablePay";
815
export { Step4QuartileDistribution } from "./steps/Step4QuartileDistribution";
916
export { Step5EmployeeCategories } from "./steps/Step5EmployeeCategories";
1017
export { Step6Review } from "./steps/Step6Review";
11-
18+
export { parseStep5Categories } from "./steps/step6/parseStep5Categories";
1219
export type {
1320
CategoryData,
1421
PayGapRow,

packages/app/src/modules/declaration-remuneration/steps/Step6Review.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useRouter } from "next/navigation";
44
import { useCallback, useRef } from "react";
55

6+
import { DownloadDeclarationPdfButton } from "~/modules/declarationPdf";
67
import { api } from "~/trpc/react";
78
import common from "../shared/common.module.scss";
89
import { FormActions } from "../shared/FormActions";
@@ -243,6 +244,8 @@ export function Step6Review({
243244
)}
244245
</div>
245246

247+
{isSubmitted && <DownloadDeclarationPdfButton />}
248+
246249
{isSubmitted ? (
247250
<FormActions
248251
nextHref="/avis-cse"

packages/app/src/modules/declaration-remuneration/steps/__tests__/Step6Review.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import { render, screen } from "@testing-library/react";
22
import { describe, expect, it, vi } from "vitest";
33
import { Step6Review } from "../Step6Review";
44

5+
vi.mock("~/modules/declarationPdf", () => ({
6+
DownloadDeclarationPdfButton: () => (
7+
<a href="/api/declaration-pdf">Télécharger le récapitulatif (PDF)</a>
8+
),
9+
}));
10+
511
const mockSubmitMutate = vi.fn();
612

713
vi.mock("~/trpc/react", () => ({
@@ -266,4 +272,18 @@ describe("Step6Review", () => {
266272
"/avis-cse",
267273
);
268274
});
275+
276+
it("renders PDF download button when submitted", () => {
277+
render(<Step6Review isSubmitted />);
278+
expect(
279+
screen.getByRole("link", { name: /télécharger le récapitulatif/i }),
280+
).toHaveAttribute("href", "/api/declaration-pdf");
281+
});
282+
283+
it("does not render PDF download button when not submitted", () => {
284+
render(<Step6Review />);
285+
expect(
286+
screen.queryByRole("link", { name: /télécharger le récapitulatif/i }),
287+
).not.toBeInTheDocument();
288+
});
269289
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Document, Page, Text, View } from "@react-pdf/renderer";
2+
3+
import { styles } from "./pdfStyles";
4+
import { CategorySection } from "./sections/CategorySection";
5+
import { PayGapTable } from "./sections/PayGapTable";
6+
import { QuartileSection } from "./sections/QuartileSection";
7+
import { VariablePaySection } from "./sections/VariablePaySection";
8+
import { WorkforceSection } from "./sections/WorkforceSection";
9+
import type { DeclarationPdfData } from "./types";
10+
11+
type Props = {
12+
data: DeclarationPdfData;
13+
};
14+
15+
export function DeclarationPdfDocument({ data }: Props) {
16+
return (
17+
<Document>
18+
<Page size="A4" style={styles.page}>
19+
<View style={styles.header}>
20+
<Text style={styles.title}>
21+
Déclaration des indicateurs de rémunération {data.year}
22+
</Text>
23+
<Text style={styles.subtitle}>
24+
Au titre des données {data.year - 1}
25+
</Text>
26+
<Text style={styles.companyInfo}>
27+
{data.companyName} — SIREN {data.siren}
28+
</Text>
29+
</View>
30+
31+
<WorkforceSection data={data} />
32+
<PayGapTable rows={data.step2Rows} title="Écart de rémunération" />
33+
<VariablePaySection data={data} />
34+
<QuartileSection data={data} />
35+
<CategorySection data={data} />
36+
37+
<Text style={styles.footer}>Document généré le {data.generatedAt}</Text>
38+
</Page>
39+
</Document>
40+
);
41+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export function DownloadDeclarationPdfButton() {
2+
return (
3+
<a
4+
className="fr-btn fr-btn--secondary fr-btn--icon-left fr-icon-file-pdf-line"
5+
download
6+
href="/api/declaration-pdf"
7+
>
8+
Télécharger le récapitulatif (PDF)
9+
</a>
10+
);
11+
}

0 commit comments

Comments
 (0)