diff --git a/client/src/pages/AutomationRules.tsx b/client/src/pages/AutomationRules.tsx index 8c0f9c6..c4021d4 100644 --- a/client/src/pages/AutomationRules.tsx +++ b/client/src/pages/AutomationRules.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +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"; @@ -33,6 +33,11 @@ import { } 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"; @@ -63,6 +68,7 @@ export default function AutomationRules() { const [wizardStep, setWizardStep] = useState(1); const [testResultsOpen, setTestResultsOpen] = useState(false); const [testResults, setTestResults] = useState(null); + const [actionFilter, setActionFilter] = useState("all"); // Form state const [ruleName, setRuleName] = useState(""); @@ -320,6 +326,11 @@ export default function AutomationRules() { { value: "<=", label: "inférieur ou égal à" }, ]; + const filteredRules = useMemo( + () => rules.filter((rule) => matchesAutomationActionFilter(rule.actions, actionFilter)), + [rules, actionFilter], + ); + return (
@@ -348,12 +359,36 @@ export default function AutomationRules() { +
+ + + + {filteredRules.length} règle{filteredRules.length > 1 ? "s" : ""} affichée{filteredRules.length > 1 ? "s" : ""} + +
{isLoading ? (
Chargement...
) : rules.length === 0 ? (
Aucune règle d'automatisme configurée
+ ) : filteredRules.length === 0 ? ( +
+ Aucune règle ne correspond à cette action +
) : ( @@ -366,7 +401,7 @@ export default function AutomationRules() { - {rules.map((rule) => { + {filteredRules.map((rule) => { let conditionsDisplay = ""; let actionsDisplay = ""; try { diff --git a/server/automationActions.test.ts b/server/automationActions.test.ts new file mode 100644 index 0000000..909ada2 --- /dev/null +++ b/server/automationActions.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { matchesAutomationActionFilter } from "@shared/automationActions"; + +describe("matchesAutomationActionFilter", () => { + it("filtre chaque type d’action métier", () => { + const actions = JSON.stringify({ + typeAchat: "OPEX", + serviceConcerne: "DSI SANTINOVA", + ventilationComptable: "SANTINOVA", + }); + + expect(matchesAutomationActionFilter(actions, "typeAchat")).toBe(true); + expect(matchesAutomationActionFilter(actions, "serviceConcerne")).toBe(true); + expect(matchesAutomationActionFilter(actions, "ventilationComptable")).toBe(true); + expect(matchesAutomationActionFilter(actions, "subscription")).toBe(false); + }); + + it("distingue les règles Abonnement Oui et Non", () => { + expect(matchesAutomationActionFilter('{"isSubscription":1}', "subscription")).toBe(true); + expect(matchesAutomationActionFilter('{"isSubscription":1}', "subscriptionYes")).toBe(true); + expect(matchesAutomationActionFilter('{"isSubscription":1}', "subscriptionNo")).toBe(false); + expect(matchesAutomationActionFilter('{"isSubscription":0}', "subscriptionYes")).toBe(false); + expect(matchesAutomationActionFilter('{"isSubscription":0}', "subscriptionNo")).toBe(true); + }); + + it("tolère les règles historiques invalides sans masquer le filtre Toutes les actions", () => { + expect(matchesAutomationActionFilter("json-invalide", "all")).toBe(true); + expect(matchesAutomationActionFilter("json-invalide", "subscription")).toBe(false); + }); +}); diff --git a/shared/automationActions.ts b/shared/automationActions.ts new file mode 100644 index 0000000..5730518 --- /dev/null +++ b/shared/automationActions.ts @@ -0,0 +1,51 @@ +/** Les catégories d’actions proposées dans les règles d’automatisme. */ +export const AUTOMATION_ACTION_FILTERS = [ + { value: "all", label: "Toutes les actions" }, + { value: "typeAchat", label: "Type d’achat" }, + { value: "serviceConcerne", label: "Service concerné" }, + { value: "ventilationComptable", label: "Ventilation comptable" }, + { value: "subscription", label: "Abonnement (Oui ou Non)" }, + { value: "subscriptionYes", label: "Abonnement : Oui" }, + { value: "subscriptionNo", label: "Abonnement : Non" }, +] as const; + +export type AutomationActionFilter = (typeof AUTOMATION_ACTION_FILTERS)[number]["value"]; + +type RuleActions = Record; + +/** + * Lit défensivement le JSON stocké en base : une règle historique invalide ne + * doit jamais empêcher l’affichage ni le filtrage de la liste complète. + */ +function parseRuleActions(actionsJson: string): RuleActions { + try { + const parsed: unknown = JSON.parse(actionsJson); + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as RuleActions + : {}; + } catch { + return {}; + } +} + +function getSubscriptionValue(actions: RuleActions): 0 | 1 | undefined { + const value = actions.isSubscription; + if (value === 1 || value === "1" || value === "OUI") return 1; + if (value === 0 || value === "0" || value === "NON") return 0; + return undefined; +} + +/** Retourne si les actions JSON d’une règle correspondent au filtre choisi. */ +export function matchesAutomationActionFilter( + actionsJson: string, + filter: AutomationActionFilter, +): boolean { + if (filter === "all") return true; + + const actions = parseRuleActions(actionsJson); + if (filter === "subscription") return Object.hasOwn(actions, "isSubscription"); + if (filter === "subscriptionYes") return getSubscriptionValue(actions) === 1; + if (filter === "subscriptionNo") return getSubscriptionValue(actions) === 0; + + return typeof actions[filter] === "string" && actions[filter].trim().length > 0; +} diff --git a/todo.md b/todo.md index a3ae560..236f5c9 100644 --- a/todo.md +++ b/todo.md @@ -770,3 +770,9 @@ - [x] Pousser le correctif vers Gitea production - [x] Construire et redémarrer uniquement le conteneur applicatif - [x] Vérifier le commit, la santé, HTTP et le bundle de production + +## Filtre par action des automatismes +- [x] Identifier les types d’action disponibles et leurs règles de détection +- [x] Ajouter un filtre d’action dans la liste des automatismes +- [x] Permettre d’isoler les règles Abonnement, Oui ou Non +- [x] Ajouter les tests et valider TypeScript, tests et build