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",
|
color: "from-orange-500 to-amber-500",
|
||||||
children: [
|
children: [
|
||||||
{ icon: Settings, label: "Paramètres IA et Signatures", path: "/settings" },
|
{ 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: List, label: "Administration des listes", path: "/lists-admin" },
|
||||||
{ icon: Zap, label: "Automatismes", path: "/automation-rules" },
|
{ icon: Zap, label: "Automatismes", path: "/automation-rules" },
|
||||||
{ icon: Brain, label: "Apprentissages IA", path: "/learning-settings" },
|
{ 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";
|
} 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 ExportAutomationRulesPanel from "@/components/ExportAutomationRulesPanel";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
AUTOMATION_ACTION_FILTERS,
|
AUTOMATION_ACTION_FILTERS,
|
||||||
type AutomationActionFilter,
|
type AutomationActionFilter,
|
||||||
@@ -337,7 +339,7 @@ export default function AutomationRules() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Automatismes</h1>
|
<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>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={() => { setWizardMode(true); setWizardStep(1); openCreateDialog(); }}>
|
<Button onClick={() => { setWizardMode(true); setWizardStep(1); openCreateDialog(); }}>
|
||||||
@@ -351,6 +353,12 @@ export default function AutomationRules() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Règles d'automatisme</CardTitle>
|
<CardTitle>Règles d'automatisme</CardTitle>
|
||||||
@@ -480,6 +488,11 @@ export default function AutomationRules() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="export">
|
||||||
|
<ExportAutomationRulesPanel />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
{/* Create/Edit Dialog */}
|
{/* Create/Edit Dialog */}
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
|||||||
@@ -333,25 +333,21 @@ export default function ImportSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">
|
<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>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Onglets Import / Export */}
|
{/* Les destinations d’export sont administrées dans Automatismes d’export. */}
|
||||||
<Tabs defaultValue="import" className="w-full">
|
<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">
|
<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
|
Paramètres d'import
|
||||||
</TabsTrigger>
|
</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">
|
<TabsTrigger value="backup" className="flex items-center gap-2 text-base">
|
||||||
<DatabaseBackup className="w-4 h-4" />
|
<DatabaseBackup className="w-4 h-4" />
|
||||||
Sauvegarde DB
|
Sauvegarde DB
|
||||||
|
|||||||
17
drizzle/0039_good_carmella_unuscione.sql
Normal file
17
drizzle/0039_good_carmella_unuscione.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE `exportAutomationRules` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`userId` int NOT NULL,
|
||||||
|
`name` varchar(255) NOT NULL,
|
||||||
|
`isActive` int NOT NULL DEFAULT 1,
|
||||||
|
`priority` int NOT NULL DEFAULT 0,
|
||||||
|
`conditionField` enum('recipientName','ventilationComptable') NOT NULL,
|
||||||
|
`conditionValue` varchar(255) NOT NULL,
|
||||||
|
`destinationType` enum('local','teams','sharepoint') NOT NULL,
|
||||||
|
`destinationPath` text NOT NULL,
|
||||||
|
`openInBrowser` int NOT NULL DEFAULT 0,
|
||||||
|
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT `exportAutomationRules_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `export_rule_user_priority_idx` ON `exportAutomationRules` (`userId`,`priority`);
|
||||||
2496
drizzle/meta/0039_snapshot.json
Normal file
2496
drizzle/meta/0039_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -274,6 +274,13 @@
|
|||||||
"when": 1788349114141,
|
"when": 1788349114141,
|
||||||
"tag": "0038_late_thunderbolt",
|
"tag": "0038_late_thunderbolt",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 39,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1788350633569,
|
||||||
|
"tag": "0039_good_carmella_unuscione",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, uniqueIndex, decimal } from "drizzle-orm/mysql-core";
|
import { decimal, index, int, mysqlEnum, mysqlTable, text, timestamp, uniqueIndex, varchar } from "drizzle-orm/mysql-core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core user table backing auth flow.
|
* Core user table backing auth flow.
|
||||||
@@ -324,6 +324,31 @@ export const automationRules = mysqlTable("automationRules", {
|
|||||||
export type AutomationRule = typeof automationRules.$inferSelect;
|
export type AutomationRule = typeof automationRules.$inferSelect;
|
||||||
export type InsertAutomationRule = typeof automationRules.$inferInsert;
|
export type InsertAutomationRule = typeof automationRules.$inferInsert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Règles de destination appliquées lors de la validation BAP.
|
||||||
|
* Elles sont séparées des règles d’import, afin de ne jamais modifier les
|
||||||
|
* champs d’une facture au moment de son export.
|
||||||
|
*/
|
||||||
|
export const exportAutomationRules = mysqlTable("exportAutomationRules", {
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
userId: int("userId").notNull(),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
isActive: int("isActive").default(1).notNull(),
|
||||||
|
priority: int("priority").default(0).notNull(),
|
||||||
|
conditionField: mysqlEnum("conditionField", ["recipientName", "ventilationComptable"]).notNull(),
|
||||||
|
conditionValue: varchar("conditionValue", { length: 255 }).notNull(),
|
||||||
|
destinationType: mysqlEnum("destinationType", ["local", "teams", "sharepoint"]).notNull(),
|
||||||
|
destinationPath: text("destinationPath").notNull(),
|
||||||
|
openInBrowser: int("openInBrowser").default(0).notNull(),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
userPriorityIdx: index("export_rule_user_priority_idx").on(table.userId, table.priority),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export type ExportAutomationRule = typeof exportAutomationRules.$inferSelect;
|
||||||
|
export type InsertExportAutomationRule = typeof exportAutomationRules.$inferInsert;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LLM Fields Configuration table
|
* LLM Fields Configuration table
|
||||||
* Stores configuration for each field used in invoice extraction
|
* Stores configuration for each field used in invoice extraction
|
||||||
|
|||||||
44
server/db.ts
44
server/db.ts
@@ -30,6 +30,9 @@ import {
|
|||||||
automationRules,
|
automationRules,
|
||||||
InsertAutomationRule,
|
InsertAutomationRule,
|
||||||
AutomationRule,
|
AutomationRule,
|
||||||
|
exportAutomationRules,
|
||||||
|
InsertExportAutomationRule,
|
||||||
|
ExportAutomationRule,
|
||||||
llmFieldsConfig,
|
llmFieldsConfig,
|
||||||
InsertLlmFieldConfig,
|
InsertLlmFieldConfig,
|
||||||
LlmFieldConfig,
|
LlmFieldConfig,
|
||||||
@@ -747,6 +750,47 @@ export async function deleteAutomationRule(id: number): Promise<void> {
|
|||||||
await db.delete(automationRules).where(eq(automationRules.id, id));
|
await db.delete(automationRules).where(eq(automationRules.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============= EXPORT AUTOMATION RULES OPERATIONS =============
|
||||||
|
|
||||||
|
export async function getExportAutomationRulesByUser(userId: number): Promise<ExportAutomationRule[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
return db.select().from(exportAutomationRules)
|
||||||
|
.where(eq(exportAutomationRules.userId, userId))
|
||||||
|
.orderBy(exportAutomationRules.priority, exportAutomationRules.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExportAutomationRuleById(id: number): Promise<ExportAutomationRule | null> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return null;
|
||||||
|
const [rule] = await db.select().from(exportAutomationRules).where(eq(exportAutomationRules.id, id));
|
||||||
|
return rule || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createExportAutomationRule(data: InsertExportAutomationRule): Promise<ExportAutomationRule> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
const result = await db.insert(exportAutomationRules).values(data);
|
||||||
|
const rule = await getExportAutomationRuleById(result[0].insertId);
|
||||||
|
if (!rule) throw new Error("Export automation rule could not be created");
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateExportAutomationRule(id: number, data: Partial<InsertExportAutomationRule>): Promise<ExportAutomationRule> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
await db.update(exportAutomationRules).set(data).where(eq(exportAutomationRules.id, id));
|
||||||
|
const rule = await getExportAutomationRuleById(id);
|
||||||
|
if (!rule) throw new Error("Export automation rule not found");
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteExportAutomationRule(id: number): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
await db.delete(exportAutomationRules).where(eq(exportAutomationRules.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
// ============= INITIALIZE DEFAULT VALUES =============
|
// ============= INITIALIZE DEFAULT VALUES =============
|
||||||
|
|
||||||
export async function initializeDefaultLists(userId: number): Promise<void> {
|
export async function initializeDefaultLists(userId: number): Promise<void> {
|
||||||
|
|||||||
26
server/exportAutomation.test.ts
Normal file
26
server/exportAutomation.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { findMatchingExportRule } from "../shared/exportAutomation";
|
||||||
|
|
||||||
|
const rules = [
|
||||||
|
{ isActive: 1, priority: 2, conditionField: "recipientName" as const, conditionValue: "Direction générale" },
|
||||||
|
{ isActive: 1, priority: 1, conditionField: "ventilationComptable" as const, conditionValue: "615200" },
|
||||||
|
{ isActive: 0, priority: 0, conditionField: "recipientName" as const, conditionValue: "Direction générale" },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("règles de destination d’export", () => {
|
||||||
|
it("sélectionne une règle de ventilation active", () => {
|
||||||
|
expect(findMatchingExportRule(rules, { recipientName: "Direction générale", ventilationComptable: "615200" })).toBe(rules[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compare les destinataires sans tenir compte de la casse et des espaces", () => {
|
||||||
|
expect(findMatchingExportRule(rules, { recipientName: " direction GÉNÉRALE " })).toBe(rules[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignore les règles inactives", () => {
|
||||||
|
expect(findMatchingExportRule([rules[2]], { recipientName: "Direction générale" })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ne retourne aucune règle si la facture ne correspond à aucun critère", () => {
|
||||||
|
expect(findMatchingExportRule(rules, { recipientName: "Service achats", ventilationComptable: "606000" })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
42
server/exportDestinationResolver.ts
Normal file
42
server/exportDestinationResolver.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { ImportSettings, Invoice } from "../drizzle/schema";
|
||||||
|
import { findMatchingExportRule } from "@shared/exportAutomation";
|
||||||
|
import { getExportAutomationRulesByUser } from "./db";
|
||||||
|
|
||||||
|
export type ResolvedExportDestination = {
|
||||||
|
exportMode: "browser" | "folder" | "both";
|
||||||
|
destinationPath: string | null;
|
||||||
|
destinationType: "local" | "teams" | "sharepoint";
|
||||||
|
source: "rule" | "default";
|
||||||
|
ruleName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout une destination pour une validation BAP. Une règle active ciblant le
|
||||||
|
* destinataire ou la ventilation est prioritaire sur les anciens paramètres
|
||||||
|
* globaux, qui restent le comportement de repli pour préserver l’existant.
|
||||||
|
*/
|
||||||
|
export async function resolveBapExportDestination(
|
||||||
|
userId: number,
|
||||||
|
invoice: Pick<Invoice, "recipientName" | "ventilationComptable">,
|
||||||
|
settings: ImportSettings | null,
|
||||||
|
): Promise<ResolvedExportDestination> {
|
||||||
|
const rules = await getExportAutomationRulesByUser(userId);
|
||||||
|
const matchingRule = findMatchingExportRule(rules, invoice);
|
||||||
|
|
||||||
|
if (matchingRule) {
|
||||||
|
return {
|
||||||
|
exportMode: matchingRule.openInBrowser === 1 ? "both" : "folder",
|
||||||
|
destinationPath: matchingRule.destinationPath,
|
||||||
|
destinationType: matchingRule.destinationType,
|
||||||
|
source: "rule",
|
||||||
|
ruleName: matchingRule.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exportMode: settings?.bapExportMode || "browser",
|
||||||
|
destinationPath: settings?.exportFolder || null,
|
||||||
|
destinationType: settings?.exportFolderType || "local",
|
||||||
|
source: "default",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -66,6 +66,11 @@ import {
|
|||||||
createAutomationRule,
|
createAutomationRule,
|
||||||
updateAutomationRule,
|
updateAutomationRule,
|
||||||
deleteAutomationRule,
|
deleteAutomationRule,
|
||||||
|
getExportAutomationRulesByUser,
|
||||||
|
getExportAutomationRuleById,
|
||||||
|
createExportAutomationRule,
|
||||||
|
updateExportAutomationRule,
|
||||||
|
deleteExportAutomationRule,
|
||||||
getSignaturesByUser,
|
getSignaturesByUser,
|
||||||
getSignatureById,
|
getSignatureById,
|
||||||
createSignature,
|
createSignature,
|
||||||
@@ -97,6 +102,7 @@ import { calculateFileSha256 } from "./fileFingerprint";
|
|||||||
import { localStoragePut, generateStorageKey } from "./localStorage";
|
import { localStoragePut, generateStorageKey } from "./localStorage";
|
||||||
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
||||||
import { drawBapCartouche } from "./bapCartouche";
|
import { drawBapCartouche } from "./bapCartouche";
|
||||||
|
import { resolveBapExportDestination } from "./exportDestinationResolver";
|
||||||
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, testImapConnection } from "./emailImportService";
|
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, testImapConnection } from "./emailImportService";
|
||||||
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
|
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
@@ -521,9 +527,10 @@ export const appRouter = router({
|
|||||||
const { localStoragePut, generateStorageKey } = await import('./localStorage');
|
const { localStoragePut, generateStorageKey } = await import('./localStorage');
|
||||||
|
|
||||||
const importSettings = await getImportSettingsByUser(ctx.user.id);
|
const importSettings = await getImportSettingsByUser(ctx.user.id);
|
||||||
const bapExportMode = importSettings?.bapExportMode || 'browser';
|
const resolvedDestination = await resolveBapExportDestination(ctx.user.id, invoice, importSettings);
|
||||||
const exportFolder = importSettings?.exportFolder || null;
|
const bapExportMode = resolvedDestination.exportMode;
|
||||||
const exportFolderType = (importSettings as any)?.exportFolderType || 'local';
|
const exportFolder = resolvedDestination.destinationPath;
|
||||||
|
const exportFolderType = resolvedDestination.destinationType;
|
||||||
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
|
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
|
||||||
|
|
||||||
let pdfUrl: string | null = null;
|
let pdfUrl: string | null = null;
|
||||||
@@ -609,11 +616,11 @@ export const appRouter = router({
|
|||||||
const _bapNumber = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
|
const _bapNumber = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
|
||||||
const bapFilename = [_bapDateStr, _bapSupplier, _bapNumber].filter(Boolean).join(' - ') + '.pdf';
|
const bapFilename = [_bapDateStr, _bapSupplier, _bapNumber].filter(Boolean).join(' - ') + '.pdf';
|
||||||
|
|
||||||
console.log(`[BAP] Mode export: ${bapExportMode}, type: ${exportFolderType}, dossier: ${exportFolder ? 'configuré' : 'non configuré'}`);
|
console.log(`[BAP] Mode export: ${bapExportMode}, type: ${exportFolderType}, source: ${resolvedDestination.source}, dossier: ${exportFolder ? 'configuré' : 'non configuré'}`);
|
||||||
if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) {
|
if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) {
|
||||||
if (exportFolderType === 'sharepoint') {
|
if (exportFolderType === 'sharepoint' || exportFolderType === 'teams') {
|
||||||
// Mode SharePoint : upload via Microsoft Graph
|
// Les fichiers Teams sont déposés via le site SharePoint de l’équipe.
|
||||||
console.log('[BAP] Démarrage upload SharePoint pour:', bapFilename);
|
console.log('[BAP] Démarrage upload Microsoft 365 pour:', bapFilename);
|
||||||
const { uploadToSharePoint } = await import('./sharepoint');
|
const { uploadToSharePoint } = await import('./sharepoint');
|
||||||
const spResult = await uploadToSharePoint(
|
const spResult = await uploadToSharePoint(
|
||||||
{
|
{
|
||||||
@@ -740,9 +747,6 @@ export const appRouter = router({
|
|||||||
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
|
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
|
||||||
const { localStoragePut, generateStorageKey } = await import('./localStorage');
|
const { localStoragePut, generateStorageKey } = await import('./localStorage');
|
||||||
const importSettings = await getImportSettingsByUser(ctx.user.id);
|
const importSettings = await getImportSettingsByUser(ctx.user.id);
|
||||||
const bapExportMode = importSettings?.bapExportMode || 'browser';
|
|
||||||
const exportFolder = importSettings?.exportFolder || null;
|
|
||||||
const exportFolderType = (importSettings as any)?.exportFolderType || 'local';
|
|
||||||
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
|
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
|
||||||
const serviceSignaturesList = await getServiceSignaturesByUser(ctx.user.id);
|
const serviceSignaturesList = await getServiceSignaturesByUser(ctx.user.id);
|
||||||
const results: Array<{ id: number; success: boolean; pdfUrl?: string | null; exportPath?: string | null; error?: string }> = [];
|
const results: Array<{ id: number; success: boolean; pdfUrl?: string | null; exportPath?: string | null; error?: string }> = [];
|
||||||
@@ -756,6 +760,10 @@ export const appRouter = router({
|
|||||||
let sharepointUploadStatus: 'success' | 'error' | 'skipped' | null = null;
|
let sharepointUploadStatus: 'success' | 'error' | 'skipped' | null = null;
|
||||||
let sharepointUploadPath: string | null = null;
|
let sharepointUploadPath: string | null = null;
|
||||||
let sharepointUploadError: string | null = null;
|
let sharepointUploadError: string | null = null;
|
||||||
|
const resolvedDestination = await resolveBapExportDestination(ctx.user.id, invoice, importSettings);
|
||||||
|
const bapExportMode = resolvedDestination.exportMode;
|
||||||
|
const exportFolder = resolvedDestination.destinationPath;
|
||||||
|
const exportFolderType = resolvedDestination.destinationType;
|
||||||
try {
|
try {
|
||||||
// ─ Lecture du PDF source ─
|
// ─ Lecture du PDF source ─
|
||||||
let sourcePdfBytes: Buffer;
|
let sourcePdfBytes: Buffer;
|
||||||
@@ -826,7 +834,7 @@ export const appRouter = router({
|
|||||||
const _bapNumber2 = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
|
const _bapNumber2 = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
|
||||||
const bapFilename = [_bapDateStr2, _bapSupplier2, _bapNumber2].filter(Boolean).join(' - ') + '.pdf';
|
const bapFilename = [_bapDateStr2, _bapSupplier2, _bapNumber2].filter(Boolean).join(' - ') + '.pdf';
|
||||||
if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) {
|
if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) {
|
||||||
if (exportFolderType === 'sharepoint') {
|
if (exportFolderType === 'sharepoint' || exportFolderType === 'teams') {
|
||||||
const { uploadToSharePoint } = await import('./sharepoint');
|
const { uploadToSharePoint } = await import('./sharepoint');
|
||||||
const spResult = await uploadToSharePoint(
|
const spResult = await uploadToSharePoint(
|
||||||
{
|
{
|
||||||
@@ -2276,6 +2284,55 @@ export const appRouter = router({
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ============= EXPORT AUTOMATION RULES =============
|
||||||
|
exportAutomationRules: router({
|
||||||
|
list: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return getExportAutomationRulesByUser(ctx.user.id);
|
||||||
|
}),
|
||||||
|
create: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
name: z.string().trim().min(1).max(255),
|
||||||
|
conditionField: z.enum(["recipientName", "ventilationComptable"]),
|
||||||
|
conditionValue: z.string().trim().min(1).max(255),
|
||||||
|
destinationType: z.enum(["local", "teams", "sharepoint"]),
|
||||||
|
destinationPath: z.string().trim().min(1),
|
||||||
|
openInBrowser: z.number().int().min(0).max(1).default(0),
|
||||||
|
isActive: z.number().int().min(0).max(1).default(1),
|
||||||
|
priority: z.number().int().min(0).default(0),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input, ctx }) => createExportAutomationRule({ userId: ctx.user.id, ...input })),
|
||||||
|
update: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
id: z.number().int().positive(),
|
||||||
|
name: z.string().trim().min(1).max(255).optional(),
|
||||||
|
conditionField: z.enum(["recipientName", "ventilationComptable"]).optional(),
|
||||||
|
conditionValue: z.string().trim().min(1).max(255).optional(),
|
||||||
|
destinationType: z.enum(["local", "teams", "sharepoint"]).optional(),
|
||||||
|
destinationPath: z.string().trim().min(1).optional(),
|
||||||
|
openInBrowser: z.number().int().min(0).max(1).optional(),
|
||||||
|
isActive: z.number().int().min(0).max(1).optional(),
|
||||||
|
priority: z.number().int().min(0).optional(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const existing = await getExportAutomationRuleById(input.id);
|
||||||
|
if (!existing || (ctx.user.role !== "admin" && existing.userId !== ctx.user.id)) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
const { id, ...data } = input;
|
||||||
|
return updateExportAutomationRule(id, data);
|
||||||
|
}),
|
||||||
|
delete: protectedProcedure
|
||||||
|
.input(z.object({ id: z.number().int().positive() }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const existing = await getExportAutomationRuleById(input.id);
|
||||||
|
if (!existing || (ctx.user.role !== "admin" && existing.userId !== ctx.user.id)) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
await deleteExportAutomationRule(input.id);
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
// ============= SIGNATURES ROUTES =============
|
// ============= SIGNATURES ROUTES =============
|
||||||
signatures: router({
|
signatures: router({
|
||||||
list: protectedProcedure.query(async ({ ctx }) => {
|
list: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
|||||||
32
shared/exportAutomation.ts
Normal file
32
shared/exportAutomation.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/** Valeurs nécessaires pour déterminer une destination d’export. */
|
||||||
|
export type ExportableInvoice = {
|
||||||
|
recipientName?: string | null;
|
||||||
|
ventilationComptable?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExportRuleCandidate = {
|
||||||
|
isActive: number;
|
||||||
|
priority: number;
|
||||||
|
conditionField: "recipientName" | "ventilationComptable";
|
||||||
|
conditionValue: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sélectionne la première règle active par priorité. Les comparaisons sont
|
||||||
|
* volontairement insensibles à la casse et aux espaces superflus.
|
||||||
|
*/
|
||||||
|
export function findMatchingExportRule<T extends ExportRuleCandidate>(
|
||||||
|
rules: T[],
|
||||||
|
invoice: ExportableInvoice,
|
||||||
|
): T | undefined {
|
||||||
|
const normalized = (value: string | null | undefined) => value?.trim().toLocaleLowerCase("fr-FR") || "";
|
||||||
|
const ordered = [...rules].sort((a, b) => a.priority - b.priority || a.conditionValue.localeCompare(b.conditionValue, "fr-FR"));
|
||||||
|
|
||||||
|
return ordered.find((rule) => {
|
||||||
|
if (rule.isActive !== 1) return false;
|
||||||
|
const invoiceValue = rule.conditionField === "recipientName"
|
||||||
|
? invoice.recipientName
|
||||||
|
: invoice.ventilationComptable;
|
||||||
|
return normalized(invoiceValue) === normalized(rule.conditionValue);
|
||||||
|
});
|
||||||
|
}
|
||||||
8
todo.md
8
todo.md
@@ -858,3 +858,11 @@
|
|||||||
- [x] Ajouter les filtres Destinataires, Entités et Ventilations après le filtre Mois
|
- [x] Ajouter les filtres Destinataires, Entités et Ventilations après le filtre Mois
|
||||||
- [x] Appliquer ces filtres à la liste BAP en préservant les filtres et le tri existants
|
- [x] Appliquer ces filtres à la liste BAP en préservant les filtres et le tri existants
|
||||||
- [x] Ajouter les tests et valider l’affichage de Factures BAP en sandbox
|
- [x] Ajouter les tests et valider l’affichage de Factures BAP en sandbox
|
||||||
|
|
||||||
|
## Automatismes d’export et simplification des Paramètres
|
||||||
|
- [x] Ajouter les onglets Automatismes d’import et Automatismes d’export
|
||||||
|
- [x] Créer des règles de destination d’export par destinataire ou ventilation comptable
|
||||||
|
- [x] Prendre en charge les destinations dossier local, Teams et SharePoint sans perdre les réglages existants
|
||||||
|
- [x] Appliquer les règles de destination lors des exports de factures BAP
|
||||||
|
- [x] Déplacer les paramètres d’export dans Automatismes et renommer la page en Paramètres
|
||||||
|
- [x] Ajouter les tests et valider les nouveaux parcours en sandbox
|
||||||
|
|||||||
@@ -4,3 +4,9 @@
|
|||||||
- Le cartouche administrateur de lecture e-mail ponctuelle est visible, explicite sur l’absence de planification créée et ne propose aucune activation automatique.
|
- Le cartouche administrateur de lecture e-mail ponctuelle est visible, explicite sur l’absence de planification créée et ne propose aucune activation automatique.
|
||||||
- Les filtres et indicateurs restent lisibles sur mobile.
|
- Les filtres et indicateurs restent lisibles sur mobile.
|
||||||
- Le tableau est enveloppé dans un conteneur à défilement horizontal afin de préserver toutes les colonnes de traçabilité sur les écrans étroits.
|
- Le tableau est enveloppé dans un conteneur à défilement horizontal afin de préserver toutes les colonnes de traçabilité sur les écrans étroits.
|
||||||
|
|
||||||
|
## Vérification visuelle — Automatismes et Paramètres
|
||||||
|
|
||||||
|
- Le 2 septembre 2026, les routes `/automation-rules` et `/import-settings` ont été vérifiées en format bureau.
|
||||||
|
- Les onglets « Automatismes d’import » et « Automatismes d’export » sont visibles dans la page Automatismes.
|
||||||
|
- La navigation et le titre affichent désormais « Paramètres » ; seuls les onglets Paramètres d’import et Sauvegarde DB restent accessibles depuis cette page.
|
||||||
|
|||||||
Reference in New Issue
Block a user