Initial commit - Facturation SANTINOVA

This commit is contained in:
manus-admin
2026-04-23 04:49:21 -04:00
commit 6ab833945c
55 changed files with 12642 additions and 0 deletions

View File

@@ -0,0 +1,203 @@
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>
);
}