Compare commits
5 Commits
3528fc6905
...
1a7d5cb64e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a7d5cb64e | ||
|
|
0536035303 | ||
|
|
d7677df41b | ||
|
|
d9c1130359 | ||
|
|
401659ec99 |
2
app.json
2
app.json
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"containerName": "demat-facturation-app",
|
||||
"image": "images/demat-facturation-dsi.jpg",
|
||||
"giteaRepo": "demat-facturation",
|
||||
"giteaRepo": "demat-facturation-dsi",
|
||||
"giteaOwner": "manus-admin",
|
||||
"ci": {
|
||||
"required": true
|
||||
|
||||
@@ -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 {
|
||||
|
||||
30
server/automationActions.test.ts
Normal file
30
server/automationActions.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
51
shared/automationActions.ts
Normal file
51
shared/automationActions.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
20
todo.md
20
todo.md
@@ -763,4 +763,22 @@
|
||||
|
||||
## Correctif recette — dialogue d’automatisme
|
||||
- [x] Vérifier le commit et localiser l’emplacement absent de l’option Abonnement
|
||||
- [ ] Corriger le dialogue d’édition et le valider en recette
|
||||
- [x] Corriger le dialogue d’édition et le valider en recette
|
||||
- [x] Arrêter le processus non applicatif autorisé qui bloque le build Docker
|
||||
|
||||
## Déploiement production — dialogue Abonnement
|
||||
- [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
|
||||
|
||||
## Déploiement — filtre par action des automatismes
|
||||
- [ ] Pousser le filtre par action vers Gitea recette
|
||||
- [ ] Déployer et vérifier le filtre par action en recette
|
||||
- [ ] Pousser la version validée vers Gitea production
|
||||
- [ ] Déployer et vérifier le filtre par action en production
|
||||
|
||||
Reference in New Issue
Block a user