Initial commit - Facturation SANTINOVA
This commit is contained in:
231
frontend/src/pages/Dashboard.tsx
Normal file
231
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement } from 'chart.js';
|
||||
import { Doughnut, Bar, Line } from 'react-chartjs-2';
|
||||
import { dashboardAPI } from '../services/api';
|
||||
import { DashboardData } from '../types';
|
||||
import { formatCurrency, formatDate, statusLabels, statusColors } from '../utils/helpers';
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement);
|
||||
|
||||
export default function Dashboard() {
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard();
|
||||
}, []);
|
||||
|
||||
const loadDashboard = async () => {
|
||||
try {
|
||||
const res = await dashboardAPI.get();
|
||||
setData(res.data);
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement dashboard:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<svg className="animate-spin h-8 w-8 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return <p className="text-gray-500">Erreur de chargement</p>;
|
||||
|
||||
const statusChartData = {
|
||||
labels: data.statusCounts.map(s => statusLabels[s.status] || s.status),
|
||||
datasets: [{
|
||||
data: data.statusCounts.map(s => s.count),
|
||||
backgroundColor: ['#3b82f6', '#f59e0b', '#6366f1', '#22c55e', '#10b981', '#ef4444', '#9ca3af'],
|
||||
borderWidth: 0,
|
||||
}],
|
||||
};
|
||||
|
||||
const monthlyChartData = {
|
||||
labels: data.monthlyEvolution.map(m => {
|
||||
const [year, month] = m.month.split('-');
|
||||
return `${month}/${year.slice(2)}`;
|
||||
}),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Montant TTC',
|
||||
data: data.monthlyEvolution.map(m => m.total),
|
||||
borderColor: '#2563eb',
|
||||
backgroundColor: 'rgba(37, 99, 235, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const supplierChartData = {
|
||||
labels: data.topSuppliers.map(s => s.supplier_name?.length > 20 ? s.supplier_name.slice(0, 20) + '...' : s.supplier_name),
|
||||
datasets: [{
|
||||
label: 'Montant total',
|
||||
data: data.topSuppliers.map(s => s.total),
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 6,
|
||||
}],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Tableau de bord</h1>
|
||||
<Link to="/invoices/upload" className="btn-primary">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Nouvelle facture
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Total factures actives</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">{data.activeTotals.count}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">{formatCurrency(data.activeTotals.total)}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-santinova-100 rounded-xl flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-santinova-600" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">En retard</p>
|
||||
<p className="text-2xl font-bold text-red-600 mt-1">{data.overdue.count}</p>
|
||||
<p className="text-sm text-red-500 mt-1">{formatCurrency(data.overdue.total)}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Échéance 7 jours</p>
|
||||
<p className="text-2xl font-bold text-amber-600 mt-1">{data.dueSoon.count}</p>
|
||||
<p className="text-sm text-amber-500 mt-1">{formatCurrency(data.dueSoon.total)}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-amber-600" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Échéance 30 jours</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">{data.dueMonth.count}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">{formatCurrency(data.dueMonth.total)}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-gray-100 rounded-xl flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-gray-600" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending actions */}
|
||||
{data.pendingForUser.length > 0 && (
|
||||
<div className="card bg-santinova-50 border-santinova-200">
|
||||
<h3 className="font-semibold text-santinova-900 mb-3">Actions en attente</h3>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{data.pendingForUser.map((p, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 bg-santinova-600 text-white rounded-full text-sm font-bold">
|
||||
{p.count}
|
||||
</span>
|
||||
<span className="text-sm text-santinova-800">{p.action}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="card">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Répartition par statut</h3>
|
||||
<div className="h-64 flex items-center justify-center">
|
||||
<Doughnut data={statusChartData} options={{ responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 8, font: { size: 11 } } } } }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card lg:col-span-2">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Évolution mensuelle</h3>
|
||||
<div className="h-64">
|
||||
<Line data={monthlyChartData} options={{ responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { callback: (v) => `${Number(v).toLocaleString('fr-FR')} €` } } } }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="card">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Top fournisseurs</h3>
|
||||
<div className="h-64">
|
||||
<Bar data={supplierChartData} options={{ responsive: true, maintainAspectRatio: false, indexAxis: 'y', plugins: { legend: { display: false } }, scales: { x: { ticks: { callback: (v) => `${Number(v).toLocaleString('fr-FR')} €` } } } }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent invoices */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900">Dernières factures</h3>
|
||||
<Link to="/invoices" className="text-sm text-santinova-600 hover:text-santinova-700">Voir tout</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{data.recentInvoices.map((inv) => (
|
||||
<Link
|
||||
key={inv.id}
|
||||
to={`/invoices/${inv.id}`}
|
||||
className="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{inv.invoiceNumber || `#${inv.id}`}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">{inv.supplierName || 'Fournisseur inconnu'}</p>
|
||||
</div>
|
||||
<div className="text-right ml-4">
|
||||
<p className="text-sm font-medium text-gray-900">{formatCurrency(inv.amountTTC)}</p>
|
||||
<span className={`badge ${statusColors[inv.status] || 'bg-gray-100 text-gray-800'}`}>
|
||||
{statusLabels[inv.status] || inv.status}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{data.recentInvoices.length === 0 && (
|
||||
<p className="text-sm text-gray-500 text-center py-4">Aucune facture</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
frontend/src/pages/Exports.tsx
Normal file
178
frontend/src/pages/Exports.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useState } from 'react';
|
||||
import { exportAPI } from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Exports() {
|
||||
const [filters, setFilters] = useState({
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
status: '',
|
||||
});
|
||||
const [loading, setLoading] = useState('');
|
||||
|
||||
const handleExportCSV = async () => {
|
||||
setLoading('csv');
|
||||
try {
|
||||
const res = await exportAPI.csv(filters);
|
||||
const blob = new Blob([res.data], { type: 'text/csv;charset=utf-8' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `export_factures_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success('Export CSV téléchargé');
|
||||
} catch (error) {
|
||||
toast.error('Erreur d\'export');
|
||||
} finally {
|
||||
setLoading('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportExcel = async () => {
|
||||
setLoading('excel');
|
||||
try {
|
||||
const res = await exportAPI.excel(filters);
|
||||
const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `export_factures_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success('Export Excel téléchargé');
|
||||
} catch (error) {
|
||||
toast.error('Erreur d\'export');
|
||||
} finally {
|
||||
setLoading('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCEGI = async () => {
|
||||
setLoading('cegi');
|
||||
try {
|
||||
const res = await exportAPI.cegi(filters);
|
||||
toast.success(`Export CEGI préparé: ${res.data.count} écritures`);
|
||||
// Download as JSON for now
|
||||
const blob = new Blob([JSON.stringify(res.data, null, 2)], { type: 'application/json' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `export_cegi_${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
toast.error('Erreur d\'export');
|
||||
} finally {
|
||||
setLoading('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Exports comptables</h1>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Filtres d'export</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Date début</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.startDate}
|
||||
onChange={(e) => setFilters({ ...filters, startDate: e.target.value })}
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Date fin</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.endDate}
|
||||
onChange={(e) => setFilters({ ...filters, endDate: e.target.value })}
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Statut</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="">Tous</option>
|
||||
<option value="approuvee">Approuvées</option>
|
||||
<option value="payee">Payées</option>
|
||||
<option value="validee">Validées</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export options */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* CSV */}
|
||||
<div className="card text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-green-600" 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.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Export CSV</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Format CSV compatible avec la plupart des logiciels comptables et tableurs.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
disabled={loading === 'csv'}
|
||||
className="btn-primary w-full justify-center"
|
||||
>
|
||||
{loading === 'csv' ? 'Export en cours...' : 'Télécharger CSV'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Excel */}
|
||||
<div className="card text-center">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-blue-600" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M13.125 12h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125M20.625 12c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5M12 14.625v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 14.625c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 13.125c0-.621.504-1.125 1.125-1.125" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Export Excel</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Format XLSX avec mise en forme pour analyse dans Microsoft Excel ou LibreOffice.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleExportExcel}
|
||||
disabled={loading === 'excel'}
|
||||
className="btn-primary w-full justify-center"
|
||||
>
|
||||
{loading === 'excel' ? 'Export en cours...' : 'Télécharger Excel'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CEGI */}
|
||||
<div className="card text-center border-2 border-dashed border-gray-300">
|
||||
<div className="w-16 h-16 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">CEGI Compta First</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Export préparatoire pour l'intégration future avec CEGI Compta First.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleExportCEGI}
|
||||
disabled={loading === 'cegi'}
|
||||
className="btn-secondary w-full justify-center"
|
||||
>
|
||||
{loading === 'cegi' ? 'Export en cours...' : 'Préparer export CEGI'}
|
||||
</button>
|
||||
<p className="text-xs text-gray-400 mt-2">Intégration en cours de développement</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
475
frontend/src/pages/InvoiceDetail.tsx
Normal file
475
frontend/src/pages/InvoiceDetail.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { invoiceAPI, purchaseOrderAPI } from '../services/api';
|
||||
import { Invoice, PurchaseOrder } from '../types';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { formatCurrency, formatDate, formatDateTime, statusLabels, statusColors, sourceLabels, matchingLabels, matchingColors } from '../utils/helpers';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function InvoiceDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [invoice, setInvoice] = useState<Invoice | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editData, setEditData] = useState<any>({});
|
||||
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrder[]>([]);
|
||||
const [selectedPO, setSelectedPO] = useState<number | ''>('');
|
||||
const [statusNote, setStatusNote] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (id) loadInvoice();
|
||||
}, [id]);
|
||||
|
||||
const loadInvoice = async () => {
|
||||
try {
|
||||
const res = await invoiceAPI.get(parseInt(id!));
|
||||
setInvoice(res.data);
|
||||
setEditData({
|
||||
invoiceNumber: res.data.invoiceNumber || '',
|
||||
supplierName: res.data.supplierName || '',
|
||||
supplierSiret: res.data.supplierSiret || '',
|
||||
supplierAddress: res.data.supplierAddress || '',
|
||||
invoiceDate: res.data.invoiceDate ? res.data.invoiceDate.slice(0, 10) : '',
|
||||
dueDate: res.data.dueDate ? res.data.dueDate.slice(0, 10) : '',
|
||||
amountHT: res.data.amountHT || '',
|
||||
amountTVA: res.data.amountTVA || '',
|
||||
amountTTC: res.data.amountTTC || '',
|
||||
tvaRate: res.data.tvaRate || '',
|
||||
notes: res.data.notes || '',
|
||||
});
|
||||
|
||||
// Load POs for matching
|
||||
const poRes = await purchaseOrderAPI.list({ limit: 100 });
|
||||
setPurchaseOrders(poRes.data.data);
|
||||
} catch (error) {
|
||||
toast.error('Erreur de chargement');
|
||||
navigate('/invoices');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await invoiceAPI.update(parseInt(id!), editData);
|
||||
toast.success('Facture mise à jour');
|
||||
setEditing(false);
|
||||
loadInvoice();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Erreur de mise à jour');
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
try {
|
||||
await invoiceAPI.changeStatus(parseInt(id!), newStatus, statusNote);
|
||||
toast.success(`Statut mis à jour: ${statusLabels[newStatus]}`);
|
||||
setStatusNote('');
|
||||
loadInvoice();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Erreur de changement de statut');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMatch = async () => {
|
||||
if (!selectedPO) return;
|
||||
try {
|
||||
const res = await invoiceAPI.match(parseInt(id!), selectedPO as number);
|
||||
toast.success(`Rapprochement effectué: ${res.data.matchingStatus === 'rapproche' ? 'OK' : 'Écart détecté'}`);
|
||||
loadInvoice();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Erreur de rapprochement');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Supprimer cette facture ?')) return;
|
||||
try {
|
||||
await invoiceAPI.delete(parseInt(id!));
|
||||
toast.success('Facture supprimée');
|
||||
navigate('/invoices');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Erreur de suppression');
|
||||
}
|
||||
};
|
||||
|
||||
const getAvailableActions = () => {
|
||||
if (!invoice || !user) return [];
|
||||
const actions: Array<{ status: string; label: string; color: string }> = [];
|
||||
const role = user.role;
|
||||
const s = invoice.status;
|
||||
|
||||
if (s === 'recue' && ['operateur', 'validateur', 'approbateur', 'admin'].includes(role)) {
|
||||
actions.push({ status: 'en_verification', label: 'Passer en vérification', color: 'btn-primary' });
|
||||
}
|
||||
if (s === 'en_verification' && ['validateur', 'approbateur', 'admin'].includes(role)) {
|
||||
actions.push({ status: 'validee', label: 'Valider', color: 'btn-success' });
|
||||
}
|
||||
if (s === 'validee' && ['approbateur', 'admin'].includes(role)) {
|
||||
actions.push({ status: 'approuvee', label: 'Approuver', color: 'btn-success' });
|
||||
}
|
||||
if (s === 'approuvee' && role === 'admin') {
|
||||
actions.push({ status: 'payee', label: 'Marquer comme payée', color: 'btn-success' });
|
||||
}
|
||||
if (s === 'payee' && role === 'admin') {
|
||||
actions.push({ status: 'archivee', label: 'Archiver', color: 'btn-secondary' });
|
||||
}
|
||||
if (!['payee', 'archivee', 'rejetee'].includes(s) && ['validateur', 'approbateur', 'admin'].includes(role)) {
|
||||
actions.push({ status: 'rejetee', label: 'Rejeter', color: 'btn-danger' });
|
||||
}
|
||||
if (s === 'rejetee' && ['operateur', 'validateur', 'approbateur', 'admin'].includes(role)) {
|
||||
actions.push({ status: 'recue', label: 'Remettre en réception', color: 'btn-secondary' });
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<svg className="animate-spin h-8 w-8 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!invoice) return null;
|
||||
|
||||
const actions = getAvailableActions();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={() => navigate('/invoices')} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Facture {invoice.invoiceNumber || `#${invoice.id}`}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">{invoice.supplierName || 'Fournisseur inconnu'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`badge text-sm px-3 py-1 ${statusColors[invoice.status]}`}>
|
||||
{statusLabels[invoice.status]}
|
||||
</span>
|
||||
{!editing ? (
|
||||
<button onClick={() => setEditing(true)} className="btn-secondary text-sm">Modifier</button>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={handleSave} className="btn-primary text-sm">Enregistrer</button>
|
||||
<button onClick={() => setEditing(false)} className="btn-secondary text-sm">Annuler</button>
|
||||
</>
|
||||
)}
|
||||
{user?.role === 'admin' && (
|
||||
<button onClick={handleDelete} className="btn-danger text-sm">Supprimer</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow actions */}
|
||||
{actions.length > 0 && (
|
||||
<div className="card bg-gray-50">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Actions de workflow</h3>
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-xs text-gray-500 mb-1">Note (optionnelle)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={statusNote}
|
||||
onChange={(e) => setStatusNote(e.target.value)}
|
||||
placeholder="Ajouter une note..."
|
||||
className="input-field text-sm"
|
||||
/>
|
||||
</div>
|
||||
{actions.map(action => (
|
||||
<button
|
||||
key={action.status}
|
||||
onClick={() => handleStatusChange(action.status)}
|
||||
className={`${action.color} text-sm`}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main info */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="card">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Informations générales</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">N° Facture</label>
|
||||
{editing ? (
|
||||
<input type="text" value={editData.invoiceNumber} onChange={(e) => setEditData({ ...editData, invoiceNumber: e.target.value })} className="input-field" />
|
||||
) : (
|
||||
<p className="text-sm font-medium">{invoice.invoiceNumber || '-'}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Fournisseur</label>
|
||||
{editing ? (
|
||||
<input type="text" value={editData.supplierName} onChange={(e) => setEditData({ ...editData, supplierName: e.target.value })} className="input-field" />
|
||||
) : (
|
||||
<p className="text-sm font-medium">{invoice.supplierName || '-'}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">SIRET</label>
|
||||
{editing ? (
|
||||
<input type="text" value={editData.supplierSiret} onChange={(e) => setEditData({ ...editData, supplierSiret: e.target.value })} className="input-field" />
|
||||
) : (
|
||||
<p className="text-sm">{invoice.supplierSiret || '-'}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Adresse fournisseur</label>
|
||||
{editing ? (
|
||||
<input type="text" value={editData.supplierAddress} onChange={(e) => setEditData({ ...editData, supplierAddress: e.target.value })} className="input-field" />
|
||||
) : (
|
||||
<p className="text-sm">{invoice.supplierAddress || '-'}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Date de facture</label>
|
||||
{editing ? (
|
||||
<input type="date" value={editData.invoiceDate} onChange={(e) => setEditData({ ...editData, invoiceDate: e.target.value })} className="input-field" />
|
||||
) : (
|
||||
<p className="text-sm">{formatDate(invoice.invoiceDate)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Date d'échéance</label>
|
||||
{editing ? (
|
||||
<input type="date" value={editData.dueDate} onChange={(e) => setEditData({ ...editData, dueDate: e.target.value })} className="input-field" />
|
||||
) : (
|
||||
<p className="text-sm">{formatDate(invoice.dueDate)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amounts */}
|
||||
<div className="card">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Montants</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Montant HT</label>
|
||||
{editing ? (
|
||||
<input type="number" step="0.01" value={editData.amountHT} onChange={(e) => setEditData({ ...editData, amountHT: e.target.value })} className="input-field" />
|
||||
) : (
|
||||
<p className="text-lg font-semibold">{formatCurrency(invoice.amountHT)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">TVA</label>
|
||||
{editing ? (
|
||||
<input type="number" step="0.01" value={editData.amountTVA} onChange={(e) => setEditData({ ...editData, amountTVA: e.target.value })} className="input-field" />
|
||||
) : (
|
||||
<p className="text-lg font-semibold">{formatCurrency(invoice.amountTVA)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Montant TTC</label>
|
||||
{editing ? (
|
||||
<input type="number" step="0.01" value={editData.amountTTC} onChange={(e) => setEditData({ ...editData, amountTTC: e.target.value })} className="input-field" />
|
||||
) : (
|
||||
<p className="text-lg font-bold text-santinova-600">{formatCurrency(invoice.amountTTC)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Taux TVA</label>
|
||||
{editing ? (
|
||||
<input type="number" step="0.01" value={editData.tvaRate} onChange={(e) => setEditData({ ...editData, tvaRate: e.target.value })} className="input-field" />
|
||||
) : (
|
||||
<p className="text-sm">{invoice.tvaRate ? `${invoice.tvaRate}%` : '-'}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice lines */}
|
||||
{invoice.lines && invoice.lines.length > 0 && (
|
||||
<div className="card overflow-hidden p-0">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-gray-900">Lignes de facture</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase px-6 py-3">Description</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase px-6 py-3">Qté</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase px-6 py-3">P.U.</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase px-6 py-3">HT</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase px-6 py-3">TVA</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase px-6 py-3">TTC</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{invoice.lines.map((line, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="px-6 py-3 text-sm">{line.description || '-'}</td>
|
||||
<td className="px-6 py-3 text-sm text-right">{line.quantity || '-'}</td>
|
||||
<td className="px-6 py-3 text-sm text-right">{line.unitPrice ? formatCurrency(line.unitPrice) : '-'}</td>
|
||||
<td className="px-6 py-3 text-sm text-right">{line.amountHT ? formatCurrency(line.amountHT) : '-'}</td>
|
||||
<td className="px-6 py-3 text-sm text-right">{line.amountTVA ? formatCurrency(line.amountTVA) : '-'}</td>
|
||||
<td className="px-6 py-3 text-sm text-right font-medium">{line.amountTTC ? formatCurrency(line.amountTTC) : '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div className="card">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Notes</h3>
|
||||
{editing ? (
|
||||
<textarea
|
||||
value={editData.notes}
|
||||
onChange={(e) => setEditData({ ...editData, notes: e.target.value })}
|
||||
className="input-field"
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">{invoice.notes || 'Aucune note'}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* File preview */}
|
||||
<div className="card">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Document original</h3>
|
||||
{invoice.filePath ? (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-2">{invoice.originalFileName}</p>
|
||||
<a
|
||||
href={`/api/invoices/${invoice.id}/file`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary text-sm w-full justify-center"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
Voir le document
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">Aucun document</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="card">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Métadonnées</h3>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Source</dt>
|
||||
<dd>{sourceLabels[invoice.source]}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Réception</dt>
|
||||
<dd>{formatDate(invoice.receptionDate)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Confiance OCR</dt>
|
||||
<dd>{invoice.ocrConfidence ? `${invoice.ocrConfidence}%` : '-'}</dd>
|
||||
</div>
|
||||
{invoice.validatedAt && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Validée le</dt>
|
||||
<dd>{formatDateTime(invoice.validatedAt)}</dd>
|
||||
</div>
|
||||
)}
|
||||
{invoice.approvedAt && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Approuvée le</dt>
|
||||
<dd>{formatDateTime(invoice.approvedAt)}</dd>
|
||||
</div>
|
||||
)}
|
||||
{invoice.paidAt && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Payée le</dt>
|
||||
<dd>{formatDateTime(invoice.paidAt)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Matching */}
|
||||
<div className="card">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Rapprochement BC</h3>
|
||||
{invoice.purchaseOrderId ? (
|
||||
<div>
|
||||
<span className={`badge ${matchingColors[invoice.matchingStatus || 'non_rapproche']}`}>
|
||||
{matchingLabels[invoice.matchingStatus || 'non_rapproche']}
|
||||
</span>
|
||||
<p className="text-sm text-gray-600 mt-2">{invoice.matchingNotes}</p>
|
||||
<Link to={`/purchase-orders/${invoice.purchaseOrderId}`} className="text-sm text-santinova-600 hover:underline mt-1 block">
|
||||
Voir le bon de commande
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-3">Non rapproché</p>
|
||||
<select
|
||||
value={selectedPO}
|
||||
onChange={(e) => setSelectedPO(e.target.value ? parseInt(e.target.value) : '')}
|
||||
className="input-field text-sm mb-2"
|
||||
>
|
||||
<option value="">Sélectionner un BC...</option>
|
||||
{purchaseOrders.map(po => (
|
||||
<option key={po.id} value={po.id}>
|
||||
{po.orderNumber} - {po.supplierName} ({formatCurrency(po.amountTTC)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={handleMatch} disabled={!selectedPO} className="btn-primary text-sm w-full justify-center">
|
||||
Rapprocher
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Audit log */}
|
||||
{invoice.auditLog && invoice.auditLog.length > 0 && (
|
||||
<div className="card">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Historique</h3>
|
||||
<div className="space-y-3">
|
||||
{invoice.auditLog.map((entry) => (
|
||||
<div key={entry.id} className="flex gap-3">
|
||||
<div className="w-2 h-2 bg-santinova-400 rounded-full mt-1.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-900">{entry.action.replace(/_/g, ' ')}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{entry.userName && `${entry.userName} - `}{formatDateTime(entry.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
frontend/src/pages/InvoiceList.tsx
Normal file
185
frontend/src/pages/InvoiceList.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { invoiceAPI } from '../services/api';
|
||||
import { Invoice, PaginatedResponse } from '../types';
|
||||
import { formatCurrency, formatDate, statusLabels, statusColors, sourceLabels, isOverdue, isDueSoon } from '../utils/helpers';
|
||||
|
||||
export default function InvoiceList() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||
const [pagination, setPagination] = useState({ total: 0, page: 1, limit: 20, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState(searchParams.get('search') || '');
|
||||
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || '');
|
||||
|
||||
useEffect(() => {
|
||||
loadInvoices();
|
||||
}, [searchParams]);
|
||||
|
||||
const loadInvoices = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {
|
||||
page: searchParams.get('page') || 1,
|
||||
limit: 20,
|
||||
};
|
||||
if (searchParams.get('status')) params.status = searchParams.get('status');
|
||||
if (searchParams.get('search')) params.search = searchParams.get('search');
|
||||
|
||||
const res = await invoiceAPI.list(params);
|
||||
setInvoices(res.data.data);
|
||||
setPagination(res.data.pagination);
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement factures:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('search', search);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
params.set('page', '1');
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
const handleStatusFilter = (status: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (status) {
|
||||
params.set('status', status);
|
||||
} else {
|
||||
params.delete('status');
|
||||
}
|
||||
params.set('page', '1');
|
||||
setStatusFilter(status);
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set('page', page.toString());
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
const statuses = ['', 'recue', 'en_verification', 'validee', 'approuvee', 'payee', 'rejetee'];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Factures</h1>
|
||||
<Link to="/invoices/upload" className="btn-primary">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Importer une facture
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card">
|
||||
<form onSubmit={handleSearch} className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Rechercher (n° facture, fournisseur...)"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => handleStatusFilter(e.target.value)}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="">Tous les statuts</option>
|
||||
{statuses.filter(s => s).map(s => (
|
||||
<option key={s} value={s}>{statusLabels[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary">Rechercher</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card overflow-hidden p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">N° Facture</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Fournisseur</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Date</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Échéance</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Montant TTC</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Statut</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} className="px-6 py-12 text-center text-gray-500">Chargement...</td></tr>
|
||||
) : invoices.length === 0 ? (
|
||||
<tr><td colSpan={7} className="px-6 py-12 text-center text-gray-500">Aucune facture trouvée</td></tr>
|
||||
) : invoices.map((inv) => (
|
||||
<tr key={inv.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<Link to={`/invoices/${inv.id}`} className="text-sm font-medium text-santinova-600 hover:text-santinova-700">
|
||||
{inv.invoiceNumber || `#${inv.id}`}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<p className="text-sm text-gray-900">{inv.supplierName || '-'}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{formatDate(inv.invoiceDate)}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`text-sm ${isOverdue(inv.dueDate, inv.status) ? 'text-red-600 font-medium' : isDueSoon(inv.dueDate, inv.status) ? 'text-amber-600' : 'text-gray-500'}`}>
|
||||
{formatDate(inv.dueDate)}
|
||||
{isOverdue(inv.dueDate, inv.status) && ' ⚠'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right text-sm font-medium text-gray-900">{formatCurrency(inv.amountTTC)}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`badge ${statusColors[inv.status] || 'bg-gray-100 text-gray-800'}`}>
|
||||
{statusLabels[inv.status] || inv.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{sourceLabels[inv.source] || inv.source}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-200 bg-gray-50">
|
||||
<p className="text-sm text-gray-500">
|
||||
{pagination.total} facture{pagination.total > 1 ? 's' : ''} - Page {pagination.page}/{pagination.totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => goToPage(pagination.page - 1)}
|
||||
disabled={pagination.page <= 1}
|
||||
className="btn-secondary text-sm py-1 px-3 disabled:opacity-50"
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
<button
|
||||
onClick={() => goToPage(pagination.page + 1)}
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
className="btn-secondary text-sm py-1 px-3 disabled:opacity-50"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
frontend/src/pages/InvoiceUpload.tsx
Normal file
203
frontend/src/pages/InvoiceUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
frontend/src/pages/Login.tsx
Normal file
94
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
toast.success('Connexion réussie');
|
||||
navigate('/');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Identifiants incorrects');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-santinova-900 via-santinova-800 to-santinova-700 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-white rounded-2xl shadow-lg mb-4">
|
||||
<span className="text-santinova-900 font-bold text-2xl">S</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white">SANTINOVA</h1>
|
||||
<p className="text-santinova-200 mt-1">Gestion de Facturation</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6">Connexion</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Adresse email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input-field"
|
||||
placeholder="votre@email.com"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input-field"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center py-2.5"
|
||||
>
|
||||
{loading ? (
|
||||
<svg className="animate-spin h-5 w-5" 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>
|
||||
) : 'Se connecter'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-santinova-300 text-sm mt-6">
|
||||
Facturation SANTINOVA v1.0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
frontend/src/pages/PurchaseOrderDetail.tsx
Normal file
242
frontend/src/pages/PurchaseOrderDetail.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { purchaseOrderAPI } from '../services/api';
|
||||
import { PurchaseOrder } from '../types';
|
||||
import { formatCurrency, formatDate, poStatusLabels, poStatusColors, statusLabels, matchingLabels } from '../utils/helpers';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function PurchaseOrderDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [po, setPO] = useState<PurchaseOrder | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editData, setEditData] = useState<any>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (id) loadPO();
|
||||
}, [id]);
|
||||
|
||||
const loadPO = async () => {
|
||||
try {
|
||||
const res = await purchaseOrderAPI.get(parseInt(id!));
|
||||
setPO(res.data);
|
||||
setEditData({
|
||||
orderNumber: res.data.orderNumber,
|
||||
supplierName: res.data.supplierName || '',
|
||||
orderDate: res.data.orderDate ? res.data.orderDate.slice(0, 10) : '',
|
||||
expectedDeliveryDate: res.data.expectedDeliveryDate ? res.data.expectedDeliveryDate.slice(0, 10) : '',
|
||||
amountHT: res.data.amountHT || '',
|
||||
amountTVA: res.data.amountTVA || '',
|
||||
amountTTC: res.data.amountTTC || '',
|
||||
status: res.data.status,
|
||||
description: res.data.description || '',
|
||||
notes: res.data.notes || '',
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error('Erreur de chargement');
|
||||
navigate('/purchase-orders');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await purchaseOrderAPI.update(parseInt(id!), editData);
|
||||
toast.success('Bon de commande mis à jour');
|
||||
setEditing(false);
|
||||
loadPO();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Erreur');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Supprimer ce bon de commande ?')) return;
|
||||
try {
|
||||
await purchaseOrderAPI.delete(parseInt(id!));
|
||||
toast.success('Bon de commande supprimé');
|
||||
navigate('/purchase-orders');
|
||||
} catch (error: any) {
|
||||
toast.error('Erreur');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<svg className="animate-spin h-8 w-8 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!po) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={() => navigate('/purchase-orders')} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">BC {po.orderNumber}</h1>
|
||||
<p className="text-sm text-gray-500">{po.supplierName || 'Fournisseur inconnu'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`badge text-sm px-3 py-1 ${poStatusColors[po.status]}`}>
|
||||
{poStatusLabels[po.status]}
|
||||
</span>
|
||||
{!editing ? (
|
||||
<button onClick={() => setEditing(true)} className="btn-secondary text-sm">Modifier</button>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={handleSave} className="btn-primary text-sm">Enregistrer</button>
|
||||
<button onClick={() => setEditing(false)} className="btn-secondary text-sm">Annuler</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={handleDelete} className="btn-danger text-sm">Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="card">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Informations</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">N° Commande</label>
|
||||
{editing ? (
|
||||
<input type="text" value={editData.orderNumber} onChange={(e) => setEditData({ ...editData, orderNumber: e.target.value })} className="input-field" />
|
||||
) : (
|
||||
<p className="text-sm font-medium">{po.orderNumber}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Fournisseur</label>
|
||||
{editing ? (
|
||||
<input type="text" value={editData.supplierName} onChange={(e) => setEditData({ ...editData, supplierName: e.target.value })} className="input-field" />
|
||||
) : (
|
||||
<p className="text-sm font-medium">{po.supplierName || '-'}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Date commande</label>
|
||||
{editing ? (
|
||||
<input type="date" value={editData.orderDate} onChange={(e) => setEditData({ ...editData, orderDate: e.target.value })} className="input-field" />
|
||||
) : (
|
||||
<p className="text-sm">{formatDate(po.orderDate)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Livraison prévue</label>
|
||||
{editing ? (
|
||||
<input type="date" value={editData.expectedDeliveryDate} onChange={(e) => setEditData({ ...editData, expectedDeliveryDate: e.target.value })} className="input-field" />
|
||||
) : (
|
||||
<p className="text-sm">{formatDate(po.expectedDeliveryDate)}</p>
|
||||
)}
|
||||
</div>
|
||||
{editing && (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Statut</label>
|
||||
<select value={editData.status} onChange={(e) => setEditData({ ...editData, status: e.target.value })} className="input-field">
|
||||
{Object.entries(poStatusLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Montants</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">HT</label>
|
||||
{editing ? (
|
||||
<input type="number" step="0.01" value={editData.amountHT} onChange={(e) => setEditData({ ...editData, amountHT: e.target.value })} className="input-field" />
|
||||
) : (
|
||||
<p className="text-lg font-semibold">{formatCurrency(po.amountHT)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">TVA</label>
|
||||
{editing ? (
|
||||
<input type="number" step="0.01" value={editData.amountTVA} onChange={(e) => setEditData({ ...editData, amountTVA: e.target.value })} className="input-field" />
|
||||
) : (
|
||||
<p className="text-lg font-semibold">{formatCurrency(po.amountTVA)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">TTC</label>
|
||||
{editing ? (
|
||||
<input type="number" step="0.01" value={editData.amountTTC} onChange={(e) => setEditData({ ...editData, amountTTC: e.target.value })} className="input-field" />
|
||||
) : (
|
||||
<p className="text-lg font-bold text-santinova-600">{formatCurrency(po.amountTTC)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Description</h3>
|
||||
{editing ? (
|
||||
<textarea value={editData.description} onChange={(e) => setEditData({ ...editData, description: e.target.value })} className="input-field" rows={3} />
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">{po.description || 'Aucune description'}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Linked invoices */}
|
||||
<div className="card">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Factures liées</h3>
|
||||
{po.linkedInvoices && po.linkedInvoices.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{po.linkedInvoices.map((inv: any) => (
|
||||
<Link
|
||||
key={inv.id}
|
||||
to={`/invoices/${inv.id}`}
|
||||
className="block p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-900">{inv.invoiceNumber || `#${inv.id}`}</p>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-xs text-gray-500">{formatCurrency(inv.amountTTC)}</span>
|
||||
<span className="text-xs text-gray-500">{statusLabels[inv.status]}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">Aucune facture liée</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lines */}
|
||||
{po.lines && po.lines.length > 0 && (
|
||||
<div className="card">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Lignes de commande</h3>
|
||||
<div className="space-y-2">
|
||||
{po.lines.map((line, idx) => (
|
||||
<div key={idx} className="p-2 bg-gray-50 rounded text-sm">
|
||||
<p className="font-medium">{line.description || '-'}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Qté: {line.quantity || '-'} | PU: {line.unitPrice ? formatCurrency(line.unitPrice) : '-'} | HT: {line.amountHT ? formatCurrency(line.amountHT) : '-'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
frontend/src/pages/PurchaseOrderList.tsx
Normal file
188
frontend/src/pages/PurchaseOrderList.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { purchaseOrderAPI, supplierAPI } from '../services/api';
|
||||
import { PurchaseOrder, Supplier } from '../types';
|
||||
import { formatCurrency, formatDate, poStatusLabels, poStatusColors } from '../utils/helpers';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function PurchaseOrderList() {
|
||||
const [orders, setOrders] = useState<PurchaseOrder[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||
const [pagination, setPagination] = useState({ total: 0, page: 1, limit: 20, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
orderNumber: '', supplierId: '', supplierName: '', orderDate: new Date().toISOString().slice(0, 10),
|
||||
expectedDeliveryDate: '', amountHT: '', amountTVA: '', amountTTC: '', description: '', notes: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadOrders();
|
||||
loadSuppliers();
|
||||
}, []);
|
||||
|
||||
const loadOrders = async () => {
|
||||
try {
|
||||
const res = await purchaseOrderAPI.list({ search: search || undefined });
|
||||
setOrders(res.data.data);
|
||||
setPagination(res.data.pagination);
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSuppliers = async () => {
|
||||
try {
|
||||
const res = await supplierAPI.list({ active: 'true' });
|
||||
setSuppliers(res.data);
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
loadOrders();
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form.orderNumber || !form.orderDate) {
|
||||
toast.error('Numéro et date requis');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const selectedSupplier = suppliers.find(s => s.id === parseInt(form.supplierId));
|
||||
await purchaseOrderAPI.create({
|
||||
...form,
|
||||
supplierId: form.supplierId ? parseInt(form.supplierId) : null,
|
||||
supplierName: selectedSupplier?.name || form.supplierName,
|
||||
});
|
||||
toast.success('Bon de commande créé');
|
||||
setShowModal(false);
|
||||
loadOrders();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Erreur');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Bons de commande</h1>
|
||||
<button onClick={() => { setForm({ orderNumber: '', supplierId: '', supplierName: '', orderDate: new Date().toISOString().slice(0, 10), expectedDeliveryDate: '', amountHT: '', amountTVA: '', amountTTC: '', description: '', notes: '' }); setShowModal(true); }} className="btn-primary">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Nouveau BC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<form onSubmit={handleSearch} className="flex gap-4">
|
||||
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Rechercher..." className="input-field flex-1" />
|
||||
<button type="submit" className="btn-primary">Rechercher</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">N° Commande</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Fournisseur</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Date</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Montant TTC</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Statut</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-gray-500">Chargement...</td></tr>
|
||||
) : orders.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-gray-500">Aucun bon de commande</td></tr>
|
||||
) : orders.map((po) => (
|
||||
<tr key={po.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<Link to={`/purchase-orders/${po.id}`} className="text-sm font-medium text-santinova-600 hover:text-santinova-700">
|
||||
{po.orderNumber}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-900">{po.supplierName || '-'}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{formatDate(po.orderDate)}</td>
|
||||
<td className="px-6 py-4 text-right text-sm font-medium text-gray-900">{formatCurrency(po.amountTTC)}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`badge ${poStatusColors[po.status] || 'bg-gray-100 text-gray-800'}`}>
|
||||
{poStatusLabels[po.status] || po.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<Link to={`/purchase-orders/${po.id}`} className="text-santinova-600 hover:text-santinova-700 text-sm">Détails</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold">Nouveau bon de commande</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">N° Commande *</label>
|
||||
<input type="text" value={form.orderNumber} onChange={(e) => setForm({ ...form, orderNumber: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Fournisseur</label>
|
||||
<select value={form.supplierId} onChange={(e) => setForm({ ...form, supplierId: e.target.value })} className="input-field">
|
||||
<option value="">Sélectionner...</option>
|
||||
{suppliers.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Date commande *</label>
|
||||
<input type="date" value={form.orderDate} onChange={(e) => setForm({ ...form, orderDate: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Date livraison prévue</label>
|
||||
<input type="date" value={form.expectedDeliveryDate} onChange={(e) => setForm({ ...form, expectedDeliveryDate: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Montant HT</label>
|
||||
<input type="number" step="0.01" value={form.amountHT} onChange={(e) => setForm({ ...form, amountHT: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">TVA</label>
|
||||
<input type="number" step="0.01" value={form.amountTVA} onChange={(e) => setForm({ ...form, amountTVA: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Montant TTC</label>
|
||||
<input type="number" step="0.01" value={form.amountTTC} onChange={(e) => setForm({ ...form, amountTTC: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className="input-field" rows={2} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button onClick={() => setShowModal(false)} className="btn-secondary">Annuler</button>
|
||||
<button onClick={handleCreate} className="btn-primary">Créer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
frontend/src/pages/SupplierList.tsx
Normal file
219
frontend/src/pages/SupplierList.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { supplierAPI } from '../services/api';
|
||||
import { Supplier } from '../types';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function SupplierList() {
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '', siret: '', address: '', city: '', postalCode: '', country: 'France',
|
||||
email: '', phone: '', contactName: '', iban: '', notes: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadSuppliers();
|
||||
}, []);
|
||||
|
||||
const loadSuppliers = async () => {
|
||||
try {
|
||||
const res = await supplierAPI.list({ search: search || undefined });
|
||||
setSuppliers(res.data);
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
loadSuppliers();
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingSupplier(null);
|
||||
setForm({ name: '', siret: '', address: '', city: '', postalCode: '', country: 'France', email: '', phone: '', contactName: '', iban: '', notes: '' });
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEdit = (supplier: Supplier) => {
|
||||
setEditingSupplier(supplier);
|
||||
setForm({
|
||||
name: supplier.name || '',
|
||||
siret: supplier.siret || '',
|
||||
address: supplier.address || '',
|
||||
city: supplier.city || '',
|
||||
postalCode: supplier.postalCode || '',
|
||||
country: supplier.country || 'France',
|
||||
email: supplier.email || '',
|
||||
phone: supplier.phone || '',
|
||||
contactName: supplier.contactName || '',
|
||||
iban: supplier.iban || '',
|
||||
notes: supplier.notes || '',
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name) {
|
||||
toast.error('Le nom est requis');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (editingSupplier) {
|
||||
await supplierAPI.update(editingSupplier.id, form);
|
||||
toast.success('Fournisseur mis à jour');
|
||||
} else {
|
||||
await supplierAPI.create(form);
|
||||
toast.success('Fournisseur créé');
|
||||
}
|
||||
setShowModal(false);
|
||||
loadSuppliers();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Erreur');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Désactiver ce fournisseur ?')) return;
|
||||
try {
|
||||
await supplierAPI.delete(id);
|
||||
toast.success('Fournisseur désactivé');
|
||||
loadSuppliers();
|
||||
} catch (error: any) {
|
||||
toast.error('Erreur');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Fournisseurs</h1>
|
||||
<button onClick={openCreate} className="btn-primary">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Nouveau fournisseur
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<form onSubmit={handleSearch} className="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Rechercher un fournisseur..."
|
||||
className="input-field flex-1"
|
||||
/>
|
||||
<button type="submit" className="btn-primary">Rechercher</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Nom</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">SIRET</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Ville</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Email</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Téléphone</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Statut</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} className="px-6 py-12 text-center text-gray-500">Chargement...</td></tr>
|
||||
) : suppliers.length === 0 ? (
|
||||
<tr><td colSpan={7} className="px-6 py-12 text-center text-gray-500">Aucun fournisseur</td></tr>
|
||||
) : suppliers.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">{s.name}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{s.siret || '-'}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{s.city || '-'}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{s.email || '-'}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{s.phone || '-'}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`badge ${s.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{s.isActive ? 'Actif' : 'Inactif'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button onClick={() => openEdit(s)} className="text-santinova-600 hover:text-santinova-700 text-sm mr-3">Modifier</button>
|
||||
<button onClick={() => handleDelete(s.id)} className="text-red-600 hover:text-red-700 text-sm">Désactiver</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold">{editingSupplier ? 'Modifier le fournisseur' : 'Nouveau fournisseur'}</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nom *</label>
|
||||
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">SIRET</label>
|
||||
<input type="text" value={form.siret} onChange={(e) => setForm({ ...form, siret: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||
<input type="text" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ville</label>
|
||||
<input type="text" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Code postal</label>
|
||||
<input type="text" value={form.postalCode} onChange={(e) => setForm({ ...form, postalCode: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Téléphone</label>
|
||||
<input type="text" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Contact</label>
|
||||
<input type="text" value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">IBAN</label>
|
||||
<input type="text" value={form.iban} onChange={(e) => setForm({ ...form, iban: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||
<textarea value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} className="input-field" rows={2} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button onClick={() => setShowModal(false)} className="btn-secondary">Annuler</button>
|
||||
<button onClick={handleSave} className="btn-primary">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
194
frontend/src/pages/UserList.tsx
Normal file
194
frontend/src/pages/UserList.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { authAPI } from '../services/api';
|
||||
import { User } from '../types';
|
||||
import { roleLabels } from '../utils/helpers';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function UserList() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
email: '', password: '', firstName: '', lastName: '', role: 'operateur',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const res = await authAPI.getUsers();
|
||||
setUsers(res.data);
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingUser(null);
|
||||
setForm({ email: '', password: '', firstName: '', lastName: '', role: 'operateur' });
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEdit = (user: User) => {
|
||||
setEditingUser(user);
|
||||
setForm({
|
||||
email: user.email,
|
||||
password: '',
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.email || !form.firstName || !form.lastName) {
|
||||
toast.error('Tous les champs sont requis');
|
||||
return;
|
||||
}
|
||||
if (!editingUser && !form.password) {
|
||||
toast.error('Le mot de passe est requis');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (editingUser) {
|
||||
await authAPI.updateUser(editingUser.id, {
|
||||
email: form.email,
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
role: form.role,
|
||||
isActive: true,
|
||||
password: form.password || undefined,
|
||||
});
|
||||
toast.success('Utilisateur mis à jour');
|
||||
} else {
|
||||
await authAPI.createUser(form);
|
||||
toast.success('Utilisateur créé');
|
||||
}
|
||||
setShowModal(false);
|
||||
loadUsers();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Erreur');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async (id: number) => {
|
||||
if (!confirm('Désactiver cet utilisateur ?')) return;
|
||||
try {
|
||||
await authAPI.deleteUser(id);
|
||||
toast.success('Utilisateur désactivé');
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
toast.error('Erreur');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Gestion des utilisateurs</h1>
|
||||
<button onClick={openCreate} className="btn-primary">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Nouvel utilisateur
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Nom</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Email</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Rôle</th>
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Statut</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-6 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{loading ? (
|
||||
<tr><td colSpan={5} className="px-6 py-12 text-center text-gray-500">Chargement...</td></tr>
|
||||
) : users.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-santinova-100 rounded-full flex items-center justify-center text-santinova-600 text-sm font-medium">
|
||||
{u.firstName?.[0]}{u.lastName?.[0]}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900">{u.firstName} {u.lastName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{u.email}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="badge bg-santinova-100 text-santinova-800">{roleLabels[u.role] || u.role}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`badge ${u.isActive !== false ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{u.isActive !== false ? 'Actif' : 'Inactif'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button onClick={() => openEdit(u)} className="text-santinova-600 hover:text-santinova-700 text-sm mr-3">Modifier</button>
|
||||
<button onClick={() => handleDeactivate(u.id)} className="text-red-600 hover:text-red-700 text-sm">Désactiver</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold">{editingUser ? 'Modifier l\'utilisateur' : 'Nouvel utilisateur'}</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Prénom *</label>
|
||||
<input type="text" value={form.firstName} onChange={(e) => setForm({ ...form, firstName: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nom *</label>
|
||||
<input type="text" value={form.lastName} onChange={(e) => setForm({ ...form, lastName: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
|
||||
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Mot de passe {editingUser ? '(laisser vide pour ne pas changer)' : '*'}
|
||||
</label>
|
||||
<input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} className="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rôle</label>
|
||||
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })} className="input-field">
|
||||
{Object.entries(roleLabels).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button onClick={() => setShowModal(false)} className="btn-secondary">Annuler</button>
|
||||
<button onClick={handleSave} className="btn-primary">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user