Skip to content

Commit aacac2d

Browse files
feat(my-space): rework declarations table and add documents side panel (#3170)
1 parent 74b7438 commit aacac2d

14 files changed

Lines changed: 504 additions & 54 deletions
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { renderToBuffer } from "@react-pdf/renderer";
2+
import { and, eq } from "drizzle-orm";
3+
4+
import {
5+
type PrefillPdfData,
6+
PrefillPdfDocument,
7+
} from "~/modules/declarationPdf/PrefillPdfDocument";
8+
import { extractSiren, getCurrentYear } from "~/modules/domain";
9+
import { auth } from "~/server/auth";
10+
import { db } from "~/server/db";
11+
import { companies, gipMdsData } from "~/server/db/schema";
12+
13+
export async function GET(request: Request) {
14+
const session = await auth();
15+
if (!session?.user?.siret) {
16+
return new Response("Non autorisé", { status: 401 });
17+
}
18+
19+
const siren = extractSiren(session.user.siret);
20+
const url = new URL(request.url);
21+
const yearParam = url.searchParams.get("year");
22+
const parsedYear = yearParam ? Number.parseInt(yearParam, 10) : null;
23+
if (
24+
parsedYear !== null &&
25+
(Number.isNaN(parsedYear) || parsedYear < 2000 || parsedYear > 2100)
26+
) {
27+
return new Response("Paramètre 'year' invalide", { status: 400 });
28+
}
29+
const year = parsedYear ?? getCurrentYear();
30+
31+
try {
32+
const [row] = await db
33+
.select()
34+
.from(gipMdsData)
35+
.where(and(eq(gipMdsData.siren, siren), eq(gipMdsData.year, year)))
36+
.limit(1);
37+
38+
if (!row) {
39+
return new Response("Aucune donnée préremplie", { status: 404 });
40+
}
41+
42+
const [company] = await db
43+
.select({ name: companies.name })
44+
.from(companies)
45+
.where(eq(companies.siren, siren))
46+
.limit(1);
47+
48+
const data: PrefillPdfData = {
49+
siren,
50+
companyName: company?.name ?? `Entreprise ${siren}`,
51+
year,
52+
periodStart: row.periodStart,
53+
periodEnd: row.periodEnd,
54+
row: row as unknown as Record<string, string | number | null>,
55+
};
56+
57+
const buffer = await renderToBuffer(PrefillPdfDocument({ data }));
58+
const filename = `donnees-preremplies-${siren}-${year}.pdf`;
59+
60+
return new Response(new Uint8Array(buffer), {
61+
headers: {
62+
"Content-Type": "application/pdf",
63+
"Content-Disposition": `attachment; filename="${filename}"`,
64+
},
65+
});
66+
} catch (error) {
67+
console.error("[prefill-pdf]", error);
68+
return new Response("Impossible de générer le PDF", { status: 500 });
69+
}
70+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { Document, Page, Text, View } from "@react-pdf/renderer";
2+
3+
import { ensurePdfFontsRegistered } from "./pdfFonts";
4+
import { styles } from "./pdfStyles";
5+
6+
export type PrefillPdfData = {
7+
siren: string;
8+
companyName: string;
9+
year: number;
10+
periodStart: string | null;
11+
periodEnd: string | null;
12+
row: Record<string, string | number | null>;
13+
};
14+
15+
type Section = {
16+
title: string;
17+
fields: Array<[string, string]>; // [label, key]
18+
};
19+
20+
const SECTIONS: Section[] = [
21+
{
22+
title: "Effectifs",
23+
fields: [
24+
["Effectif EMA", "workforceEma"],
25+
["Femmes — annuel global", "womenCountAnnualGlobal"],
26+
["Hommes — annuel global", "menCountAnnualGlobal"],
27+
["Femmes — horaire global", "womenCountHourlyGlobal"],
28+
["Hommes — horaire global", "menCountHourlyGlobal"],
29+
],
30+
},
31+
{
32+
title: "Indicateur A — Écart de rémunération moyen global",
33+
fields: [
34+
["Écart annuel moyen", "globalAnnualMeanGap"],
35+
["Rémunération annuelle moyenne — femmes", "globalAnnualMeanWomen"],
36+
["Rémunération annuelle moyenne — hommes", "globalAnnualMeanMen"],
37+
["Écart horaire moyen", "globalHourlyMeanGap"],
38+
],
39+
},
40+
{
41+
title: "Indicateur B — Écart de rémunération variable moyen",
42+
fields: [
43+
["Écart annuel moyen", "variableAnnualMeanGap"],
44+
["Rémunération annuelle moyenne — femmes", "variableAnnualMeanWomen"],
45+
["Rémunération annuelle moyenne — hommes", "variableAnnualMeanMen"],
46+
],
47+
},
48+
{
49+
title: "Indicateur C — Écart de rémunération médian global",
50+
fields: [
51+
["Écart annuel médian", "globalAnnualMedianGap"],
52+
["Rémunération annuelle médiane — femmes", "globalAnnualMedianWomen"],
53+
["Rémunération annuelle médiane — hommes", "globalAnnualMedianMen"],
54+
],
55+
},
56+
{
57+
title: "Indicateur D — Écart de rémunération variable médian",
58+
fields: [
59+
["Écart annuel médian", "variableAnnualMedianGap"],
60+
["Rémunération annuelle médiane — femmes", "variableAnnualMedianWomen"],
61+
["Rémunération annuelle médiane — hommes", "variableAnnualMedianMen"],
62+
],
63+
},
64+
{
65+
title: "Indicateur E — Proportion de rémunération variable",
66+
fields: [
67+
["Proportion — femmes", "variableProportionWomen"],
68+
["Proportion — hommes", "variableProportionMen"],
69+
],
70+
},
71+
{
72+
title: "Indicateur F — Distribution par quartile (annuel)",
73+
fields: [
74+
["Seuil Q1", "annualQuartileThreshold1"],
75+
["Seuil Q2", "annualQuartileThreshold2"],
76+
["Seuil Q3", "annualQuartileThreshold3"],
77+
["Seuil Q4", "annualQuartileThreshold4"],
78+
["Q1 — femmes", "annualQuartile1ProportionWomen"],
79+
["Q1 — hommes", "annualQuartile1ProportionMen"],
80+
["Q2 — femmes", "annualQuartile2ProportionWomen"],
81+
["Q2 — hommes", "annualQuartile2ProportionMen"],
82+
["Q3 — femmes", "annualQuartile3ProportionWomen"],
83+
["Q3 — hommes", "annualQuartile3ProportionMen"],
84+
["Q4 — femmes", "annualQuartile4ProportionWomen"],
85+
["Q4 — hommes", "annualQuartile4ProportionMen"],
86+
],
87+
},
88+
{
89+
title: "Indice de confiance",
90+
fields: [["Indice de confiance global", "confidenceIndex"]],
91+
},
92+
];
93+
94+
function formatValue(value: string | number | null | undefined): string {
95+
if (value === null || value === undefined || value === "") return "—";
96+
return String(value);
97+
}
98+
99+
type Props = {
100+
data: PrefillPdfData;
101+
};
102+
103+
export function PrefillPdfDocument({ data }: Props) {
104+
ensurePdfFontsRegistered();
105+
106+
return (
107+
<Document>
108+
<Page size="A4" style={styles.page}>
109+
<View style={styles.header}>
110+
<Text style={styles.title}>
111+
Données préremplies {data.year} (issues des données DSN)
112+
</Text>
113+
<Text style={styles.subtitle}>
114+
Au titre des données {data.year - 1}
115+
</Text>
116+
<Text style={styles.companyInfo}>
117+
{data.companyName} — SIREN {data.siren}
118+
</Text>
119+
{data.periodStart && data.periodEnd && (
120+
<Text style={styles.companyInfo}>
121+
Période : {data.periodStart}{data.periodEnd}
122+
</Text>
123+
)}
124+
</View>
125+
126+
{SECTIONS.map((section) => (
127+
<View key={section.title} style={styles.card}>
128+
<Text style={styles.cardTitle}>{section.title}</Text>
129+
{section.fields.map(([label, key], index) => {
130+
const isLast = index === section.fields.length - 1;
131+
return (
132+
<View
133+
key={key}
134+
style={isLast ? styles.tableRowLast : styles.tableRow}
135+
>
136+
<Text style={styles.tableCellLabel}>{label}</Text>
137+
<Text style={styles.tableCellValue}>
138+
{formatValue(data.row[key])}
139+
</Text>
140+
</View>
141+
);
142+
})}
143+
</View>
144+
))}
145+
</Page>
146+
</Document>
147+
);
148+
}

packages/app/src/modules/my-space/DeclarationProcessPanel.module.scss

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,60 @@
154154
gap: 1rem;
155155
}
156156

157+
// Documents panel — custom card list
158+
.documentList {
159+
list-style: none;
160+
padding: 0;
161+
margin: 0;
162+
display: flex;
163+
flex-direction: column;
164+
gap: 1rem;
165+
width: 100%;
166+
min-width: 0;
167+
}
168+
169+
.documentItem {
170+
padding: 0;
171+
margin: 0;
172+
width: 100%;
173+
min-width: 0;
174+
}
175+
176+
.documentCard {
177+
border: 1px solid var(--border-default-grey);
178+
background-color: var(--background-default-grey);
179+
padding: 1.5rem;
180+
display: flex;
181+
flex-direction: column;
182+
gap: 0.75rem;
183+
width: 100%;
184+
min-width: 0;
185+
box-sizing: border-box;
186+
}
187+
188+
.documentCard a.documentCardTitle {
189+
display: block;
190+
width: 100%;
191+
max-width: 100%;
192+
min-width: 0;
193+
font-family: inherit;
194+
font-size: 1.25rem;
195+
font-weight: 700;
196+
line-height: 1.75rem;
197+
color: var(--text-title-blue-france);
198+
overflow-wrap: anywhere;
199+
word-break: break-word;
200+
white-space: normal;
201+
background-image: none;
202+
}
203+
204+
.documentCardFooter {
205+
display: flex;
206+
align-items: center;
207+
justify-content: space-between;
208+
gap: 1rem;
209+
}
210+
157211
// Footer
158212
.footer {
159213
display: flex;

packages/app/src/modules/my-space/DeclarationsSection.tsx

Lines changed: 49 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import { getCurrentYear } from "~/modules/domain";
77

88
import { DeclarationLink } from "./DeclarationLink";
99
import { getDeclarationStepLabel } from "./DeclarationStepLabel";
10+
import {
11+
DocumentsPanel,
12+
getDocumentResourceCount,
13+
getDocumentsPanelId,
14+
} from "./DocumentsPanel";
1015
import { StatusBadge } from "./StatusBadge";
1116
import type { DeclarationItem, DeclarationType } from "./types";
1217

@@ -23,15 +28,6 @@ type Props = {
2328
hasNoSanction: boolean;
2429
};
2530

26-
function formatDate(date: Date | null): string {
27-
if (!date) return "Aucune";
28-
return new Intl.DateTimeFormat("fr-FR", {
29-
day: "2-digit",
30-
month: "2-digit",
31-
year: "numeric",
32-
}).format(date);
33-
}
34-
3531
const TYPE_DEADLINES: Record<DeclarationType, string> = {
3632
remuneration: "01/06",
3733
representation: "01/03",
@@ -204,33 +200,54 @@ function DeclarationsTable({
204200
<th scope="col">Déclaration</th>
205201
<th scope="col">Année</th>
206202
<th scope="col">Étape</th>
207-
<th scope="col">Statut</th>
208203
<th scope="col">Échéance</th>
209-
<th scope="col">Mise à jour</th>
204+
<th scope="col">Statut</th>
205+
<th scope="col">Ressources</th>
210206
</tr>
211207
</thead>
212208
<tbody>
213-
{declarations.map((declaration) => (
214-
<tr key={`${declaration.type}-${declaration.year}`}>
215-
<td>
216-
<DeclarationLink
217-
hasCse={hasCse}
218-
siren={siren}
219-
type={declaration.type}
220-
userPhone={userPhone}
221-
>
222-
{TYPE_LABELS[declaration.type]}
223-
</DeclarationLink>
224-
</td>
225-
<td>{declaration.year}</td>
226-
<td>{getDeclarationStepLabel(declaration.currentStep)}</td>
227-
<td>
228-
<StatusBadge status={declaration.status} />
229-
</td>
230-
<td>{getDeadline(declaration)}</td>
231-
<td>{formatDate(declaration.updatedAt)}</td>
232-
</tr>
233-
))}
209+
{declarations.map((declaration) => {
210+
const resourceCount = getDocumentResourceCount(declaration);
211+
return (
212+
<tr key={`${declaration.type}-${declaration.year}`}>
213+
<td>
214+
<DeclarationLink
215+
hasCse={hasCse}
216+
siren={siren}
217+
type={declaration.type}
218+
userPhone={userPhone}
219+
>
220+
{TYPE_LABELS[declaration.type]}
221+
</DeclarationLink>
222+
</td>
223+
<td>{declaration.year}</td>
224+
<td>
225+
{getDeclarationStepLabel(declaration.currentStep)}
226+
</td>
227+
<td>{getDeadline(declaration)}</td>
228+
<td>
229+
<StatusBadge status={declaration.status} />
230+
</td>
231+
<td>
232+
{resourceCount > 0 ? (
233+
<>
234+
<button
235+
aria-controls={getDocumentsPanelId(declaration)}
236+
className="fr-link"
237+
data-fr-opened="false"
238+
type="button"
239+
>
240+
Documents ({resourceCount})
241+
</button>
242+
<DocumentsPanel declaration={declaration} />
243+
</>
244+
) : (
245+
"Aucune"
246+
)}
247+
</td>
248+
</tr>
249+
);
250+
})}
234251
</tbody>
235252
</table>
236253
</div>

0 commit comments

Comments
 (0)