feat(glossaries): option C - page management + wizard /new 2-step creation
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m6s

This commit is contained in:
2026-06-28 09:55:14 +02:00
parent 5d6afd2dad
commit 030950c962
5 changed files with 1005 additions and 536 deletions

View File

@@ -0,0 +1,565 @@
'use client';
import { useState, useRef } from 'react';
import {
BookText, Upload, PenLine, ArrowRight, ArrowLeft, CheckCircle2,
AlertCircle, Loader2, Scale, Cpu, TrendingUp, HeartPulse,
Megaphone, Users, FlaskConical, ShoppingCart, X, Plus
} from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { useGlossaries, useGlossaryTemplates } from '../useGlossaries';
import { SUPPORTED_LANGUAGES } from '../types';
import type { GlossaryTermInput } from '../types';
import { parseFileToTerms } from '../csvUtils';
import { useToast } from '@/components/ui/toast';
// ── Icônes par catégorie de modèle ─────────────────────────────────────────
const TEMPLATE_ICONS: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
legal: Scale,
technology: Cpu,
finance: TrendingUp,
medical: HeartPulse,
marketing: Megaphone,
hr: Users,
scientific: FlaskConical,
ecommerce: ShoppingCart,
};
// ── Types ──────────────────────────────────────────────────────────────────
type CreationMethod = 'template' | 'file' | 'manual' | null;
type Step = 1 | 2;
export default function NewGlossaryPage() {
const { t } = useI18n();
const router = useRouter();
const { toast } = useToast();
const { createGlossary, importTemplate, isCreating, isImportingTemplate } = useGlossaries();
const { templates, isLoading: isLoadingTemplates } = useGlossaryTemplates();
const [step, setStep] = useState<Step>(1);
const [method, setMethod] = useState<CreationMethod>(null);
// État pour modèle
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
const [isImportingThis, setIsImportingThis] = useState(false);
// État pour fichier
const fileInputRef = useRef<HTMLInputElement>(null);
const [fileStatus, setFileStatus] = useState<'idle' | 'parsing' | 'success' | 'error'>('idle');
const [fileError, setFileError] = useState('');
const [parsedTerms, setParsedTerms] = useState<GlossaryTermInput[]>([]);
const [parsedFileName, setParsedFileName] = useState('');
const [isDragging, setIsDragging] = useState(false);
// État pour manuel
const [manualName, setManualName] = useState('');
const [manualSrc, setManualSrc] = useState('fr');
const [manualTgt, setManualTgt] = useState('multi');
const isProcessing = isCreating || isImportingTemplate || isImportingThis;
// ── Handlers ────────────────────────────────────────────────────────────
const handleSelectMethod = (m: CreationMethod) => {
setMethod(m);
setStep(2);
// reset
setSelectedTemplateId(null);
setFileStatus('idle');
setParsedTerms([]);
setParsedFileName('');
setManualName('');
};
const handleBack = () => {
setStep(1);
setMethod(null);
};
// Importer un modèle
const handleImportTemplate = async () => {
if (!selectedTemplateId) return;
const tpl = templates.find(t => t.id === selectedTemplateId);
setIsImportingThis(true);
try {
await importTemplate(selectedTemplateId, tpl?.name);
toast({ title: 'Glossaire importé !', description: `Le modèle « ${tpl?.name} » a été ajouté à vos glossaires.` });
router.push('/dashboard/glossaries');
} catch {
toast({ variant: 'destructive', title: 'Erreur', description: 'Impossible d\'importer ce modèle.' });
} finally {
setIsImportingThis(false);
}
};
// Parser un fichier CSV
const processFile = async (file: File) => {
const ext = file.name.split('.').pop()?.toLowerCase();
if (!ext || !['csv', 'xlsx', 'xls', 'ods', 'txt', 'tsv'].includes(ext)) {
setFileStatus('error');
setFileError('Format non supporté. Utilisez CSV, Excel (.xlsx), ODS, ou TSV.');
return;
}
if (file.size > 5 * 1024 * 1024) {
setFileStatus('error');
setFileError('Fichier trop volumineux (maximum 5 MB).');
return;
}
setFileStatus('parsing');
setFileError('');
try {
const terms = await parseFileToTerms(file);
if (terms.length === 0) {
setFileStatus('error');
setFileError('Le fichier est vide ou n\'a pas pu être lu.');
return;
}
setFileStatus('success');
setParsedTerms(terms);
setParsedFileName(file.name.replace(/\.[^.]+$/, '').replace(/[_-]/g, ' '));
} catch {
setFileStatus('error');
setFileError('Erreur lors de la lecture du fichier.');
}
};
// 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 });
toast({ title: 'Glossaire créé !', description: `${parsedTerms.length} termes importés depuis votre fichier.` });
router.push('/dashboard/glossaries');
} catch {
toast({ variant: 'destructive', title: 'Erreur', description: 'Impossible de créer le glossaire.' });
}
};
// Créer manuellement
const handleCreateManual = async () => {
if (!manualName.trim()) return;
try {
await createGlossary({ name: manualName.trim(), source_language: manualSrc, target_language: manualTgt, terms: [] });
toast({ title: 'Glossaire créé !', description: `« ${manualName} » est prêt. Ajoutez vos premiers termes.` });
router.push('/dashboard/glossaries');
} catch {
toast({ variant: 'destructive', title: 'Erreur', description: 'Impossible de créer le glossaire.' });
}
};
// ── Rendu ───────────────────────────────────────────────────────────────
return (
<div className="max-w-4xl mx-auto w-full p-6 lg:p-8">
{/* ── Fil d'Ariane ──────────────────────────────────────────── */}
<div className="flex items-center gap-2 mb-8 text-[11px] font-medium text-[#555555] dark:text-white/40">
<Link href="/dashboard/glossaries" className="hover:text-[#1A1A1A] dark:hover:text-white transition-colors">
Mes glossaires
</Link>
<ArrowRight size={11} />
<span className="text-[#1A1A1A] dark:text-white font-bold">Nouveau glossaire</span>
</div>
{/* ── Indicateur d'étapes ────────────────────────────────────── */}
<div className="flex items-center gap-4 mb-10">
{/* Étape 1 */}
<div className="flex items-center gap-2">
<div className={cn(
'w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-black transition-all',
step >= 1 ? 'bg-[#1A1A1A] text-white' : 'bg-[#EBEBEB] text-[#888888]'
)}>
{step > 1 ? <CheckCircle2 size={14} /> : '1'}
</div>
<span className={cn('text-xs font-bold uppercase tracking-wider', step === 1 ? 'text-[#1A1A1A] dark:text-white' : 'text-[#888888]')}>
Choisir le type
</span>
</div>
<div className="flex-1 h-px bg-[#E5E3DF] dark:bg-white/10" />
{/* Étape 2 */}
<div className="flex items-center gap-2">
<div className={cn(
'w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-black transition-all',
step === 2 ? 'bg-[#1A1A1A] text-white' : 'bg-[#EBEBEB] text-[#888888]'
)}>
2
</div>
<span className={cn('text-xs font-bold uppercase tracking-wider', step === 2 ? 'text-[#1A1A1A] dark:text-white' : 'text-[#888888]')}>
Configurer
</span>
</div>
</div>
{/* ══════════════ ÉTAPE 1 : Choisir le type ══════════════════ */}
{step === 1 && (
<div className="animate-fade-in">
<div className="mb-8">
<h1 className="text-3xl font-serif font-medium text-[#1A1A1A] dark:text-white tracking-tight mb-2">
Comment voulez-vous créer votre{' '}
<span className="italic">glossaire ?</span>
</h1>
<p className="text-[#555555] dark:text-white/50 text-sm font-light">
Choisissez la méthode qui correspond à votre situation.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{/* Option 1 — Modèle professionnel */}
<button
onClick={() => handleSelectMethod('template')}
className="group text-left p-7 bg-white dark:bg-[#141414] border-2 border-[#E5E3DF] dark:border-white/10 rounded-2xl hover:border-[#C5A17A] dark:hover:border-brand-accent/50 hover:shadow-lg transition-all cursor-pointer"
>
<div className="w-12 h-12 rounded-xl bg-[#F0EDE8] dark:bg-brand-accent/10 flex items-center justify-center text-[#8B6F47] mb-5 group-hover:scale-110 transition-transform">
<BookText size={24} />
</div>
<h2 className="text-base font-serif font-bold text-[#1A1A1A] dark:text-white mb-2 tracking-tight">
Depuis un modèle
</h2>
<p className="text-[12px] text-[#555555] dark:text-white/50 font-light leading-relaxed">
Choisissez parmi nos modèles professionnels pré-remplis : RH, Médical, Juridique, Tech
</p>
<div className="mt-5 flex items-center gap-1.5 text-[11px] font-bold text-[#8B6F47] dark:text-brand-accent uppercase tracking-wider">
Choisir un modèle <ArrowRight size={12} />
</div>
</button>
{/* Option 2 — Fichier CSV */}
<button
onClick={() => handleSelectMethod('file')}
className="group text-left p-7 bg-white dark:bg-[#141414] border-2 border-[#E5E3DF] dark:border-white/10 rounded-2xl hover:border-[#C5A17A] dark:hover:border-brand-accent/50 hover:shadow-lg transition-all cursor-pointer"
>
<div className="w-12 h-12 rounded-xl bg-[#F0EDE8] dark:bg-brand-accent/10 flex items-center justify-center text-[#8B6F47] mb-5 group-hover:scale-110 transition-transform">
<Upload size={24} />
</div>
<h2 className="text-base font-serif font-bold text-[#1A1A1A] dark:text-white mb-2 tracking-tight">
Importer un fichier
</h2>
<p className="text-[12px] text-[#555555] dark:text-white/50 font-light leading-relaxed">
Importez vos propres termes depuis un fichier CSV, Excel (.xlsx) ou ODS existant.
</p>
<div className="mt-5 flex items-center gap-1.5 text-[11px] font-bold text-[#8B6F47] dark:text-brand-accent uppercase tracking-wider">
Importer un fichier <ArrowRight size={12} />
</div>
</button>
{/* Option 3 — Manuel */}
<button
onClick={() => handleSelectMethod('manual')}
className="group text-left p-7 bg-white dark:bg-[#141414] border-2 border-[#E5E3DF] dark:border-white/10 rounded-2xl hover:border-[#C5A17A] dark:hover:border-brand-accent/50 hover:shadow-lg transition-all cursor-pointer"
>
<div className="w-12 h-12 rounded-xl bg-[#F0EDE8] dark:bg-brand-accent/10 flex items-center justify-center text-[#8B6F47] mb-5 group-hover:scale-110 transition-transform">
<PenLine size={24} />
</div>
<h2 className="text-base font-serif font-bold text-[#1A1A1A] dark:text-white mb-2 tracking-tight">
Créer manuellement
</h2>
<p className="text-[12px] text-[#555555] dark:text-white/50 font-light leading-relaxed">
Partez de zéro et ajoutez vos termes un par un directement dans l'éditeur.
</p>
<div className="mt-5 flex items-center gap-1.5 text-[11px] font-bold text-[#8B6F47] dark:text-brand-accent uppercase tracking-wider">
Créer vide <ArrowRight size={12} />
</div>
</button>
</div>
</div>
)}
{/* ══════════════ ÉTAPE 2 : Configurer ═══════════════════════ */}
{step === 2 && (
<div className="animate-fade-in">
{/* Bouton retour */}
<button
onClick={handleBack}
className="flex items-center gap-2 text-[11px] font-bold uppercase tracking-wider text-[#555555] hover:text-[#1A1A1A] dark:hover:text-white transition-colors mb-8 cursor-pointer"
>
<ArrowLeft size={14} /> Retour
</button>
{/* ── CAS A : Depuis un modèle ─── */}
{method === 'template' && (
<div>
<h1 className="text-2xl font-serif font-medium text-[#1A1A1A] dark:text-white mb-2">
Choisissez un <span className="italic">modèle professionnel</span>
</h1>
<p className="text-[#555555] dark:text-white/50 text-sm font-light mb-8">
Sélectionnez le modèle le plus adapté à vos documents. Vous pourrez modifier les termes après l'import.
</p>
{isLoadingTemplates ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-8 animate-spin text-[#C5A17A]" />
</div>
) : templates.length === 0 ? (
<div className="p-12 text-center bg-white dark:bg-[#141414] border border-[#E5E3DF] dark:border-white/5 rounded-2xl">
<p className="text-sm text-[#555555] dark:text-white/40">
Tous les modèles disponibles ont déjà é importés.
</p>
<button onClick={handleBack} className="mt-4 text-[11px] font-bold text-[#7A5C35] hover:underline cursor-pointer">
Choisir une autre méthode
</button>
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
{templates.map((tpl) => {
const Icon = TEMPLATE_ICONS[tpl.id] || BookText;
const isSelected = selectedTemplateId === tpl.id;
return (
<button
key={tpl.id}
onClick={() => setSelectedTemplateId(isSelected ? null : tpl.id)}
className={cn(
'text-left p-5 rounded-xl border-2 transition-all cursor-pointer',
isSelected
? 'border-[#1A1A1A] dark:border-white bg-[#F5F3EF] dark:bg-white/5 shadow-md'
: 'border-[#E5E3DF] dark:border-white/10 bg-white dark:bg-[#141414] hover:border-[#C5A17A] dark:hover:border-brand-accent/40'
)}
>
<div className="flex items-start gap-4">
<div className={cn(
'w-10 h-10 rounded-xl flex items-center justify-center shrink-0',
isSelected ? 'bg-[#1A1A1A] text-white' : 'bg-[#F0EDE8] dark:bg-brand-accent/10 text-[#8B6F47]'
)}>
<Icon size={18} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-serif font-bold text-[#1A1A1A] dark:text-white leading-tight">
{tpl.name.split(' - ')[0]}
</p>
<span className="shrink-0 text-[9px] font-mono font-semibold text-[#666666] dark:text-white/40">
{tpl.terms_count} termes
</span>
</div>
<p className="text-[11px] text-[#555555] dark:text-white/40 font-light mt-1 line-clamp-2">
{tpl.description}
</p>
</div>
{isSelected && (
<CheckCircle2 size={18} className="text-[#1A1A1A] dark:text-white shrink-0 mt-0.5" />
)}
</div>
</button>
);
})}
</div>
{/* Bouton d'import */}
<div className="flex items-center justify-end gap-4">
{selectedTemplateId && (
<p className="text-[11px] text-[#555555] dark:text-white/40 font-light">
Modèle sélectionné : <strong className="text-[#1A1A1A] dark:text-white">{templates.find(t => t.id === selectedTemplateId)?.name.split(' - ')[0]}</strong>
</p>
)}
<button
onClick={handleImportTemplate}
disabled={!selectedTemplateId || 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"
>
{isImportingThis ? <><Loader2 size={14} className="animate-spin" /> Import en cours</> : <><CheckCircle2 size={14} /> Importer ce modèle</>}
</button>
</div>
</>
)}
</div>
)}
{/* ── CAS B : Depuis un fichier ─── */}
{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>
</div>
{/* 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); }}
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]',
fileStatus === 'error' && 'border-red-400 bg-red-50 dark:bg-red-500/5'
)}
>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls,.ods,.txt,.tsv"
className="hidden"
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>
</>
)}
{fileStatus === 'success' && (
<>
<CheckCircle2 size={32} 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>
</div>
</>
)}
{fileStatus === 'error' && (
<>
<AlertCircle size={32} 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>
</div>
</>
)}
{fileStatus === 'idle' && (
<>
<div className="w-14 h-14 rounded-2xl bg-[#F0EDE8] flex items-center justify-center text-[#8B6F47]">
<Upload size={28} />
</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>
</div>
</>
)}
</div>
{/* Nom du glossaire (si fichier parsé) */}
{fileStatus === 'success' && (
<div className="mt-6">
<label className="block text-xs font-bold text-[#333333] dark:text-white/70 uppercase tracking-wider mb-2">
Nom du glossaire
</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"
/>
</div>
)}
<div className="flex justify-end mt-6">
<button
onClick={handleCreateFromFile}
disabled={fileStatus !== 'success' || 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</>}
</button>
</div>
</div>
)}
{/* ── CAS C : Manuel ─── */}
{method === 'manual' && (
<div>
<h1 className="text-2xl font-serif font-medium text-[#1A1A1A] dark:text-white mb-2">
Créez votre glossaire <span className="italic">manuellement</span>
</h1>
<p className="text-[#555555] dark:text-white/50 text-sm font-light mb-8">
Donnez un nom à votre glossaire et choisissez les langues. Vous ajouterez les termes dans l'éditeur.
</p>
<div className="bg-white dark:bg-[#141414] border border-[#E5E3DF] dark:border-white/5 rounded-2xl p-8 space-y-6">
{/* Nom */}
<div>
<label className="block text-xs font-bold text-[#333333] dark:text-white/70 uppercase tracking-wider mb-2">
Nom du glossaire <span className="text-red-500">*</span>
</label>
<input
type="text"
value={manualName}
onChange={e => setManualName(e.target.value)}
placeholder="Ex : Termes juridiques internes, Glossaire RH…"
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>
{/* 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={manualSrc}
onChange={e => setManualSrc(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>
</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={manualTgt}
onChange={e => setManualTgt(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>
</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">Que se passe-t-il ensuite ?</strong>{' '}
Un glossaire vide sera créé. Vous serez redirigé vers l'éditeur pour ajouter vos termes.
</p>
</div>
</div>
<div className="flex justify-end mt-6">
<button
onClick={handleCreateManual}
disabled={!manualName.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</> : <><Plus size={14} /> Créer le glossaire</>}
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}