1074 lines
47 KiB
TypeScript
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>
|
|
);
|
|
}
|