Files
demat-facturation/client/src/pages/AutomationRules.tsx
Manus cd50372d30 Checkpoint: Correction de l'erreur Select.Item dans le formulaire de création de règles d'automatisme
Problème résolu :
 Erreur "Select.Item must have a value prop that is not an empty string" corrigée
 Le bouton "Nouvelle règle" fonctionne maintenant correctement
 Le formulaire de création de règles s'affiche sans erreur

Modifications techniques :
- client/src/pages/AutomationRules.tsx : Remplacement des valeurs vides ("") par une valeur spéciale "__NONE__" dans les trois Select (Type d'achat, Service concerné, Ventilation comptable)
- Ajout de la logique de conversion : "__NONE__" → undefined lors de la sauvegarde
- Conservation de l'affichage "Ne pas modifier" pour l'utilisateur

Contexte :
Le composant Select de shadcn/ui ne permet pas d'utiliser des chaînes vides comme valeur pour SelectItem. La solution consiste à utiliser une valeur sentinelle ("__NONE__") qui est convertie en undefined lors de la sauvegarde, permettant ainsi de ne pas modifier le champ correspondant lors de l'application de la règle.

Cette correction permet maintenant de créer des règles d'automatisme sans erreur, avec la possibilité de choisir de ne pas modifier certains champs.
2026-02-11 12:44:44 -05:00

526 lines
19 KiB
TypeScript

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 || "__NONE__"}
onValueChange={(value) => setActions({ ...actions, typeAchat: value === "__NONE__" ? undefined : value })}
>
<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>
</SelectContent>
</Select>
</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 })}
>
<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>
))}
</SelectContent>
</Select>
</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 })}
>
<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>
))}
</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>
);
}