Checkpoint: Modification de la page Factures avec sélection multiple et export PDF.

Nouvelles fonctionnalités :
- Ajout du champ exportStatus dans la base de données (exported/not_exported/export_error)
- Cases à cocher pour sélection multiple des factures
- Case "Tout sélectionner" (sélectionne uniquement les factures avec score 100%)
- Nouveau système de statuts : Exporté (vert), Non exporté (bleu), Erreur export (rouge)
- Bouton "Exporter" avec compteur de factures sélectionnées
- Validation stricte : seules les factures avec score de qualité 100% peuvent être exportées
- Les factures non éligibles (score < 100%) sont grisées et non sélectionnables
- Téléchargement automatique des PDFs des factures sélectionnées
- Message d'information expliquant la règle des 100%

La page affiche maintenant le statut d'export au lieu du statut de traitement, conformément aux spécifications.
This commit is contained in:
Manus
2026-01-08 09:17:29 -05:00
parent a237057b77
commit d24f7c5569
7 changed files with 1035 additions and 87 deletions

View File

@@ -1,9 +1,10 @@
import { useState } from "react";
import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Table,
TableBody,
@@ -13,23 +14,34 @@ import {
TableRow,
} from "@/components/ui/table";
import { trpc } from "@/lib/trpc";
import { Search, Eye, Trash2, FileText } from "lucide-react";
import { Search, FileText, Download } from "lucide-react";
import { toast } from "sonner";
import { useLocation } from "wouter";
export default function Invoices() {
const [, setLocation] = useLocation();
const [searchQuery, setSearchQuery] = useState("");
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const { data: invoices, isLoading } = trpc.invoices.list.useQuery();
const utils = trpc.useUtils();
const deleteMutation = trpc.invoices.delete.useMutation({
onSuccess: () => {
toast.success("Facture supprimée");
const exportMutation = trpc.sftp.exportToPdf.useMutation({
onSuccess: (data) => {
toast.success(`${data.invoices.length} facture(s) exportée(s) avec succès`);
// Download PDFs
data.invoices.forEach(inv => {
const link = document.createElement('a');
link.href = inv.fileUrl;
link.download = `${inv.supplierName || 'facture'}_${inv.invoiceNumber || 'unknown'}.pdf`;
link.click();
});
setSelectedIds([]);
utils.invoices.list.invalidate();
},
onError: (error) => {
toast.error(error.message || "Erreur lors de la suppression");
toast.error(error.message || "Erreur lors de l'export");
},
});
@@ -42,20 +54,43 @@ export default function Invoices() {
);
});
const handleDelete = (id: number) => {
if (confirm("Êtes-vous sûr de vouloir supprimer cette facture ?")) {
deleteMutation.mutate({ id });
const handleSelectAll = (checked: boolean) => {
if (checked) {
// Select only invoices with score 100
const eligibleIds = (filteredInvoices || [])
.filter(inv => (inv.qualityScore || 0) === 100)
.map(inv => inv.id);
setSelectedIds(eligibleIds);
} else {
setSelectedIds([]);
}
};
const getStatusBadge = (status: string) => {
const handleSelectOne = (id: number, checked: boolean) => {
if (checked) {
setSelectedIds([...selectedIds, id]);
} else {
setSelectedIds(selectedIds.filter(selectedId => selectedId !== id));
}
};
const handleExport = () => {
if (selectedIds.length === 0) {
toast.error("Veuillez sélectionner au moins une facture");
return;
}
exportMutation.mutate({ invoiceIds: selectedIds });
};
const getExportStatusBadge = (status: string) => {
switch (status) {
case "completed":
return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">Complé</Badge>;
case "processing":
return <Badge className="bg-blue-100 text-blue-800 hover:bg-blue-100">En cours</Badge>;
case "error":
return <Badge className="bg-red-100 text-red-800 hover:bg-red-100">Erreur</Badge>;
case "exported":
return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">Expor</Badge>;
case "not_exported":
return <Badge className="bg-blue-100 text-blue-800 hover:bg-blue-100">Non exporté</Badge>;
case "export_error":
return <Badge className="bg-red-100 text-red-800 hover:bg-red-100">Erreur export</Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
@@ -63,11 +98,18 @@ export default function Invoices() {
const getQualityBadge = (score: number | null) => {
if (score === null) return <Badge variant="outline">-</Badge>;
if (score >= 80) return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">{score}</Badge>;
if (score >= 60) return <Badge className="bg-yellow-100 text-yellow-800 hover:bg-yellow-100">{score}</Badge>;
return <Badge className="bg-red-100 text-red-800 hover:bg-red-100">{score}</Badge>;
if (score === 100) return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">{score}%</Badge>;
if (score >= 80) return <Badge className="bg-yellow-100 text-yellow-800 hover:bg-yellow-100">{score}%</Badge>;
return <Badge className="bg-red-100 text-red-800 hover:bg-red-100">{score}%</Badge>;
};
const isEligibleForExport = (invoice: any) => {
return (invoice.qualityScore || 0) === 100;
};
const allEligibleSelected = (filteredInvoices?.length || 0) > 0 &&
(filteredInvoices || []).filter(inv => isEligibleForExport(inv)).every(inv => selectedIds.includes(inv.id));
return (
<DashboardLayout>
<div className="space-y-6">
@@ -76,46 +118,71 @@ export default function Invoices() {
<h1 className="text-3xl font-bold">Factures</h1>
<p className="text-gray-500 mt-1">Gérez toutes vos factures importées</p>
</div>
<div className="flex gap-2">
<Button
onClick={handleExport}
disabled={selectedIds.length === 0 || exportMutation.isPending}
className="bg-blue-600 hover:bg-blue-700"
>
<Download className="w-4 h-4 mr-2" />
Exporter ({selectedIds.length})
</Button>
<Button onClick={() => setLocation("/upload")}>
<FileText className="w-4 h-4 mr-2" />
Importer
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Liste des factures</CardTitle>
<CardDescription>
<div className="flex items-center gap-2 mt-2">
<Search className="w-4 h-4 text-gray-400" />
<CardContent className="pt-6">
{/* Search */}
<div className="mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Rechercher par fournisseur ou numéro..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="max-w-md"
className="pl-10"
/>
</div>
</CardDescription>
</CardHeader>
<CardContent>
</div>
{/* Table */}
{isLoading ? (
<div className="text-center py-8 text-gray-500">Chargement...</div>
) : filteredInvoices && filteredInvoices.length > 0 ? (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={allEligibleSelected}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead>Fournisseur</TableHead>
<TableHead>N° Facture</TableHead>
<TableHead>Date</TableHead>
<TableHead>Montant</TableHead>
<TableHead>Score</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredInvoices.map((invoice) => (
<TableRow key={invoice.id}>
{filteredInvoices.map((invoice) => {
const eligible = isEligibleForExport(invoice);
return (
<TableRow key={invoice.id} className={!eligible ? "opacity-50" : ""}>
<TableCell>
<Checkbox
checked={selectedIds.includes(invoice.id)}
onCheckedChange={(checked) => handleSelectOne(invoice.id, checked as boolean)}
disabled={!eligible}
/>
</TableCell>
<TableCell className="font-medium">
{invoice.supplierName || "Inconnu"}
</TableCell>
@@ -127,42 +194,38 @@ export default function Invoices() {
</TableCell>
<TableCell>
{invoice.totalAmount
? `${parseFloat(invoice.totalAmount).toFixed(2)}`
? `${parseFloat(invoice.totalAmount as string).toFixed(2)}`
: "-"}
</TableCell>
<TableCell>{getQualityBadge(invoice.qualityScore)}</TableCell>
<TableCell>{getStatusBadge(invoice.status)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => setLocation(`/invoices/${invoice.id}`)}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(invoice.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
</div>
</TableCell>
<TableCell>{getExportStatusBadge(invoice.exportStatus || "not_exported")}</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<div className="text-center py-12">
<FileText className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p>Aucune facture trouvée</p>
<p className="text-gray-500">Aucune facture trouvée</p>
<p className="text-sm text-gray-400 mt-1">
{searchQuery ? "Essayez une autre recherche" : "Commencez par importer un fichier PDF"}
</p>
</div>
)}
</CardContent>
</Card>
{/* Info message */}
{filteredInvoices && filteredInvoices.some(inv => (inv.qualityScore || 0) < 100) && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800">
<strong>Note :</strong> Seules les factures avec un score de qualité de 100% peuvent être exportées.
Les factures avec un score inférieur sont grisées et ne peuvent pas être sélectionnées.
</p>
</div>
)}
</div>
</DashboardLayout>
);

View File

@@ -0,0 +1 @@
ALTER TABLE `invoices` ADD `exportStatus` enum('not_exported','exported','export_error') DEFAULT 'not_exported' NOT NULL;

View File

@@ -0,0 +1,819 @@
{
"version": "5",
"dialect": "mysql",
"id": "5a495536-d69b-4bd9-9618-0644c5a001bd",
"prevId": "82b3cbc0-6925-4f7c-b3c7-0a1c0e96f34d",
"tables": {
"importLogs": {
"name": "importLogs",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"userId": {
"name": "userId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sourceFileId": {
"name": "sourceFileId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fileName": {
"name": "fileName",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"totalInvoicesDetected": {
"name": "totalInvoicesDetected",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"invoicesImported": {
"name": "invoicesImported",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"duplicatesIgnored": {
"name": "duplicatesIgnored",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"errors": {
"name": "errors",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"duplicateDetails": {
"name": "duplicateDetails",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"errorDetails": {
"name": "errorDetails",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"importedAt": {
"name": "importedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"importLogs_id": {
"name": "importLogs_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"invoices": {
"name": "invoices",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"userId": {
"name": "userId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sourceFileId": {
"name": "sourceFileId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"invoiceIndexInFile": {
"name": "invoiceIndexInFile",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"fileName": {
"name": "fileName",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fileKey": {
"name": "fileKey",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fileUrl": {
"name": "fileUrl",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"supplierName": {
"name": "supplierName",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"invoiceNumber": {
"name": "invoiceNumber",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"invoiceDate": {
"name": "invoiceDate",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"deliveryNoteNumber": {
"name": "deliveryNoteNumber",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"orderNumber": {
"name": "orderNumber",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"totalAmount": {
"name": "totalAmount",
"type": "decimal(10,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pageRange": {
"name": "pageRange",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"qualityScore": {
"name": "qualityScore",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"metadataFileKey": {
"name": "metadataFileKey",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"metadataFileUrl": {
"name": "metadataFileUrl",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('processing','completed','error')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'processing'"
},
"errorMessage": {
"name": "errorMessage",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exportStatus": {
"name": "exportStatus",
"type": "enum('not_exported','exported','export_error')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'not_exported'"
},
"manuallyEdited": {
"name": "manuallyEdited",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"exportedAt": {
"name": "exportedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exportMode": {
"name": "exportMode",
"type": "enum('manual','automatic')",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {
"supplier_invoice_date_unique": {
"name": "supplier_invoice_date_unique",
"columns": [
"supplierName",
"invoiceNumber",
"invoiceDate"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"invoices_id": {
"name": "invoices_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"llmLogs": {
"name": "llmLogs",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"userId": {
"name": "userId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sourceFileId": {
"name": "sourceFileId",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"invoiceId": {
"name": "invoiceId",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"operation": {
"name": "operation",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model": {
"name": "model",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"promptSent": {
"name": "promptSent",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rawResponse": {
"name": "rawResponse",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cleanedResponse": {
"name": "cleanedResponse",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"success": {
"name": "success",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"errorMessage": {
"name": "errorMessage",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"processingTimeMs": {
"name": "processingTimeMs",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pageRange": {
"name": "pageRange",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"llmLogs_id": {
"name": "llmLogs_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"sourceFiles": {
"name": "sourceFiles",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"userId": {
"name": "userId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fileName": {
"name": "fileName",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fileKey": {
"name": "fileKey",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fileUrl": {
"name": "fileUrl",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"totalInvoicesDetected": {
"name": "totalInvoicesDetected",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"processingStatus": {
"name": "processingStatus",
"type": "enum('processing','completed','error')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'processing'"
},
"processingProgress": {
"name": "processingProgress",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"sourceFiles_id": {
"name": "sourceFiles_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"userSettings": {
"name": "userSettings",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"userId": {
"name": "userId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"llmModel": {
"name": "llmModel",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'mistral-large-latest'"
},
"orderNumberFormat": {
"name": "orderNumberFormat",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"invoiceNumberKeywords": {
"name": "invoiceNumberKeywords",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"deliveryNoteKeywords": {
"name": "deliveryNoteKeywords",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"orderNumberKeywords": {
"name": "orderNumberKeywords",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supplierKeywords": {
"name": "supplierKeywords",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"totalAmountKeywords": {
"name": "totalAmountKeywords",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sftpHost": {
"name": "sftpHost",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sftpPort": {
"name": "sftpPort",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 22
},
"sftpUsername": {
"name": "sftpUsername",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sftpPassword": {
"name": "sftpPassword",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sftpRemotePath": {
"name": "sftpRemotePath",
"type": "varchar(500)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'/'"
},
"sftpAutoExport": {
"name": "sftpAutoExport",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"llmLogsRetentionMonths": {
"name": "llmLogsRetentionMonths",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 3
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"userSettings_id": {
"name": "userSettings_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"userSettings_userId_unique": {
"name": "userSettings_userId_unique",
"columns": [
"userId"
]
}
},
"checkConstraint": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"openId": {
"name": "openId",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"azureAdId": {
"name": "azureAdId",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"passwordHash": {
"name": "passwordHash",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"loginMethod": {
"name": "loginMethod",
"type": "enum('manus','local','azure-ad')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('user','admin')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"isActive": {
"name": "isActive",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"lastSignedIn": {
"name": "lastSignedIn",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_openId_unique": {
"name": "users_openId_unique",
"columns": [
"openId"
]
},
"users_azureAdId_unique": {
"name": "users_azureAdId_unique",
"columns": [
"azureAdId"
]
},
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View File

@@ -15,6 +15,13 @@
"when": 1767869488758,
"tag": "0001_breezy_the_spike",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1767881720296,
"tag": "0002_striped_red_skull",
"breakpoints": true
}
]
}

View File

@@ -78,6 +78,9 @@ export const invoices = mysqlTable("invoices", {
status: mysqlEnum("status", ["processing", "completed", "error"]).default("processing").notNull(),
errorMessage: text("errorMessage"),
// Export status
exportStatus: mysqlEnum("exportStatus", ["not_exported", "exported", "export_error"]).default("not_exported").notNull(),
// Manual correction tracking
manuallyEdited: int("manuallyEdited").default(0).notNull(), // 0 = false, 1 = true

View File

@@ -424,6 +424,49 @@ export const appRouter = router({
return { success };
}),
exportToPdf: protectedProcedure
.input(z.object({ invoiceIds: z.array(z.number()) }))
.mutation(async ({ input, ctx }) => {
// Validate that all invoices have quality score of 100
const invoices = await Promise.all(
input.invoiceIds.map(id => getInvoiceById(id))
);
const invalidInvoices = invoices.filter(
inv => !inv || inv.userId !== ctx.user.id || (inv.qualityScore || 0) < 100
);
if (invalidInvoices.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Toutes les factures doivent avoir un score de qualit\u00e9 de 100% pour \u00eatre export\u00e9es"
});
}
// Update export status
for (const invoice of invoices) {
if (invoice) {
await updateInvoice(invoice.id, {
exportStatus: "exported",
exportedAt: new Date(),
exportMode: "manual",
});
}
}
// Return the file URLs for PDF generation on client side
return {
success: true,
invoices: invoices.filter(Boolean).map(inv => ({
id: inv!.id,
fileUrl: inv!.fileUrl,
supplierName: inv!.supplierName,
invoiceNumber: inv!.invoiceNumber,
invoiceDate: inv!.invoiceDate,
}))
};
}),
exportInvoices: protectedProcedure
.input(z.object({ invoiceIds: z.array(z.number()) }))
.mutation(async ({ input, ctx }) => {

12
todo.md
View File

@@ -74,3 +74,15 @@
- [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"
## Modification page Factures - Export PDF
- [x] Ajouter champ exportStatus dans le schéma invoices (exported/not_exported/export_error)
- [x] Ajouter champ exportedAt dans le schéma invoices (déjà existant)
- [x] Créer route tRPC pour exporter les factures en PDF
- [x] Créer route tRPC pour mettre à jour le statut d'export
- [x] Ajouter cases à cocher pour sélection multiple dans la page Factures
- [x] Ajouter case "Tout sélectionner"
- [x] Modifier l'affichage des statuts (Exporté/Non exporté/Erreur export)
- [x] Ajouter bouton "Exporter" (actif uniquement pour score 100%)
- [x] Implémenter le téléchargement de PDF pour les factures sélectionnées
- [x] Valider que seules les factures avec score 100% peuvent être exportées