From 4a8be36d008413c9b82ac48f043f29862b6d302a Mon Sep 17 00:00:00 2001 From: Manus Date: Wed, 11 Feb 2026 08:03:30 -0500 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20Ajout=20de=203=20champs=20m=C3=A9?= =?UTF-8?q?tier=20aux=20factures=20(Service=20concern=C3=A9,=20Type=20d'ac?= =?UTF-8?q?hat,=20Ventilation=20comptable)=20avec=20interface=20d'administ?= =?UTF-8?q?ration=20pour=20g=C3=A9rer=20les=20listes=20enrichissables.=20L?= =?UTF-8?q?es=20champs=20apparaissent=20dans=20la=20liste=20des=20factures?= =?UTF-8?q?=20et=20dans=20le=20formulaire=20d'=C3=A9dition.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.tsx | 2 + client/src/components/DashboardLayout.tsx | 7 +- client/src/pages/InvoiceDetail.tsx | 78 ++ client/src/pages/Invoices.tsx | 6 + client/src/pages/ListsAdmin.tsx | 262 +++++ drizzle/0004_white_the_hunter.sql | 21 + drizzle/meta/0004_snapshot.json | 1081 +++++++++++++++++++++ drizzle/meta/_journal.json | 7 + drizzle/schema.ts | 41 + server/db.ts | 77 +- server/routers.ts | 76 ++ todo.md | 11 + 12 files changed, 1665 insertions(+), 4 deletions(-) create mode 100644 client/src/pages/ListsAdmin.tsx create mode 100644 drizzle/0004_white_the_hunter.sql create mode 100644 drizzle/meta/0004_snapshot.json diff --git a/client/src/App.tsx b/client/src/App.tsx index b5cad5b..8227c19 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -14,6 +14,7 @@ import Settings from "./pages/Settings"; import ImportSettings from "./pages/ImportSettings"; import History from "./pages/History"; import Users from "./pages/Users"; +import ListsAdmin from "./pages/ListsAdmin"; function Router() { return ( @@ -28,6 +29,7 @@ function Router() { + diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index 7011e39..281a8e7 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -21,7 +21,7 @@ import { } from "@/components/ui/sidebar"; import { getLoginUrl } from "@/const"; import { useIsMobile } from "@/hooks/useMobile"; -import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download } from "lucide-react"; +import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List } from "lucide-react"; import { CSSProperties, useEffect, useRef, useState } from "react"; import { useLocation } from "wouter"; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; @@ -32,8 +32,9 @@ const menuItems = [ { icon: Upload, label: "Importer", path: "/upload" }, { icon: FileText, label: "Factures", path: "/invoices" }, { icon: History, label: "Historique", path: "/history" }, - { icon: Settings, label: "Param\u00e8tres", path: "/settings" }, - { icon: Download, label: "Param\u00e8tres de r\u00e9ception", path: "/import-settings" }, + { icon: Settings, label: "Paramètres", path: "/settings" }, + { icon: Download, label: "Paramètres de réception", path: "/import-settings" }, + { icon: List, label: "Administration des listes", path: "/lists-admin" }, { icon: Users, label: "Utilisateurs", path: "/users", adminOnly: true }, ]; diff --git a/client/src/pages/InvoiceDetail.tsx b/client/src/pages/InvoiceDetail.tsx index 8e3d679..db8b15e 100644 --- a/client/src/pages/InvoiceDetail.tsx +++ b/client/src/pages/InvoiceDetail.tsx @@ -26,6 +26,9 @@ export default function InvoiceDetail() { deliveryNoteNumber: "", orderNumber: "", totalAmount: "", + serviceConcerne: "", + typeAchat: "", + ventilationComptable: "", }); // Initialize form data when invoice loads @@ -40,6 +43,9 @@ export default function InvoiceDetail() { deliveryNoteNumber: invoice.deliveryNoteNumber || "", orderNumber: invoice.orderNumber || "", totalAmount: invoice.totalAmount ? invoice.totalAmount.toString() : "", + serviceConcerne: invoice.serviceConcerne || "", + typeAchat: invoice.typeAchat || "", + ventilationComptable: invoice.ventilationComptable || "", }); } }, [invoice]); @@ -79,6 +85,9 @@ export default function InvoiceDetail() { deliveryNoteNumber: formData.deliveryNoteNumber || undefined, orderNumber: formData.orderNumber || undefined, totalAmount: formData.totalAmount ? formData.totalAmount : undefined, + serviceConcerne: formData.serviceConcerne || undefined, + typeAchat: (formData.typeAchat as "CAPEX" | "OPEX" | "") || undefined, + ventilationComptable: formData.ventilationComptable || undefined, }, }); }; @@ -321,6 +330,75 @@ export default function InvoiceDetail() { + + {/* Business Fields Card */} + + + Champs métier + + Informations de classification comptable + + + +
+ + {isEditing ? ( + + ) : ( +
{invoice.serviceConcerne || "-"}
+ )} +
+ +
+ + {isEditing ? ( + + ) : ( +
{invoice.typeAchat || "-"}
+ )} +
+ +
+ + {isEditing ? ( + + ) : ( +
{invoice.ventilationComptable || "-"}
+ )} +
+
+
{/* Info Card */} diff --git a/client/src/pages/Invoices.tsx b/client/src/pages/Invoices.tsx index 8473fb1..5404a4b 100644 --- a/client/src/pages/Invoices.tsx +++ b/client/src/pages/Invoices.tsx @@ -304,6 +304,9 @@ export default function Invoices() { N° Facture Date Montant + Service + Type achat + Ventilation Score Statut @@ -337,6 +340,9 @@ export default function Invoices() { ? `${parseFloat(invoice.totalAmount as string).toFixed(2)} €` : "-"} + {invoice.serviceConcerne || "-"} + {invoice.typeAchat || "-"} + {invoice.ventilationComptable || "-"} {getQualityBadge(invoice.qualityScore)} {getExportStatusBadge(invoice.exportStatus || "not_exported")} diff --git a/client/src/pages/ListsAdmin.tsx b/client/src/pages/ListsAdmin.tsx new file mode 100644 index 0000000..9516409 --- /dev/null +++ b/client/src/pages/ListsAdmin.tsx @@ -0,0 +1,262 @@ +import { useState } from "react"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { Loader2, Plus, Trash2, Settings } from "lucide-react"; +import DashboardLayout from "@/components/DashboardLayout"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +export default function ListsAdmin() { + const { data: departments, isLoading: loadingDepartments } = trpc.departments.getByUser.useQuery(); + const { data: allocations, isLoading: loadingAllocations } = trpc.accountingAllocations.getByUser.useQuery(); + + const createDepartmentMutation = trpc.departments.create.useMutation(); + const deleteDepartmentMutation = trpc.departments.delete.useMutation(); + const createAllocationMutation = trpc.accountingAllocations.create.useMutation(); + const deleteAllocationMutation = trpc.accountingAllocations.delete.useMutation(); + + const utils = trpc.useUtils(); + + const [newDepartment, setNewDepartment] = useState(""); + const [newAllocation, setNewAllocation] = useState(""); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [itemToDelete, setItemToDelete] = useState<{ type: "department" | "allocation"; id: number; name: string } | null>(null); + + const handleCreateDepartment = async () => { + if (!newDepartment.trim()) { + toast.error("Erreur", { description: "Le nom ne peut pas être vide" }); + return; + } + + try { + await createDepartmentMutation.mutateAsync({ name: newDepartment.trim() }); + await utils.departments.getByUser.invalidate(); + setNewDepartment(""); + toast.success("Service ajouté", { description: `"${newDepartment}" a été ajouté à la liste` }); + } catch (error: any) { + toast.error("Erreur", { description: error.message || "Impossible d'ajouter le service" }); + } + }; + + const handleCreateAllocation = async () => { + if (!newAllocation.trim()) { + toast.error("Erreur", { description: "Le nom ne peut pas être vide" }); + return; + } + + try { + await createAllocationMutation.mutateAsync({ name: newAllocation.trim() }); + await utils.accountingAllocations.getByUser.invalidate(); + setNewAllocation(""); + toast.success("Ventilation ajoutée", { description: `"${newAllocation}" a été ajoutée à la liste` }); + } catch (error: any) { + toast.error("Erreur", { description: error.message || "Impossible d'ajouter la ventilation" }); + } + }; + + const confirmDelete = (type: "department" | "allocation", id: number, name: string) => { + setItemToDelete({ type, id, name }); + setDeleteDialogOpen(true); + }; + + const handleDelete = async () => { + if (!itemToDelete) return; + + try { + if (itemToDelete.type === "department") { + await deleteDepartmentMutation.mutateAsync({ id: itemToDelete.id }); + await utils.departments.getByUser.invalidate(); + } else { + await deleteAllocationMutation.mutateAsync({ id: itemToDelete.id }); + await utils.accountingAllocations.getByUser.invalidate(); + } + toast.success("Supprimé", { description: `"${itemToDelete.name}" a été supprimé` }); + } catch (error: any) { + toast.error("Erreur", { description: error.message || "Impossible de supprimer" }); + } finally { + setDeleteDialogOpen(false); + setItemToDelete(null); + } + }; + + if (loadingDepartments || loadingAllocations) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+
+

+ + Administration des listes +

+

+ Gérez les listes de valeurs pour les champs métier des factures +

+
+ + {/* Department List */} + + + Service concerné + + Gérez la liste des services disponibles pour la classification des factures + + + +
+
+ setNewDepartment(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleCreateDepartment(); + } + }} + /> +
+ +
+ +
+ {departments && departments.length > 0 ? ( + departments.map((dept) => ( +
+ {dept.name} + +
+ )) + ) : ( +

+ Aucun service configuré. Ajoutez-en un pour commencer. +

+ )} +
+
+
+ + {/* Accounting Allocation List */} + + + Ventilation comptable + + Gérez la liste des ventilations comptables disponibles pour la classification des factures + + + +
+
+ setNewAllocation(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleCreateAllocation(); + } + }} + /> +
+ +
+ +
+ {allocations && allocations.length > 0 ? ( + allocations.map((alloc) => ( +
+ {alloc.name} + +
+ )) + ) : ( +

+ Aucune ventilation configurée. Ajoutez-en une pour commencer. +

+ )} +
+
+
+
+ + {/* Delete Confirmation Dialog */} + + + + Confirmer la suppression + + Êtes-vous sûr de vouloir supprimer "{itemToDelete?.name}" ? + Cette action est irréversible. + + + + Annuler + + Supprimer + + + + +
+ ); +} diff --git a/drizzle/0004_white_the_hunter.sql b/drizzle/0004_white_the_hunter.sql new file mode 100644 index 0000000..a034d93 --- /dev/null +++ b/drizzle/0004_white_the_hunter.sql @@ -0,0 +1,21 @@ +CREATE TABLE `accountingAllocationList` ( + `id` int AUTO_INCREMENT NOT NULL, + `userId` int NOT NULL, + `name` varchar(100) NOT NULL, + `createdAt` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `accountingAllocationList_id` PRIMARY KEY(`id`), + CONSTRAINT `user_allocation_unique` UNIQUE(`userId`,`name`) +); +--> statement-breakpoint +CREATE TABLE `departmentList` ( + `id` int AUTO_INCREMENT NOT NULL, + `userId` int NOT NULL, + `name` varchar(100) NOT NULL, + `createdAt` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `departmentList_id` PRIMARY KEY(`id`), + CONSTRAINT `user_department_unique` UNIQUE(`userId`,`name`) +); +--> statement-breakpoint +ALTER TABLE `invoices` ADD `serviceConcerne` varchar(100);--> statement-breakpoint +ALTER TABLE `invoices` ADD `typeAchat` enum('CAPEX','OPEX');--> statement-breakpoint +ALTER TABLE `invoices` ADD `ventilationComptable` varchar(100); \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..1700c3b --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1081 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "4a2d5a3a-114f-424b-93f0-414994217d29", + "prevId": "1e89df9d-47b6-47da-8afc-6024d8bdc8ba", + "tables": { + "accountingAllocationList": { + "name": "accountingAllocationList", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_allocation_unique": { + "name": "user_allocation_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "accountingAllocationList_id": { + "name": "accountingAllocationList_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "departmentList": { + "name": "departmentList", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_department_unique": { + "name": "user_department_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "departmentList_id": { + "name": "departmentList_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "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": {} + }, + "importSettings": { + "name": "importSettings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manualImportEnabled": { + "name": "manualImportEnabled", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "autoImportEnabled": { + "name": "autoImportEnabled", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "autoImportSourcePath": { + "name": "autoImportSourcePath", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoImportFrequency": { + "name": "autoImportFrequency", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 60 + }, + "emailImportEnabled": { + "name": "emailImportEnabled", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "emailImportAddress": { + "name": "emailImportAddress", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailImportPassword": { + "name": "emailImportPassword", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailImportHost": { + "name": "emailImportHost", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailImportPort": { + "name": "emailImportPort", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 993 + }, + "emailImportFrequency": { + "name": "emailImportFrequency", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "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": { + "importSettings_id": { + "name": "importSettings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "importSettings_userId_unique": { + "name": "importSettings_userId_unique", + "columns": [ + "userId" + ] + } + }, + "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 + }, + "serviceConcerne": { + "name": "serviceConcerne", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "typeAchat": { + "name": "typeAchat", + "type": "enum('CAPEX','OPEX')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ventilationComptable": { + "name": "ventilationComptable", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 3a5ce16..7eb78d9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1770742897031, "tag": "0003_previous_killraven", "breakpoints": true + }, + { + "idx": 4, + "version": "5", + "when": 1770814574366, + "tag": "0004_white_the_hunter", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 0cbd47f..30d0f0c 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -84,6 +84,11 @@ export const invoices = mysqlTable("invoices", { // Manual correction tracking manuallyEdited: int("manuallyEdited").default(0).notNull(), // 0 = false, 1 = true + // Business fields (optional) + serviceConcerne: varchar("serviceConcerne", { length: 100 }), // Service concerné (DSI, TRAVAUX, etc.) + typeAchat: mysqlEnum("typeAchat", ["CAPEX", "OPEX"]), // Type d'achat + ventilationComptable: varchar("ventilationComptable", { length: 100 }), // Ventilation comptable (TOUS, PA, HEP, etc.) + // SFTP Export tracking exportedAt: timestamp("exportedAt"), exportMode: mysqlEnum("exportMode", ["manual", "automatic"]), @@ -202,3 +207,39 @@ export const llmLogs = mysqlTable("llmLogs", { export type LlmLog = typeof llmLogs.$inferSelect; export type InsertLlmLog = typeof llmLogs.$inferInsert; + +/** + * Department list table for managing "Service concerné" values + */ +export const departmentList = mysqlTable("departmentList", { + id: int("id").autoincrement().primaryKey(), + userId: int("userId").notNull(), // Each user has their own list + name: varchar("name", { length: 100 }).notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}, (table) => { + return { + // Unique constraint: no duplicate department names for the same user + userDepartmentIdx: uniqueIndex("user_department_unique").on(table.userId, table.name), + }; +}); + +export type Department = typeof departmentList.$inferSelect; +export type InsertDepartment = typeof departmentList.$inferInsert; + +/** + * Accounting allocation list table for managing "Ventilation comptable" values + */ +export const accountingAllocationList = mysqlTable("accountingAllocationList", { + id: int("id").autoincrement().primaryKey(), + userId: int("userId").notNull(), // Each user has their own list + name: varchar("name", { length: 100 }).notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}, (table) => { + return { + // Unique constraint: no duplicate allocation names for the same user + userAllocationIdx: uniqueIndex("user_allocation_unique").on(table.userId, table.name), + }; +}); + +export type AccountingAllocation = typeof accountingAllocationList.$inferSelect; +export type InsertAccountingAllocation = typeof accountingAllocationList.$inferInsert; diff --git a/server/db.ts b/server/db.ts index 1a227b3..5ec1d8c 100644 --- a/server/db.ts +++ b/server/db.ts @@ -20,7 +20,13 @@ import { LlmLog, importSettings, InsertImportSettings, - ImportSettings + ImportSettings, + departmentList, + InsertDepartment, + Department, + accountingAllocationList, + InsertAccountingAllocation, + AccountingAllocation } from "../drizzle/schema"; import { ENV } from './_core/env'; @@ -480,3 +486,72 @@ export async function upsertImportSettings(data: InsertImportSettings): Promise< return inserted[0]!; } } + +// ============= DEPARTMENT LIST OPERATIONS ============= + +export async function getDepartmentsByUser(userId: number): Promise { + const db = await getDb(); + if (!db) return []; + return await db.select().from(departmentList).where(eq(departmentList.userId, userId)).orderBy(departmentList.name); +} + +export async function createDepartment(data: InsertDepartment): Promise { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const [department] = await db.insert(departmentList).values(data).$returningId(); + return await db.select().from(departmentList).where(eq(departmentList.id, department.id)).then(rows => rows[0]); +} + +export async function deleteDepartment(id: number): Promise { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + await db.delete(departmentList).where(eq(departmentList.id, id)); +} + +// ============= ACCOUNTING ALLOCATION LIST OPERATIONS ============= + +export async function getAccountingAllocationsByUser(userId: number): Promise { + const db = await getDb(); + if (!db) return []; + return await db.select().from(accountingAllocationList).where(eq(accountingAllocationList.userId, userId)).orderBy(accountingAllocationList.name); +} + +export async function createAccountingAllocation(data: InsertAccountingAllocation): Promise { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const [allocation] = await db.insert(accountingAllocationList).values(data).$returningId(); + return await db.select().from(accountingAllocationList).where(eq(accountingAllocationList.id, allocation.id)).then(rows => rows[0]); +} + +export async function deleteAccountingAllocation(id: number): Promise { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + await db.delete(accountingAllocationList).where(eq(accountingAllocationList.id, id)); +} + +// ============= INITIALIZE DEFAULT VALUES ============= + +export async function initializeDefaultLists(userId: number): Promise { + const db = await getDb(); + if (!db) return; + + // Initialize default departments + const defaultDepartments = ["DSI", "TRAVAUX", "AUTRE"]; + for (const name of defaultDepartments) { + try { + await db.insert(departmentList).values({ userId, name }).onDuplicateKeyUpdate({ set: { name } }); + } catch (error) { + // Ignore duplicate errors + } + } + + // Initialize default accounting allocations + const defaultAllocations = ["TOUS", "PA", "HEP", "SANITAIRE", "AUTRE"]; + for (const name of defaultAllocations) { + try { + await db.insert(accountingAllocationList).values({ userId, name }).onDuplicateKeyUpdate({ set: { name } }); + } catch (error) { + // Ignore duplicate errors + } + } +} diff --git a/server/routers.ts b/server/routers.ts index 7f9690a..644f8c6 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -29,6 +29,13 @@ import { getLlmLogsByInvoice, getImportSettingsByUser, upsertImportSettings, + getDepartmentsByUser, + createDepartment, + deleteDepartment, + getAccountingAllocationsByUser, + createAccountingAllocation, + deleteAccountingAllocation, + initializeDefaultLists, } from "./db"; import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth"; import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor"; @@ -297,6 +304,9 @@ export const appRouter = router({ deliveryNoteNumber: z.string().optional(), orderNumber: z.string().optional(), totalAmount: z.string().optional(), + serviceConcerne: z.string().optional(), + typeAchat: z.enum(["CAPEX", "OPEX"]).optional(), + ventilationComptable: z.string().optional(), }), })) .mutation(async ({ input, ctx }) => { @@ -670,6 +680,72 @@ export const appRouter = router({ return settings; }), }), + + // ============= DEPARTMENT LIST ROUTES ============= + departments: router({ + getByUser: protectedProcedure.query(async ({ ctx }) => { + return await getDepartmentsByUser(ctx.user.id); + }), + + create: protectedProcedure + .input(z.object({ + name: z.string().min(1).max(100), + })) + .mutation(async ({ input, ctx }) => { + // Initialize default lists if this is the first department + const existing = await getDepartmentsByUser(ctx.user.id); + if (existing.length === 0) { + await initializeDefaultLists(ctx.user.id); + } + + return await createDepartment({ + userId: ctx.user.id, + name: input.name, + }); + }), + + delete: protectedProcedure + .input(z.object({ + id: z.number(), + })) + .mutation(async ({ input }) => { + await deleteDepartment(input.id); + return { success: true }; + }), + }), + + // ============= ACCOUNTING ALLOCATION LIST ROUTES ============= + accountingAllocations: router({ + getByUser: protectedProcedure.query(async ({ ctx }) => { + return await getAccountingAllocationsByUser(ctx.user.id); + }), + + create: protectedProcedure + .input(z.object({ + name: z.string().min(1).max(100), + })) + .mutation(async ({ input, ctx }) => { + // Initialize default lists if this is the first allocation + const existing = await getAccountingAllocationsByUser(ctx.user.id); + if (existing.length === 0) { + await initializeDefaultLists(ctx.user.id); + } + + return await createAccountingAllocation({ + userId: ctx.user.id, + name: input.name, + }); + }), + + delete: protectedProcedure + .input(z.object({ + id: z.number(), + })) + .mutation(async ({ input }) => { + await deleteAccountingAllocation(input.id); + return { success: true }; + }), + }), }); export type AppRouter = typeof appRouter; diff --git a/todo.md b/todo.md index a58775b..5116b57 100644 --- a/todo.md +++ b/todo.md @@ -195,3 +195,14 @@ ## Bugs interface ImportSettings - [x] Corriger l'encodage Unicode du bouton "Vérifier maintenant" - [x] Ajouter rafraîchissement automatique du statut du service après démarrage/arrêt (email et dossier) + +## Champs métier pour les factures +- [x] Ajouter colonnes service_concerne, type_achat, ventilation_comptable à la table invoices +- [x] Créer table departmentList pour gérer la liste "Service concerné" +- [x] Créer table accountingAllocationList pour gérer la liste "Ventilation comptable" +- [x] Ajouter routes tRPC pour CRUD des listes enrichissables +- [x] Créer page d'administration pour gérer les listes (ListsAdmin.tsx) +- [x] Ajouter les champs dans le formulaire d'édition de facture (InvoiceDetail.tsx) +- [x] Ajouter les colonnes dans la liste des factures (Invoices.tsx) +- [x] Initialiser les valeurs par défaut dans les tables (initializeDefaultLists) +- [x] Ajouter le lien "Administration des listes" dans le menu de navigation