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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user