Checkpoint: Ajout d'un tableau de bord détaillé avec statistiques avancées et graphiques.
Nouvelles fonctionnalités : - Cartes de statistiques : Factures traitées, Montant total, Score moyen, Dernière activité - Graphique en barres : Évolution des factures par semaine (30 derniers jours) - Graphique circulaire : Top 10 fournisseurs avec pourcentages - Liste détaillée : Tous les fournisseurs avec nombre de factures et montants - Bouton "Voir les statistiques détaillées" pour afficher/masquer les graphiques - Calculs automatiques : montant moyen par facture, score de qualité avec label (Excellent/Bon/Moyen/Faible) Le dashboard utilise Recharts pour les visualisations et offre une vue complète de l'activité de traitement des factures.
This commit is contained in:
@@ -1,117 +1,262 @@
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { FileText, CheckCircle, Clock, AlertCircle } from "lucide-react";
|
||||
import { FileText, DollarSign, TrendingUp, Clock } from "lucide-react";
|
||||
import { BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
|
||||
const COLORS = [
|
||||
'#3b82f6', // blue
|
||||
'#8b5cf6', // purple
|
||||
'#ec4899', // pink
|
||||
'#f59e0b', // amber
|
||||
'#10b981', // emerald
|
||||
'#06b6d4', // cyan
|
||||
'#f97316', // orange
|
||||
'#6366f1', // indigo
|
||||
'#14b8a6', // teal
|
||||
'#a855f7', // violet
|
||||
];
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: stats, isLoading } = trpc.invoices.getStats.useQuery();
|
||||
const { data: invoices } = trpc.invoices.list.useQuery();
|
||||
const [showDetailedStats, setShowDetailedStats] = useState(false);
|
||||
|
||||
const recentInvoices = invoices?.slice(0, 5) || [];
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-muted-foreground">Chargement des statistiques...</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date | string | null) => {
|
||||
if (!date) return 'N/A';
|
||||
return new Date(date).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getScoreLabel = (score: number) => {
|
||||
if (score >= 90) return 'Excellent';
|
||||
if (score >= 75) return 'Bon';
|
||||
if (score >= 60) return 'Moyen';
|
||||
return 'Faible';
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 90) return 'text-green-600';
|
||||
if (score >= 75) return 'text-blue-600';
|
||||
if (score >= 60) return 'text-yellow-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Tableau de bord</h1>
|
||||
<p className="text-gray-500 mt-1">Vue d'ensemble de vos factures</p>
|
||||
<p className="text-muted-foreground">Activité des 30 derniers jours</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDetailedStats(!showDetailedStats)}
|
||||
>
|
||||
{showDetailedStats ? 'Masquer' : 'Voir les statistiques détaillées'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">Total</CardTitle>
|
||||
<FileText className="w-4 h-4 text-gray-400" />
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Factures traitées</CardTitle>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats?.total || 0}</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Factures au total</p>
|
||||
<div className="text-2xl font-bold">{stats?.completed || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats?.completed || 0} complétées, {stats?.error || 0} en erreur
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">Complétées</CardTitle>
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Montant total</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">{stats?.completed || 0}</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Extraction réussie</p>
|
||||
<div className="text-2xl font-bold">{formatCurrency(stats?.totalAmount || 0)}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Moyenne : {stats?.completed ? formatCurrency((stats.totalAmount || 0) / stats.completed) : '0 €'} / facture
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">En cours</CardTitle>
|
||||
<Clock className="w-4 h-4 text-blue-500" />
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Score moyen</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">{stats?.processing || 0}</div>
|
||||
<p className="text-xs text-gray-500 mt-1">En traitement</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`text-2xl font-bold ${getScoreColor(stats?.averageScore || 0)}`}>
|
||||
{stats?.averageScore || 0}
|
||||
</div>
|
||||
<div className={`text-sm font-medium ${getScoreColor(stats?.averageScore || 0)}`}>
|
||||
{getScoreLabel(stats?.averageScore || 0)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Qualité d'extraction</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">Erreurs</CardTitle>
|
||||
<AlertCircle className="w-4 h-4 text-red-500" />
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Dernière activité</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">{stats?.error || 0}</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Échecs d'extraction</p>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats?.lastActivity
|
||||
? new Date(stats.lastActivity).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })
|
||||
: 'N/A'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats?.lastActivity
|
||||
? new Date(stats.lastActivity).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
|
||||
: ''}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent invoices */}
|
||||
{/* Detailed Stats */}
|
||||
{showDetailedStats && (
|
||||
<>
|
||||
{/* Charts Row */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Weekly Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Factures récentes</CardTitle>
|
||||
<CardDescription>Les 5 dernières factures importées</CardDescription>
|
||||
<CardTitle>Factures par semaine</CardTitle>
|
||||
<CardDescription>
|
||||
Nombre de factures traitées chaque semaine (30 derniers jours)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentInvoices.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>Aucune facture pour le moment</p>
|
||||
<p className="text-sm mt-1">Commencez par importer un fichier PDF</p>
|
||||
</div>
|
||||
{stats?.weeklyData && stats.weeklyData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={stats.weeklyData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="week" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" fill="#3b82f6" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentInvoices.map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className="flex items-center justify-between p-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{invoice.supplierName || "Fournisseur inconnu"}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{invoice.invoiceNumber || "N° inconnu"} • {invoice.invoiceDate ? new Date(invoice.invoiceDate).toLocaleDateString("fr-FR") : "Date inconnue"}
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
Aucune donnée disponible
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Suppliers Pie Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top 10 fournisseurs</CardTitle>
|
||||
<CardDescription>
|
||||
Répartition des factures par fournisseur (30 derniers jours)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats?.topSuppliers && stats.topSuppliers.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={stats.topSuppliers}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={(entry) => `${entry.name}: ${entry.percentage.toFixed(0)}%`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="count"
|
||||
>
|
||||
{stats.topSuppliers.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
Aucune donnée disponible
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Suppliers List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Détail par fournisseur</CardTitle>
|
||||
<CardDescription>Liste des fournisseurs (30 derniers jours)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats?.suppliersList && stats.suppliersList.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{stats.suppliersList.map((supplier, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 rounded-lg border hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: COLORS[index % COLORS.length] }}
|
||||
/>
|
||||
<span className="font-medium">{supplier.name}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold">
|
||||
{invoice.totalAmount ? `${parseFloat(invoice.totalAmount).toFixed(2)} €` : "-"}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{invoice.status === "completed" && (
|
||||
<span className="text-green-600">Complété</span>
|
||||
)}
|
||||
{invoice.status === "processing" && (
|
||||
<span className="text-blue-600">En cours</span>
|
||||
)}
|
||||
{invoice.status === "error" && (
|
||||
<span className="text-red-600">Erreur</span>
|
||||
)}
|
||||
<div className="font-semibold">{supplier.count} factures</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatCurrency(supplier.amount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucun fournisseur trouvé
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
|
||||
87
server/db.ts
87
server/db.ts
@@ -252,14 +252,97 @@ export async function searchInvoices(userId: number, query: string): Promise<Inv
|
||||
|
||||
export async function getInvoiceStats(userId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return { total: 0, completed: 0, processing: 0, error: 0 };
|
||||
if (!db) return {
|
||||
total: 0,
|
||||
completed: 0,
|
||||
processing: 0,
|
||||
error: 0,
|
||||
totalAmount: 0,
|
||||
averageScore: 0,
|
||||
lastActivity: null,
|
||||
weeklyData: [],
|
||||
topSuppliers: [],
|
||||
suppliersList: []
|
||||
};
|
||||
|
||||
const allInvoices = await getInvoicesByUserId(userId);
|
||||
|
||||
// Calculate basic stats
|
||||
const completed = allInvoices.filter(i => i.status === "completed");
|
||||
const totalAmount = completed.reduce((sum, inv) => sum + (Number(inv.totalAmount) || 0), 0);
|
||||
const averageScore = completed.length > 0
|
||||
? completed.reduce((sum, inv) => sum + (inv.qualityScore || 0), 0) / completed.length
|
||||
: 0;
|
||||
|
||||
// Get last activity
|
||||
const sortedByDate = [...allInvoices].sort((a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
const lastActivity = sortedByDate[0]?.createdAt || null;
|
||||
|
||||
// Calculate weekly data (last 30 days)
|
||||
const now = new Date();
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const weeklyMap = new Map<string, number>();
|
||||
|
||||
allInvoices.forEach(inv => {
|
||||
const date = new Date(inv.createdAt);
|
||||
if (date >= thirtyDaysAgo) {
|
||||
const weekStart = new Date(date);
|
||||
weekStart.setDate(date.getDate() - date.getDay()); // Start of week
|
||||
const weekKey = `${weekStart.getDate().toString().padStart(2, '0')}/${(weekStart.getMonth() + 1).toString().padStart(2, '0')}`;
|
||||
weeklyMap.set(weekKey, (weeklyMap.get(weekKey) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
const weeklyData = Array.from(weeklyMap.entries())
|
||||
.map(([week, count]) => ({ week, count }))
|
||||
.sort((a, b) => {
|
||||
const [dayA, monthA] = a.week.split('/').map(Number);
|
||||
const [dayB, monthB] = b.week.split('/').map(Number);
|
||||
return (monthA! * 100 + dayA!) - (monthB! * 100 + dayB!);
|
||||
});
|
||||
|
||||
// Calculate top suppliers
|
||||
const supplierMap = new Map<string, { count: number; amount: number }>();
|
||||
completed.forEach(inv => {
|
||||
const supplier = inv.supplierName || 'Inconnu';
|
||||
const current = supplierMap.get(supplier) || { count: 0, amount: 0 };
|
||||
supplierMap.set(supplier, {
|
||||
count: current.count + 1,
|
||||
amount: current.amount + (Number(inv.totalAmount) || 0)
|
||||
});
|
||||
});
|
||||
|
||||
const topSuppliers = Array.from(supplierMap.entries())
|
||||
.map(([name, data]) => ({
|
||||
name,
|
||||
count: data.count,
|
||||
amount: data.amount,
|
||||
percentage: completed.length > 0 ? (data.count / completed.length * 100) : 0
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
const suppliersList = Array.from(supplierMap.entries())
|
||||
.map(([name, data]) => ({
|
||||
name,
|
||||
count: data.count,
|
||||
amount: data.amount
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return {
|
||||
total: allInvoices.length,
|
||||
completed: allInvoices.filter(i => i.status === "completed").length,
|
||||
completed: completed.length,
|
||||
processing: allInvoices.filter(i => i.status === "processing").length,
|
||||
error: allInvoices.filter(i => i.status === "error").length,
|
||||
totalAmount,
|
||||
averageScore: Math.round(averageScore),
|
||||
lastActivity,
|
||||
weeklyData,
|
||||
topSuppliers,
|
||||
suppliersList
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
8
todo.md
8
todo.md
@@ -66,3 +66,11 @@
|
||||
|
||||
- [x] Diagnostiquer le problème de connexion locale avec o.pareige@itinova.org
|
||||
- [x] Corriger l'authentification locale (ajout support cookie auth_token)
|
||||
|
||||
## Amélioration du Dashboard
|
||||
- [x] Créer route tRPC pour statistiques détaillées (factures par semaine, top fournisseurs)
|
||||
- [x] Créer cartes de statistiques (Factures traitées, Montant total, Score moyen, Dernière activité)
|
||||
- [x] Créer graphique en barres des factures par semaine (Recharts)
|
||||
- [x] Créer graphique circulaire du top 10 fournisseurs (Recharts)
|
||||
- [x] Créer liste détaillée des fournisseurs avec nombre de factures
|
||||
- [x] Ajouter bouton "Voir les statistiques détaillées"
|
||||
|
||||
Reference in New Issue
Block a user