Checkpoint: Automatismes séparé en onglets Import et Export. Les règles d’export ciblent un destinataire ou une ventilation, avec destination locale, Teams ou SharePoint et ouverture navigateur optionnelle. La validation BAP unique ou en masse applique la première règle active par priorité puis conserve le paramétrage historique comme repli. Paramètres d’export déplacés vers Automatismes et page renommée Paramètres. Migration additive, tests, TypeScript, build et contrôles visuels validés.
This commit is contained in:
@@ -71,7 +71,7 @@ const menuStructure: MenuItem[] = [
|
||||
color: "from-orange-500 to-amber-500",
|
||||
children: [
|
||||
{ icon: Settings, label: "Paramètres IA et Signatures", path: "/settings" },
|
||||
{ icon: Download, label: "Paramètres import / export", path: "/import-settings" },
|
||||
{ icon: Download, label: "Paramètres", path: "/import-settings" },
|
||||
{ icon: List, label: "Administration des listes", path: "/lists-admin" },
|
||||
{ icon: Zap, label: "Automatismes", path: "/automation-rules" },
|
||||
{ icon: Brain, label: "Apprentissages IA", path: "/learning-settings" },
|
||||
|
||||
186
client/src/components/ExportAutomationRulesPanel.tsx
Normal file
186
client/src/components/ExportAutomationRulesPanel.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FolderOpen, Monitor, Pencil, Plus, Power, PowerOff, Save, Trash2 } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type ConditionField = "recipientName" | "ventilationComptable";
|
||||
type DestinationType = "local" | "teams" | "sharepoint";
|
||||
|
||||
const LABELS: Record<ConditionField, string> = {
|
||||
recipientName: "Destinataire",
|
||||
ventilationComptable: "Ventilation",
|
||||
};
|
||||
|
||||
const DESTINATION_LABELS: Record<DestinationType, string> = {
|
||||
local: "Dossier local",
|
||||
teams: "Teams",
|
||||
sharepoint: "SharePoint",
|
||||
};
|
||||
|
||||
export default function ExportAutomationRulesPanel() {
|
||||
const utils = trpc.useUtils();
|
||||
const { data: rules = [], isLoading } = trpc.exportAutomationRules.list.useQuery();
|
||||
const { data: invoices = [] } = trpc.invoices.list.useQuery();
|
||||
const { data: importSettings } = trpc.importSettings.get.useQuery();
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [conditionField, setConditionField] = useState<ConditionField>("recipientName");
|
||||
const [conditionValue, setConditionValue] = useState("");
|
||||
const [destinationType, setDestinationType] = useState<DestinationType>("local");
|
||||
const [destinationPath, setDestinationPath] = useState("");
|
||||
const [openInBrowser, setOpenInBrowser] = useState(false);
|
||||
const [defaultExportMode, setDefaultExportMode] = useState<"browser" | "folder" | "both">("browser");
|
||||
const [defaultDestinationType, setDefaultDestinationType] = useState<DestinationType>("local");
|
||||
const [defaultDestinationPath, setDefaultDestinationPath] = useState("");
|
||||
const [azureTenantId, setAzureTenantId] = useState("");
|
||||
const [azureClientId, setAzureClientId] = useState("");
|
||||
const [azureClientSecret, setAzureClientSecret] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!importSettings) return;
|
||||
setDefaultExportMode(importSettings.bapExportMode || "browser");
|
||||
setDefaultDestinationType((importSettings.exportFolderType || "local") as DestinationType);
|
||||
setDefaultDestinationPath(importSettings.exportFolder || "");
|
||||
setAzureTenantId(importSettings.azureTenantId || "");
|
||||
setAzureClientId(importSettings.azureClientId || "");
|
||||
}, [importSettings]);
|
||||
|
||||
const conditionValues = useMemo(() => {
|
||||
const field = conditionField === "recipientName" ? "recipientName" : "ventilationComptable";
|
||||
return Array.from(new Set(invoices.map((invoice: any) => invoice[field]).filter(Boolean))).sort();
|
||||
}, [conditionField, invoices]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingId(null);
|
||||
setName("");
|
||||
setConditionField("recipientName");
|
||||
setConditionValue("");
|
||||
setDestinationType("local");
|
||||
setDestinationPath("");
|
||||
setOpenInBrowser(false);
|
||||
};
|
||||
|
||||
const createMutation = trpc.exportAutomationRules.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Règle d’export créée");
|
||||
utils.exportAutomationRules.list.invalidate();
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => toast.error(error.message || "Impossible de créer la règle"),
|
||||
});
|
||||
const updateMutation = trpc.exportAutomationRules.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Règle d’export mise à jour");
|
||||
utils.exportAutomationRules.list.invalidate();
|
||||
resetForm();
|
||||
},
|
||||
onError: (error) => toast.error(error.message || "Impossible de mettre à jour la règle"),
|
||||
});
|
||||
const deleteMutation = trpc.exportAutomationRules.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Règle d’export supprimée");
|
||||
utils.exportAutomationRules.list.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(error.message || "Impossible de supprimer la règle"),
|
||||
});
|
||||
const updateDefaultExportMutation = trpc.importSettings.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Paramètres d’export enregistrés");
|
||||
setAzureClientSecret("");
|
||||
utils.importSettings.get.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(error.message || "Impossible d’enregistrer les paramètres d’export"),
|
||||
});
|
||||
|
||||
const saveRule = () => {
|
||||
if (!name.trim() || !conditionValue.trim() || !destinationPath.trim()) {
|
||||
toast.error("Nom, valeur de condition et destination sont obligatoires");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
conditionField,
|
||||
conditionValue: conditionValue.trim(),
|
||||
destinationType,
|
||||
destinationPath: destinationPath.trim(),
|
||||
openInBrowser: openInBrowser ? 1 : 0,
|
||||
};
|
||||
if (editingId) updateMutation.mutate({ id: editingId, ...payload });
|
||||
else createMutation.mutate({ ...payload, isActive: 1, priority: rules.length });
|
||||
};
|
||||
|
||||
const editRule = (rule: any) => {
|
||||
setEditingId(rule.id);
|
||||
setName(rule.name);
|
||||
setConditionField(rule.conditionField);
|
||||
setConditionValue(rule.conditionValue);
|
||||
setDestinationType(rule.destinationType);
|
||||
setDestinationPath(rule.destinationPath);
|
||||
setOpenInBrowser(rule.openInBrowser === 1);
|
||||
};
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
const isMicrosoftDestination = destinationType === "teams" || destinationType === "sharepoint";
|
||||
|
||||
const saveDefaultExport = () => {
|
||||
if (!importSettings) return;
|
||||
if (defaultExportMode !== "browser" && !defaultDestinationPath.trim()) {
|
||||
toast.error("Une destination est obligatoire lorsque l’export vers un dossier est activé");
|
||||
return;
|
||||
}
|
||||
updateDefaultExportMutation.mutate({
|
||||
manualImportEnabled: importSettings.manualImportEnabled,
|
||||
autoImportEnabled: importSettings.autoImportEnabled,
|
||||
emailImportEnabled: importSettings.emailImportEnabled,
|
||||
exportFolder: defaultDestinationPath.trim() || null,
|
||||
exportFolderType: defaultDestinationType,
|
||||
bapExportMode: defaultExportMode,
|
||||
azureTenantId: azureTenantId.trim() || null,
|
||||
azureClientId: azureClientId.trim() || null,
|
||||
...(azureClientSecret ? { azureClientSecret } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Card className="border-orange-200">
|
||||
<CardHeader><CardTitle>Paramètres d’export par défaut</CardTitle><CardDescription>Ils s’appliquent lorsqu’aucun automatisme d’export ne correspond à la facture BAP.</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2"><div className="space-y-2"><Label>Mode d’export BAP</Label><Select value={defaultExportMode} onValueChange={(value) => setDefaultExportMode(value as "browser" | "folder" | "both")}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="browser">Ouvrir dans le navigateur</SelectItem><SelectItem value="folder">Exporter vers la destination</SelectItem><SelectItem value="both">Destination et navigateur</SelectItem></SelectContent></Select></div><div className="space-y-2"><Label>Type de destination</Label><Select value={defaultDestinationType} onValueChange={(value) => setDefaultDestinationType(value as DestinationType)} disabled={defaultExportMode === "browser"}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="local">Dossier local</SelectItem><SelectItem value="teams">Teams</SelectItem><SelectItem value="sharepoint">SharePoint</SelectItem></SelectContent></Select></div></div>
|
||||
<div className="space-y-2"><Label htmlFor="default-export-path">{defaultDestinationType === "local" ? "Chemin du dossier local" : "URL SharePoint du dossier"}</Label><Input id="default-export-path" value={defaultDestinationPath} disabled={defaultExportMode === "browser"} onChange={(event) => setDefaultDestinationPath(event.target.value)} placeholder={defaultDestinationType === "local" ? "/chemin/serveur/factures-bap" : "https://…sharepoint.com/sites/…/Documents/Factures"} /></div>
|
||||
{(defaultDestinationType === "teams" || defaultDestinationType === "sharepoint") && <div className="grid gap-3 rounded-lg border border-cyan-200 bg-cyan-50/40 p-3 md:grid-cols-3"><div className="space-y-1"><Label htmlFor="azure-tenant">Tenant ID</Label><Input id="azure-tenant" value={azureTenantId} onChange={(event) => setAzureTenantId(event.target.value)} /></div><div className="space-y-1"><Label htmlFor="azure-client">Client ID</Label><Input id="azure-client" value={azureClientId} onChange={(event) => setAzureClientId(event.target.value)} /></div><div className="space-y-1"><Label htmlFor="azure-secret">Secret client</Label><Input id="azure-secret" type="password" value={azureClientSecret} onChange={(event) => setAzureClientSecret(event.target.value)} placeholder="Conserver le secret actuel" /></div><p className="col-span-full text-xs text-cyan-800">Teams utilise l’URL SharePoint du dossier Fichiers du canal. Laissez le secret vide pour conserver celui déjà enregistré.</p></div>}
|
||||
<Button onClick={saveDefaultExport} disabled={!importSettings || updateDefaultExportMutation.isPending}><Save className="mr-2 h-4 w-4" />{updateDefaultExportMutation.isPending ? "Enregistrement…" : "Enregistrer les paramètres d’export"}</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-violet-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><FolderOpen className="h-5 w-5 text-violet-600" />Nouvelle règle de destination</CardTitle>
|
||||
<CardDescription>La première règle active par priorité qui correspond à la facture est appliquée lors de la validation BAP.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2"><Label htmlFor="export-rule-name">Nom de la règle</Label><Input id="export-rule-name" value={name} onChange={(event) => setName(event.target.value)} placeholder="Ex. DSI SANTINOVA vers SharePoint" /></div>
|
||||
<div className="space-y-2"><Label>Critère</Label><Select value={conditionField} onValueChange={(value) => { setConditionField(value as ConditionField); setConditionValue(""); }}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="recipientName">Destinataire</SelectItem><SelectItem value="ventilationComptable">Ventilation comptable</SelectItem></SelectContent></Select></div>
|
||||
</div>
|
||||
<div className="space-y-2"><Label htmlFor="export-rule-value">Valeur du critère</Label><Input id="export-rule-value" list="export-condition-values" value={conditionValue} onChange={(event) => setConditionValue(event.target.value)} placeholder={conditionField === "recipientName" ? "Choisir ou saisir un destinataire" : "Choisir ou saisir une ventilation"} /><datalist id="export-condition-values">{conditionValues.map((value) => <option value={value} key={value} />)}</datalist></div>
|
||||
<div className="space-y-2"><Label>Destination</Label><div className="grid gap-2 sm:grid-cols-3">{(["local", "teams", "sharepoint"] as DestinationType[]).map((type) => <Button key={type} type="button" variant={destinationType === type ? "default" : "outline"} className={destinationType === type ? "bg-violet-600 hover:bg-violet-700" : ""} onClick={() => setDestinationType(type)}>{DESTINATION_LABELS[type]}</Button>)}</div></div>
|
||||
<div className="space-y-2"><Label htmlFor="export-rule-path">{isMicrosoftDestination ? "URL SharePoint du dossier" : "Chemin du dossier local"}</Label><Input id="export-rule-path" value={destinationPath} onChange={(event) => setDestinationPath(event.target.value)} placeholder={isMicrosoftDestination ? "https://…sharepoint.com/sites/…/Documents/Factures" : "/chemin/serveur/factures-bap"} /><p className="text-xs text-muted-foreground">{isMicrosoftDestination ? "Pour Teams, indiquez l’URL SharePoint du dossier Fichiers du canal. Les droits Microsoft existants sont réutilisés." : "Ce chemin est créé si nécessaire sur le serveur qui héberge l’application."}</p></div>
|
||||
<div className="flex items-center justify-between rounded-lg border bg-slate-50 p-3"><div><Label htmlFor="export-open-browser">Ouvrir aussi dans le navigateur</Label><p className="text-xs text-muted-foreground">Conserve le téléchargement dans le navigateur en complément de la destination définie.</p></div><Switch id="export-open-browser" checked={openInBrowser} onCheckedChange={setOpenInBrowser} /></div>
|
||||
<div className="flex gap-2"><Button onClick={saveRule} disabled={isSaving}><Save className="mr-2 h-4 w-4" />{editingId ? "Mettre à jour" : "Créer la règle"}</Button>{editingId && <Button variant="outline" onClick={resetForm}>Annuler</Button>}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Règles d’export</CardTitle><CardDescription>Les règles inactives restent enregistrées mais ne sont jamais appliquées.</CardDescription></CardHeader>
|
||||
<CardContent>{isLoading ? <p className="text-sm text-muted-foreground">Chargement…</p> : rules.length === 0 ? <p className="text-sm text-muted-foreground">Aucune règle de destination. Les paramètres existants restent le comportement de repli.</p> : <Table><TableHeader><TableRow><TableHead>Nom</TableHead><TableHead>Condition</TableHead><TableHead>Destination</TableHead><TableHead>Statut</TableHead><TableHead className="w-32">Actions</TableHead></TableRow></TableHeader><TableBody>{rules.map((rule: any) => <TableRow key={rule.id}><TableCell className="font-medium">{rule.name}{rule.openInBrowser === 1 && <span className="ml-2 inline-flex items-center text-xs text-slate-500"><Monitor className="mr-1 h-3 w-3" />Navigateur</span>}</TableCell><TableCell>{LABELS[rule.conditionField as ConditionField]} : {rule.conditionValue}</TableCell><TableCell><Badge variant="outline">{DESTINATION_LABELS[rule.destinationType as DestinationType]}</Badge><div className="mt-1 max-w-xs truncate text-xs text-muted-foreground" title={rule.destinationPath}>{rule.destinationPath}</div></TableCell><TableCell>{rule.isActive === 1 ? <Badge className="bg-green-100 text-green-800 hover:bg-green-100">Actif</Badge> : <Badge variant="secondary">Inactif</Badge>}</TableCell><TableCell><div className="flex gap-1"><Button size="icon" variant="ghost" title={rule.isActive === 1 ? "Désactiver" : "Activer"} onClick={() => updateMutation.mutate({ id: rule.id, isActive: rule.isActive === 1 ? 0 : 1 })}>{rule.isActive === 1 ? <PowerOff className="h-4 w-4" /> : <Power className="h-4 w-4" />}</Button><Button size="icon" variant="ghost" title="Modifier" onClick={() => editRule(rule)}><Pencil className="h-4 w-4" /></Button><Button size="icon" variant="ghost" title="Supprimer" onClick={() => { if (window.confirm(`Supprimer la règle « ${rule.name} » ?`)) deleteMutation.mutate({ id: rule.id }); }}><Trash2 className="h-4 w-4 text-red-600" /></Button></div></TableCell></TableRow>)}</TableBody></Table>}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import ExportAutomationRulesPanel from "@/components/ExportAutomationRulesPanel";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
AUTOMATION_ACTION_FILTERS,
|
||||
type AutomationActionFilter,
|
||||
@@ -337,7 +339,7 @@ export default function AutomationRules() {
|
||||
<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>
|
||||
<p className="text-gray-500 mt-1">Gérez séparément le classement à l’import et les destinations d’export</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => { setWizardMode(true); setWizardStep(1); openCreateDialog(); }}>
|
||||
@@ -351,6 +353,12 @@ export default function AutomationRules() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="import" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="import">Automatismes d’import</TabsTrigger>
|
||||
<TabsTrigger value="export">Automatismes d’export</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="import" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Règles d'automatisme</CardTitle>
|
||||
@@ -480,6 +488,11 @@ export default function AutomationRules() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="export">
|
||||
<ExportAutomationRulesPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
|
||||
@@ -333,25 +333,21 @@ export default function ImportSettings() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">
|
||||
Paramètres import / export
|
||||
Paramètres
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
Configurez les méthodes d'importation et les options d'export des factures BAP
|
||||
Configurez les méthodes d’importation et la sauvegarde de la base de données
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Onglets Import / Export */}
|
||||
{/* Les destinations d’export sont administrées dans Automatismes d’export. */}
|
||||
<Tabs defaultValue="import" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 h-12 mb-6">
|
||||
<TabsList className="grid w-full grid-cols-2 h-12 mb-6">
|
||||
<TabsTrigger value="import" className="flex items-center gap-2 text-base">
|
||||
<ArrowDownToLine className="w-4 h-4" />
|
||||
<Inbox className="w-4 h-4" />
|
||||
Paramètres d'import
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="export" className="flex items-center gap-2 text-base">
|
||||
<Share2 className="w-4 h-4" />
|
||||
Paramètres d'export
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="backup" className="flex items-center gap-2 text-base">
|
||||
<DatabaseBackup className="w-4 h-4" />
|
||||
Sauvegarde DB
|
||||
|
||||
Reference in New Issue
Block a user