Skip to content

Commit af8ad64

Browse files
fix(avis-cse): track CSE opinion finalization with dedicated timestamp (#3271)
1 parent fbf9084 commit af8ad64

24 files changed

Lines changed: 479 additions & 80 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/campaign-deadlines-gating.e2e.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,20 @@ test.describe("Campaign deadlines gating", () => {
4545
// shared auth.setup project handles.
4646
async function seedSubmittedCompliance() {
4747
await resetDeclarationToDraft();
48-
await setCompanyHasCse(true);
49-
await setUserPhone("0122334455");
5048
await setDeclarationComplianceState({
5149
status: "submitted",
5250
currentStep: 6,
5351
compliancePath: "corrective_action",
5452
});
5553
}
5654

55+
// Phone + CSE flags must be set before login so the JWT picks them up and
56+
// the missing-info-modal does not intercept clicks on /mon-espace.
57+
async function seedUserProfile() {
58+
await setUserPhone("0122334455");
59+
await setCompanyHasCse(true);
60+
}
61+
5762
test.afterAll(async () => {
5863
await deleteCampaignDeadlines(testDeclarationYear);
5964
await resetDeclarationToDraft();
@@ -68,6 +73,7 @@ test.describe("Campaign deadlines gating", () => {
6873
test("panel shows Modifier link and 'Modifiable jusqu'au' text", async ({
6974
page,
7075
}) => {
76+
await seedUserProfile();
7177
await page.context().clearCookies();
7278
await loginWithProConnect(page);
7379
// The declaration row is only created by getOrCreate() when visiting a
@@ -99,6 +105,7 @@ test.describe("Campaign deadlines gating", () => {
99105
test("submitted declaration can re-enter a non-recap step", async ({
100106
page,
101107
}) => {
108+
await seedUserProfile();
102109
await page.context().clearCookies();
103110
await loginWithProConnect(page);
104111
await page.goto("/declaration-remuneration");
@@ -117,6 +124,7 @@ test.describe("Campaign deadlines gating", () => {
117124
test("panel hides Modifier link and shows 'Modification close depuis'", async ({
118125
page,
119126
}) => {
127+
await seedUserProfile();
120128
await page.context().clearCookies();
121129
await loginWithProConnect(page);
122130
await page.goto("/declaration-remuneration");
@@ -143,6 +151,7 @@ test.describe("Campaign deadlines gating", () => {
143151
test("submitted declaration non-recap step redirects to recap", async ({
144152
page,
145153
}) => {
154+
await seedUserProfile();
146155
await page.context().clearCookies();
147156
await loginWithProConnect(page);
148157
await page.goto("/declaration-remuneration");

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

0 commit comments

Comments
 (0)