Skip to content

Commit 2ddaf44

Browse files
committed
fix(avis-cse): track CSE opinion finalization with dedicated timestamp
The side panel used "any cseOpinions row exists" as the "démarche close" signal, so saving a draft at step 1 immediately flipped the declaration to validated/clôturée. Replace the boolean with an explicit cseOpinionCompletedAt timestamp on declarations, set by a new idempotent cseOpinion.finalize mutation called at the end of step 2. Files remain modifiable until decl2JointEvaluationDeadline, wired as a TransmittedRow in the stepper's closed variant.
1 parent 0008e82 commit 2ddaf44

23 files changed

Lines changed: 468 additions & 78 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Track when a CSE opinion deposit (avis CSE) is finalized by the declarant.
2+
-- A NULL value means the flow is still in progress. The side panel uses this
3+
-- column (rather than "any cseOpinions row exists") to decide whether the
4+
-- declaration should be shown as "clôturée".
5+
ALTER TABLE "app_declaration"
6+
ADD COLUMN IF NOT EXISTS "cse_opinion_completed_at" timestamp with time zone;

packages/app/drizzle/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,13 @@
197197
"when": 1775900000000,
198198
"tag": "0027_add_global_settings",
199199
"breakpoints": true
200+
},
201+
{
202+
"idx": 28,
203+
"version": "7",
204+
"when": 1776000000000,
205+
"tag": "0028_add_cse_opinion_completed_at",
206+
"breakpoints": true
200207
}
201208
]
202209
}

packages/app/src/e2e/declaration-process-panel.e2e.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,11 +155,46 @@ test.describe("Declaration process panel", () => {
155155
});
156156
});
157157

