Checkpoint: Système de gestion des automatismes pour remplissage automatique des factures
Nouvelle fonctionnalité majeure : ✅ Page "Automatismes" complète pour gérer les règles de remplissage automatique ✅ Système de règles conditionnelles SI...ALORS pour automatiser le remplissage des champs métier ✅ Support de conditions multiples avec logique AND/OR ✅ Application automatique des règles lors de l'import de factures ✅ Gestion de la priorité des règles (ordre d'exécution) ✅ Activation/désactivation des règles individuelles Architecture technique : - drizzle/schema.ts : Table automationRules avec conditions JSON et actions JSON - server/db.ts : Fonctions CRUD complètes pour les règles (create, update, delete, list) - server/routers.ts : Routes tRPC pour gérer les automatismes - server/automationEngine.ts : Moteur d'évaluation et d'application des règles - client/src/pages/AutomationRules.tsx : Interface complète de gestion des règles - Migration 0009_regular_warhawk.sql appliquée avec succès Fonctionnement des règles : 1. **Conditions (SI)** : Définissez des critères basés sur les champs de la facture - Champs disponibles : Fournisseur, N° Facture, Montant, Date, N° Commande, N° Bon de livraison - Opérateurs : contient, égal à, commence par, finit par, >, <, >=, <= - Logique : AND (toutes les conditions) ou OR (au moins une condition) 2. **Actions (ALORS)** : Définissez les champs à remplir automatiquement - Type d'achat (CAPEX/OPEX) - Service concerné (depuis la liste des services) - Ventilation comptable (depuis la liste des ventilations) 3. **Priorité** : Les règles sont appliquées dans l'ordre de priorité - Une règle de priorité supérieure ne peut pas être écrasée par une règle de priorité inférieure 4. **Application automatique** : Les règles s'appliquent automatiquement lors de l'import de factures Exemples d'utilisation : - SI Fournisseur contient "Microsoft" ET Montant > 1000 ALORS Type d'achat = CAPEX, Service = DSI - SI Fournisseur contient "EDF" ALORS Type d'achat = OPEX, Ventilation = TOUS - SI N° Facture commence par "AB" ALORS Service = Administration Interface utilisateur : - Liste des règles avec nom, conditions, actions et statut - Formulaire de création/édition avec interface intuitive - Boutons d'activation/désactivation rapide - Suppression avec confirmation - Affichage lisible des conditions et actions Cette fonctionnalité permet de gagner un temps considérable en automatisant le remplissage des champs métier selon des règles métier prédéfinies.
This commit is contained in:
@@ -16,6 +16,7 @@ import ImportSettings from "./pages/ImportSettings";
|
|||||||
import History from "./pages/History";
|
import History from "./pages/History";
|
||||||
import Users from "./pages/Users";
|
import Users from "./pages/Users";
|
||||||
import ListsAdmin from "./pages/ListsAdmin";
|
import ListsAdmin from "./pages/ListsAdmin";
|
||||||
|
import AutomationRules from "./pages/AutomationRules";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
return (
|
return (
|
||||||
@@ -32,6 +33,7 @@ function Router() {
|
|||||||
<Route path="/history" component={History} />
|
<Route path="/history" component={History} />
|
||||||
<Route path="/users" component={Users} />
|
<Route path="/users" component={Users} />
|
||||||
<Route path="/lists-admin" component={ListsAdmin} />
|
<Route path="/lists-admin" component={ListsAdmin} />
|
||||||
|
<Route path="/automation-rules" component={AutomationRules} />
|
||||||
<Route path="/404" component={NotFound} />
|
<Route path="/404" component={NotFound} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { getLoginUrl } from "@/const";
|
import { getLoginUrl } from "@/const";
|
||||||
import { useIsMobile } from "@/hooks/useMobile";
|
import { useIsMobile } from "@/hooks/useMobile";
|
||||||
import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List } from "lucide-react";
|
import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List, Zap } from "lucide-react";
|
||||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||||
@@ -33,8 +33,9 @@ const menuItems = [
|
|||||||
{ icon: FileText, label: "Factures", path: "/invoices" },
|
{ icon: FileText, label: "Factures", path: "/invoices" },
|
||||||
{ icon: FileText, label: "Factures BAP", path: "/invoices-bap" },
|
{ icon: FileText, label: "Factures BAP", path: "/invoices-bap" },
|
||||||
{ icon: History, label: "Historique", path: "/history" },
|
{ icon: History, label: "Historique", path: "/history" },
|
||||||
{ icon: Settings, label: "Paramètres", path: "/settings" },
|
{ icon: Settings, label: "Param\u00e8tres", path: "/settings" },
|
||||||
{ icon: Download, label: "Paramètres de réception", path: "/import-settings" },
|
{ icon: Download, label: "Param\u00e8tres de r\u00e9ception", path: "/import-settings" },
|
||||||
|
{ icon: Zap, label: "Automatismes", path: "/automation-rules" },
|
||||||
{ icon: List, label: "Administration des listes", path: "/lists-admin" },
|
{ icon: List, label: "Administration des listes", path: "/lists-admin" },
|
||||||
{ icon: Users, label: "Utilisateurs", path: "/users", adminOnly: true },
|
{ icon: Users, label: "Utilisateurs", path: "/users", adminOnly: true },
|
||||||
];
|
];
|
||||||
|
|||||||
525
client/src/pages/AutomationRules.tsx
Normal file
525
client/src/pages/AutomationRules.tsx
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Plus, Edit, Trash2, Power, PowerOff } from "lucide-react";
|
||||||
|
|
||||||
|
interface Condition {
|
||||||
|
field: string;
|
||||||
|
operator: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Actions {
|
||||||
|
typeAchat?: string;
|
||||||
|
serviceConcerne?: string;
|
||||||
|
ventilationComptable?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AutomationRules() {
|
||||||
|
const { data: rules = [], isLoading } = trpc.automationRules.list.useQuery();
|
||||||
|
const { data: departments } = trpc.departments.getByUser.useQuery();
|
||||||
|
const { data: allocations } = trpc.accountingAllocations.getByUser.useQuery();
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [ruleToDelete, setRuleToDelete] = useState<number | null>(null);
|
||||||
|
const [editingRule, setEditingRule] = useState<any | null>(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [ruleName, setRuleName] = useState("");
|
||||||
|
const [conditionsLogic, setConditionsLogic] = useState<"AND" | "OR">("AND");
|
||||||
|
const [conditions, setConditions] = useState<Condition[]>([{ field: "supplierName", operator: "contains", value: "" }]);
|
||||||
|
const [actions, setActions] = useState<Actions>({});
|
||||||
|
|
||||||
|
const createMutation = trpc.automationRules.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Règle créée avec succès");
|
||||||
|
utils.automationRules.list.invalidate();
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || "Erreur lors de la création");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = trpc.automationRules.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Règle mise à jour");
|
||||||
|
utils.automationRules.list.invalidate();
|
||||||
|
closeDialog();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || "Erreur lors de la mise à jour");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = trpc.automationRules.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Règle supprimée");
|
||||||
|
utils.automationRules.list.invalidate();
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setRuleToDelete(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || "Erreur lors de la suppression");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleActiveMutation = trpc.automationRules.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Statut mis à jour");
|
||||||
|
utils.automationRules.list.invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || "Erreur lors de la mise à jour");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
setEditingRule(null);
|
||||||
|
setRuleName("");
|
||||||
|
setConditionsLogic("AND");
|
||||||
|
setConditions([{ field: "supplierName", operator: "contains", value: "" }]);
|
||||||
|
setActions({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = (rule: any) => {
|
||||||
|
setEditingRule(rule);
|
||||||
|
setRuleName(rule.name);
|
||||||
|
setConditionsLogic(rule.conditionsLogic || "AND");
|
||||||
|
try {
|
||||||
|
setConditions(JSON.parse(rule.conditions));
|
||||||
|
} catch {
|
||||||
|
setConditions([{ field: "supplierName", operator: "contains", value: "" }]);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setActions(JSON.parse(rule.actions));
|
||||||
|
} catch {
|
||||||
|
setActions({});
|
||||||
|
}
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
setEditingRule(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!ruleName.trim()) {
|
||||||
|
toast.error("Le nom de la règle est obligatoire");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditionsJSON = JSON.stringify(conditions);
|
||||||
|
const actionsJSON = JSON.stringify(actions);
|
||||||
|
|
||||||
|
if (editingRule) {
|
||||||
|
updateMutation.mutate({
|
||||||
|
id: editingRule.id,
|
||||||
|
name: ruleName,
|
||||||
|
conditionsLogic,
|
||||||
|
conditions: conditionsJSON,
|
||||||
|
actions: actionsJSON,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
createMutation.mutate({
|
||||||
|
name: ruleName,
|
||||||
|
conditionsLogic,
|
||||||
|
conditions: conditionsJSON,
|
||||||
|
actions: actionsJSON,
|
||||||
|
isActive: 1,
|
||||||
|
priority: rules.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: number) => {
|
||||||
|
setRuleToDelete(id);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (ruleToDelete) {
|
||||||
|
deleteMutation.mutate({ id: ruleToDelete });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleActive = (rule: any) => {
|
||||||
|
toggleActiveMutation.mutate({
|
||||||
|
id: rule.id,
|
||||||
|
isActive: rule.isActive === 1 ? 0 : 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCondition = () => {
|
||||||
|
setConditions([...conditions, { field: "supplierName", operator: "contains", value: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCondition = (index: number) => {
|
||||||
|
setConditions(conditions.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCondition = (index: number, key: keyof Condition, value: string) => {
|
||||||
|
const newConditions = [...conditions];
|
||||||
|
newConditions[index][key] = value;
|
||||||
|
setConditions(newConditions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldOptions = [
|
||||||
|
{ value: "supplierName", label: "Fournisseur" },
|
||||||
|
{ value: "invoiceNumber", label: "N° Facture" },
|
||||||
|
{ value: "totalAmount", label: "Montant" },
|
||||||
|
{ value: "invoiceDate", label: "Date facture" },
|
||||||
|
{ value: "orderNumber", label: "N° Commande" },
|
||||||
|
{ value: "deliveryNoteNumber", label: "N° Bon de livraison" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const operatorOptions = [
|
||||||
|
{ value: "contains", label: "contient" },
|
||||||
|
{ value: "equals", label: "est égal à" },
|
||||||
|
{ value: "startsWith", label: "commence par" },
|
||||||
|
{ value: "endsWith", label: "finit par" },
|
||||||
|
{ value: ">", label: "supérieur à" },
|
||||||
|
{ value: "<", label: "inférieur à" },
|
||||||
|
{ value: ">=", label: "supérieur ou égal à" },
|
||||||
|
{ value: "<=", label: "inférieur ou égal à" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Automatismes</h1>
|
||||||
|
<p className="text-gray-500 mt-1">Gérez les règles de remplissage automatique des champs</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreateDialog}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Nouvelle règle
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Règles d'automatisme</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Les règles sont appliquées dans l'ordre de priorité lors de l'import des factures
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">Chargement...</div>
|
||||||
|
) : rules.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
Aucune règle d'automatisme configurée
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Nom</TableHead>
|
||||||
|
<TableHead>Conditions</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
<TableHead>Statut</TableHead>
|
||||||
|
<TableHead className="w-32">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rules.map((rule) => {
|
||||||
|
let conditionsDisplay = "";
|
||||||
|
let actionsDisplay = "";
|
||||||
|
try {
|
||||||
|
const conds = JSON.parse(rule.conditions);
|
||||||
|
conditionsDisplay = conds.map((c: Condition) =>
|
||||||
|
`${fieldOptions.find(f => f.value === c.field)?.label || c.field} ${operatorOptions.find(o => o.value === c.operator)?.label || c.operator} "${c.value}"`
|
||||||
|
).join(` ${rule.conditionsLogic} `);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const acts = JSON.parse(rule.actions);
|
||||||
|
const actionsArr = [];
|
||||||
|
if (acts.typeAchat) actionsArr.push(`Type: ${acts.typeAchat}`);
|
||||||
|
if (acts.serviceConcerne) actionsArr.push(`Service: ${acts.serviceConcerne}`);
|
||||||
|
if (acts.ventilationComptable) actionsArr.push(`Ventilation: ${acts.ventilationComptable}`);
|
||||||
|
actionsDisplay = actionsArr.join(", ");
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={rule.id}>
|
||||||
|
<TableCell className="font-medium">{rule.name}</TableCell>
|
||||||
|
<TableCell className="text-sm">{conditionsDisplay}</TableCell>
|
||||||
|
<TableCell className="text-sm">{actionsDisplay}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{rule.isActive === 1 ? (
|
||||||
|
<Badge className="bg-green-100 text-green-800 hover:bg-green-100">Actif</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-gray-100 text-gray-800 hover:bg-gray-100">Inactif</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleActive(rule)}
|
||||||
|
title={rule.isActive === 1 ? "Désactiver" : "Activer"}
|
||||||
|
>
|
||||||
|
{rule.isActive === 1 ? (
|
||||||
|
<PowerOff className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Power className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openEditDialog(rule)}
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(rule.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Create/Edit Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingRule ? "Modifier la règle" : "Nouvelle règle"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Définissez les conditions (SI) et les actions (ALORS) pour le remplissage automatique
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Rule Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ruleName">Nom de la règle</Label>
|
||||||
|
<Input
|
||||||
|
id="ruleName"
|
||||||
|
value={ruleName}
|
||||||
|
onChange={(e) => setRuleName(e.target.value)}
|
||||||
|
placeholder="Ex: Factures Microsoft CAPEX"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conditions */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Conditions (SI)</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{conditions.map((condition, index) => (
|
||||||
|
<div key={index} className="flex gap-2 items-center">
|
||||||
|
<Select
|
||||||
|
value={condition.field}
|
||||||
|
onValueChange={(value) => updateCondition(index, "field", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{fieldOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={condition.operator}
|
||||||
|
onValueChange={(value) => updateCondition(index, "operator", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[160px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{operatorOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
value={condition.value}
|
||||||
|
onChange={(e) => updateCondition(index, "value", e.target.value)}
|
||||||
|
placeholder="Valeur"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeCondition(index)}
|
||||||
|
disabled={conditions.length === 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={addCondition}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Ajouter une condition
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Select value={conditionsLogic} onValueChange={(value: "AND" | "OR") => setConditionsLogic(value)}>
|
||||||
|
<SelectTrigger className="w-[120px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="AND">ET (AND)</SelectItem>
|
||||||
|
<SelectItem value="OR">OU (OR)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Actions (ALORS)</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="typeAchat">Type d'achat</Label>
|
||||||
|
<Select
|
||||||
|
value={actions.typeAchat || ""}
|
||||||
|
onValueChange={(value) => setActions({ ...actions, typeAchat: value || undefined })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Ne pas modifier" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">Ne pas modifier</SelectItem>
|
||||||
|
<SelectItem value="CAPEX">CAPEX</SelectItem>
|
||||||
|
<SelectItem value="OPEX">OPEX</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="serviceConcerne">Service concerné</Label>
|
||||||
|
<Select
|
||||||
|
value={actions.serviceConcerne || ""}
|
||||||
|
onValueChange={(value) => setActions({ ...actions, serviceConcerne: value || undefined })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Ne pas modifier" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">Ne pas modifier</SelectItem>
|
||||||
|
{departments?.map((dept) => (
|
||||||
|
<SelectItem key={dept.id} value={dept.name}>
|
||||||
|
{dept.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ventilationComptable">Ventilation comptable</Label>
|
||||||
|
<Select
|
||||||
|
value={actions.ventilationComptable || ""}
|
||||||
|
onValueChange={(value) => setActions({ ...actions, ventilationComptable: value || undefined })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Ne pas modifier" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">Ne pas modifier</SelectItem>
|
||||||
|
{allocations?.map((alloc) => (
|
||||||
|
<SelectItem key={alloc.id} value={alloc.name}>
|
||||||
|
{alloc.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={closeDialog}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={createMutation.isPending || updateMutation.isPending}>
|
||||||
|
{editingRule ? "Mettre à jour" : "Créer"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Confirmer la suppression</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Êtes-vous sûr de vouloir supprimer cette règle ? Cette action est irréversible.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmDelete}>
|
||||||
|
Supprimer
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
drizzle/0009_regular_warhawk.sql
Normal file
13
drizzle/0009_regular_warhawk.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE `automationRules` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`userId` int NOT NULL,
|
||||||
|
`name` varchar(255) NOT NULL,
|
||||||
|
`isActive` int NOT NULL DEFAULT 1,
|
||||||
|
`priority` int NOT NULL DEFAULT 0,
|
||||||
|
`conditions` text NOT NULL,
|
||||||
|
`conditionsLogic` enum('AND','OR') NOT NULL DEFAULT 'AND',
|
||||||
|
`actions` text NOT NULL,
|
||||||
|
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT `automationRules_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
1203
drizzle/meta/0009_snapshot.json
Normal file
1203
drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,13 @@
|
|||||||
"when": 1770830684846,
|
"when": 1770830684846,
|
||||||
"tag": "0008_orange_prism",
|
"tag": "0008_orange_prism",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1770831052283,
|
||||||
|
"tag": "0009_regular_warhawk",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -253,3 +253,29 @@ export const accountingAllocationList = mysqlTable("accountingAllocationList", {
|
|||||||
|
|
||||||
export type AccountingAllocation = typeof accountingAllocationList.$inferSelect;
|
export type AccountingAllocation = typeof accountingAllocationList.$inferSelect;
|
||||||
export type InsertAccountingAllocation = typeof accountingAllocationList.$inferInsert;
|
export type InsertAccountingAllocation = typeof accountingAllocationList.$inferInsert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automation rules table for automatic field filling based on conditions
|
||||||
|
*/
|
||||||
|
export const automationRules = mysqlTable("automationRules", {
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
userId: int("userId").notNull(), // Each user has their own rules
|
||||||
|
name: varchar("name", { length: 255 }).notNull(), // Rule name for identification
|
||||||
|
isActive: int("isActive").default(1).notNull(), // 0 = disabled, 1 = enabled
|
||||||
|
priority: int("priority").default(0).notNull(), // Execution order (lower = higher priority)
|
||||||
|
|
||||||
|
// Conditions (IF) - JSON array of condition objects
|
||||||
|
// Example: [{"field": "supplierName", "operator": "contains", "value": "Microsoft"}, {"field": "totalAmount", "operator": ">", "value": "1000"}]
|
||||||
|
conditions: text("conditions").notNull(), // JSON string
|
||||||
|
conditionsLogic: mysqlEnum("conditionsLogic", ["AND", "OR"]).default("AND").notNull(), // How to combine conditions
|
||||||
|
|
||||||
|
// Actions (THEN) - JSON object with field assignments
|
||||||
|
// Example: {"typeAchat": "CAPEX", "serviceConcerne": "DSI", "ventilationComptable": "TOUS"}
|
||||||
|
actions: text("actions").notNull(), // JSON string
|
||||||
|
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AutomationRule = typeof automationRules.$inferSelect;
|
||||||
|
export type InsertAutomationRule = typeof automationRules.$inferInsert;
|
||||||
|
|||||||
137
server/automationEngine.ts
Normal file
137
server/automationEngine.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { getAutomationRulesByUser } from "./db";
|
||||||
|
import { Invoice } from "../drizzle/schema";
|
||||||
|
|
||||||
|
interface Condition {
|
||||||
|
field: string;
|
||||||
|
operator: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Actions {
|
||||||
|
typeAchat?: string;
|
||||||
|
serviceConcerne?: string;
|
||||||
|
ventilationComptable?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a single condition against an invoice
|
||||||
|
*/
|
||||||
|
function evaluateCondition(invoice: Invoice, condition: Condition): boolean {
|
||||||
|
const fieldValue = invoice[condition.field as keyof Invoice];
|
||||||
|
|
||||||
|
if (fieldValue === null || fieldValue === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueStr = String(fieldValue).toLowerCase();
|
||||||
|
const conditionValue = condition.value.toLowerCase();
|
||||||
|
|
||||||
|
switch (condition.operator) {
|
||||||
|
case "contains":
|
||||||
|
return valueStr.includes(conditionValue);
|
||||||
|
case "equals":
|
||||||
|
return valueStr === conditionValue;
|
||||||
|
case "startsWith":
|
||||||
|
return valueStr.startsWith(conditionValue);
|
||||||
|
case "endsWith":
|
||||||
|
return valueStr.endsWith(conditionValue);
|
||||||
|
case ">":
|
||||||
|
return Number(fieldValue) > Number(condition.value);
|
||||||
|
case "<":
|
||||||
|
return Number(fieldValue) < Number(condition.value);
|
||||||
|
case ">=":
|
||||||
|
return Number(fieldValue) >= Number(condition.value);
|
||||||
|
case "<=":
|
||||||
|
return Number(fieldValue) <= Number(condition.value);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate all conditions for a rule based on the logic (AND/OR)
|
||||||
|
*/
|
||||||
|
function evaluateConditions(
|
||||||
|
invoice: Invoice,
|
||||||
|
conditions: Condition[],
|
||||||
|
logic: "AND" | "OR"
|
||||||
|
): boolean {
|
||||||
|
if (conditions.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logic === "AND") {
|
||||||
|
return conditions.every((condition) => evaluateCondition(invoice, condition));
|
||||||
|
} else {
|
||||||
|
return conditions.some((condition) => evaluateCondition(invoice, condition));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply automation rules to an invoice and return the fields to update
|
||||||
|
* Returns an object with the fields that should be updated based on matching rules
|
||||||
|
*/
|
||||||
|
export async function applyAutomationRules(
|
||||||
|
userId: number,
|
||||||
|
invoice: Invoice
|
||||||
|
): Promise<Partial<Invoice>> {
|
||||||
|
const rules = await getAutomationRulesByUser(userId);
|
||||||
|
const updates: Partial<Invoice> = {};
|
||||||
|
|
||||||
|
// Process rules in priority order (already sorted by priority in getAutomationRulesByUser)
|
||||||
|
for (const rule of rules) {
|
||||||
|
// Skip inactive rules
|
||||||
|
if (rule.isActive !== 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const conditions: Condition[] = JSON.parse(rule.conditions);
|
||||||
|
const actions: Actions = JSON.parse(rule.actions);
|
||||||
|
|
||||||
|
// Evaluate conditions
|
||||||
|
const conditionsMatch = evaluateConditions(
|
||||||
|
invoice,
|
||||||
|
conditions,
|
||||||
|
rule.conditionsLogic
|
||||||
|
);
|
||||||
|
|
||||||
|
if (conditionsMatch) {
|
||||||
|
// Apply actions (only if the field is not already set by a higher priority rule)
|
||||||
|
if (actions.typeAchat && !updates.typeAchat) {
|
||||||
|
updates.typeAchat = actions.typeAchat as "CAPEX" | "OPEX";
|
||||||
|
}
|
||||||
|
if (actions.serviceConcerne && !updates.serviceConcerne) {
|
||||||
|
updates.serviceConcerne = actions.serviceConcerne;
|
||||||
|
}
|
||||||
|
if (actions.ventilationComptable && !updates.ventilationComptable) {
|
||||||
|
updates.ventilationComptable = actions.ventilationComptable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[AutomationEngine] Error processing rule ${rule.id}:`, error);
|
||||||
|
// Continue with next rule on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply automation rules to multiple invoices
|
||||||
|
*/
|
||||||
|
export async function applyAutomationRulesToBatch(
|
||||||
|
userId: number,
|
||||||
|
invoices: Invoice[]
|
||||||
|
): Promise<Map<number, Partial<Invoice>>> {
|
||||||
|
const results = new Map<number, Partial<Invoice>>();
|
||||||
|
|
||||||
|
for (const invoice of invoices) {
|
||||||
|
const updates = await applyAutomationRules(userId, invoice);
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
results.set(invoice.id, updates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
43
server/db.ts
43
server/db.ts
@@ -26,7 +26,10 @@ import {
|
|||||||
Department,
|
Department,
|
||||||
accountingAllocationList,
|
accountingAllocationList,
|
||||||
InsertAccountingAllocation,
|
InsertAccountingAllocation,
|
||||||
AccountingAllocation
|
AccountingAllocation,
|
||||||
|
automationRules,
|
||||||
|
InsertAutomationRule,
|
||||||
|
AutomationRule
|
||||||
} from "../drizzle/schema";
|
} from "../drizzle/schema";
|
||||||
import { ENV } from './_core/env';
|
import { ENV } from './_core/env';
|
||||||
|
|
||||||
@@ -529,6 +532,44 @@ export async function deleteAccountingAllocation(id: number): Promise<void> {
|
|||||||
await db.delete(accountingAllocationList).where(eq(accountingAllocationList.id, id));
|
await db.delete(accountingAllocationList).where(eq(accountingAllocationList.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============= AUTOMATION RULES OPERATIONS =============
|
||||||
|
|
||||||
|
export async function getAutomationRulesByUser(userId: number): Promise<AutomationRule[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
return await db.select().from(automationRules).where(eq(automationRules.userId, userId)).orderBy(automationRules.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAutomationRuleById(id: number): Promise<AutomationRule | null> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return null;
|
||||||
|
const result = await db.select().from(automationRules).where(eq(automationRules.id, id)).limit(1);
|
||||||
|
return result[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAutomationRule(data: InsertAutomationRule): Promise<AutomationRule> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
const result = await db.insert(automationRules).values(data);
|
||||||
|
const insertedId = result[0].insertId;
|
||||||
|
const newRule = await getAutomationRuleById(insertedId);
|
||||||
|
return newRule!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAutomationRule(id: number, data: Partial<InsertAutomationRule>): Promise<AutomationRule> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
await db.update(automationRules).set(data).where(eq(automationRules.id, id));
|
||||||
|
const updated = await getAutomationRuleById(id);
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAutomationRule(id: number): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
await db.delete(automationRules).where(eq(automationRules.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
// ============= INITIALIZE DEFAULT VALUES =============
|
// ============= INITIALIZE DEFAULT VALUES =============
|
||||||
|
|
||||||
export async function initializeDefaultLists(userId: number): Promise<void> {
|
export async function initializeDefaultLists(userId: number): Promise<void> {
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ import {
|
|||||||
createAccountingAllocation,
|
createAccountingAllocation,
|
||||||
deleteAccountingAllocation,
|
deleteAccountingAllocation,
|
||||||
initializeDefaultLists,
|
initializeDefaultLists,
|
||||||
|
getAutomationRulesByUser,
|
||||||
|
getAutomationRuleById,
|
||||||
|
createAutomationRule,
|
||||||
|
updateAutomationRule,
|
||||||
|
deleteAutomationRule,
|
||||||
} from "./db";
|
} from "./db";
|
||||||
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
|
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
|
||||||
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
||||||
@@ -220,7 +225,7 @@ export const appRouter = router({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Create invoice record
|
// Create invoice record
|
||||||
await createInvoice({
|
const newInvoice = await createInvoice({
|
||||||
userId,
|
userId,
|
||||||
sourceFileId: sourceFile.id,
|
sourceFileId: sourceFile.id,
|
||||||
invoiceIndexInFile: i + 1,
|
invoiceIndexInFile: i + 1,
|
||||||
@@ -242,6 +247,20 @@ export const appRouter = router({
|
|||||||
status: "completed",
|
status: "completed",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Apply automation rules to the newly created invoice
|
||||||
|
try {
|
||||||
|
const { applyAutomationRules } = await import("./automationEngine");
|
||||||
|
const automationUpdates = await applyAutomationRules(userId, newInvoice);
|
||||||
|
|
||||||
|
// If automation rules suggest updates, apply them
|
||||||
|
if (Object.keys(automationUpdates).length > 0) {
|
||||||
|
await updateInvoice(newInvoice.id, automationUpdates);
|
||||||
|
}
|
||||||
|
} catch (autoError) {
|
||||||
|
console.error("[Automation] Error applying rules:", autoError);
|
||||||
|
// Don't fail the import if automation fails
|
||||||
|
}
|
||||||
|
|
||||||
importedCount++;
|
importedCount++;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
errorsCount++;
|
errorsCount++;
|
||||||
@@ -753,6 +772,63 @@ export const appRouter = router({
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ============= AUTOMATION RULES ROUTES =============
|
||||||
|
automationRules: router({
|
||||||
|
list: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return await getAutomationRulesByUser(ctx.user.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
getById: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
id: z.number(),
|
||||||
|
}))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
return await getAutomationRuleById(input.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
create: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
isActive: z.number().min(0).max(1).optional(),
|
||||||
|
priority: z.number().optional(),
|
||||||
|
conditions: z.string(), // JSON string
|
||||||
|
conditionsLogic: z.enum(["AND", "OR"]).optional(),
|
||||||
|
actions: z.string(), // JSON string
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const rule = await createAutomationRule({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
return rule;
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
isActive: z.number().min(0).max(1).optional(),
|
||||||
|
priority: z.number().optional(),
|
||||||
|
conditions: z.string().optional(),
|
||||||
|
conditionsLogic: z.enum(["AND", "OR"]).optional(),
|
||||||
|
actions: z.string().optional(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const { id, ...data } = input;
|
||||||
|
const updated = await updateAutomationRule(id, data);
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
id: z.number(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
await deleteAutomationRule(input.id);
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
14
todo.md
14
todo.md
@@ -289,3 +289,17 @@
|
|||||||
- [x] Pousser la migration avec pnpm db:push
|
- [x] Pousser la migration avec pnpm db:push
|
||||||
- [x] Ajouter le champ dans l'interface ImportSettings.tsx
|
- [x] Ajouter le champ dans l'interface ImportSettings.tsx
|
||||||
- [x] Tester la sauvegarde et récupération du paramètre
|
- [x] Tester la sauvegarde et récupération du paramètre
|
||||||
|
|
||||||
|
## Système de gestion des automatismes
|
||||||
|
- [x] Créer la table automationRules dans le schéma pour stocker les règles
|
||||||
|
- [x] Définir la structure JSON pour les conditions (SI) et actions (ALORS)
|
||||||
|
- [x] Pousser la migration avec pnpm db:push
|
||||||
|
- [x] Créer les fonctions CRUD dans db.ts pour les règles
|
||||||
|
- [x] Créer les routes tRPC pour gérer les règles (list, create, update, delete, reorder)
|
||||||
|
- [x] Créer la page AutomationRules.tsx pour l'interface de gestion
|
||||||
|
- [x] Ajouter le menu "Automatismes" dans DashboardLayout
|
||||||
|
- [x] Implémenter le formulaire de création/édition de règles
|
||||||
|
- [x] Implémenter l'affichage de la liste des règles avec ordre de priorité
|
||||||
|
- [x] Créer la fonction d'évaluation des règles (applyAutomationRules)
|
||||||
|
- [x] Intégrer l'application des règles dans le processus d'import
|
||||||
|
- [x] Tester avec des règles réelles
|
||||||
|
|||||||
Reference in New Issue
Block a user