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",
|
"containerName": "demat-facturation-app",
|
||||||
"image": "images/demat-facturation-dsi.jpg",
|
"image": "images/demat-facturation-dsi.jpg",
|
||||||
"giteaRepo": "demat-facturation",
|
"giteaRepo": "demat-facturation-dsi",
|
||||||
"giteaOwner": "manus-admin",
|
"giteaOwner": "manus-admin",
|
||||||
"ci": {
|
"ci": {
|
||||||
"required": true
|
"required": true
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import DashboardLayout from "@/components/DashboardLayout";
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -33,6 +33,11 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import {
|
||||||
|
AUTOMATION_ACTION_FILTERS,
|
||||||
|
type AutomationActionFilter,
|
||||||
|
matchesAutomationActionFilter,
|
||||||
|
} from "@shared/automationActions";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Plus, Edit, Trash2, Power, PowerOff, Copy } from "lucide-react";
|
import { Plus, Edit, Trash2, Power, PowerOff, Copy } from "lucide-react";
|
||||||
|
|
||||||
@@ -63,6 +68,7 @@ export default function AutomationRules() {
|
|||||||
const [wizardStep, setWizardStep] = useState(1);
|
const [wizardStep, setWizardStep] = useState(1);
|
||||||
const [testResultsOpen, setTestResultsOpen] = useState(false);
|
const [testResultsOpen, setTestResultsOpen] = useState(false);
|
||||||
const [testResults, setTestResults] = useState<any>(null);
|
const [testResults, setTestResults] = useState<any>(null);
|
||||||
|
const [actionFilter, setActionFilter] = useState<AutomationActionFilter>("all");
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [ruleName, setRuleName] = useState("");
|
const [ruleName, setRuleName] = useState("");
|
||||||
@@ -320,6 +326,11 @@ export default function AutomationRules() {
|
|||||||
{ value: "<=", label: "inférieur ou égal à" },
|
{ value: "<=", label: "inférieur ou égal à" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const filteredRules = useMemo(
|
||||||
|
() => rules.filter((rule) => matchesAutomationActionFilter(rule.actions, actionFilter)),
|
||||||
|
[rules, actionFilter],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -348,12 +359,36 @@ export default function AutomationRules() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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 ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-gray-500">Chargement...</div>
|
<div className="text-center py-8 text-gray-500">Chargement...</div>
|
||||||
) : rules.length === 0 ? (
|
) : rules.length === 0 ? (
|
||||||
<div className="text-center py-8 text-gray-500">
|
<div className="text-center py-8 text-gray-500">
|
||||||
Aucune règle d'automatisme configurée
|
Aucune règle d'automatisme configurée
|
||||||
</div>
|
</div>
|
||||||
|
) : filteredRules.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
Aucune règle ne correspond à cette action
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -366,7 +401,7 @@ export default function AutomationRules() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{rules.map((rule) => {
|
{filteredRules.map((rule) => {
|
||||||
let conditionsDisplay = "";
|
let conditionsDisplay = "";
|
||||||
let actionsDisplay = "";
|
let actionsDisplay = "";
|
||||||
try {
|
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
|
## Correctif recette — dialogue d’automatisme
|
||||||
- [x] Vérifier le commit et localiser l’emplacement absent de l’option Abonnement
|
- [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