204 lines
8.6 KiB
TypeScript
204 lines
8.6 KiB
TypeScript
import React, { useState, useRef } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { invoiceAPI } from '../services/api';
|
|
import toast from 'react-hot-toast';
|
|
|
|
export default function InvoiceUpload() {
|
|
const [files, setFiles] = useState<File[]>([]);
|
|
const [source, setSource] = useState('upload');
|
|
const [uploading, setUploading] = useState(false);
|
|
const [progress, setProgress] = useState<Record<string, 'pending' | 'uploading' | 'done' | 'error'>>({});
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const navigate = useNavigate();
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
const droppedFiles = Array.from(e.dataTransfer.files).filter(f =>
|
|
f.type === 'application/pdf' || f.type.startsWith('image/')
|
|
);
|
|
setFiles(prev => [...prev, ...droppedFiles]);
|
|
};
|
|
|
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files) {
|
|
const selectedFiles = Array.from(e.target.files);
|
|
setFiles(prev => [...prev, ...selectedFiles]);
|
|
}
|
|
};
|
|
|
|
const removeFile = (index: number) => {
|
|
setFiles(prev => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const handleUpload = async () => {
|
|
if (files.length === 0) {
|
|
toast.error('Veuillez sélectionner au moins un fichier');
|
|
return;
|
|
}
|
|
|
|
setUploading(true);
|
|
const newProgress: Record<string, 'pending' | 'uploading' | 'done' | 'error'> = {};
|
|
files.forEach(f => { newProgress[f.name] = 'pending'; });
|
|
setProgress(newProgress);
|
|
|
|
let successCount = 0;
|
|
|
|
for (const file of files) {
|
|
setProgress(prev => ({ ...prev, [file.name]: 'uploading' }));
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('source', source);
|
|
|
|
await invoiceAPI.upload(formData);
|
|
setProgress(prev => ({ ...prev, [file.name]: 'done' }));
|
|
successCount++;
|
|
} catch (error: any) {
|
|
setProgress(prev => ({ ...prev, [file.name]: 'error' }));
|
|
toast.error(`Erreur pour ${file.name}: ${error.response?.data?.error || 'Erreur inconnue'}`);
|
|
}
|
|
}
|
|
|
|
setUploading(false);
|
|
|
|
if (successCount > 0) {
|
|
toast.success(`${successCount} facture${successCount > 1 ? 's' : ''} importée${successCount > 1 ? 's' : ''} avec succès`);
|
|
setTimeout(() => navigate('/invoices'), 1500);
|
|
}
|
|
};
|
|
|
|
const formatFileSize = (bytes: number) => {
|
|
if (bytes < 1024) return `${bytes} o`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-3xl mx-auto space-y-6">
|
|
<h1 className="text-2xl font-bold text-gray-900">Importer des factures</h1>
|
|
|
|
{/* Source selection */}
|
|
<div className="card">
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Source du document</label>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
{[
|
|
{ value: 'upload', label: 'Upload manuel', icon: '📄' },
|
|
{ value: 'scan', label: 'Scan papier', icon: '📷' },
|
|
{ value: 'email', label: 'Email', icon: '📧' },
|
|
{ value: 'portail', label: 'Portail fournisseur', icon: '🌐' },
|
|
].map(s => (
|
|
<button
|
|
key={s.value}
|
|
onClick={() => setSource(s.value)}
|
|
className={`p-3 rounded-lg border-2 text-center transition-colors ${
|
|
source === s.value
|
|
? 'border-santinova-500 bg-santinova-50 text-santinova-700'
|
|
: 'border-gray-200 hover:border-gray-300 text-gray-600'
|
|
}`}
|
|
>
|
|
<span className="text-2xl block mb-1">{s.icon}</span>
|
|
<span className="text-sm font-medium">{s.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Drop zone */}
|
|
<div
|
|
onDrop={handleDrop}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="card border-2 border-dashed border-gray-300 hover:border-santinova-400 cursor-pointer transition-colors text-center py-12"
|
|
>
|
|
<svg className="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
|
|
</svg>
|
|
<p className="text-gray-600 font-medium">Glissez-déposez vos fichiers ici</p>
|
|
<p className="text-sm text-gray-400 mt-1">ou cliquez pour sélectionner</p>
|
|
<p className="text-xs text-gray-400 mt-2">PDF, JPEG, PNG, TIFF - Max 20 Mo par fichier</p>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept=".pdf,.jpg,.jpeg,.png,.tiff,.tif,.webp,.bmp"
|
|
onChange={handleFileSelect}
|
|
className="hidden"
|
|
/>
|
|
</div>
|
|
|
|
{/* File list */}
|
|
{files.length > 0 && (
|
|
<div className="card">
|
|
<h3 className="font-semibold text-gray-900 mb-3">Fichiers sélectionnés ({files.length})</h3>
|
|
<div className="space-y-2">
|
|
{files.map((file, index) => (
|
|
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="w-8 h-8 bg-santinova-100 rounded flex items-center justify-center text-santinova-600 text-xs font-medium">
|
|
{file.name.split('.').pop()?.toUpperCase()}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-gray-900 truncate">{file.name}</p>
|
|
<p className="text-xs text-gray-500">{formatFileSize(file.size)}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{progress[file.name] === 'uploading' && (
|
|
<svg className="animate-spin h-5 w-5 text-santinova-600" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
)}
|
|
{progress[file.name] === 'done' && (
|
|
<svg className="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
|
</svg>
|
|
)}
|
|
{progress[file.name] === 'error' && (
|
|
<svg className="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
|
</svg>
|
|
)}
|
|
{!uploading && (
|
|
<button onClick={() => removeFile(index)} className="text-gray-400 hover:text-red-500">
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-4 flex gap-3">
|
|
<button
|
|
onClick={handleUpload}
|
|
disabled={uploading}
|
|
className="btn-primary flex-1 justify-center"
|
|
>
|
|
{uploading ? 'Import en cours...' : `Importer ${files.length} fichier${files.length > 1 ? 's' : ''}`}
|
|
</button>
|
|
{!uploading && (
|
|
<button onClick={() => setFiles([])} className="btn-secondary">
|
|
Tout effacer
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Info box */}
|
|
<div className="card bg-blue-50 border-blue-200">
|
|
<h3 className="font-semibold text-blue-900 mb-2">Extraction automatique par IA</h3>
|
|
<p className="text-sm text-blue-700">
|
|
Après l'import, l'IA analysera automatiquement vos documents pour extraire les informations clés :
|
|
fournisseur, numéro de facture, dates, montants et lignes de détail.
|
|
Vous pourrez ensuite vérifier et corriger les données extraites.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|