Skip to content

Commit 713d7bb

Browse files
feat: dropdown dynamique avec acteurs locaux dans la modale de contact
Quand l'adresse du ménage est connue (simulation RGA effectuée), la dropdown propose nominativement les AMO et structures Aller-vers disponibles sur son territoire (valeur=amo/aller_vers, précision=nom de la structure). Sinon, fallback sur ECFR générique. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b927ad4 commit 713d7bb

3 files changed

Lines changed: 160 additions & 20 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"use server";
2+
3+
import { getSession } from "@/features/auth/server";
4+
import { parcoursRepo } from "@/shared/database/repositories";
5+
import { allersVersRepository } from "@/shared/database/repositories/allers-vers.repository";
6+
import { entreprisesAmoRepository } from "@/shared/database/repositories/entreprises-amo.repository";
7+
import { getCodeDepartementFromCodeInsee, normalizeCodeInsee } from "@/features/parcours/amo/utils/amo.utils";
8+
import type { ActionResult } from "@/shared/types";
9+
10+
export interface ActeursLocaux {
11+
amos: { id: string; nom: string }[];
12+
allersVers: { id: string; nom: string }[];
13+
}
14+
15+
/**
16+
* Récupère les AMO et structures Aller-Vers disponibles sur le territoire du ménage.
17+
* Retourne des listes vides si l'adresse n'est pas encore connue (avant simulation RGA).
18+
*/
19+
export async function getActeursLocauxDisponibles(): Promise<ActionResult<ActeursLocaux>> {
20+
try {
21+
const session = await getSession();
22+
if (!session?.userId) {
23+
return { success: false, error: "Non connecté" };
24+
}
25+
26+
const parcours = await parcoursRepo.findByUserId(session.userId);
27+
28+
if (!parcours?.rgaSimulationData?.logement?.commune) {
29+
return { success: true, data: { amos: [], allersVers: [] } };
30+
}
31+
32+
const codeInsee = normalizeCodeInsee(parcours.rgaSimulationData.logement.commune);
33+
if (!codeInsee) {
34+
return { success: true, data: { amos: [], allersVers: [] } };
35+
}
36+
37+
const codeDepartement = getCodeDepartementFromCodeInsee(codeInsee);
38+
const codeEpci = parcours.rgaSimulationData.logement?.epci
39+
? String(parcours.rgaSimulationData.logement.epci).trim()
40+
: undefined;
41+
42+
const [amos, allersVers] = await Promise.all([
43+
entreprisesAmoRepository.findByCodeInsee(codeInsee, codeDepartement),
44+
allersVersRepository.findByEpciWithDepartementFallback(codeDepartement, codeEpci),
45+
]);
46+
47+
return {
48+
success: true,
49+
data: {
50+
amos: amos.map(({ id, nom }) => ({ id, nom })),
51+
allersVers: allersVers.map(({ id, nom }) => ({ id, nom })),
52+
},
53+
};
54+
} catch (error) {
55+
console.error("Erreur getActeursLocauxDisponibles:", error);
56+
return { success: false, error: "Erreur lors de la récupération des acteurs locaux" };
57+
}
58+
}

