feat(glossaries): implement a 3-step wizard for CSV/file imports with custom source/target language selection
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m21s

This commit is contained in:
2026-06-28 11:38:18 +02:00
parent 36aeac2c5e
commit ebb2537fda

View File

@@ -53,6 +53,9 @@ export default function NewGlossaryPage() {
const [parsedTerms, setParsedTerms] = useState<GlossaryTermInput[]>([]);
const [parsedFileName, setParsedFileName] = useState('');
const [isDragging, setIsDragging] = useState(false);
const [fileWizardStep, setFileWizardStep] = useState<1 | 2 | 3>(1);
const [fileSrcLang, setFileSrcLang] = useState('fr');
const [fileTgtLang, setFileTgtLang] = useState('multi');
// État pour manuel
const [manualName, setManualName] = useState('');
@@ -72,6 +75,9 @@ export default function NewGlossaryPage() {
setParsedTerms([]);
setParsedFileName('');
setManualName('');
setFileWizardStep(1);
setFileSrcLang('fr');
setFileTgtLang('multi');
};
const handleBack = () => {
@@ -126,11 +132,30 @@ export default function NewGlossaryPage() {
}
};
// Télécharger exemple CSV
const handleDownloadSample = () => {
const sample = `source,target\nbénéfice,profit\nflux de trésorerie,cash flow\nbilan,balance sheet\ncompte de résultat,income statement\nrésiliation,termination`;
const blob = new Blob([sample], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'exemple_glossaire.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Créer depuis fichier
const handleCreateFromFile = async () => {
if (!parsedTerms.length) return;
try {
await createGlossary({ name: parsedFileName || 'Mon glossaire', source_language: 'fr', target_language: 'multi', terms: parsedTerms });
await createGlossary({
name: parsedFileName || 'Mon glossaire',
source_language: fileSrcLang,
target_language: fileTgtLang,
terms: parsedTerms,
});
toast({ title: 'Glossaire créé !', description: `${parsedTerms.length} termes importés depuis votre fichier.` });
router.push('/dashboard/glossaries');
} catch {
@@ -371,35 +396,123 @@ export default function NewGlossaryPage() {
</div>
)}
{/* ── CAS B : Depuis un fichier ─── */}
{/* ── CAS B : Depuis un fichier — Wizard 3 sous-étapes ─── */}
{method === 'file' && (
<div>
<h1 className="text-2xl font-serif font-medium text-[#1A1A1A] dark:text-white mb-2">
Importez votre <span className="italic">fichier de termes</span>
</h1>
<p className="text-[#555555] dark:text-white/50 text-sm font-light mb-8">
Formats acceptés : CSV, Excel (.xlsx), ODS, TSV maximum 5 MB.
</p>
{/* Format attendu */}
<div className="mb-6 p-4 rounded-xl bg-[#F5F3EF] dark:bg-white/[0.02] border border-[#D9D6D0] dark:border-white/5">
<p className="text-[11px] font-bold text-[#333333] dark:text-white/70 mb-1">Format CSV attendu :</p>
<code className="text-[10px] text-[#555555] dark:text-white/50 font-mono">
terme_source,terme_cible<br/>
contrat,contract<br/>
résiliation,termination
</code>
{/* Indicateur sous-étapes */}
<div className="flex items-center gap-2 mb-8">
{([1, 2, 3] as const).map((s, i) => {
const labels = ['Format', 'Fichier', 'Configurer'];
const done = fileWizardStep > s;
const active = fileWizardStep === s;
return (
<>
<div key={s} className="flex items-center gap-1.5">
<div className={cn(
'w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-black transition-all',
done ? 'bg-emerald-600 text-white' : active ? 'bg-[#1A1A1A] text-white' : 'bg-[#EBEBEB] dark:bg-white/10 text-[#888]'
)}>
{done ? <CheckCircle2 size={12} /> : s}
</div>
<span className={cn('text-[10px] font-bold uppercase tracking-wider hidden sm:block',
active ? 'text-[#1A1A1A] dark:text-white' : 'text-[#AAAAAA]'
)}>{labels[i]}</span>
</div>
{i < 2 && <div className="flex-1 h-px bg-[#E5E3DF] dark:bg-white/10" />}
</>
);
})}
</div>
{/* ── Sous-étape 1 : Format ─── */}
{fileWizardStep === 1 && (
<div className="space-y-5">
<div className="bg-white dark:bg-[#141414] border border-[#E5E3DF] dark:border-white/5 rounded-2xl p-6 space-y-5">
<div>
<p className="text-xs font-bold text-[#333333] dark:text-white/70 uppercase tracking-wider mb-3">Format attendu</p>
<p className="text-[12px] text-[#555555] dark:text-white/50 font-light mb-4 leading-relaxed">
Votre fichier doit avoir <strong className="text-[#1A1A1A] dark:text-white">2 colonnes</strong> : la première pour les termes source, la seconde pour les termes cibles.
La première ligne peut être un en-tête (elle sera ignorée automatiquement).
</p>
{/* Tableau exemple */}
<div className="rounded-xl overflow-hidden border border-[#E5E3DF] dark:border-white/10 text-[11px] font-mono">
<div className="grid grid-cols-2 bg-[#F5F3EF] dark:bg-white/5 border-b border-[#E5E3DF] dark:border-white/10">
<div className="px-4 py-2 font-bold text-[#8B6F47] uppercase tracking-wider">source</div>
<div className="px-4 py-2 font-bold text-[#8B6F47] uppercase tracking-wider border-l border-[#E5E3DF] dark:border-white/10">target</div>
</div>
{[['bénéfice', 'profit'], ['flux de trésorerie', 'cash flow'], ['bilan', 'balance sheet'], ['résiliation', 'termination']].map(([s, t]) => (
<div key={s} className="grid grid-cols-2 border-b border-[#E5E3DF] dark:border-white/5 last:border-0 hover:bg-[#FAFAF8] dark:hover:bg-white/[0.02]">
<div className="px-4 py-2 text-[#1A1A1A] dark:text-white">{s}</div>
<div className="px-4 py-2 text-[#555555] dark:text-white/60 border-l border-[#E5E3DF] dark:border-white/10">{t}</div>
</div>
))}
</div>
</div>
{/* Formats acceptés */}
<div className="flex flex-wrap gap-2">
{['CSV (.csv)', 'Excel (.xlsx)', 'Excel (.xls)', 'ODS (.ods)', 'TSV (.tsv)', 'Texte (.txt)'].map(f => (
<span key={f} className="px-2.5 py-1 rounded-lg bg-[#F0EDE8] dark:bg-white/5 text-[10px] font-bold text-[#8B6F47] dark:text-brand-accent uppercase tracking-wider">{f}</span>
))}
</div>
{/* Règles */}
<div className="space-y-2">
{[
'Séparateur : virgule (CSV) ou tabulation (TSV)',
'Encodage recommandé : UTF-8',
'Taille maximale : 5 MB',
'Limite : 500 termes par glossaire',
'Les champs contenant des virgules doivent être entre guillemets',
].map(rule => (
<div key={rule} className="flex items-start gap-2 text-[11px] text-[#555555] dark:text-white/50">
<CheckCircle2 size={12} className="text-emerald-500 mt-0.5 shrink-0" />
{rule}
</div>
))}
</div>
</div>
{/* Télécharger exemple */}
<div className="flex items-center justify-between">
<button
onClick={handleDownloadSample}
className="flex items-center gap-2 text-[11px] font-bold text-[#8B6F47] dark:text-brand-accent hover:underline cursor-pointer"
>
<Upload size={12} /> Télécharger un exemple CSV
</button>
<button
onClick={() => setFileWizardStep(2)}
className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all cursor-pointer"
>
Suivant <ArrowRight size={13} />
</button>
</div>
</div>
)}
{/* ── Sous-étape 2 : Fichier ─── */}
{fileWizardStep === 2 && (
<div className="space-y-5">
{/* Zone de dépôt */}
<div
onDragOver={e => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={e => { e.preventDefault(); setIsDragging(false); const f = e.dataTransfer.files[0]; if (f) processFile(f); }}
onDrop={e => {
e.preventDefault(); setIsDragging(false);
const f = e.dataTransfer.files[0];
if (f) processFile(f);
}}
onClick={() => fileInputRef.current?.click()}
className={cn(
'flex flex-col items-center justify-center gap-4 p-12 rounded-2xl border-2 border-dashed cursor-pointer transition-all min-h-[200px]',
isDragging ? 'border-[#C5A17A] bg-[#F5F0EA]' : 'border-[#D9D6D0] dark:border-white/10 hover:border-[#C5A17A] hover:bg-[#FAFAF8]',
'flex flex-col items-center justify-center gap-4 p-12 rounded-2xl border-2 border-dashed cursor-pointer transition-all min-h-[220px]',
isDragging ? 'border-[#C5A17A] bg-[#F5F0EA]' : 'border-[#D9D6D0] dark:border-white/10 hover:border-[#C5A17A] hover:bg-[#FAFAF8] dark:hover:bg-white/[0.02]',
fileStatus === 'error' && 'border-red-400 bg-red-50 dark:bg-red-500/5'
)}
>
@@ -411,14 +524,11 @@ export default function NewGlossaryPage() {
onChange={e => { const f = e.target.files?.[0]; if (f) processFile(f); e.target.value = ''; }}
/>
{fileStatus === 'parsing' && (
<>
<Loader2 size={32} className="animate-spin text-[#C5A17A]" />
<p className="text-sm font-medium text-[#555555]">Lecture du fichier</p>
</>
<><Loader2 size={32} className="animate-spin text-[#C5A17A]" /><p className="text-sm font-medium text-[#555555] dark:text-white/50">Lecture du fichier</p></>
)}
{fileStatus === 'success' && (
<>
<CheckCircle2 size={32} className="text-emerald-600" />
<CheckCircle2 size={36} className="text-emerald-600" />
<div className="text-center">
<p className="text-sm font-bold text-emerald-700 dark:text-emerald-400">{parsedTerms.length} termes détectés</p>
<p className="text-[11px] text-[#555555] dark:text-white/40 mt-1">Cliquez pour changer de fichier</p>
@@ -427,50 +537,122 @@ export default function NewGlossaryPage() {
)}
{fileStatus === 'error' && (
<>
<AlertCircle size={32} className="text-red-500" />
<AlertCircle size={36} className="text-red-500" />
<div className="text-center">
<p className="text-sm font-medium text-red-600">{fileError}</p>
<p className="text-[11px] text-[#555555] mt-1">Cliquez pour réessayer</p>
<p className="text-[11px] text-[#555555] dark:text-white/40 mt-1">Cliquez pour réessayer</p>
</div>
</>
)}
{fileStatus === 'idle' && (
<>
<div className="w-14 h-14 rounded-2xl bg-[#F0EDE8] flex items-center justify-center text-[#8B6F47]">
<Upload size={28} />
<div className="w-16 h-16 rounded-2xl bg-[#F0EDE8] dark:bg-white/5 flex items-center justify-center text-[#8B6F47]">
<Upload size={32} />
</div>
<div className="text-center">
<p className="text-sm font-serif font-bold text-[#1A1A1A] dark:text-white">
Glissez votre fichier ici
</p>
<p className="text-[11px] text-[#555555] dark:text-white/40 mt-1">
ou cliquez pour parcourir
</p>
<p className="text-sm font-serif font-bold text-[#1A1A1A] dark:text-white">Glissez votre fichier ici</p>
<p className="text-[11px] text-[#555555] dark:text-white/40 mt-1">CSV, Excel, ODS, TSV max 5 MB</p>
</div>
<span className="text-[10px] text-[#AAAAAA] font-light">ou cliquez pour parcourir</span>
</>
)}
</div>
{/* Nom du glossaire (si fichier parsé) */}
{fileStatus === 'success' && (
<div className="mt-6">
<div className="flex items-center justify-between">
<button onClick={() => setFileWizardStep(1)} className="flex items-center gap-2 text-[11px] font-bold uppercase tracking-wider text-[#555555] hover:text-[#1A1A1A] dark:hover:text-white transition-colors cursor-pointer">
<ArrowLeft size={13} /> Retour
</button>
<button
onClick={() => setFileWizardStep(3)}
disabled={fileStatus !== 'success'}
className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
Suivant <ArrowRight size={13} />
</button>
</div>
</div>
)}
{/* ── Sous-étape 3 : Configurer ─── */}
{fileWizardStep === 3 && (
<div className="space-y-5">
<div className="bg-white dark:bg-[#141414] border border-[#E5E3DF] dark:border-white/5 rounded-2xl p-6 space-y-6">
{/* Résumé fichier */}
<div className="flex items-center gap-3 p-3 rounded-xl bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20">
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
<div>
<p className="text-[11px] font-bold text-emerald-800 dark:text-emerald-400">{parsedTerms.length} termes prêts à importer</p>
<button onClick={() => { setFileWizardStep(2); setFileStatus('idle'); setParsedTerms([]); }} className="text-[10px] text-emerald-700 dark:text-emerald-500 hover:underline cursor-pointer">
Changer de fichier
</button>
</div>
</div>
{/* Nom */}
<div>
<label className="block text-xs font-bold text-[#333333] dark:text-white/70 uppercase tracking-wider mb-2">
Nom du glossaire
Nom du glossaire <span className="text-red-500">*</span>
</label>
<input
type="text"
value={parsedFileName}
onChange={e => setParsedFileName(e.target.value)}
placeholder="Ex : Termes RH internes"
className="w-full px-4 py-3 rounded-xl border border-[#D9D6D0] dark:border-white/10 bg-white dark:bg-[#141414] text-[#1A1A1A] dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent/30 focus:border-brand-accent/50"
placeholder="Ex : Termes RH internes, Glossaire juridique…"
className="w-full px-4 py-3 rounded-xl border border-[#D9D6D0] dark:border-white/10 bg-[#FAFAF8] dark:bg-[#1A1A1A] text-[#1A1A1A] dark:text-white text-sm placeholder:text-[#AAAAAA] dark:placeholder:text-white/25 focus:outline-none focus:ring-2 focus:ring-brand-accent/30 focus:border-brand-accent/50"
/>
</div>
)}
<div className="flex justify-end mt-6">
{/* Langues */}
<div className="grid grid-cols-2 gap-5">
<div>
<label className="block text-xs font-bold text-[#333333] dark:text-white/70 uppercase tracking-wider mb-2">
Langue source
</label>
<select
value={fileSrcLang}
onChange={e => setFileSrcLang(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-[#D9D6D0] dark:border-white/10 bg-[#FAFAF8] dark:bg-[#1A1A1A] text-[#1A1A1A] dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent/30"
>
{SUPPORTED_LANGUAGES.filter(l => l.code !== 'multi').map(l => (
<option key={l.code} value={l.code}>{l.flag} {l.label}</option>
))}
</select>
<p className="text-[10px] text-[#AAAAAA] dark:text-white/30 mt-1.5 font-light">Langue de la 1ère colonne de votre fichier</p>
</div>
<div>
<label className="block text-xs font-bold text-[#333333] dark:text-white/70 uppercase tracking-wider mb-2">
Langue cible
</label>
<select
value={fileTgtLang}
onChange={e => setFileTgtLang(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-[#D9D6D0] dark:border-white/10 bg-[#FAFAF8] dark:bg-[#1A1A1A] text-[#1A1A1A] dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent/30"
>
{SUPPORTED_LANGUAGES.map(l => (
<option key={l.code} value={l.code}>{l.flag} {l.label}</option>
))}
</select>
<p className="text-[10px] text-[#AAAAAA] dark:text-white/30 mt-1.5 font-light">Langue de la 2ème colonne de votre fichier</p>
</div>
</div>
{/* Info */}
<div className="p-3.5 rounded-xl bg-[#F5F3EF] dark:bg-white/[0.02] border border-[#D9D6D0] dark:border-white/5">
<p className="text-[11px] text-[#555555] dark:text-white/50 font-light leading-relaxed">
<strong className="font-bold text-[#1A1A1A] dark:text-white/80">Conseil :</strong>{' '}
Choisissez <strong>Multilingue</strong> comme langue cible si vous prévoyez d'utiliser ce glossaire pour traduire vers plusieurs langues. Vous pourrez ajouter des traductions dans l'éditeur.
</p>
</div>
</div>
<div className="flex items-center justify-between">
<button onClick={() => setFileWizardStep(2)} className="flex items-center gap-2 text-[11px] font-bold uppercase tracking-wider text-[#555555] hover:text-[#1A1A1A] dark:hover:text-white transition-colors cursor-pointer">
<ArrowLeft size={13} /> Retour
</button>
<button
onClick={handleCreateFromFile}
disabled={fileStatus !== 'success' || isProcessing}
disabled={!parsedFileName.trim() || isProcessing}
className="flex items-center gap-2 px-7 py-3 rounded-xl bg-[#1A1A1A] hover:bg-[#333333] text-white text-xs font-bold uppercase tracking-widest transition-all disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
{isCreating ? <><Loader2 size={14} className="animate-spin" /> Création</> : <><CheckCircle2 size={14} /> Créer le glossaire</>}
@@ -478,6 +660,8 @@ export default function NewGlossaryPage() {
</div>
</div>
)}
</div>
)}
{/* ── CAS C : Manuel ─── */}
{method === 'manual' && (