Selection de la plateforma avant import

This commit is contained in:
2026-07-05 10:40:33 +02:00
parent 1f75878d87
commit 186ffc2596
+73 -21
View File
@@ -340,29 +340,46 @@ const FIELD_HINTS_OVERRIDE = {
}, },
}; };
function buildFieldsList(mod) { function buildFieldsList(mod, scopePlateforme) {
const def = MODULES[mod]; const def = MODULES[mod];
if (!def) return ''; if (!def) return '';
const line = (key, required) => {
if (key === 'plateforme_id' && scopePlateforme) {
return `- ${key} (non nécessaire) : ne l'inclus pas — cet import concerne exclusivement la plateforme `
+ `"${scopePlateforme.nom}" (voir section PLATEFORME ci-dessous).`;
}
const hint = FIELD_HINTS_OVERRIDE[mod]?.[key] ?? FIELD_HINTS[key] ?? '';
return `- ${key} (${required ? 'obligatoire' : 'optionnel'})${hint ? ` : ${hint}` : ''}`;
};
const lines = []; const lines = [];
for (const key of def.required) { for (const key of def.required) lines.push(line(key, true));
const hint = FIELD_HINTS_OVERRIDE[mod]?.[key] ?? FIELD_HINTS[key] ?? ''; for (const key of def.optional) lines.push(line(key, false));
lines.push(`- ${key} (obligatoire)${hint ? ` : ${hint}` : ''}`);
}
for (const key of def.optional) {
const hint = FIELD_HINTS_OVERRIDE[mod]?.[key] ?? FIELD_HINTS[key] ?? '';
lines.push(`- ${key} (optionnel)${hint ? ` : ${hint}` : ''}`);
}
return lines.join('\n'); return lines.join('\n');
} }
function buildReferenceSection(mod, plats, investissements) { function buildReferenceSection(mod, plats, investissements, scopePlateforme) {
if ((mod === 'depots_retraits' || mod === 'investissements') && plats.length > 0) { if (mod === 'depots_retraits' || mod === 'investissements') {
const names = plats.map(p => p.nom).join(' | '); if (scopePlateforme) {
return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPLATEFORMES EXISTANTES (pour plateforme_id)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nUtilise EXACTEMENT l'un de ces noms si la plateforme correspond à l'une d'elles :\n${names}\n\n⚠️ Si le fichier ne permet pas d'identifier avec certitude de quelle plateforme il provient (nom absent du fichier, logo/en-tête ambigu, etc.), NE DEVINE PAS et NE GÉNÈRE PAS le JSON tout de suite : pose-moi d'abord la question « De quelle plateforme provient ce fichier ? » en ne me proposant QUE ces réponses possibles :\n${names}\nAttends ma réponse, puis utilise le nom choisi comme valeur de plateforme_id pour TOUTES les lignes générées.\n\n`; return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPLATEFORME\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nCet import concerne EXCLUSIVEMENT la plateforme "${scopePlateforme.nom}". `
+ `N'inclus PAS de champ plateforme_id dans le JSON généré (il est déjà connu et sera appliqué automatiquement à chaque ligne).\n\n`;
}
if (plats.length > 0) {
const names = plats.map(p => p.nom).join(' | ');
return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPLATEFORMES EXISTANTES (pour plateforme_id)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nUtilise EXACTEMENT l'un de ces noms si la plateforme correspond à l'une d'elles :\n${names}\n\n⚠️ Si le fichier ne permet pas d'identifier avec certitude de quelle plateforme il provient (nom absent du fichier, logo/en-tête ambigu, etc.), NE DEVINE PAS et NE GÉNÈRE PAS le JSON tout de suite : pose-moi d'abord la question « De quelle plateforme provient ce fichier ? » en ne me proposant QUE ces réponses possibles :\n${names}\nAttends ma réponse, puis utilise le nom choisi comme valeur de plateforme_id pour TOUTES les lignes générées.\n\n`;
}
} }
if (mod === 'remboursements' && investissements.length > 0) { if (mod === 'remboursements' && investissements.length > 0) {
const names = investissements.map(i => i.nom_projet).join(' | '); const names = investissements.map(i => i.nom_projet).join(' | ');
return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nINVESTISSEMENTS EXISTANTS (pour investissement_id)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nUtilise EXACTEMENT l'un de ces noms de projet si le remboursement correspond à l'un d'eux :\n${names}\n\n⚠️ Si le fichier ne permet pas d'identifier avec certitude à quel investissement une ligne correspond, NE DEVINE PAS et NE GÉNÈRE PAS le JSON tout de suite : pose-moi d'abord la question « À quel investissement correspond ce fichier ? » en ne me proposant QUE ces réponses possibles :\n${names}\nAttends ma réponse, puis utilise le nom choisi comme valeur d'investissement_id pour TOUTES les lignes générées.\n\n`; const scopeNote = scopePlateforme ? ` de la plateforme "${scopePlateforme.nom}"` : '';
return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nINVESTISSEMENTS EXISTANTS${scopeNote} (pour investissement_id)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPour CHAQUE ligne, recherche dans la liste ci-dessous le nom de projet dont la correspondance est la PLUS PROCHE `
+ `(émetteur, référence de commande/facture, mots-clés communs — même partiels ou approximatifs) et utilise EXACTEMENT ce nom comme `
+ `valeur d'investissement_id. Une correspondance approximative suffit : ne cherche pas une identité parfaite, une similarité claire et `
+ `raisonnable est acceptable. Ne me pose AUCUNE question ligne par ligne — traite tout le fichier directement.\n${names}\n\n`
+ `⚠️ Ce n'est que si, après analyse de l'ensemble du fichier, une ou plusieurs lignes ne présentent VRAIMENT aucune ressemblance avec `
+ `l'un de ces noms (aucun mot commun, aucune référence reconnaissable) que tu peux me poser, à la toute fin, UNE SEULE question groupée `
+ `pour l'ensemble de ces lignes (pas une par ligne), du type « Je n'ai pas trouvé de correspondance fiable pour les lignes suivantes : `
+ `[...]. À quel investissement correspondent-elles ? » en ne me proposant QUE ces réponses possibles :\n${names}\n`
+ `Sinon, génère directement le JSON complet pour toutes les lignes sans attendre de réponse.\n\n`;
} }
return ''; return '';
} }
@@ -440,7 +457,7 @@ function IaImportPasteBlock({ moduleLabel, iaJson, setIaJson, iaErr, setIaErr, o
} }
/** Bloc collapsible : prompt IA dynamique (champs du module + plateformes/investissements existants) */ /** Bloc collapsible : prompt IA dynamique (champs du module + plateformes/investissements existants) */
function IaImportPromptBlock({ module, moduleLabel, plats, investissements, open, setOpen }) { function IaImportPromptBlock({ module, moduleLabel, plats, investissements, scopePlateforme, open, setOpen }) {
const LS_KEY = 'cl_import_ia_prompt_template'; const LS_KEY = 'cl_import_ia_prompt_template';
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [prompt, setPrompt] = useState(() => localStorage.getItem(LS_KEY) || DEFAULT_IA_IMPORT_PROMPT); const [prompt, setPrompt] = useState(() => localStorage.getItem(LS_KEY) || DEFAULT_IA_IMPORT_PROMPT);
@@ -449,8 +466,8 @@ function IaImportPromptBlock({ module, moduleLabel, plats, investissements, open
const resolvedPrompt = () => prompt const resolvedPrompt = () => prompt
.replace('{{MODULE_LABEL}}', moduleLabel) .replace('{{MODULE_LABEL}}', moduleLabel)
.replace('{{FIELDS_LIST}}', buildFieldsList(module)) .replace('{{FIELDS_LIST}}', buildFieldsList(module, scopePlateforme))
.replace('{{REFERENCE_SECTION}}', buildReferenceSection(module, plats, investissements)); .replace('{{REFERENCE_SECTION}}', buildReferenceSection(module, plats, investissements, scopePlateforme));
const startEdit = () => { setDraft(prompt); setEditing(true); }; const startEdit = () => { setDraft(prompt); setEditing(true); };
const saveEdit = () => { setPrompt(draft); localStorage.setItem(LS_KEY, draft); setEditing(false); }; const saveEdit = () => { setPrompt(draft); localStorage.setItem(LS_KEY, draft); setEditing(false); };
@@ -647,6 +664,8 @@ export default function ImportsSection() {
// null par défaut : seuls "Contexte" et "Historique" sont visibles au chargement. // null par défaut : seuls "Contexte" et "Historique" sont visibles au chargement.
// Si on arrive via un lien direct (?module=...), on ouvre directement "Fichier source". // Si on arrive via un lien direct (?module=...), on ouvre directement "Fichier source".
const [activeMode, setActiveMode] = useState(() => searchParams.get('module') ? 'fichier' : null); const [activeMode, setActiveMode] = useState(() => searchParams.get('module') ? 'fichier' : null);
// '' = import multi-plateforme (défaut, comportement inchangé) ; sinon id de la plateforme unique visée.
const [scopePlateformeId, setScopePlateformeId] = useState('');
const [file, setFile] = useState(null); const [file, setFile] = useState(null);
const [preview, setPreview] = useState(null); const [preview, setPreview] = useState(null);
const [mapping, setMapping] = useState({}); const [mapping, setMapping] = useState({});
@@ -691,6 +710,18 @@ export default function ImportsSection() {
const allTargets = def ? [...def.required, ...def.optional] : []; const allTargets = def ? [...def.required, ...def.optional] : [];
const missingInv = def?.needsInvestisseur && !activeId; const missingInv = def?.needsInvestisseur && !activeId;
// Un import peut viser une plateforme unique plutôt que multi-plateforme (défaut) —
// pertinent uniquement pour les modules dont les lignes sont rattachées à une plateforme.
const platformScopable = module === 'depots_retraits' || module === 'investissements' || module === 'remboursements';
const scopePlateforme = platformScopable
? plats.find(p => String(p.id) === String(scopePlateformeId)) || null
: null;
// Pour Remboursements, restreint la liste de référence (et les résolutions) aux
// investissements de la plateforme ciblée quand une plateforme unique est choisie.
const scopedInvestissements = (module === 'remboursements' && scopePlateforme)
? investissements.filter(i => String(i.plateforme_id) === String(scopePlateforme.id))
: investissements;
/** /**
* Analyse un fichier (upload classique OU JSON généré par IA) pour le module ciblé : * Analyse un fichier (upload classique OU JSON généré par IA) pour le module ciblé :
* upload + auto-mapping des colonnes + pré-remplissage des valeurs par défaut résolues. * upload + auto-mapping des colonnes + pré-remplissage des valeurs par défaut résolues.
@@ -719,6 +750,9 @@ export default function ImportsSection() {
} }
if (col) { auto[t] = col; usedHeaders.add(col); } if (col) { auto[t] = col; usedHeaders.add(col); }
} }
// Plateforme unique choisie à l'étape 1 : elle est déjà connue, on ignore toute colonne
// "plateforme" du fichier pour ne jamais la contredire (voir aussi buildReferenceSection/buildFieldsList).
if (scopePlateformeId && targets.includes('plateforme_id')) delete auto.plateforme_id;
setMapping(auto); setMapping(auto);
// Pré-remplit la valeur par défaut quand toutes les lignes de l'échantillon // Pré-remplit la valeur par défaut quand toutes les lignes de l'échantillon
@@ -726,7 +760,7 @@ export default function ImportsSection() {
const autoDefaults = {}; const autoDefaults = {};
for (const t of Object.keys(auto)) { for (const t of Object.keys(auto)) {
if (!NAME_RESOLVABLE_FIELDS.has(t)) continue; if (!NAME_RESOLVABLE_FIELDS.has(t)) continue;
const refList = t === 'plateforme_id' ? plats : investissements; const refList = t === 'plateforme_id' ? plats : scopedInvestissements;
const nameKey = t === 'plateforme_id' ? 'nom' : 'nom_projet'; const nameKey = t === 'plateforme_id' ? 'nom' : 'nom_projet';
const rawValues = [...new Set( const rawValues = [...new Set(
r.sampleRows.map(row => row[auto[t]]).filter(v => v !== null && v !== undefined && String(v).trim() !== '') r.sampleRows.map(row => row[auto[t]]).filter(v => v !== null && v !== undefined && String(v).trim() !== '')
@@ -735,6 +769,7 @@ export default function ImportsSection() {
const match = resolvePreviewMatch(rawValues[0], refList, nameKey); const match = resolvePreviewMatch(rawValues[0], refList, nameKey);
if (match) autoDefaults[t] = String(match.id); if (match) autoDefaults[t] = String(match.id);
} }
if (scopePlateformeId && targets.includes('plateforme_id')) autoDefaults.plateforme_id = scopePlateformeId;
if (Object.keys(autoDefaults).length > 0) setDefaults(d => ({ ...d, ...autoDefaults })); if (Object.keys(autoDefaults).length > 0) setDefaults(d => ({ ...d, ...autoDefaults }));
} catch (e) { setErr(e.message); } } catch (e) { setErr(e.message); }
finally { setBusy(false); } finally { setBusy(false); }
@@ -830,8 +865,10 @@ export default function ImportsSection() {
<label>Module cible</label> <label>Module cible</label>
<select value={module} onChange={e => { <select value={module} onChange={e => {
const v = e.target.value; const v = e.target.value;
const stillScopable = v === 'depots_retraits' || v === 'investissements' || v === 'remboursements';
setModule(v); setModule(v);
setPreview(null); setMapping({}); setResult(null); setErr(null); setPreview(null); setMapping({}); setResult(null); setErr(null);
if (!stillScopable) setScopePlateformeId('');
if (v !== 'investissements' && activeMode === 'dossier') setActiveMode(null); if (v !== 'investissements' && activeMode === 'dossier') setActiveMode(null);
}}> }}>
{Object.entries(MODULES).map(([k, v]) => ( {Object.entries(MODULES).map(([k, v]) => (
@@ -839,6 +876,18 @@ export default function ImportsSection() {
))} ))}
</select> </select>
</div> </div>
{platformScopable && (
<div style={{ flex: '0 0 auto', width: 280 }}>
<label>Plateforme</label>
<select value={scopePlateformeId} onChange={e => {
setScopePlateformeId(e.target.value);
setPreview(null); setMapping({}); setDefaults({}); setResult(null); setErr(null);
}}>
<option value="">Toutes les plateformes (multi-plateforme)</option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}</option>)}
</select>
</div>
)}
</div> </div>
<div style={{ display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap' }}>
@@ -896,6 +945,7 @@ export default function ImportsSection() {
<h3 style={{ margin: '0 0 4px' }}>Étape 2 - Précisez le fichier source</h3> <h3 style={{ margin: '0 0 4px' }}>Étape 2 - Précisez le fichier source</h3>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}> <p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Importez des données depuis un fichier Excel, CSV ou JSON pour le module <strong>{def?.label ?? module}</strong>. Importez des données depuis un fichier Excel, CSV ou JSON pour le module <strong>{def?.label ?? module}</strong>.
{scopePlateforme && <> Toutes les lignes seront associées à <strong>{scopePlateforme.nom}</strong>.</>}
</p> </p>
<div className="row"> <div className="row">
<div style={{ flex: 2 }}> <div style={{ flex: 2 }}>
@@ -935,6 +985,7 @@ export default function ImportsSection() {
Utilisez un assistant IA pour convertir l'export de votre plateforme (CSV, Excel, PDF) en données structurées, Utilisez un assistant IA pour convertir l'export de votre plateforme (CSV, Excel, PDF) en données structurées,
sans mappage manuel des colonnes. Module cible actuel : <strong>{def?.label ?? module}</strong> sans mappage manuel des colonnes. Module cible actuel : <strong>{def?.label ?? module}</strong>
{' '}(modifiable en section 1 ci-dessus). {' '}(modifiable en section 1 ci-dessus).
{scopePlateforme && <> Import limité à <strong>{scopePlateforme.nom}</strong> : le prompt généré ci-dessous en tient compte.</>}
</p> </p>
<IaImportPasteBlock <IaImportPasteBlock
moduleLabel={def?.label ?? module} moduleLabel={def?.label ?? module}
@@ -947,7 +998,8 @@ export default function ImportsSection() {
module={module} module={module}
moduleLabel={def?.label ?? module} moduleLabel={def?.label ?? module}
plats={plats} plats={plats}
investissements={investissements} investissements={scopedInvestissements}
scopePlateforme={scopePlateforme}
open={iaPromptOpen} open={iaPromptOpen}
setOpen={setIaPromptOpen} setOpen={setIaPromptOpen}
/> />
@@ -1020,7 +1072,7 @@ export default function ImportsSection() {
{preview.headers.map(h => <option key={h} value={h}>{h}</option>)} {preview.headers.map(h => <option key={h} value={h}>{h}</option>)}
</select> </select>
{NAME_RESOLVABLE_FIELDS.has(t) && mapping[t] && (() => { {NAME_RESOLVABLE_FIELDS.has(t) && mapping[t] && (() => {
const refList = t === 'plateforme_id' ? plats : investissements; const refList = t === 'plateforme_id' ? plats : scopedInvestissements;
const nameKey = t === 'plateforme_id' ? 'nom' : 'nom_projet'; const nameKey = t === 'plateforme_id' ? 'nom' : 'nom_projet';
const rawValues = [...new Set( const rawValues = [...new Set(
preview.sampleRows preview.sampleRows
@@ -1061,7 +1113,7 @@ export default function ImportsSection() {
) : t === 'investissement_id' ? ( ) : t === 'investissement_id' ? (
<select value={defaults[t] || ''} onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}> <select value={defaults[t] || ''} onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}>
<option value="">—</option> <option value="">—</option>
{investissements.map(i => <option key={i.id} value={i.id}>{i.nom_projet}</option>)} {scopedInvestissements.map(i => <option key={i.id} value={i.id}>{i.nom_projet}</option>)}
</select> </select>
) : ( ) : (
<input value={defaults[t] || ''} onChange={e => setDefaults({ ...defaults, [t]: e.target.value })} <input value={defaults[t] || ''} onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}