158+
test.describe("Variant: cse with opinions saved but not finalized", () => {
159+
test.beforeAll(async () => {
160+
await setDeclarationComplianceState({
161+
compliancePath: "joint_evaluation",
162+
complianceCompletedAt: new Date(),
163+
cseOpinionCompletedAt: null,
164+
});
165+
await insertJointEvaluationFile(CURRENT_YEAR);
166+
await insertCseOpinion(CURRENT_YEAR);
167+
});
168+
169+
test("keeps CSE step current while finalization has not been done", async ({
170+
page,
171+
}) => {
172+
await page.context().clearCookies();
173+
await loginWithProConnect(page);
174+
await waitForDsfrModal(page, PANEL_ID);
175+
176+
const panel = page.locator(`#${PANEL_ID}`);
177+
const remuButton = page.getByRole("button", { name: "Rémunération" });
178+
await expect(remuButton.first()).toBeVisible();
179+
await clickAndExpectDialogOpen(page, remuButton.first(), PANEL_ID);
180+
181+
await expect(panel.getByText("Démarche close")).not.toBeVisible();
182+
await expect(
183+
panel.getByText("Déposer le ou les avis du CSE"),
184+
).toBeVisible();
185+
const ctaLink = panel.getByRole("link", {
186+
name: "Continuer la déclaration",
187+
});
188+
await expect(ctaLink).toHaveAttribute("href", /avis-cse/);
189+
});
190+
});
191+
158192
test.describe("Variant: closed (compliance completed + CSE deposited)", () => {
159193
test.beforeAll(async () => {
160194
await setDeclarationComplianceState({
161195
compliancePath: "joint_evaluation",
162196
complianceCompletedAt: new Date(),
197+
cseOpinionCompletedAt: new Date(),
163198
});
164199
await insertJointEvaluationFile(CURRENT_YEAR);
165200
await insertCseOpinion(CURRENT_YEAR);
@@ -180,7 +215,7 @@ test.describe("Declaration process panel", () => {
180215
await expect(panel.getByText("Démarche close")).toBeVisible();
181216
await expect(
182217
panel.getByText(
183-
"Cette démarche est terminée, aucune modification n'est possible.",
218+
"Cette démarche est terminée. Les avis du CSE restent modifiables jusqu'à l'échéance.",
184219
),
185220
).toBeVisible();
186221
});

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export async function setDeclarationComplianceState(state: {
9494
compliancePath?: string | null;
9595
secondDeclarationStatus?: string | null;
9696
complianceCompletedAt?: Date | null;
97+
cseOpinionCompletedAt?: Date | null;
9798
}) {
9899
const sql = createConnection();
99100
try {
@@ -103,7 +104,8 @@ export async function setDeclarationComplianceState(state: {
103104
current_step = ${state.currentStep ?? 6},
104105
compliance_path = ${state.compliancePath ?? null},
105106
second_declaration_status = ${state.secondDeclarationStatus ?? null},
106-
compliance_completed_at = ${state.complianceCompletedAt ?? null}
107+
compliance_completed_at = ${state.complianceCompletedAt ?? null},
108+
cse_opinion_completed_at = ${state.cseOpinionCompletedAt ?? null}
107109
WHERE siren = ${TEST_SIREN}
108110
`;
109111
} finally {

packages/app/src/modules/audit/shared/actionKeys.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const AUDIT_ACTIONS = {
3030
CSE_OPINION_SAVE: "cse_opinion.save",
3131
CSE_OPINION_UPLOAD_FILE: "cse_opinion.upload_file",
3232
CSE_OPINION_DELETE_FILE: "cse_opinion.delete_file",
33+
CSE_OPINION_FINALIZE: "cse_opinion.finalize",
3334

3435
// ── Joint evaluation mutations ─────────────────────────
3536
JOINT_EVALUATION_UPLOAD_FILE: "joint_evaluation.upload_file",
@@ -104,6 +105,7 @@ export const AUDIT_ACTION_CATEGORIES: Record<AuditActionKey, AuditCategory> = {
104105
[AUDIT_ACTIONS.CSE_OPINION_SAVE]: "mutation",
105106
[AUDIT_ACTIONS.CSE_OPINION_UPLOAD_FILE]: "mutation",
106107
[AUDIT_ACTIONS.CSE_OPINION_DELETE_FILE]: "mutation",
108+
[AUDIT_ACTIONS.CSE_OPINION_FINALIZE]: "mutation",
107109

108110
[AUDIT_ACTIONS.JOINT_EVALUATION_UPLOAD_FILE]: "mutation",
109111

packages/app/src/modules/cseOpinion/Step2Upload.tsx

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useCallback, useState } from "react";
66

77
import { useReadOnlyGuard } from "~/modules/auth";
88
import { NewTabNotice } from "~/modules/layout/shared/NewTabNotice";
9-
import { FileUpload, useFileUploadForm } from "~/modules/shared";
9+
import { FileUpload, getDsfrModal, useFileUploadForm } from "~/modules/shared";
1010
import { api } from "~/trpc/react";
1111

1212
import { CseStepIndicator } from "./components/CseStepIndicator";
@@ -29,6 +29,7 @@ export function Step2Upload({
2929
const router = useRouter();
3030
const utils = api.useUtils();
3131
const [deletingFileId, setDeletingFileId] = useState<string | null>(null);
32+
const [finalizeError, setFinalizeError] = useState<string | null>(null);
3233
const readOnlyGuard = useReadOnlyGuard();
3334

3435
const refreshFileList = useCallback(() => {
@@ -44,6 +45,21 @@ export function Step2Upload({
4445
onError: () => setDeletingFileId(null),
4546
});
4647

48+
const finalizeMutation = api.cseOpinion.finalize.useMutation();
49+
50+
const finalizeAndRedirect = useCallback(async () => {
51+
try {
52+
await finalizeMutation.mutateAsync();
53+
router.push("/avis-cse/confirmation");
54+
} catch (error) {
55+
setFinalizeError(
56+
error instanceof Error
57+
? error.message
58+
: "Erreur lors de la validation du dépôt.",
59+
);
60+
}
61+
}, [finalizeMutation, router]);
62+
4763
const {
4864
closeModal,
4965
handleConfirm,
@@ -56,14 +72,44 @@ export function Step2Upload({
5672
} = useFileUploadForm({
5773
flowType: "cse_opinion",
5874
onUploaded: refreshFileList,
59-
onAllUploaded: () => router.push("/avis-cse/confirmation"),
75+
onAllUploaded: () => {
76+
void finalizeAndRedirect();
77+
},
6078
});
6179

80+
const skipUploadSubmit = useCallback(
81+
(event: React.FormEvent) => {
82+
event.preventDefault();
83+
setFinalizeError(null);
84+
const dialog = modalRef.current;
85+
if (!dialog) return;
86+
const modal = getDsfrModal(dialog);
87+
if (modal) {
88+
modal.disclose();
89+
} else {
90+
dialog.showModal();
91+
}
92+
},
93+
[modalRef],
94+
);
95+
96+
const hasExistingFiles = existingFiles.length > 0;
97+
const hasSelectedFiles = selectedFiles.length > 0;
98+
const formSubmit =
99+
!hasSelectedFiles && hasExistingFiles ? skipUploadSubmit : handleSubmit;
100+
const confirmAction = hasSelectedFiles
101+
? handleConfirm
102+
: () => {
103+
closeModal();
104+
void finalizeAndRedirect();
105+
};
106+
62107
const remainingSlots = MAX_CSE_FILES - existingFiles.length;
108+
const isSubmitting = isPending || finalizeMutation.isPending;
63109

64110
return (
65111
<>
66-
<form onSubmit={handleSubmit}>
112+
<form onSubmit={formSubmit}>
67113
<div className="fr-grid-row fr-grid-row--middle fr-mb-3w">
68114
<div className="fr-col">
69115
<h1 className="fr-h4 fr-mb-0">
@@ -118,6 +164,12 @@ export function Step2Upload({
118164
/>
119165
</div>
120166

167+
{finalizeError && (
168+
<p className="fr-error-text fr-mt-2w" role="alert">
169+
{finalizeError}
170+
</p>
171+
)}
172+
121173
<div className={`fr-mt-4w ${formStyles.actions}`}>
122174
<Link
123175
className="fr-btn fr-btn--tertiary fr-icon-arrow-left-line fr-btn--icon-left"
@@ -129,10 +181,10 @@ export function Step2Upload({
129181
<button
130182
{...readOnlyGuard.buttonProps}
131183
className="fr-btn fr-icon-arrow-right-line fr-btn--icon-right"
132-
disabled={isPending || readOnlyGuard.isReadOnly}
184+
disabled={isSubmitting || readOnlyGuard.isReadOnly}
133185
type="submit"
134186
>
135-
{isPending ? "Envoi en cours\u2026" : "Soumettre"}
187+
{isSubmitting ? "Envoi en cours\u2026" : "Soumettre"}
136188
</button>
137189
{readOnlyGuard.tooltip}
138190
</span>
@@ -143,7 +195,7 @@ export function Step2Upload({
143195
declarationYear={declarationYear}
144196
modalRef={modalRef}
145197
onClose={closeModal}
146-
onSubmit={handleConfirm}
198+
onSubmit={confirmAction}
147199
/>
148200
</>
149201
);

packages/app/src/modules/cseOpinion/__tests__/Step2Upload.test.tsx

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ let deleteMutationOptions: {
1717
onError?: () => void;
1818
} = {};
1919
const deleteMutateMock = vi.fn();
20+
const finalizeMutateAsyncMock = vi.fn();
2021

2122
vi.mock("next/navigation", () => ({
2223
useRouter: () => ({
@@ -47,6 +48,13 @@ vi.mock("~/trpc/react", () => ({
4748
};
4849
},
4950
},
51+
finalize: {
52+
useMutation: () => ({
53+
mutateAsync: finalizeMutateAsyncMock,
54+
isPending: false,
55+
error: null,
56+
}),
57+
},
5058
},
5159
useUtils: () => ({
5260
cseOpinion: {
@@ -74,6 +82,8 @@ describe("Step2Upload", () => {
7482
refreshMock.mockReset();
7583
invalidateMock.mockReset();
7684
deleteMutateMock.mockReset();
85+
finalizeMutateAsyncMock.mockReset();
86+
finalizeMutateAsyncMock.mockResolvedValue({ success: true });
7787
uploadFileMock.mockReset();
7888
deleteMutationOptions = {};
7989
// jsdom doesn't implement <dialog>; stub showModal/close so the dialog
@@ -337,7 +347,7 @@ describe("Step2Upload", () => {
337347
).toBeInTheDocument();
338348
});
339349

340-
it("uploads the file then refreshes the list and redirects on success", async () => {
350+
it("uploads the file then finalizes and redirects on success", async () => {
341351
const user = userEvent.setup();
342352
uploadFileMock.mockResolvedValue({
343353
ok: true,
@@ -367,11 +377,63 @@ describe("Step2Upload", () => {
367377
expect(invalidateMock).toHaveBeenCalled();
368378
});
369379
expect(refreshMock).toHaveBeenCalled();
380+
await waitFor(() => {
381+
expect(finalizeMutateAsyncMock).toHaveBeenCalled();
382+
});
370383
await waitFor(() => {
371384
expect(pushMock).toHaveBeenCalledWith("/avis-cse/confirmation");
372385
});
373386
});
374387

388+
it("finalizes without uploading when only existing files are present", async () => {
389+
const user = userEvent.setup();
390+
render(
391+
<Step2Upload
392+
declarationYear={2025}
393+
existingFiles={[makeFile("avis-1.pdf", "file-1")]}
394+
/>,
395+
);
396+
397+
await user.click(screen.getByRole("button", { name: /Soumettre/ }));
398+
399+
const certifyCheckbox = screen.getByRole("checkbox");
400+
await user.click(certifyCheckbox);
401+
await user.click(screen.getByRole("button", { name: "Valider" }));
402+
403+
await waitFor(() => {
404+
expect(finalizeMutateAsyncMock).toHaveBeenCalled();
405+
});
406+
expect(uploadFileMock).not.toHaveBeenCalled();
407+
await waitFor(() => {
408+
expect(pushMock).toHaveBeenCalledWith("/avis-cse/confirmation");
409+
});
410+
});
411+
412+
it("shows an error and does not redirect when finalize fails", async () => {
413+
const user = userEvent.setup();
414+
finalizeMutateAsyncMock.mockRejectedValueOnce(
415+
new Error("Au moins un fichier d'avis CSE doit être transmis."),
416+
);
417+
418+
render(
419+
<Step2Upload
420+
declarationYear={2025}
421+
existingFiles={[makeFile("avis-1.pdf", "file-1")]}
422+
/>,
423+
);
424+
425+
await user.click(screen.getByRole("button", { name: /Soumettre/ }));
426+
await user.click(screen.getByRole("checkbox"));
427+
await user.click(screen.getByRole("button", { name: "Valider" }));
428+
429+
await waitFor(() => {
430+
expect(
431+
screen.getByText("Au moins un fichier d'avis CSE doit être transmis."),
432+
).toBeInTheDocument();
433+
});
434+
expect(pushMock).not.toHaveBeenCalled();
435+
});
436+
375437
it("invalidates the file list and clears the deleting state on delete success", () => {
376438
render(
377439
<Step2Upload

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@ function ClosedMessage() {
144144
<div className={styles.closedMessage}>
145145
<p className="fr-text--bold fr-mb-0">Démarche close</p>
146146
<p className="fr-mb-0">
147-
Cette démarche est terminée, aucune modification n'est possible.
147+
Cette démarche est terminée. Les avis du CSE restent modifiables jusqu'à
148+
l'échéance.
148149
</p>
149150
</div>
150151
);

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,11 @@ export function VerticalStepper({
6969
<div className={styles.stepLine} />
7070
<div className={styles.stepRow}>
7171
<StepCircle number={3} status={step3} />
72-
<Step3Content campaignDeadlines={campaignDeadlines} variant={variant} />
72+
<Step3Content
73+
campaignDeadlines={campaignDeadlines}
74+
siren={siren}
75+
variant={variant}
76+
/>
7377
</div>
7478
</div>
7579
);
@@ -268,17 +272,18 @@ function Step2Content({
268272

269273
function Step3Content({
270274
campaignDeadlines,
275+
siren,
271276
variant,
272277
}: {
273278
campaignDeadlines: CampaignDeadlines;
279+
siren: string;
274280
variant: PanelVariant;
275281
}) {
276282
const title = (
277283
<p className="fr-text--bold fr-mb-0">Déposer le ou les avis du CSE</p>
278284
);
279285

280286
if (
281-
variant === "closed" ||
282287
variant === "start" ||
283288
variant === "compliance_choice" ||
284289
variant === "compliance" ||
@@ -287,6 +292,19 @@ function Step3Content({
287292
return title;
288293
}
289294

295+
if (variant === "closed") {
296+
return (
297+
<div className={styles.stepContent}>
298+
{title}
299+
<TransmittedRow
300+
label="Vos avis du CSE ont été transmis"
301+
modifiableUntil={campaignDeadlines.decl2JointEvaluationDeadline}
302+
modifyHref={`/avis-cse/etape/2?siren=${siren}`}
303+
/>
304+
</div>
305+
);
306+
}
307+
290308
// cse variant
291309
return (
292310
<div className={styles.stepContent}>

0 commit comments

Comments
 (0)