Checkpoint: Ajout d’un filtre par action dans la liste des automatismes, avec catégories Type d’achat, Service, Ventilation, Abonnement, Abonnement Oui et Abonnement Non. Tests, TypeScript, build et rendu visuel validés en sandbox.

This commit is contained in:
Manus
2026-08-27 07:02:36 +00:00
parent d7677df41b
commit 0536035303
4 changed files with 124 additions and 2 deletions

View File

@@ -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<any>(null);
const [actionFilter, setActionFilter] = useState<AutomationActionFilter>("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 (
<DashboardLayout>
<div className="space-y-6">
@@ -348,12 +359,36 @@ export default function AutomationRules() {
</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>
@@ -366,7 +401,7 @@ export default function AutomationRules() {
</TableRow>
</TableHeader>
<TableBody>
{rules.map((rule) => {
{filteredRules.map((rule) => {
let conditionsDisplay = "";
let actionsDisplay = "";
try {

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { matchesAutomationActionFilter } from "@shared/automationActions";
describe("matchesAutomationActionFilter", () => {
it("filtre chaque type daction 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);
});
});

View File

@@ -0,0 +1,51 @@
/** Les catégories dactions proposées dans les règles dautomatisme. */
export const AUTOMATION_ACTION_FILTERS = [
{ value: "all", label: "Toutes les actions" },
{ value: "typeAchat", label: "Type dachat" },
{ 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<string, unknown>;
/**
* Lit défensivement le JSON stocké en base : une règle historique invalide ne
* doit jamais empêcher laffichage 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 dune 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;
}

View File

@@ -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 daction disponibles et leurs règles de détection
- [x] Ajouter un filtre daction dans la liste des automatismes
- [x] Permettre disoler les règles Abonnement, Oui ou Non
- [x] Ajouter les tests et valider TypeScript, tests et build