Checkpoint: Ajout de 3 champs métier aux factures (Service concerné, Type d'achat, Ventilation comptable) avec interface d'administration pour gérer les listes enrichissables. Les champs apparaissent dans la liste des factures et dans le formulaire d'édition.
This commit is contained in:
@@ -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() {
|
||||
<Route path="/import-settings" component={ImportSettings} />
|
||||
<Route path="/history" component={History} />
|
||||
<Route path="/users" component={Users} />
|
||||
<Route path="/lists-admin" component={ListsAdmin} />
|
||||
<Route path="/404" component={NotFound} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Business Fields Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Champs métier</CardTitle>
|
||||
<CardDescription>
|
||||
Informations de classification comptable
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="serviceConcerne">Service concerné</Label>
|
||||
{isEditing ? (
|
||||
<select
|
||||
id="serviceConcerne"
|
||||
value={formData.serviceConcerne}
|
||||
onChange={(e) => setFormData({ ...formData, serviceConcerne: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option value="DSI">DSI</option>
|
||||
<option value="TRAVAUX">TRAVAUX</option>
|
||||
<option value="AUTRE">AUTRE</option>
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-sm mt-1">{invoice.serviceConcerne || "-"}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="typeAchat">Type d'achat</Label>
|
||||
{isEditing ? (
|
||||
<select
|
||||
id="typeAchat"
|
||||
value={formData.typeAchat}
|
||||
onChange={(e) => setFormData({ ...formData, typeAchat: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option value="CAPEX">CAPEX</option>
|
||||
<option value="OPEX">OPEX</option>
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-sm mt-1">{invoice.typeAchat || "-"}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="ventilationComptable">Ventilation comptable</Label>
|
||||
{isEditing ? (
|
||||
<select
|
||||
id="ventilationComptable"
|
||||
value={formData.ventilationComptable}
|
||||
onChange={(e) => setFormData({ ...formData, ventilationComptable: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option value="TOUS">TOUS</option>
|
||||
<option value="PA">PA</option>
|
||||
<option value="HEP">HEP</option>
|
||||
<option value="SANITAIRE">SANITAIRE</option>
|
||||
<option value="AUTRE">AUTRE</option>
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-sm mt-1">{invoice.ventilationComptable || "-"}</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Info Card */}
|
||||
<Card>
|
||||
|
||||
@@ -304,6 +304,9 @@ export default function Invoices() {
|
||||
<TableHead>N° Facture</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Montant</TableHead>
|
||||
<TableHead>Service</TableHead>
|
||||
<TableHead>Type achat</TableHead>
|
||||
<TableHead>Ventilation</TableHead>
|
||||
<TableHead>Score</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
</TableRow>
|
||||
@@ -337,6 +340,9 @@ export default function Invoices() {
|
||||
? `${parseFloat(invoice.totalAmount as string).toFixed(2)} €`
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{invoice.serviceConcerne || "-"}</TableCell>
|
||||
<TableCell className="text-sm">{invoice.typeAchat || "-"}</TableCell>
|
||||
<TableCell className="text-sm">{invoice.ventilationComptable || "-"}</TableCell>
|
||||
<TableCell>{getQualityBadge(invoice.qualityScore)}</TableCell>
|
||||
<TableCell>{getExportStatusBadge(invoice.exportStatus || "not_exported")}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
262
client/src/pages/ListsAdmin.tsx
Normal file
262
client/src/pages/ListsAdmin.tsx
Normal file
@@ -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 (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
|
||||
<Settings className="h-8 w-8" />
|
||||
Administration des listes
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Gérez les listes de valeurs pour les champs métier des factures
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Department List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Service concerné</CardTitle>
|
||||
<CardDescription>
|
||||
Gérez la liste des services disponibles pour la classification des factures
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Nouveau service (ex: INFORMATIQUE)"
|
||||
value={newDepartment}
|
||||
onChange={(e) => setNewDepartment(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleCreateDepartment();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreateDepartment}
|
||||
disabled={createDepartmentMutation.isPending || !newDepartment.trim()}
|
||||
>
|
||||
{createDepartmentMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-2">Ajouter</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{departments && departments.length > 0 ? (
|
||||
departments.map((dept) => (
|
||||
<div
|
||||
key={dept.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<span className="font-medium">{dept.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete("department", dept.id, dept.name)}
|
||||
disabled={deleteDepartmentMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Aucun service configuré. Ajoutez-en un pour commencer.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Accounting Allocation List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ventilation comptable</CardTitle>
|
||||
<CardDescription>
|
||||
Gérez la liste des ventilations comptables disponibles pour la classification des factures
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Nouvelle ventilation (ex: MAINTENANCE)"
|
||||
value={newAllocation}
|
||||
onChange={(e) => setNewAllocation(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleCreateAllocation();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreateAllocation}
|
||||
disabled={createAllocationMutation.isPending || !newAllocation.trim()}
|
||||
>
|
||||
{createAllocationMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-2">Ajouter</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{allocations && allocations.length > 0 ? (
|
||||
allocations.map((alloc) => (
|
||||
<div
|
||||
key={alloc.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<span className="font-medium">{alloc.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete("allocation", alloc.id, alloc.name)}
|
||||
disabled={deleteAllocationMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Aucune ventilation configurée. Ajoutez-en une pour commencer.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmer la suppression</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Êtes-vous sûr de vouloir supprimer "{itemToDelete?.name}" ?
|
||||
Cette action est irréversible.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Supprimer
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
21
drizzle/0004_white_the_hunter.sql
Normal file
21
drizzle/0004_white_the_hunter.sql
Normal file
@@ -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);
|
||||
1081
drizzle/meta/0004_snapshot.json
Normal file
1081
drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
77
server/db.ts
77
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<Department[]> {
|
||||
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<Department> {
|
||||
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<void> {
|
||||
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<AccountingAllocation[]> {
|
||||
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<AccountingAllocation> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
11
todo.md
11
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
|
||||
|
||||
Reference in New Issue
Block a user