Files
demat-facturation/client/src/pages/AutomationRules.tsx

1074 lines
47 KiB
TypeScript

import { useMemo, 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 {
AUTOMATION_ACTION_FILTERS,
type AutomationActionFilter,
matchesAutomationActionFilter,
} from "@shared/automationActions";
import { toast } from "sonner";
import { Plus, Edit, Trash2, Power, PowerOff, Copy } from "lucide-react";
interface Condition {
field: string;
operator: string;
value: string;
}
interface Actions {
typeAchat?: string;
serviceConcerne?: string;
ventilationComptable?: string;
isSubscription?: 0 | 1;
}
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);
const [wizardMode, setWizardMode] = useState(false);
const [wizardStep, setWizardStep] = useState(1);
const [testResultsOpen, setTestResultsOpen] = useState(false);
const [testResults, setTestResults] = useState<any>(null);
const [actionFilter, setActionFilter] = useState<AutomationActionFilter>("all");
// 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>({});
// Textes personnalisés quand "Autre" est sélectionné
const [customTypeAchat, setCustomTypeAchat] = useState("");
const [customServiceConcerne, setCustomServiceConcerne] = useState("");
const [customVentilation, setCustomVentilation] = useState("");
// Helper : détecte si une valeur est "Autre" (insensible à la casse)
const isAutre = (val?: string) => val?.toUpperCase() === "AUTRE";
// Helper : résout la valeur finale (si Autre + texte perso, retourne le texte perso)
const resolveAction = (val?: string, custom?: string) => {
if (!val || val === "__NONE__") return undefined;
if (isAutre(val) && custom?.trim()) return custom.trim().toUpperCase();
if (isAutre(val)) return "AUTRE";
return val;
};
const normalizeSubscriptionAction = (value: unknown): 0 | 1 | undefined => {
if (value === 1 || value === "1" || value === "OUI") return 1;
if (value === 0 || value === "0" || value === "NON") return 0;
return undefined;
};
const createMutation = trpc.automationRules.create.useMutation({
onSuccess: (rule) => {
if (rule.isActive === 1) {
toast.success("Règle créée et appliquée aux factures BAP existantes");
} else {
toast.success("Règle créée avec succès");
}
utils.automationRules.list.invalidate();
utils.invoices.list.invalidate(); // Refresh invoices list
closeDialog();
},
onError: (error) => {
toast.error(error.message || "Erreur lors de la création");
},
});
const updateMutation = trpc.automationRules.update.useMutation({
onSuccess: (rule) => {
if (rule && rule.isActive === 1) {
toast.success("Règle mise à jour et appliquée aux factures BAP existantes");
} else {
toast.success("Règle mise à jour");
}
utils.automationRules.list.invalidate();
utils.invoices.list.invalidate(); // Refresh invoices list
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 testMutation = trpc.automationRules.test.useMutation({
onSuccess: (data) => {
setTestResults(data);
setTestResultsOpen(true);
},
onError: (error) => {
toast.error(error.message || "Erreur lors du test");
},
});
const duplicateMutation = trpc.automationRules.duplicate.useMutation({
onSuccess: () => {
toast.success("Règle dupliquée avec succès");
utils.automationRules.list.invalidate();
},
onError: (error) => {
toast.error(error.message || "Erreur lors de la duplication");
},
});
const openCreateDialog = () => {
setEditingRule(null);
setRuleName("");
setConditionsLogic("AND");
setConditions([{ field: "supplierName", operator: "contains", value: "" }]);
setActions({});
setCustomTypeAchat("");
setCustomServiceConcerne("");
setCustomVentilation("");
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 {
const parsedActions = JSON.parse(rule.actions);
// Détecter si les valeurs stockées ne correspondent pas aux valeurs fixes
// (ce qui signifie qu'un texte personnalisé avait été saisi)
const knownTypeAchat = ["CAPEX", "OPEX", "AUTRE", undefined];
const deptNames = departments?.map(d => d.name) || [];
const allocNames = allocations?.map(a => a.name) || [];
if (parsedActions.typeAchat && !knownTypeAchat.includes(parsedActions.typeAchat)) {
setCustomTypeAchat(parsedActions.typeAchat);
parsedActions.typeAchat = "AUTRE";
} else {
setCustomTypeAchat("");
}
if (parsedActions.serviceConcerne && !deptNames.includes(parsedActions.serviceConcerne) && parsedActions.serviceConcerne !== "AUTRE") {
setCustomServiceConcerne(parsedActions.serviceConcerne);
parsedActions.serviceConcerne = "AUTRE";
} else {
setCustomServiceConcerne("");
}
if (parsedActions.ventilationComptable && !allocNames.includes(parsedActions.ventilationComptable) && parsedActions.ventilationComptable !== "AUTRE") {
setCustomVentilation(parsedActions.ventilationComptable);
parsedActions.ventilationComptable = "AUTRE";
} else {
setCustomVentilation("");
}
setActions({ ...parsedActions, isSubscription: normalizeSubscriptionAction(parsedActions.isSubscription) });
} catch {
setActions({});
setCustomTypeAchat("");
setCustomServiceConcerne("");
setCustomVentilation("");
}
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);
// Résoudre les valeurs finales (remplacer AUTRE par le texte personnalisé si fourni)
const resolvedActions: Actions = {
typeAchat: resolveAction(actions.typeAchat, customTypeAchat),
serviceConcerne: resolveAction(actions.serviceConcerne, customServiceConcerne),
ventilationComptable: resolveAction(actions.ventilationComptable, customVentilation),
isSubscription: actions.isSubscription,
};
// Supprimer les clés undefined
if (!resolvedActions.typeAchat) delete resolvedActions.typeAchat;
if (!resolvedActions.serviceConcerne) delete resolvedActions.serviceConcerne;
if (!resolvedActions.ventilationComptable) delete resolvedActions.ventilationComptable;
if (resolvedActions.isSubscription === undefined) delete resolvedActions.isSubscription;
const actionsJSON = JSON.stringify(resolvedActions);
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: "recipientName", label: "Destinataire" },
{ 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 à" },
];
const filteredRules = useMemo(
() => rules.filter((rule) => matchesAutomationActionFilter(rule.actions, actionFilter)),
[rules, actionFilter],
);
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>
<div className="flex gap-2">
<Button onClick={() => { setWizardMode(true); setWizardStep(1); openCreateDialog(); }}>
<Plus className="w-4 h-4 mr-2" />
Assistant de création
</Button>
<Button variant="outline" onClick={() => { setWizardMode(false); openCreateDialog(); }}>
<Plus className="w-4 h-4 mr-2" />
Mode avancé
</Button>
</div>
</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>
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
<Label htmlFor="automation-action-filter" className="font-medium text-slate-700">
Filtrer par action
</Label>
<Select value={actionFilter} onValueChange={(value) => setActionFilter(value as AutomationActionFilter)}>
<SelectTrigger id="automation-action-filter" className="w-[260px] bg-white">
<SelectValue placeholder="Toutes les actions" />
</SelectTrigger>
<SelectContent>
{AUTOMATION_ACTION_FILTERS.map((filter) => (
<SelectItem key={filter.value} value={filter.value}>
{filter.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-slate-500">
{filteredRules.length} règle{filteredRules.length > 1 ? "s" : ""} affichée{filteredRules.length > 1 ? "s" : ""}
</span>
</div>
{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>
) : filteredRules.length === 0 ? (
<div className="text-center py-8 text-gray-500">
Aucune règle ne correspond à cette action
</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>
{filteredRules.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}`);
if (acts.isSubscription !== undefined) actionsArr.push(`Abonnement: ${normalizeSubscriptionAction(acts.isSubscription) === 1 ? "Oui" : "Non"}`);
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)}
title="Modifier"
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => duplicateMutation.mutate({ id: rule.id })}
title="Dupliquer"
>
<Copy className="w-4 h-4 text-blue-600" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(rule.id)}
title="Supprimer"
>
<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>
{wizardMode && !editingRule ? (
<div className="flex items-center gap-2">
<span>Assistant de création</span>
<Badge variant="outline">Étape {wizardStep}/3</Badge>
</div>
) : (
editingRule ? "Modifier la règle" : "Nouvelle règle"
)}
</DialogTitle>
<DialogDescription>
{wizardMode && !editingRule ? (
wizardStep === 1 ? "Définissez les conditions pour déclencher la règle" :
wizardStep === 2 ? "Choisissez les champs à remplir automatiquement" :
"Vérifiez et validez votre règle"
) : (
"Définissez les conditions (SI) et les actions (ALORS) pour le remplissage automatique"
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{wizardMode && !editingRule ? (
// WIZARD MODE - Étapes guidées
<div className="space-y-6">
{wizardStep === 1 && (
// ÉTAPE 1: Nom et conditions simples
<div className="space-y-4">
<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>
<div className="space-y-3">
<h3 className="font-semibold">Quand appliquer cette règle ?</h3>
<p className="text-sm text-gray-500">Définissez les conditions pour déclencher la règle</p>
<div className="space-y-4">
<div className="space-y-2">
<Label>Le fournisseur</Label>
<div className="flex gap-2">
<Select
value={conditions[0]?.operator || "contains"}
onValueChange={(value) => updateCondition(0, "operator", value)}
>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="contains">contient</SelectItem>
<SelectItem value="equals">est égal à</SelectItem>
<SelectItem value="startsWith">commence par</SelectItem>
</SelectContent>
</Select>
<Input
value={conditions[0]?.value || ""}
onChange={(e) => {
if (conditions.length === 0) {
setConditions([{ field: "supplierName", operator: "contains", value: e.target.value }]);
} else {
updateCondition(0, "value", e.target.value);
}
}}
placeholder="Ex: Microsoft, Amazon, etc."
className="flex-1"
/>
</div>
</div>
</div>
</div>
</div>
)}
{wizardStep === 2 && (
// ÉTAPE 2: Actions
<div className="space-y-4">
<div>
<h3 className="font-semibold mb-1">Que voulez-vous remplir automatiquement ?</h3>
<p className="text-sm text-gray-500">Sélectionnez les champs à compléter</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="typeAchat">Type d'achat</Label>
<Select
value={actions.typeAchat || "__NONE__"}
onValueChange={(value) => { setActions({ ...actions, typeAchat: value === "__NONE__" ? undefined : value }); if (value !== "AUTRE") setCustomTypeAchat(""); }}
>
<SelectTrigger>
<SelectValue placeholder="Ne pas modifier" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__NONE__">Ne pas modifier</SelectItem>
<SelectItem value="CAPEX">CAPEX</SelectItem>
<SelectItem value="OPEX">OPEX</SelectItem>
<SelectItem value="AUTRE">Autre...</SelectItem>
</SelectContent>
</Select>
{isAutre(actions.typeAchat) && (
<Input
value={customTypeAchat}
onChange={(e) => setCustomTypeAchat(e.target.value)}
placeholder="Texte à afficher sur le BAP (ex: INVESTISSEMENT)"
className="mt-1 border-orange-300 focus:border-orange-500"
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="serviceConcerne">Service concerné</Label>
<Select
value={actions.serviceConcerne || "__NONE__"}
onValueChange={(value) => { setActions({ ...actions, serviceConcerne: value === "__NONE__" ? undefined : value }); if (value !== "AUTRE") setCustomServiceConcerne(""); }}
>
<SelectTrigger>
<SelectValue placeholder="Ne pas modifier" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__NONE__">Ne pas modifier</SelectItem>
{departments?.map((dept) => (
<SelectItem key={dept.id} value={dept.name}>
{dept.name}
</SelectItem>
))}
<SelectItem value="AUTRE">Autre...</SelectItem>
</SelectContent>
</Select>
{isAutre(actions.serviceConcerne) && (
<Input
value={customServiceConcerne}
onChange={(e) => setCustomServiceConcerne(e.target.value)}
placeholder="Texte à afficher sur le BAP (ex: LOGISTIQUE)"
className="mt-1 border-orange-300 focus:border-orange-500"
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="ventilationComptable">Ventilation comptable</Label>
<Select
value={actions.ventilationComptable || "__NONE__"}
onValueChange={(value) => { setActions({ ...actions, ventilationComptable: value === "__NONE__" ? undefined : value }); if (value !== "AUTRE") setCustomVentilation(""); }}
>
<SelectTrigger>
<SelectValue placeholder="Ne pas modifier" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__NONE__">Ne pas modifier</SelectItem>
{allocations?.map((alloc) => (
<SelectItem key={alloc.id} value={alloc.name}>
{alloc.name}
</SelectItem>
))}
<SelectItem value="AUTRE">Autre...</SelectItem>
</SelectContent>
</Select>
{isAutre(actions.ventilationComptable) && (
<Input
value={customVentilation}
onChange={(e) => setCustomVentilation(e.target.value)}
placeholder="Texte à afficher sur le BAP (ex: SANITAIRE)"
className="mt-1 border-orange-300 focus:border-orange-500"
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="isSubscription">Abonnement</Label>
<Select
value={actions.isSubscription === undefined ? "__NONE__" : String(actions.isSubscription)}
onValueChange={(value) => setActions({ ...actions, isSubscription: value === "__NONE__" ? undefined : Number(value) as 0 | 1 })}
>
<SelectTrigger>
<SelectValue placeholder="Ne pas modifier" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__NONE__">Ne pas modifier</SelectItem>
<SelectItem value="1">Oui</SelectItem>
<SelectItem value="0">Non</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
)}
{wizardStep === 3 && (
// ÉTAPE 3: Révision
<div className="space-y-4">
<div>
<h3 className="font-semibold mb-1">Récapitulatif de la règle</h3>
<p className="text-sm text-gray-500">Vérifiez les informations avant de créer</p>
</div>
<Card>
<CardContent className="pt-6 space-y-4">
<div>
<div className="text-sm font-semibold text-gray-500">Nom</div>
<div className="text-lg">{ruleName || "(non défini)"}</div>
</div>
<div>
<div className="text-sm font-semibold text-gray-500">Condition</div>
<div className="text-lg">
Le fournisseur {conditions[0]?.operator === "contains" ? "contient" : conditions[0]?.operator === "equals" ? "est égal à" : "commence par"} "{conditions[0]?.value || "(vide)"}"
</div>
</div>
<div>
<div className="text-sm font-semibold text-gray-500">Actions</div>
<div className="space-y-1">
{actions.typeAchat && <div>• Type d'achat : {actions.typeAchat}</div>}
{actions.serviceConcerne && <div> Service : {actions.serviceConcerne}</div>}
{actions.ventilationComptable && <div> Ventilation : {actions.ventilationComptable}</div>}
{actions.isSubscription !== undefined && <div> Abonnement : {actions.isSubscription === 1 ? "Oui" : "Non"}</div>}
{!actions.typeAchat && !actions.serviceConcerne && !actions.ventilationComptable && actions.isSubscription === undefined && (
<div className="text-gray-500 italic">Aucune action définie</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>
)}
</div>
) : (
// MODE AVANCÉ - Formulaire complet
<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 rounded-md border border-emerald-200 bg-emerald-50 p-3">
<Label htmlFor="isSubscription" className="font-medium text-emerald-950">Abonnement</Label>
<Select
value={actions.isSubscription === undefined ? "__NONE__" : String(actions.isSubscription)}
onValueChange={(value) => setActions({ ...actions, isSubscription: value === "__NONE__" ? undefined : Number(value) as 0 | 1 })}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="Ne pas modifier" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__NONE__">Ne pas modifier</SelectItem>
<SelectItem value="1">Oui</SelectItem>
<SelectItem value="0">Non</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-emerald-800">Définit si les factures correspondant à la règle sont des abonnements.</p>
</div>
<div className="space-y-2">
<Label htmlFor="typeAchat">Type d'achat</Label>
<Select
value={actions.typeAchat || "__NONE__"}
onValueChange={(value) => { setActions({ ...actions, typeAchat: value === "__NONE__" ? undefined : value }); if (value !== "AUTRE") setCustomTypeAchat(""); }}
>
<SelectTrigger>
<SelectValue placeholder="Ne pas modifier" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__NONE__">Ne pas modifier</SelectItem>
<SelectItem value="CAPEX">CAPEX</SelectItem>
<SelectItem value="OPEX">OPEX</SelectItem>
<SelectItem value="AUTRE">Autre...</SelectItem>
</SelectContent>
</Select>
{isAutre(actions.typeAchat) && (
<Input
value={customTypeAchat}
onChange={(e) => setCustomTypeAchat(e.target.value)}
placeholder="Saisir le texte à afficher sur le BAP (ex: INVESTISSEMENT)"
className="mt-1 border-orange-300 focus:border-orange-500"
autoFocus
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="serviceConcerne">Service concerné</Label>
<Select
value={actions.serviceConcerne || "__NONE__"}
onValueChange={(value) => { setActions({ ...actions, serviceConcerne: value === "__NONE__" ? undefined : value }); if (value !== "AUTRE") setCustomServiceConcerne(""); }}
>
<SelectTrigger>
<SelectValue placeholder="Ne pas modifier" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__NONE__">Ne pas modifier</SelectItem>
{departments?.map((dept) => (
<SelectItem key={dept.id} value={dept.name}>
{dept.name}
</SelectItem>
))}
<SelectItem value="AUTRE">Autre...</SelectItem>
</SelectContent>
</Select>
{isAutre(actions.serviceConcerne) && (
<Input
value={customServiceConcerne}
onChange={(e) => setCustomServiceConcerne(e.target.value)}
placeholder="Saisir le texte à afficher sur le BAP (ex: LOGISTIQUE)"
className="mt-1 border-orange-300 focus:border-orange-500"
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="ventilationComptable">Ventilation comptable</Label>
<Select
value={actions.ventilationComptable || "__NONE__"}
onValueChange={(value) => { setActions({ ...actions, ventilationComptable: value === "__NONE__" ? undefined : value }); if (value !== "AUTRE") setCustomVentilation(""); }}
>
<SelectTrigger>
<SelectValue placeholder="Ne pas modifier" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__NONE__">Ne pas modifier</SelectItem>
{allocations?.map((alloc) => (
<SelectItem key={alloc.id} value={alloc.name}>
{alloc.name}
</SelectItem>
))}
<SelectItem value="AUTRE">Autre...</SelectItem>
</SelectContent>
</Select>
{isAutre(actions.ventilationComptable) && (
<Input
value={customVentilation}
onChange={(e) => setCustomVentilation(e.target.value)}
placeholder="Saisir le texte à afficher sur le BAP (ex: SANITAIRE)"
className="mt-1 border-orange-300 focus:border-orange-500"
/>
)}
</div>
</div>
</div>
</div>
)}
</div>
<DialogFooter className="flex justify-between">
{wizardMode && !editingRule ? (
// Navigation wizard
<>
<Button
variant="outline"
onClick={() => {
if (wizardStep > 1) {
setWizardStep(wizardStep - 1);
} else {
closeDialog();
}
}}
>
{wizardStep === 1 ? "Annuler" : "Précédent"}
</Button>
<Button
onClick={() => {
if (wizardStep < 3) {
if (wizardStep === 1 && !ruleName.trim()) {
toast.error("Le nom de la règle est obligatoire");
return;
}
if (wizardStep === 1 && !conditions[0]?.value?.trim()) {
toast.error("Veuillez définir au moins une condition");
return;
}
setWizardStep(wizardStep + 1);
} else {
handleSave();
}
}}
disabled={createMutation.isPending}
>
{wizardStep === 3 ? "Créer la règle" : "Suivant"}
</Button>
</>
) : (
// Navigation mode avancé
<>
<Button
variant="secondary"
onClick={() => {
if (!conditions.some(c => c.value.trim())) {
toast.error("Veuillez remplir au moins une condition");
return;
}
testMutation.mutate({
conditions: JSON.stringify(conditions),
conditionsLogic,
actions: JSON.stringify(actions),
});
}}
disabled={testMutation.isPending}
>
{testMutation.isPending ? "Test en cours..." : "Tester la règle"}
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={closeDialog}>
Annuler
</Button>
<Button onClick={handleSave} disabled={createMutation.isPending || updateMutation.isPending}>
{editingRule ? "Mettre à jour" : "Créer"}
</Button>
</div>
</>
)}
</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>
{/* Test Results Dialog */}
<Dialog open={testResultsOpen} onOpenChange={setTestResultsOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Résultats du test</DialogTitle>
<DialogDescription>
Simulation de l'application de la règle sur vos factures existantes
</DialogDescription>
</DialogHeader>
{testResults && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{testResults.totalInvoices}</div>
<div className="text-sm text-gray-500">Factures totales</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold text-green-600">{testResults.affectedCount}</div>
<div className="text-sm text-gray-500">Factures affectées</div>
</CardContent>
</Card>
</div>
{testResults.affectedInvoices && testResults.affectedInvoices.length > 0 && (
<div>
<h4 className="font-semibold mb-2">Aperçu des factures affectées (10 premières) :</h4>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Fournisseur</TableHead>
<TableHead>N° Facture</TableHead>
<TableHead>Modifications</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{testResults.affectedInvoices.map((inv: any) => (
<TableRow key={inv.id}>
<TableCell>{inv.supplierName || "-"}</TableCell>
<TableCell>{inv.invoiceNumber || "-"}</TableCell>
<TableCell className="text-sm">
{inv.changes.typeAchat && <div>Type: {inv.changes.typeAchat}</div>}
{inv.changes.serviceConcerne && <div>Service: {inv.changes.serviceConcerne}</div>}
{inv.changes.ventilationComptable && <div>Ventilation: {inv.changes.ventilationComptable}</div>}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
)}
<DialogFooter>
<Button onClick={() => setTestResultsOpen(false)}>Fermer</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</DashboardLayout>
);
}