Skip to content

Commit 4c86b4f

Browse files
committed
refactor(admin): GIP publication date from SUIT + clearer year selector (#3264, #3242)
1 parent fbc14ed commit 4c86b4f

7 files changed

Lines changed: 220 additions & 78 deletions

File tree

packages/app/src/modules/admin/settings/CampaignDeadlinesForm.tsx

Lines changed: 107 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useEffect, useState } from "react";
44

5+
import { FIRST_DECLARATION_YEAR, getCurrentYear } from "~/modules/domain";
56
import { useZodForm } from "~/modules/shared/useZodForm";
67
import { api } from "~/trpc/react";
78

@@ -15,12 +16,10 @@ type Props = {
1516
configuredYears: number[];
1617
};
1718

18-
type DateFieldKey = Exclude<keyof CampaignDeadlinesFormInput, "year">;
19-
20-
const OPTIONAL_FIELDS: readonly DateFieldKey[] = [
21-
"gipPublicationDate",
22-
"campaignStartDate",
23-
];
19+
type DateFieldKey = Exclude<
20+
keyof CampaignDeadlinesFormInput,
21+
"year" | "gipPublicationDate"
22+
>;
2423

2524
const DECL1_FIELDS: readonly DateFieldKey[] = [
2625
"decl1ModificationDeadline",
@@ -35,7 +34,6 @@ const DECL2_FIELDS: readonly DateFieldKey[] = [
3534
];
3635

3736
const FIELD_LABELS: Record<DateFieldKey, string> = {
38-
gipPublicationDate: "Date de publication des données GIP",
3937
campaignStartDate: "Date de démarrage de la campagne",
4038
decl1ModificationDeadline: "Date limite de modification",
4139
decl1JustificationDeadline: "Date limite de justification",
@@ -47,7 +45,11 @@ const FIELD_LABELS: Record<DateFieldKey, string> = {
4745

4846
/**
4947
* Edits all campaign deadlines for a given year. The year selector lets the
50-
* admin switch between already-configured years or start a new one.
48+
* admin switch between every year since the platform launched, and a visible
49+
* box around the fieldsets clarifies that they depend on the selected year.
50+
*
51+
* `gipPublicationDate` is displayed read-only: its value is written by the
52+
* GIP MDS CSV import (`horodatage` column) and cannot be changed from the UI.
5153
*/
5254
export function CampaignDeadlinesForm({ initialYear, configuredYears }: Props) {
5355
const [selectedYear, setSelectedYear] = useState<number>(initialYear);
@@ -68,7 +70,6 @@ export function CampaignDeadlinesForm({ initialYear, configuredYears }: Props) {
6870
if (!deadlinesQuery.data) return;
6971
form.reset({
7072
year: selectedYear,
71-
gipPublicationDate: deadlinesQuery.data.gipPublicationDate ?? "",
7273
campaignStartDate: deadlinesQuery.data.campaignStartDate ?? "",
7374
decl1ModificationDeadline: deadlinesQuery.data.decl1ModificationDeadline,
7475
decl1JustificationDeadline:
@@ -103,13 +104,18 @@ export function CampaignDeadlinesForm({ initialYear, configuredYears }: Props) {
103104
mutation.mutate(values);
104105
});
105106

106-
const yearOptions = buildYearOptions(configuredYears, selectedYear);
107+
const yearOptions = buildYearOptions(configuredYears);
108+
const gipPublicationDate = deadlinesQuery.data?.gipPublicationDate ?? null;
107109

108110
return (
109111
<>
110112
<div className="fr-select-group fr-mb-3w">
111113
<label className="fr-label" htmlFor="campaign-year-selector">
112-
Année à éditer
114+
Sélectionnez l'année de campagne à modifier
115+
<span className="fr-hint-text">
116+
Choisissez une année dans la liste. Les champs ci-dessous
117+
correspondent à l'année sélectionnée.
118+
</span>
113119
</label>
114120
<select
115121
className="fr-select"
@@ -118,8 +124,8 @@ export function CampaignDeadlinesForm({ initialYear, configuredYears }: Props) {
118124
value={selectedYear}
119125
>
120126
{yearOptions.map((y) => (
121-
<option key={y} value={y}>
122-
{y}
127+
<option key={y.year} value={y.year}>
128+
{y.label}
123129
</option>
124130
))}
125131
</select>
@@ -131,53 +137,65 @@ export function CampaignDeadlinesForm({ initialYear, configuredYears }: Props) {
131137
{...form.register("year", { valueAsNumber: true })}
132138
/>
133139

134-
<fieldset className="fr-fieldset">
135-
<legend className="fr-fieldset__legend">Campagne</legend>
136-
<div className="fr-fieldset__content fr-grid-row fr-grid-row--gutters">
137-
{OPTIONAL_FIELDS.map((key) => (
138-
<div className="fr-col-12 fr-col-md-6" key={key}>
140+
<div className="fr-p-3w fr-background-alt--grey">
141+
<p className="fr-text--sm fr-text-mention--grey fr-mb-2w">
142+
Paramètres applicables à la campagne <strong>{selectedYear}</strong>
143+
.
144+
</p>
145+
146+
<fieldset className="fr-fieldset">
147+
<legend className="fr-fieldset__legend">Campagne</legend>
148+
<div className="fr-fieldset__content fr-grid-row fr-grid-row--gutters">
149+
<div className="fr-col-12 fr-col-md-6">
150+
<GipPublicationReadOnly value={gipPublicationDate} />
151+
</div>
152+
<div className="fr-col-12 fr-col-md-6">
139153
<DateField
140-
error={form.formState.errors[key]?.message}
141-
fieldKey={key}
142-
register={form.register(key)}
154+
error={form.formState.errors.campaignStartDate?.message}
155+
fieldKey="campaignStartDate"
156+
register={form.register("campaignStartDate")}
143157
required={false}
144158
/>
145159
</div>
146-
))}
147-
</div>
148-
</fieldset>
160+
</div>
161+
</fieldset>
149162

150-
<fieldset className="fr-fieldset">
151-
<legend className="fr-fieldset__legend">Première déclaration</legend>
152-
<div className="fr-fieldset__content fr-grid-row fr-grid-row--gutters">
153-
{DECL1_FIELDS.map((key) => (
154-
<div className="fr-col-12 fr-col-md-4" key={key}>
155-
<DateField
156-
error={form.formState.errors[key]?.message}
157-
fieldKey={key}
158-
register={form.register(key)}
159-
required={true}
160-
/>
161-
</div>
162-
))}
163-
</div>
164-
</fieldset>
163+
<fieldset className="fr-fieldset">
164+
<legend className="fr-fieldset__legend">
165+
Première déclaration
166+
</legend>
167+
<div className="fr-fieldset__content fr-grid-row fr-grid-row--gutters">
168+
{DECL1_FIELDS.map((key) => (
169+
<div className="fr-col-12 fr-col-md-4" key={key}>
170+
<DateField
171+
error={form.formState.errors[key]?.message}
172+
fieldKey={key}
173+
register={form.register(key)}
174+
required={true}
175+
/>
176+
</div>
177+
))}
178+
</div>
179+
</fieldset>
165180

166-
<fieldset className="fr-fieldset">
167-
<legend className="fr-fieldset__legend">Deuxième déclaration</legend>
168-
<div className="fr-fieldset__content fr-grid-row fr-grid-row--gutters">
169-
{DECL2_FIELDS.map((key) => (
170-
<div className="fr-col-12 fr-col-md-4" key={key}>
171-
<DateField
172-
error={form.formState.errors[key]?.message}
173-
fieldKey={key}
174-
register={form.register(key)}
175-
required={true}
176-
/>
177-
</div>
178-
))}
179-
</div>
180-
</fieldset>
181+
<fieldset className="fr-fieldset">
182+
<legend className="fr-fieldset__legend">
183+
Deuxième déclaration
184+
</legend>
185+
<div className="fr-fieldset__content fr-grid-row fr-grid-row--gutters">
186+
{DECL2_FIELDS.map((key) => (
187+
<div className="fr-col-12 fr-col-md-4" key={key}>
188+
<DateField
189+
error={form.formState.errors[key]?.message}
190+
fieldKey={key}
191+
register={form.register(key)}
192+
required={true}
193+
/>
194+
</div>
195+
))}
196+
</div>
197+
</fieldset>
198+
</div>
181199

182200
{status === "success" && (
183201
<div
@@ -248,10 +266,29 @@ function DateField({ fieldKey, register, error, required }: DateFieldProps) {
248266
);
249267
}
250268

269+
function GipPublicationReadOnly({ value }: { value: string | null }) {
270+
return (
271+
<div className="fr-input-group">
272+
<label className="fr-label" htmlFor="settings-gipPublicationDate">
273+
Date de publication des données GIP
274+
<span className="fr-hint-text">
275+
Lecture seule — valeur issue du fichier GIP récupéré depuis SUIT.
276+
</span>
277+
</label>
278+
<input
279+
className="fr-input"
280+
id="settings-gipPublicationDate"
281+
readOnly
282+
type="text"
283+
value={value ?? "Non disponible"}
284+
/>
285+
</div>
286+
);
287+
}
288+
251289
function buildDefaults(year: number): CampaignDeadlinesFormInput {
252290
return {
253291
year,
254-
gipPublicationDate: "",
255292
campaignStartDate: "",
256293
decl1ModificationDeadline: "",
257294
decl1JustificationDeadline: "",
@@ -262,14 +299,22 @@ function buildDefaults(year: number): CampaignDeadlinesFormInput {
262299
};
263300
}
264301

302+
/**
303+
* Lists every year between FIRST_DECLARATION_YEAR and the current year + 1 so
304+
* the admin can pick any campaign, flagging years without a DB row as
305+
* "non configurée" rather than hiding them.
306+
*/
265307
function buildYearOptions(
266308
configuredYears: readonly number[],
267-
selectedYear: number,
268-
): number[] {
269-
const set = new Set<number>(configuredYears);
270-
set.add(selectedYear);
271-
for (let offset = -1; offset <= 2; offset++) {
272-
set.add(selectedYear + offset);
309+
): Array<{ year: number; label: string }> {
310+
const max = getCurrentYear() + 1;
311+
const configured = new Set(configuredYears);
312+
const years: Array<{ year: number; label: string }> = [];
313+
for (let y = FIRST_DECLARATION_YEAR; y <= max; y++) {
314+
years.push({
315+
year: y,
316+
label: configured.has(y) ? String(y) : `${y} (non configurée)`,
317+
});
273318
}
274-
return Array.from(set).sort((a, b) => a - b);
319+
return years;
275320
}

packages/app/src/modules/admin/settings/__tests__/CampaignDeadlinesForm.test.tsx

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -89,23 +89,43 @@ describe("CampaignDeadlinesForm", () => {
8989
queryState.isLoading = false;
9090
});
9191

92-
it("lists the configured years plus a bracket around the selected year", () => {
92+
it("lists every year since FIRST_DECLARATION_YEAR up to next year", () => {
9393
render(
9494
<CampaignDeadlinesForm
9595
configuredYears={[2024, 2025, 2026]}
9696
initialYear={2026}
9797
/>,
9898
);
99-
const select = screen.getByLabelText(/année à éditer/i);
99+
const select = screen.getByLabelText(
100+
/sélectionnez l'année de campagne à modifier/i,
101+
);
100102
const values = Array.from(select.querySelectorAll("option")).map(
101103
(o) => o.value,
102104
);
105+
// Includes configured years and at least the current + next year.
103106
expect(values).toEqual(
104-
expect.arrayContaining(["2024", "2025", "2026", "2027", "2028"]),
107+
expect.arrayContaining(["2019", "2024", "2025", "2026"]),
105108
);
106109
});
107110

108-
it("populates the fields from the query data", async () => {
111+
it("flags non-configured years with a hint suffix", () => {
112+
render(
113+
<CampaignDeadlinesForm
114+
configuredYears={[2024, 2025]}
115+
initialYear={2025}
116+
/>,
117+
);
118+
const select = screen.getByLabelText(
119+
/sélectionnez l'année de campagne à modifier/i,
120+
);
121+
const options = Array.from(select.querySelectorAll("option"));
122+
const labelFor = (year: string) =>
123+
options.find((o) => o.value === year)?.textContent;
124+
expect(labelFor("2024")).toBe("2024");
125+
expect(labelFor("2026")).toMatch(/non configurée/i);
126+
});
127+
128+
it("populates editable fields from the query and shows the GIP date read-only", async () => {
109129
render(
110130
<CampaignDeadlinesForm configuredYears={[2026]} initialYear={2026} />,
111131
);
@@ -114,9 +134,11 @@ describe("CampaignDeadlinesForm", () => {
114134
document.getElementById("settings-decl1ModificationDeadline"),
115135
).toHaveValue("2026-06-01");
116136
});
117-
expect(
118-
screen.getByLabelText(/date de publication des données gip/i),
119-
).toHaveValue("2026-03-01");
137+
const gipField = screen.getByLabelText(
138+
/date de publication des données gip/i,
139+
);
140+
expect(gipField).toHaveValue("2026-03-01");
141+
expect(gipField).toHaveAttribute("readonly");
120142
});
121143

122144
it("submits the form values on save", async () => {

packages/app/src/modules/admin/settings/__tests__/schemas.test.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ describe("campaignDeadlinesFormSchema", () => {
1515
it("accepts a valid payload", () => {
1616
const result = campaignDeadlinesFormSchema.safeParse({
1717
year: 2026,
18-
gipPublicationDate: "2026-03-01",
1918
campaignStartDate: "2026-03-15",
2019
...validDates,
2120
});
@@ -25,21 +24,33 @@ describe("campaignDeadlinesFormSchema", () => {
2524
it("coerces empty optional dates to null", () => {
2625
const result = campaignDeadlinesFormSchema.safeParse({
2726
year: 2026,
28-
gipPublicationDate: "",
2927
campaignStartDate: "",
3028
...validDates,
3129
});
3230
expect(result.success).toBe(true);
3331
if (result.success) {
34-
expect(result.data.gipPublicationDate).toBeNull();
3532
expect(result.data.campaignStartDate).toBeNull();
3633
}
3734
});
3835

36+
it("ignores any extra gipPublicationDate field sent from the client", () => {
37+
const result = campaignDeadlinesFormSchema.safeParse({
38+
year: 2026,
39+
gipPublicationDate: "2026-03-01",
40+
campaignStartDate: null,
41+
...validDates,
42+
});
43+
expect(result.success).toBe(true);
44+
if (result.success) {
45+
expect(
46+
"gipPublicationDate" in (result.data as Record<string, unknown>),
47+
).toBe(false);
48+
}
49+
});
50+
3951
it("rejects invalid date formats", () => {
4052
const result = campaignDeadlinesFormSchema.safeParse({
4153
year: 2026,
42-
gipPublicationDate: null,
4354
campaignStartDate: null,
4455
...validDates,
4556
decl1ModificationDeadline: "2026/06/01",
@@ -50,7 +61,6 @@ describe("campaignDeadlinesFormSchema", () => {
5061
it("rejects years below FIRST_DECLARATION_YEAR", () => {
5162
const result = campaignDeadlinesFormSchema.safeParse({
5263
year: 1999,
53-
gipPublicationDate: null,
5464
campaignStartDate: null,
5565
...validDates,
5666
});
@@ -60,7 +70,6 @@ describe("campaignDeadlinesFormSchema", () => {
6070
it("rejects when decl2 is not after decl1", () => {
6171
const result = campaignDeadlinesFormSchema.safeParse({
6272
year: 2026,
63-
gipPublicationDate: null,
6473
campaignStartDate: null,
6574
...validDates,
6675
decl2ModificationDeadline: "2026-05-01",

packages/app/src/modules/admin/settings/schemas.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ export const campaignYearSchema = z
2828
export const campaignDeadlinesFormSchema = z
2929
.object({
3030
year: campaignYearSchema,
31-
gipPublicationDate: optionalIsoDateString,
3231
campaignStartDate: optionalIsoDateString,
3332
decl1ModificationDeadline: isoDateString,
3433
decl1JustificationDeadline: isoDateString,

0 commit comments

Comments
 (0)