src/features/parcours/core/actions/contact-info.actions.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,10 @@ export async function updateContactInfoAction(params: {
7272
return { success: false, error: "Veuillez préciser la source d'acquisition" };
7373
}
7474

75-
const sourceAcquisitionPrecision =
76-
sourceAcquisition === SourceAcquisition.AUTRE ? precisionTrimmed.slice(0, 500) : null;
75+
const SOURCES_AVEC_PRECISION = [SourceAcquisition.AUTRE, SourceAcquisition.AMO, SourceAcquisition.ALLER_VERS];
76+
const sourceAcquisitionPrecision = sourceAcquisition && SOURCES_AVEC_PRECISION.includes(sourceAcquisition)
77+
? precisionTrimmed.slice(0, 500) || null
78+
: null;
7779

7880
const updated = await userRepo.updateContactInfo(session.userId, {
7981
emailContact: params.emailContact.trim(),

src/features/parcours/core/components/ContactInfoModal.tsx

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

33
import { useState, useEffect, useRef } from "react";
44
import { updateContactInfoAction } from "../actions/contact-info.actions";
5+
import { getActeursLocauxDisponibles, type ActeursLocaux } from "../actions/acteurs-locaux.actions";
56
import { SourceAcquisition, SOURCE_ACQUISITION_LABELS } from "@/shared/domain/value-objects";
67

78
interface ContactInfoModalProps {
@@ -11,11 +12,9 @@ interface ContactInfoModalProps {
1112
onSuccess: () => void;
1213
}
1314

14-
// À l'inscription, l'adresse du ménage n'est pas encore connue (la simulation RGA
15-
// n'a pas eu lieu). On propose donc l'option générique ECFR pour couvrir tous les
16-
// acteurs locaux (DDT, AMO, Aller-vers). Ces acteurs pourront être proposés
17-
// nominativement après la simulation, quand le département sera connu.
18-
const SOURCE_OPTIONS: SourceAcquisition[] = [
15+
// Options statiques affichées quand aucun acteur local n'est identifié (adresse inconnue).
16+
// ECFR remplace nominativement AMO/Aller-vers quand le département n'est pas encore connu.
17+
const SOURCE_OPTIONS_STATIQUES: SourceAcquisition[] = [
1918
SourceAcquisition.ECFR,
2019
SourceAcquisition.FLYERS,
2120
SourceAcquisition.MEDIAS,
@@ -26,15 +25,55 @@ const SOURCE_OPTIONS: SourceAcquisition[] = [
2625
SourceAcquisition.AUTRE,
2726
];
2827

28+
// Options statiques affichées après ECFR quand les acteurs locaux sont connus.
29+
const SOURCE_OPTIONS_COMPLEMENTAIRES: SourceAcquisition[] = [
30+
SourceAcquisition.FLYERS,
31+
SourceAcquisition.MEDIAS,
32+
SourceAcquisition.BULLETIN_COMMUNAL,
33+
SourceAcquisition.PROS_BATIMENT_IMMOBILIER,
34+
SourceAcquisition.REUNION_PUBLIQUE_SALON,
35+
SourceAcquisition.MOTEUR_RECHERCHE,
36+
SourceAcquisition.AUTRE,
37+
];
38+
39+
// Valeur encodée pour une option dynamique : "type::nom" (e.g. "amo::Association ABC")
40+
// permet de récupérer à la fois la valeur enum et le nom de la structure.
41+
function encodeOptionDynamique(type: "amo" | "aller_vers", nom: string): string {
42+
return `${type}::${nom}`;
43+
}
44+
45+
function decodeOptionDynamique(encoded: string): { type: SourceAcquisition; precision: string } | null {
46+
const sep = encoded.indexOf("::");
47+
if (sep === -1) return null;
48+
const type = encoded.substring(0, sep);
49+
const precision = encoded.substring(sep + 2);
50+
if (type === "amo") return { type: SourceAcquisition.AMO, precision };
51+
if (type === "aller_vers") return { type: SourceAcquisition.ALLER_VERS, precision };
52+
return null;
53+
}
54+
2955
export default function ContactInfoModal({ isOpen, defaultEmail, onClose, onSuccess }: ContactInfoModalProps) {
3056
const [email, setEmail] = useState(defaultEmail || "");
3157
const [telephone, setTelephone] = useState("");
32-
const [sourceAcquisition, setSourceAcquisition] = useState<string>("");
58+
const [selectValue, setSelectValue] = useState<string>("");
3359
const [sourceAcquisitionPrecision, setSourceAcquisitionPrecision] = useState("");
3460
const [error, setError] = useState<string | null>(null);
3561
const [isSubmitting, setIsSubmitting] = useState(false);
62+
const [acteursLocaux, setActeursLocaux] = useState<ActeursLocaux | null>(null);
3663
const dialogRef = useRef<HTMLDialogElement>(null);
3764

65+
const hasDynamicOptions =
66+
acteursLocaux !== null && (acteursLocaux.amos.length > 0 || acteursLocaux.allersVers.length > 0);
67+
68+
// Charger les acteurs locaux au montage
69+
useEffect(() => {
70+
getActeursLocauxDisponibles().then((result) => {
71+
if (result.success) {
72+
setActeursLocaux(result.data);
73+
}
74+
});
75+
}, []);
76+
3877
// Gérer l'ouverture/fermeture via le DSFR
3978
useEffect(() => {
4079
const dialog = dialogRef.current;
@@ -88,12 +127,17 @@ export default function ContactInfoModal({ isOpen, defaultEmail, onClose, onSucc
88127
return;
89128
}
90129

91-
if (!sourceAcquisition) {
130+
if (!selectValue) {
92131
setError("Merci d'indiquer comment vous avez connu le fonds");
93132
return;
94133
}
95134

96-
if (sourceAcquisition === SourceAcquisition.AUTRE && !sourceAcquisitionPrecision.trim()) {
135+
// Décoder la valeur sélectionnée (option dynamique ou statique)
136+
const decoded = decodeOptionDynamique(selectValue);
137+
const sourceAcquisition = decoded ? decoded.type : (selectValue as SourceAcquisition);
138+
const precision = decoded ? decoded.precision : sourceAcquisitionPrecision.trim();
139+
140+
if (sourceAcquisition === SourceAcquisition.AUTRE && !precision) {
97141
setError("Merci de préciser comment vous avez connu le fonds");
98142
return;
99143
}
@@ -104,8 +148,7 @@ export default function ContactInfoModal({ isOpen, defaultEmail, onClose, onSucc
104148
emailContact: email.trim(),
105149
telephone: telephone.trim(),
106150
sourceAcquisition,
107-
sourceAcquisitionPrecision:
108-
sourceAcquisition === SourceAcquisition.AUTRE ? sourceAcquisitionPrecision.trim() : null,
151+
sourceAcquisitionPrecision: precision || null,
109152
});
110153

111154
setIsSubmitting(false);
@@ -117,6 +160,8 @@ export default function ContactInfoModal({ isOpen, defaultEmail, onClose, onSucc
117160
}
118161
};
119162

163+
const showPrecisionField = selectValue === SourceAcquisition.AUTRE && !decodeOptionDynamique(selectValue);
164+
120165
return (
121166
<dialog ref={dialogRef} id="modal-contact-info" className="fr-modal" aria-labelledby="modal-contact-info-title">
122167
<div className="fr-container fr-container--fluid fr-container-md">
@@ -191,18 +236,53 @@ export default function ContactInfoModal({ isOpen, defaultEmail, onClose, onSucc
191236
className="fr-select"
192237
id="contact-source-acquisition"
193238
name="sourceAcquisition"
194-
value={sourceAcquisition}
195-
onChange={(e) => setSourceAcquisition(e.target.value)}>
239+
value={selectValue}
240+
onChange={(e) => {
241+
setSelectValue(e.target.value);
242+
setSourceAcquisitionPrecision("");
243+
}}>
196244
<option value="">Sélectionnez une option</option>
197-
{SOURCE_OPTIONS.map((value) => (
198-
<option key={value} value={value}>
199-
{SOURCE_ACQUISITION_LABELS[value]}
200-
</option>
201-
))}
245+
246+
{hasDynamicOptions && (
247+
<>
248+
{acteursLocaux!.amos.length > 0 && (
249+
<optgroup label="AMO (Assistant à Maîtrise d'Ouvrage)">
250+
{acteursLocaux!.amos.map((amo) => (
251+
<option key={amo.id} value={encodeOptionDynamique("amo", amo.nom)}>
252+
{amo.nom}
253+
</option>
254+
))}
255+
</optgroup>
256+
)}
257+
{acteursLocaux!.allersVers.length > 0 && (
258+
<optgroup label="Équipe Aller-vers">
259+
{acteursLocaux!.allersVers.map((av) => (
260+
<option key={av.id} value={encodeOptionDynamique("aller_vers", av.nom)}>
261+
{av.nom}
262+
</option>
263+
))}
264+
</optgroup>
265+
)}
266+
<optgroup label="Autre canal">
267+
{SOURCE_OPTIONS_COMPLEMENTAIRES.map((value) => (
268+
<option key={value} value={value}>
269+
{SOURCE_ACQUISITION_LABELS[value]}
270+
</option>
271+
))}
272+
</optgroup>
273+
</>
274+
)}
275+
276+
{!hasDynamicOptions &&
277+
SOURCE_OPTIONS_STATIQUES.map((value) => (
278+
<option key={value} value={value}>
279+
{SOURCE_ACQUISITION_LABELS[value]}
280+
</option>
281+
))}
202282
</select>
203283
</div>
204284

205-
{sourceAcquisition === SourceAcquisition.AUTRE && (
285+
{showPrecisionField && (
206286
<div className="fr-form-group fr-mt-2w">
207287
<label className="fr-label" htmlFor="contact-source-acquisition-precision">
208288
<strong>Pouvez-vous préciser ?</strong>

0 commit comments

Comments
 (0)