Compare commits

...

8 Commits

Author SHA1 Message Date
Manus
f7c522b18e Checkpoint: Les onglets Automatismes d’import et Automatismes d’export sont visuellement différenciés en bleu et violet. Le libellé « Mode d’export BAP » devient « Mode d’export » et le texte de repli ne mentionne plus BAP. Tests, TypeScript et rendu visuel validés en sandbox.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 2m15s
2026-09-02 12:42:46 +00:00
Manus
071376c9b1 Checkpoint: La configuration Microsoft Azure AD est de nouveau centralisée dans Paramètres : URL SharePoint, Tenant ID, Client ID, secret masqué, date d’expiration et tests de connexion/upload. Les Automatismes d’export ne contiennent plus d’identifiants et restent dédiés aux règles de destination. Tests, TypeScript, build et rendu de la page Paramètres validés en sandbox. 2026-09-02 12:31:50 +00:00
Manus
582ca77f28 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. 2026-09-02 12:13:23 +00:00
Manus
da19629ee0 Checkpoint: Factures BAP inclut désormais, après Année et Mois, les filtres Destinataires, Entités et Ventilations comptables de la page Factures. Leur logique est identique, avec valeurs absentes et règle SANTINOVA/ITINOVA fondée sur DSI SANTINOVA. Les filtres sont conservés au retour depuis le détail. Tests, TypeScript, build et rendu visuel validés en sandbox. 2026-09-02 11:52:20 +00:00
Manus
0b2f1b781d Checkpoint: Historique des imports enrichi avec le compte, la source, le déclencheur, les résultats, les erreurs et les informations du fichier source. Les administrateurs peuvent déclencher une lecture e-mail ponctuelle pour une boîte configurée, avec confirmation explicite, sans activer ni créer de planification. Migration importTrigger appliquée en sandbox ; TypeScript, 48 tests, build et rendus bureau/mobile validés. 2026-09-02 11:43:23 +00:00
Manus
f53bb82256 Checkpoint: Investigation des deux factures créées le 1er septembre 2026 en production : les deux enregistrements sont rattachés à des sources d’import e-mail du compte utilisateur 2. La planification de ce compte était encore activée avec une cadence de 30 minutes ; elle a depuis été désactivée avec les autres comptes, et le service applicatif a été recréé sans toucher aux données métier. 2026-09-02 10:46:37 +00:00
Manus
4ad6cb2705 Checkpoint: Import e-mail automatique désactivé pour tous les comptes en recette et en production. Seuls les conteneurs applicatifs ont été recréés pour supprimer les planifications en mémoire. Contrôles confirmés : zéro paramètre e-mail activé, aucun redémarrage du service e-mail dans les journaux, conteneurs sains et HTTP 200 sur les deux environnements. 2026-09-01 14:19:19 +00:00
Manus
d692e228de Checkpoint: Budget réel trié par montant total décroissant et graphique annuel BAP versus abonnements déployés et vérifiés en recette puis production. Les routes /dashboard et /real-budget répondent HTTP 200, le conteneur applicatif est sain, et le bundle graphique est actif. Le dashboard et le portail de production référencent déjà l’application en ligne ; aucune modification n’était nécessaire. 2026-08-30 09:27:14 +00:00
25 changed files with 6040 additions and 448 deletions

View File

@@ -0,0 +1,101 @@
import { useEffect, useMemo, useState } from "react";
import { Calendar, CheckCircle2, Cloud, Loader2, Save, Upload, Wifi } from "lucide-react";
import { trpc } from "@/lib/trpc";
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 { toast } from "sonner";
/** Les identifiants Azure restent centralisés dans Paramètres, hors des règles métier. */
export default function AzureAdExportSettingsCard() {
const utils = trpc.useUtils();
const { data: settings } = trpc.importSettings.get.useQuery();
const [sharepointUrl, setSharepointUrl] = useState("");
const [tenantId, setTenantId] = useState("");
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [secretExpiresAt, setSecretExpiresAt] = useState("");
useEffect(() => {
if (!settings) return;
setSharepointUrl(settings.exportFolder || "");
setTenantId(settings.azureTenantId || "");
setClientId(settings.azureClientId || "");
setSecretExpiresAt(settings.azureSecretExpiresAt ? new Date(settings.azureSecretExpiresAt).toISOString().slice(0, 10) : "");
}, [settings]);
const updateMutation = trpc.importSettings.update.useMutation({
onSuccess: () => {
toast.success("Configuration Microsoft Azure AD enregistrée");
setClientSecret("");
utils.importSettings.get.invalidate();
},
onError: (error) => toast.error(error.message || "Impossible d’enregistrer la configuration Azure AD"),
});
const testConnectionMutation = trpc.importSettings.testAzureConnection.useMutation();
const testUploadMutation = trpc.importSettings.testSharePointUpload.useMutation();
const expiration = useMemo(() => {
if (!secretExpiresAt) return null;
const date = new Date(`${secretExpiresAt}T12:00:00Z`);
if (Number.isNaN(date.getTime())) return null;
return Math.ceil((date.getTime() - Date.now()) / 86_400_000);
}, [secretExpiresAt]);
const save = () => {
if (!settings) return;
if (!sharepointUrl.trim() || !tenantId.trim() || !clientId.trim()) {
toast.error("URL SharePoint, Tenant ID et Client ID sont obligatoires");
return;
}
updateMutation.mutate({
manualImportEnabled: settings.manualImportEnabled,
autoImportEnabled: settings.autoImportEnabled,
emailImportEnabled: settings.emailImportEnabled,
exportFolder: sharepointUrl.trim(),
exportFolderType: "sharepoint",
bapExportMode: settings.bapExportMode,
azureTenantId: tenantId.trim(),
azureClientId: clientId.trim(),
azureSecretExpiresAt: secretExpiresAt || null,
...(clientSecret ? { azureClientSecret: clientSecret } : {}),
});
};
const testConnection = async () => {
try {
const result = await testConnectionMutation.mutateAsync();
result.success ? toast.success(result.message || "Connexion Azure AD réussie") : toast.error(result.error || "Connexion Azure AD impossible");
} catch (error: any) {
toast.error(error.message || "Connexion Azure AD impossible");
}
};
const testUpload = async () => {
try {
const result = await testUploadMutation.mutateAsync();
if (!result.success) {
toast.error(result.error || "Upload SharePoint impossible");
return;
}
toast.success("Fichier test déposé dans SharePoint");
if (result.webUrl) window.open(result.webUrl, "_blank", "noopener,noreferrer");
} catch (error: any) {
toast.error(error.message || "Upload SharePoint impossible");
}
};
const canTest = Boolean(sharepointUrl && tenantId && clientId);
return <Card className="border-2 border-cyan-200">
<CardHeader className="border-b bg-gradient-to-r from-cyan-50 to-sky-50"><div className="flex items-center gap-3"><div className="rounded-lg bg-cyan-600 p-2"><Cloud className="h-6 w-6 text-white" /></div><div><CardTitle>Configuration Microsoft Azure AD</CardTitle><CardDescription>Accès partagé utilisé par les destinations SharePoint et Teams des automatismes d’export.</CardDescription></div></div></CardHeader>
<CardContent className="space-y-4 pt-6">
<div className="space-y-2"><Label htmlFor="sharepoint-url">URL SharePoint / chemin</Label><Input id="sharepoint-url" value={sharepointUrl} onChange={(event) => setSharepointUrl(event.target.value)} placeholder="https://…sharepoint.com/:f:/r/sites/…/Documents/Factures" /><p className="text-xs text-muted-foreground">Pour un canal Teams, utilisez l’URL SharePoint du dossier Fichiers concerné.</p></div>
<div className="grid gap-4 md:grid-cols-2"><div className="space-y-2"><Label htmlFor="azure-tenant-id">ID de l’annuaire (Tenant ID)</Label><Input id="azure-tenant-id" value={tenantId} onChange={(event) => setTenantId(event.target.value)} /></div><div className="space-y-2"><Label htmlFor="azure-client-id">ID d’application (Client ID)</Label><Input id="azure-client-id" value={clientId} onChange={(event) => setClientId(event.target.value)} /></div></div>
<div className="grid gap-4 md:grid-cols-2"><div className="space-y-2"><Label htmlFor="azure-client-secret">Secret client</Label><Input id="azure-client-secret" type="password" value={clientSecret} onChange={(event) => setClientSecret(event.target.value)} placeholder="Conserver le secret configuré" /><p className="text-xs text-muted-foreground">Laissez vide pour conserver le secret enregistré. Permissions requises : Files.ReadWrite.All et Sites.ReadWrite.All.</p></div><div className="space-y-2"><Label htmlFor="azure-secret-expiration">Date d’expiration du secret</Label><Input id="azure-secret-expiration" type="date" value={secretExpiresAt} onChange={(event) => setSecretExpiresAt(event.target.value)} />{expiration !== null && <p className={expiration <= 30 ? "text-xs text-amber-700" : "text-xs text-emerald-700"}><Calendar className="mr-1 inline h-3 w-3" />{expiration < 0 ? "Secret expiré" : `Secret valide encore ${expiration} jours`}</p>}</div></div>
<div className="flex flex-wrap gap-2 border-t pt-4"><Button onClick={save} disabled={updateMutation.isPending}><Save className="mr-2 h-4 w-4" />{updateMutation.isPending ? "Enregistrement…" : "Enregistrer Azure AD"}</Button><Button variant="outline" onClick={testConnection} disabled={testConnectionMutation.isPending || !canTest}>{testConnectionMutation.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Wifi className="mr-2 h-4 w-4" />}Tester la connexion Azure AD</Button><Button variant="outline" onClick={testUpload} disabled={testUploadMutation.isPending || !canTest}>{testUploadMutation.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}Tester l’upload SharePoint</Button></div>
{settings?.azureClientSecret && <p className="flex items-center gap-1 text-xs text-emerald-700"><CheckCircle2 className="h-3.5 w-3.5" />Un secret client est déjà enregistré de manière masquée.</p>}
</CardContent>
</Card>;
}

View File

@@ -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" },

View File

@@ -0,0 +1,177 @@
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("");
useEffect(() => {
if (!importSettings) return;
setDefaultExportMode(importSettings.bapExportMode || "browser");
setDefaultDestinationType((importSettings.exportFolderType || "local") as DestinationType);
setDefaultDestinationPath(importSettings.exportFolder || "");
}, [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");
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,
});
};
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.</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</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") && <p className="rounded-lg border border-cyan-200 bg-cyan-50/40 p-3 text-xs text-cyan-800">La configuration Microsoft Azure AD est centralisée dans <strong>Paramètres</strong>. Teams utilise l’URL SharePoint du dossier Fichiers du canal.</p>}
<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>
);
}

View File

@@ -8,6 +8,9 @@ export type BapFilters = {
statusFilter: string;
selectedYear: string;
selectedMonth: string;
recipientFilter: string;
entityFilter: string;
ventilationFilter: string;
sortField: "invoiceDate" | "createdAt";
sortDir: "asc" | "desc";
};
@@ -34,6 +37,10 @@ function isYear(value: string | null): value is string {
return value === "all" || (value !== null && /^\d{4}$/.test(value));
}
function isSafeFilterValue(value: string | null): value is string {
return value !== null && value.length > 0 && value.length <= 200;
}
/** Lit et valide les filtres BAP transmis dans l’URL. */
export function parseBapFilters(search: string, defaultYear: string): BapFilters {
const params = asSearchParams(search);
@@ -48,6 +55,9 @@ export function parseBapFilters(search: string, defaultYear: string): BapFilters
statusFilter: status && BAP_STATUSES.has(status) ? status : "all",
selectedYear: isYear(year) ? year : defaultYear,
selectedMonth: month === "all" || isMonth(month) ? month : "all",
recipientFilter: isSafeFilterValue(params.get("recipient")) ? params.get("recipient")! : "all",
entityFilter: ["all", "santinova", "itinova"].includes(params.get("entity") || "") ? params.get("entity")! : "all",
ventilationFilter: isSafeFilterValue(params.get("ventilation")) ? params.get("ventilation")! : "all",
sortField: sort === "invoiceDate" ? "invoiceDate" : "createdAt",
sortDir: direction === "asc" ? "asc" : "desc",
};
@@ -61,6 +71,9 @@ export function buildBapDetailLocation(invoiceId: number, filters: BapFilters):
status: filters.statusFilter,
year: filters.selectedYear,
month: filters.selectedMonth,
recipient: filters.recipientFilter,
entity: filters.entityFilter,
ventilation: filters.ventilationFilter,
sort: filters.sortField,
dir: filters.sortDir,
});
@@ -74,7 +87,7 @@ export function getBapReturnLocation(search: string): string {
if (params.get("returnTo") !== "bap") return "/invoices";
const allowed = new URLSearchParams();
for (const key of ["q", "status", "year", "month", "sort", "dir"]) {
for (const key of ["q", "status", "year", "month", "recipient", "entity", "ventilation", "sort", "dir"]) {
const value = params.get(key);
if (value) allowed.set(key, value);
}

View File

@@ -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 className="h-11 bg-slate-100 p-1">
<TabsTrigger value="import" className="px-4 text-blue-700 hover:bg-blue-50 data-[state=active]:bg-blue-600 data-[state=active]:text-white data-[state=active]:shadow-sm">Automatismes d’import</TabsTrigger>
<TabsTrigger value="export" className="px-4 text-violet-700 hover:bg-violet-50 data-[state=active]:bg-violet-600 data-[state=active]:text-white data-[state=active]:shadow-sm">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}>

View File

@@ -1,6 +1,7 @@
import { useState, useMemo } from "react";
import { useMemo, useState } from "react";
import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent } from "@/components/ui/card";
import { useAuth } from "@/_core/hooks/useAuth";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
@@ -12,20 +13,24 @@ import {
import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc";
import {
History as HistoryIcon,
FileText,
Trash2,
Eye,
CheckCircle2,
XCircle,
AlertTriangle,
CalendarDays,
RotateCcw,
Package,
X,
Upload,
CheckCircle2,
Clock3,
Eye,
FileText,
FolderOpen,
History as HistoryIcon,
Loader2,
Mail,
Package,
Play,
RotateCcw,
Trash2,
Upload,
UserRound,
X,
XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
@@ -55,6 +60,39 @@ import {
} from "@/components/ui/select";
type StatusFilter = "all" | "imported" | "not_imported" | "duplicates" | "errors";
type SourceFilter = "all" | "email" | "folder" | "file";
type TriggerFilter = "all" | "manual" | "automatic" | "unknown";
type DetailedImportLog = {
id: number;
userId: number;
sourceFileId: number;
fileName: string;
totalInvoicesDetected: number;
invoicesImported: number;
duplicatesIgnored: number;
errors: number;
duplicateDetails: string | null;
errorDetails: string | null;
warningMessage: string | null;
importSource: "file" | "folder" | "email";
importTrigger: "manual" | "automatic" | "unknown";
importedAt: Date;
userName: string | null;
userEmail: string | null;
sourceCreatedAt: Date | null;
sourceStatus: "processing" | "completed" | "error" | null;
};
type EmailAccount = {
userId: number;
userName: string | null;
userEmail: string;
emailAddress: string | null;
authMode: "basic" | "oauth2";
automaticEnabled: number;
isConfigured: number;
};
const MONTHS = [
{ value: "1", label: "Janvier" },
@@ -71,111 +109,149 @@ const MONTHS = [
{ value: "12", label: "Décembre" },
];
const YEARS = Array.from({ length: 5 }, (_, i) => String(new Date().getFullYear() - i));
const YEARS = Array.from({ length: 5 }, (_, index) => String(new Date().getFullYear() - index));
function formatAccount(account: Pick<EmailAccount, "userName" | "userEmail" | "emailAddress">) {
const owner = account.userName?.trim() || account.userEmail;
return account.emailAddress ? `${owner} — ${account.emailAddress}` : owner;
}
function SourceBadge({ source }: { source: DetailedImportLog["importSource"] }) {
if (source === "email") {
return <Badge className="gap-1 border border-blue-200 bg-blue-100 text-blue-700 hover:bg-blue-100"><Mail className="h-3 w-3" />E-mail</Badge>;
}
if (source === "folder") {
return <Badge className="gap-1 border border-purple-200 bg-purple-100 text-purple-700 hover:bg-purple-100"><FolderOpen className="h-3 w-3" />Dossier</Badge>;
}
return <Badge className="gap-1 border border-slate-200 bg-slate-100 text-slate-700 hover:bg-slate-100"><Upload className="h-3 w-3" />Fichier</Badge>;
}
function TriggerBadge({ trigger }: { trigger: DetailedImportLog["importTrigger"] }) {
if (trigger === "manual") {
return <Badge variant="outline" className="border-teal-200 bg-teal-50 text-teal-700">Manuel</Badge>;
}
if (trigger === "automatic") {
return <Badge variant="outline" className="border-indigo-200 bg-indigo-50 text-indigo-700">Planifié</Badge>;
}
return <Badge variant="outline" className="border-slate-200 bg-slate-50 text-slate-500">Non tracé</Badge>;
}
export default function History() {
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const { data: logs, isLoading } = trpc.importLogs.getByUser.useQuery();
const { data: emailAccounts, isLoading: accountsLoading } = trpc.emailImportService.getConfiguredAccounts.useQuery(undefined, {
enabled: isAdmin,
staleTime: 30_000,
});
const deleteAllMutation = trpc.importLogs.deleteAll.useMutation();
const checkAccountNowMutation = trpc.emailImportService.checkAccountNow.useMutation();
const utils = trpc.useUtils();
// Filtres
const detailedLogs = (logs ?? []) as DetailedImportLog[];
const accounts = (emailAccounts ?? []) as EmailAccount[];
const configuredAccounts = useMemo(() => accounts.filter((account) => account.isConfigured === 1), [accounts]);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [sourceFilter, setSourceFilter] = useState<SourceFilter>("all");
const [triggerFilter, setTriggerFilter] = useState<TriggerFilter>("all");
const [yearFilter, setYearFilter] = useState<string>(String(new Date().getFullYear()));
const [monthFilter, setMonthFilter] = useState<string>("all");
// Dialog visualisation détails
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [selectedLog, setSelectedLog] = useState<(typeof logs extends (infer T)[] | undefined ? T : never) | null>(null);
const [selectedLog, setSelectedLog] = useState<DetailedImportLog | null>(null);
const [selectedAccountId, setSelectedAccountId] = useState("none");
const [confirmManualCheckOpen, setConfirmManualCheckOpen] = useState(false);
const selectedAccount = useMemo(
() => configuredAccounts.find((account) => String(account.userId) === selectedAccountId),
[configuredAccounts, selectedAccountId],
);
const handleDeleteAll = async () => {
try {
await deleteAllMutation.mutateAsync();
await utils.importLogs.getByUser.invalidate();
toast.success("Logs supprimés", {
description: "Tous les logs d'import ont été supprimés avec succès.",
});
toast.success("Historique supprimé", { description: "Les enregistrements visibles pour votre rôle ont été supprimés." });
} catch {
toast.error("Erreur", {
description: "Impossible de supprimer les logs d'import.",
});
toast.error("Impossible de supprimer l’historique des imports.");
}
};
// Filtrage par période
const periodFiltered = useMemo(() => {
if (!logs) return [];
return logs.filter((log) => {
const d = new Date(log.importedAt);
if (yearFilter !== "all" && d.getFullYear() !== Number(yearFilter)) return false;
if (monthFilter !== "all" && (d.getMonth() + 1) !== Number(monthFilter)) return false;
return true;
});
}, [logs, yearFilter, monthFilter]);
const handleManualEmailCheck = async () => {
if (!selectedAccount) return;
try {
toast.info("Lecture ponctuelle de la boîte e-mail en cours…");
const result = await checkAccountNowMutation.mutateAsync({ userId: selectedAccount.userId });
if (!result.success) {
toast.error(result.message);
return;
}
await utils.importLogs.getByUser.invalidate();
toast.success("Lecture e-mail terminée", { description: result.message });
} catch (error: any) {
toast.error(error?.message || "Impossible de vérifier cette boîte e-mail.");
} finally {
setConfirmManualCheckOpen(false);
}
};
const periodFiltered = useMemo(() => detailedLogs.filter((log) => {
const date = new Date(log.importedAt);
if (yearFilter !== "all" && date.getFullYear() !== Number(yearFilter)) return false;
if (monthFilter !== "all" && date.getMonth() + 1 !== Number(monthFilter)) return false;
return true;
}), [detailedLogs, yearFilter, monthFilter]);
// Compteurs sur la période filtrée
const counts = useMemo(() => {
const totalImported = periodFiltered.reduce((s, l) => s + l.invoicesImported, 0);
const totalDuplicates = periodFiltered.reduce((s, l) => s + l.duplicatesIgnored, 0);
const totalErrors = periodFiltered.reduce((s, l) => s + l.errors, 0);
const totalDetected = periodFiltered.reduce((s, l) => s + l.totalInvoicesDetected, 0);
const totalNotImported = totalDuplicates + totalErrors;
return { totalImported, totalDuplicates, totalErrors, totalDetected, totalNotImported };
const totalImported = periodFiltered.reduce((sum, log) => sum + log.invoicesImported, 0);
const totalDuplicates = periodFiltered.reduce((sum, log) => sum + log.duplicatesIgnored, 0);
const totalErrors = periodFiltered.reduce((sum, log) => sum + log.errors, 0);
const totalDetected = periodFiltered.reduce((sum, log) => sum + log.totalInvoicesDetected, 0);
return { totalImported, totalDuplicates, totalErrors, totalDetected, totalNotImported: totalDuplicates + totalErrors };
}, [periodFiltered]);
// Filtrage par statut
const filteredLogs = useMemo(() => {
return periodFiltered.filter((log) => {
if (statusFilter === "all") return true;
if (statusFilter === "imported") return log.invoicesImported > 0;
if (statusFilter === "not_imported") return log.duplicatesIgnored > 0 || log.errors > 0;
if (statusFilter === "duplicates") return log.duplicatesIgnored > 0;
if (statusFilter === "errors") return log.errors > 0;
return true;
});
}, [periodFiltered, statusFilter]);
const filteredLogs = useMemo(() => periodFiltered.filter((log) => {
if (sourceFilter !== "all" && log.importSource !== sourceFilter) return false;
if (triggerFilter !== "all" && log.importTrigger !== triggerFilter) return false;
if (statusFilter === "imported") return log.invoicesImported > 0;
if (statusFilter === "not_imported") return log.duplicatesIgnored > 0 || log.errors > 0;
if (statusFilter === "duplicates") return log.duplicatesIgnored > 0;
if (statusFilter === "errors") return log.errors > 0;
return true;
}), [periodFiltered, sourceFilter, statusFilter, triggerFilter]);
const hasPeriodFilter = yearFilter !== "all" || monthFilter !== "all";
const filterButtons: { key: StatusFilter; label: string; count: number; color: string; activeColor: string }[] = [
{ key: "all", label: "Tous", count: periodFiltered.length, color: "bg-gray-100 text-gray-700 hover:bg-gray-200", activeColor: "bg-gray-700 text-white" },
{ key: "imported", label: "Importées", count: counts.totalImported, color: "bg-green-50 text-green-700 hover:bg-green-100 border border-green-200", activeColor: "bg-green-600 text-white" },
{ key: "not_imported", label: "Non importées", count: counts.totalNotImported, color: "bg-orange-50 text-orange-700 hover:bg-orange-100 border border-orange-200", activeColor: "bg-orange-500 text-white" },
{ key: "duplicates", label: "Doublons", count: counts.totalDuplicates, color: "bg-yellow-50 text-yellow-700 hover:bg-yellow-100 border border-yellow-200", activeColor: "bg-yellow-500 text-white" },
{ key: "errors", label: "Erreurs", count: counts.totalErrors, color: "bg-red-50 text-red-700 hover:bg-red-100 border border-red-200", activeColor: "bg-red-600 text-white" },
const statusButtons: { key: StatusFilter; label: string; count: number; base: string; active: string }[] = [
{ key: "all", label: "Tous", count: periodFiltered.length, base: "bg-slate-100 text-slate-700 hover:bg-slate-200", active: "bg-slate-700 text-white" },
{ key: "imported", label: "Importées", count: counts.totalImported, base: "border border-green-200 bg-green-50 text-green-700 hover:bg-green-100", active: "bg-green-600 text-white" },
{ key: "not_imported", label: "Non importées", count: counts.totalNotImported, base: "border border-orange-200 bg-orange-50 text-orange-700 hover:bg-orange-100", active: "bg-orange-500 text-white" },
{ key: "duplicates", label: "Doublons", count: counts.totalDuplicates, base: "border border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100", active: "bg-yellow-500 text-white" },
{ key: "errors", label: "Erreurs", count: counts.totalErrors, base: "border border-red-200 bg-red-50 text-red-700 hover:bg-red-100", active: "bg-red-600 text-white" },
];
const hasFilters = yearFilter !== "all" || monthFilter !== "all" || sourceFilter !== "all" || triggerFilter !== "all";
return (
<DashboardLayout>
<div className="space-y-6">
{/* En-tête */}
<div className="flex justify-between items-start">
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-start">
<div>
<h1 className="text-3xl font-bold">Historique des imports</h1>
<p className="text-gray-500 mt-1">Consultez l'historique de tous vos imports de factures</p>
<p className="mt-1 text-gray-500">Suivez la date, la source, le compte et le mode de déclenchement de chaque ajout.</p>
</div>
{logs && logs.length > 0 && (
{detailedLogs.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trash2 className="mr-2 h-4 w-4" />
Effacer les logs
</Button>
<Button variant="destructive" size="sm"><Trash2 className="mr-2 h-4 w-4" />Effacer l’historique</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action supprimera définitivement tous les logs d'import. Cette opération est irréversible.
</AlertDialogDescription>
<AlertDialogTitle>Supprimer l’historique ?</AlertDialogTitle>
<AlertDialogDescription>Cette action est irréversible et supprime les enregistrements accessibles avec votre rôle.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteAll}
disabled={deleteAllMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteAllMutation.isPending ? "Suppression..." : "Supprimer"}
<AlertDialogAction onClick={handleDeleteAll} disabled={deleteAllMutation.isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{deleteAllMutation.isPending ? "Suppression…" : "Supprimer"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -183,340 +259,108 @@ export default function History() {
)}
</div>
{/* Compteurs */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Card className="border-green-200 bg-green-50">
<CardContent className="p-4 flex items-center gap-3">
<CheckCircle2 className="h-8 w-8 text-green-600 shrink-0" />
<div>
<p className="text-2xl font-bold text-green-700">{counts.totalImported}</p>
<p className="text-xs text-green-600 font-medium">Factures importées</p>
</div>
</CardContent>
</Card>
<Card className="border-orange-200 bg-orange-50">
<CardContent className="p-4 flex items-center gap-3">
<XCircle className="h-8 w-8 text-orange-500 shrink-0" />
<div>
<p className="text-2xl font-bold text-orange-600">{counts.totalNotImported}</p>
<p className="text-xs text-orange-500 font-medium">Non importées</p>
</div>
</CardContent>
</Card>
<Card className="border-yellow-200 bg-yellow-50">
<CardContent className="p-4 flex items-center gap-3">
<RotateCcw className="h-8 w-8 text-yellow-600 shrink-0" />
<div>
<p className="text-2xl font-bold text-yellow-700">{counts.totalDuplicates}</p>
<p className="text-xs text-yellow-600 font-medium">Doublons ignorés</p>
</div>
</CardContent>
</Card>
<Card className="border-red-200 bg-red-50">
<CardContent className="p-4 flex items-center gap-3">
<AlertTriangle className="h-8 w-8 text-red-500 shrink-0" />
<div>
<p className="text-2xl font-bold text-red-600">{counts.totalErrors}</p>
<p className="text-xs text-red-500 font-medium">Erreurs</p>
{isAdmin && (
<Card className="border-teal-200 bg-gradient-to-r from-teal-50 to-cyan-50">
<CardHeader className="pb-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex gap-3">
<div className="rounded-lg bg-teal-600 p-2 text-white"><Mail className="h-5 w-5" /></div>
<div>
<CardTitle className="text-lg">Lecture e-mail ponctuelle</CardTitle>
<CardDescription>Déclenchez une seule vérification pour une boîte configurée, sans réactiver le traitement automatique.</CardDescription>
</div>
</div>
<Badge variant="outline" className="w-fit border-teal-200 bg-white text-teal-700">Aucune planification créée</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1 space-y-2">
<label className="text-sm font-medium" htmlFor="manual-email-account">Compte à vérifier</label>
<Select value={selectedAccountId} onValueChange={setSelectedAccountId} disabled={accountsLoading || configuredAccounts.length === 0}>
<SelectTrigger id="manual-email-account" className="bg-white"><SelectValue placeholder={accountsLoading ? "Chargement des comptes…" : "Choisir une boîte e-mail"} /></SelectTrigger>
<SelectContent>
<SelectItem value="none">Choisir une boîte e-mail</SelectItem>
{configuredAccounts.map((account) => <SelectItem key={account.userId} value={String(account.userId)}>{formatAccount(account)}</SelectItem>)}
</SelectContent>
</Select>
</div>
<AlertDialog open={confirmManualCheckOpen} onOpenChange={setConfirmManualCheckOpen}>
<Button onClick={() => setConfirmManualCheckOpen(true)} disabled={!selectedAccount || checkAccountNowMutation.isPending} className="bg-teal-600 hover:bg-teal-700">
{checkAccountNowMutation.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Play className="mr-2 h-4 w-4" />}Vérifier maintenant
</Button>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Lancer une lecture e-mail ponctuelle ?</AlertDialogTitle>
<AlertDialogDescription>
La boîte « {selectedAccount ? formatAccount(selectedAccount) : ""} » sera vérifiée une fois. Les nouveaux PDF pourront être analysés, mais l’import automatique restera désactivé.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={handleManualEmailCheck} disabled={checkAccountNowMutation.isPending} className="bg-teal-600 hover:bg-teal-700">
{checkAccountNowMutation.isPending ? "Vérification…" : "Lancer la vérification"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card className="border-green-200 bg-green-50"><CardContent className="flex items-center gap-3 p-4"><CheckCircle2 className="h-8 w-8 shrink-0 text-green-600" /><div><p className="text-2xl font-bold text-green-700">{counts.totalImported}</p><p className="text-xs font-medium text-green-600">Factures importées</p></div></CardContent></Card>
<Card className="border-orange-200 bg-orange-50"><CardContent className="flex items-center gap-3 p-4"><XCircle className="h-8 w-8 shrink-0 text-orange-500" /><div><p className="text-2xl font-bold text-orange-600">{counts.totalNotImported}</p><p className="text-xs font-medium text-orange-500">Non importées</p></div></CardContent></Card>
<Card className="border-yellow-200 bg-yellow-50"><CardContent className="flex items-center gap-3 p-4"><RotateCcw className="h-8 w-8 shrink-0 text-yellow-600" /><div><p className="text-2xl font-bold text-yellow-700">{counts.totalDuplicates}</p><p className="text-xs font-medium text-yellow-600">Doublons ignorés</p></div></CardContent></Card>
<Card className="border-red-200 bg-red-50"><CardContent className="flex items-center gap-3 p-4"><AlertTriangle className="h-8 w-8 shrink-0 text-red-500" /><div><p className="text-2xl font-bold text-red-600">{counts.totalErrors}</p><p className="text-xs font-medium text-red-600">Erreurs</p></div></CardContent></Card>
</div>
{/* Filtres */}
<div className="flex flex-wrap gap-3 items-center">
{/* Filtres statut */}
<Card className="border-blue-100 bg-blue-50/40"><CardContent className="flex flex-wrap items-center gap-3 p-4">
<div className="flex flex-wrap gap-2">
{filterButtons.map((btn) => (
<button
key={btn.key}
onClick={() => setStatusFilter(btn.key)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
statusFilter === btn.key ? btn.activeColor : btn.color
}`}
>
{btn.label}
<span className={`ml-1.5 inline-flex items-center justify-center rounded-full text-xs px-1.5 py-0.5 font-bold ${
statusFilter === btn.key ? "bg-white/20" : "bg-white/60"
}`}>
{btn.count}
</span>
</button>
))}
{statusButtons.map((button) => <button key={button.key} onClick={() => setStatusFilter(button.key)} className={`rounded-full px-3 py-1.5 text-sm font-medium transition-colors ${statusFilter === button.key ? button.active : button.base}`}>
{button.label}<span className={`ml-1.5 rounded-full px-1.5 py-0.5 text-xs font-bold ${statusFilter === button.key ? "bg-white/20" : "bg-white/70"}`}>{button.count}</span>
</button>)}
</div>
{/* Séparateur */}
<div className="h-6 w-px bg-gray-200" />
{/* Filtre Période */}
<div className="flex items-center gap-2">
<div className="hidden h-6 w-px bg-blue-200 sm:block" />
<div className="flex flex-wrap items-center gap-2">
<CalendarDays className="h-4 w-4 text-gray-500" />
<Select value={yearFilter} onValueChange={(v) => { setYearFilter(v); if (v === "all") setMonthFilter("all"); }}>
<SelectTrigger className="h-8 w-28 text-sm">
<SelectValue placeholder="Année" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toute année</SelectItem>
{YEARS.map((y) => (
<SelectItem key={y} value={y}>{y}</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={monthFilter}
onValueChange={setMonthFilter}
disabled={yearFilter === "all"}
>
<SelectTrigger className="h-8 w-32 text-sm">
<SelectValue placeholder="Mois" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les mois</SelectItem>
{MONTHS.map((m) => (
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
))}
</SelectContent>
</Select>
{hasPeriodFilter && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-gray-500 hover:text-gray-700"
onClick={() => { setYearFilter("all"); setMonthFilter("all"); }}
title="Réinitialiser la période"
>
<X className="h-4 w-4" />
</Button>
)}
<Select value={yearFilter} onValueChange={(value) => { setYearFilter(value); if (value === "all") setMonthFilter("all"); }}><SelectTrigger className="h-8 w-28 bg-white text-sm"><SelectValue placeholder="Année" /></SelectTrigger><SelectContent><SelectItem value="all">Toute année</SelectItem>{YEARS.map((year) => <SelectItem key={year} value={year}>{year}</SelectItem>)}</SelectContent></Select>
<Select value={monthFilter} onValueChange={setMonthFilter} disabled={yearFilter === "all"}><SelectTrigger className="h-8 w-32 bg-white text-sm"><SelectValue placeholder="Mois" /></SelectTrigger><SelectContent><SelectItem value="all">Tous les mois</SelectItem>{MONTHS.map((month) => <SelectItem key={month.value} value={month.value}>{month.label}</SelectItem>)}</SelectContent></Select>
<Select value={sourceFilter} onValueChange={(value) => setSourceFilter(value as SourceFilter)}><SelectTrigger className="h-8 w-28 bg-white text-sm"><SelectValue placeholder="Source" /></SelectTrigger><SelectContent><SelectItem value="all">Toute source</SelectItem><SelectItem value="email">E-mail</SelectItem><SelectItem value="folder">Dossier</SelectItem><SelectItem value="file">Fichier</SelectItem></SelectContent></Select>
<Select value={triggerFilter} onValueChange={(value) => setTriggerFilter(value as TriggerFilter)}><SelectTrigger className="h-8 w-32 bg-white text-sm"><SelectValue placeholder="Déclencheur" /></SelectTrigger><SelectContent><SelectItem value="all">Tout mode</SelectItem><SelectItem value="manual">Manuel</SelectItem><SelectItem value="automatic">Planifié</SelectItem><SelectItem value="unknown">Non tracé</SelectItem></SelectContent></Select>
{hasFilters && <Button variant="ghost" size="sm" className="h-8 px-2 text-gray-500" onClick={() => { setYearFilter("all"); setMonthFilter("all"); setSourceFilter("all"); setTriggerFilter("all"); }} title="Réinitialiser les filtres"><X className="h-4 w-4" /></Button>}
</div>
<span className="ml-auto text-sm text-gray-500">{filteredLogs.length} import{filteredLogs.length > 1 ? "s" : ""} · {counts.totalDetected} facture{counts.totalDetected > 1 ? "s" : ""} détectée{counts.totalDetected > 1 ? "s" : ""}</span>
</CardContent></Card>
{/* Résumé */}
<span className="text-sm text-gray-500 ml-auto">
{filteredLogs.length} import{filteredLogs.length > 1 ? "s" : ""} — {counts.totalDetected} facture{counts.totalDetected > 1 ? "s" : ""} détectée{counts.totalDetected > 1 ? "s" : ""}
</span>
</div>
{/* Tableau */}
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="text-center py-12 text-gray-500">
<Package className="w-10 h-10 mx-auto mb-3 text-gray-300 animate-pulse" />
<p>Chargement...</p>
</div>
) : filteredLogs.length > 0 ? (
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="font-semibold">Fichier importé</TableHead>
<TableHead className="font-semibold">Date d'import</TableHead>
<TableHead className="font-semibold text-center">Source</TableHead>
<TableHead className="font-semibold text-center">Détectées</TableHead>
<TableHead className="font-semibold text-center">Importées</TableHead>
<TableHead className="font-semibold text-center">Doublons</TableHead>
<TableHead className="font-semibold text-center">Erreurs</TableHead>
<TableHead className="font-semibold text-center w-20">Détails</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLogs.map((log) => (
<TableRow key={log.id} className="hover:bg-gray-50/50 transition-colors">
<TableCell className="font-medium max-w-xs truncate" title={log.fileName}>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-500 shrink-0" />
<span className="truncate">{log.fileName}</span>
</div>
</TableCell>
<TableCell className="text-sm text-gray-600">
{new Date(log.importedAt).toLocaleString("fr-FR")}
</TableCell>
<TableCell className="text-center">
{log.importSource === "email" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 border border-blue-200">
<Mail className="h-3 w-3" />
Email
</span>
) : log.importSource === "folder" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700 border border-purple-200">
<FolderOpen className="h-3 w-3" />
Dossier
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 border border-gray-200">
<Upload className="h-3 w-3" />
Fichier
</span>
)}
</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="font-mono">
{log.totalInvoicesDetected}
</Badge>
</TableCell>
<TableCell className="text-center">
{log.invoicesImported > 0 ? (
<Badge className="bg-green-100 text-green-800 hover:bg-green-100 font-mono">
{log.invoicesImported}
</Badge>
) : (
<span className="text-gray-400 text-sm">0</span>
)}
</TableCell>
<TableCell className="text-center">
{log.duplicatesIgnored > 0 ? (
<Badge className="bg-yellow-100 text-yellow-800 hover:bg-yellow-100 font-mono">
{log.duplicatesIgnored}
</Badge>
) : (
<span className="text-gray-400">-</span>
)}
</TableCell>
<TableCell className="text-center">
{log.errors > 0 ? (
<Badge className="bg-red-100 text-red-800 hover:bg-red-100 font-mono">
{log.errors}
</Badge>
) : (
<span className="text-gray-400">-</span>
)}
</TableCell>
<TableCell className="text-center">
<Button
size="sm"
variant="outline"
className="h-8 px-2 text-blue-600 border-blue-200 hover:bg-blue-50 hover:border-blue-400"
title="Voir les détails de cet import"
onClick={() => {
setSelectedLog(log);
setDetailDialogOpen(true);
}}
>
<Eye className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-12 text-gray-500">
<HistoryIcon className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p className="font-medium">Aucun import trouvé</p>
<p className="text-sm mt-1">Modifiez les filtres pour afficher plus de résultats</p>
</div>
)}
</CardContent>
</Card>
<Card><CardContent className="overflow-x-auto p-0">
{isLoading ? <div className="py-12 text-center text-gray-500"><Package className="mx-auto mb-3 h-10 w-10 animate-pulse text-gray-300" /><p>Chargement de l’historique…</p></div> : filteredLogs.length > 0 ? (
<Table><TableHeader><TableRow className="bg-gray-50"><TableHead className="font-semibold">Fichier importé</TableHead><TableHead className="font-semibold">Date et heure</TableHead><TableHead className="font-semibold">Compte</TableHead><TableHead className="text-center font-semibold">Source</TableHead><TableHead className="text-center font-semibold">Mode</TableHead><TableHead className="text-center font-semibold">Résultat</TableHead><TableHead className="w-20 text-center font-semibold">Détails</TableHead></TableRow></TableHeader>
<TableBody>{filteredLogs.map((log) => <TableRow key={log.id} className="hover:bg-gray-50/50"><TableCell className="max-w-xs font-medium" title={log.fileName}><div className="flex items-center gap-2"><FileText className="h-4 w-4 shrink-0 text-blue-500" /><span className="truncate">{log.fileName}</span></div></TableCell><TableCell className="whitespace-nowrap text-sm text-gray-600"><div className="flex items-center gap-1"><Clock3 className="h-3.5 w-3.5 text-gray-400" />{new Date(log.importedAt).toLocaleString("fr-FR")}</div></TableCell><TableCell className="max-w-48 text-sm text-gray-600"><div className="flex items-center gap-1.5 truncate"><UserRound className="h-3.5 w-3.5 shrink-0 text-gray-400" /><span className="truncate" title={log.userEmail || "Compte supprimé"}>{log.userName?.trim() || log.userEmail || "Compte supprimé"}</span></div></TableCell><TableCell className="text-center"><SourceBadge source={log.importSource} /></TableCell><TableCell className="text-center"><TriggerBadge trigger={log.importTrigger} /></TableCell><TableCell className="text-center"><span className="font-mono text-sm text-green-700">+{log.invoicesImported}</span>{log.duplicatesIgnored > 0 && <span className="ml-1 font-mono text-xs text-yellow-700">/ {log.duplicatesIgnored} d.</span>}{log.errors > 0 && <span className="ml-1 font-mono text-xs text-red-700">/ {log.errors} e.</span>}</TableCell><TableCell className="text-center"><Button size="sm" variant="outline" className="h-8 px-2 text-blue-600" title="Voir les détails de cet import" onClick={() => { setSelectedLog(log); setDetailDialogOpen(true); }}><Eye className="h-4 w-4" /></Button></TableCell></TableRow>)}</TableBody>
</Table>
) : <div className="py-12 text-center text-gray-500"><HistoryIcon className="mx-auto mb-3 h-12 w-12 text-gray-300" /><p className="font-medium">Aucun import trouvé</p><p className="mt-1 text-sm">Modifiez les filtres pour afficher plus de résultats.</p></div>}
</CardContent></Card>
</div>
{/* Dialog visualisation détails */}
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-blue-500" />
Détails de l'import
</DialogTitle>
</DialogHeader>
{selectedLog && (
<div className="space-y-4">
{/* Infos générales */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500 font-medium mb-1">Fichier</p>
<p className="text-sm font-semibold break-all">{selectedLog.fileName}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500 font-medium mb-1">Date d'import</p>
<p className="text-sm font-semibold">{new Date(selectedLog.importedAt).toLocaleString("fr-FR")}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500 font-medium mb-1">Source import</p>
<p className="text-sm font-semibold">
{selectedLog.importSource === "email" ? (
<span className="inline-flex items-center gap-1 text-blue-700"><Mail className="h-4 w-4" /> Email</span>
) : selectedLog.importSource === "folder" ? (
<span className="inline-flex items-center gap-1 text-purple-700"><FolderOpen className="h-4 w-4" /> Dossier</span>
) : (
<span className="inline-flex items-center gap-1 text-gray-700"><Upload className="h-4 w-4" /> Fichier</span>
)}
</p>
</div>
</div>
{/* Statistiques */}
<div className="grid grid-cols-4 gap-2">
<div className="text-center bg-gray-50 rounded-lg p-3">
<p className="text-2xl font-bold text-gray-700">{selectedLog.totalInvoicesDetected}</p>
<p className="text-xs text-gray-500 mt-1">Détectées</p>
</div>
<div className="text-center bg-green-50 rounded-lg p-3">
<p className="text-2xl font-bold text-green-700">{selectedLog.invoicesImported}</p>
<p className="text-xs text-green-600 mt-1">Importées</p>
</div>
<div className="text-center bg-yellow-50 rounded-lg p-3">
<p className="text-2xl font-bold text-yellow-700">{selectedLog.duplicatesIgnored}</p>
<p className="text-xs text-yellow-600 mt-1">Doublons</p>
</div>
<div className="text-center bg-red-50 rounded-lg p-3">
<p className="text-2xl font-bold text-red-600">{selectedLog.errors}</p>
<p className="text-xs text-red-500 mt-1">Erreurs</p>
</div>
</div>
{/* Détails doublons */}
{selectedLog.duplicateDetails && (() => {
try {
const details = JSON.parse(selectedLog.duplicateDetails);
if (Array.isArray(details) && details.length > 0) {
return (
<div>
<h3 className="text-sm font-semibold text-yellow-700 mb-2 flex items-center gap-1">
<RotateCcw className="h-4 w-4" />
Doublons ignorés ({details.length})
</h3>
<div className="space-y-1 max-h-40 overflow-y-auto">
{details.map((d: string | { supplier?: string; invoiceNumber?: string; reason?: string }, i: number) => (
<div key={i} className="text-xs bg-yellow-50 border border-yellow-100 rounded px-3 py-1.5 text-yellow-800">
{typeof d === "string" ? d : `${d.supplier || ""} — ${d.invoiceNumber || ""} ${d.reason ? `(${d.reason})` : ""}`}
</div>
))}
</div>
</div>
);
}
} catch { /* JSON invalide */ }
return null;
})()}
{/* Détails erreurs */}
{selectedLog.errorDetails && (() => {
try {
const details = JSON.parse(selectedLog.errorDetails);
if (Array.isArray(details) && details.length > 0) {
return (
<div>
<h3 className="text-sm font-semibold text-red-600 mb-2 flex items-center gap-1">
<AlertTriangle className="h-4 w-4" />
Erreurs ({details.length})
</h3>
<div className="space-y-1 max-h-40 overflow-y-auto">
{details.map((d: string | { message?: string; file?: string }, i: number) => (
<div key={i} className="text-xs bg-red-50 border border-red-100 rounded px-3 py-1.5 text-red-700">
{typeof d === "string" ? d : `${d.file ? `[${d.file}] ` : ""}${d.message || ""}`}
</div>
))}
</div>
</div>
);
}
} catch { /* JSON invalide */ }
return null;
})()}
</div>
)}
</DialogContent>
</Dialog>
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}><DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto"><DialogHeader><DialogTitle className="flex items-center gap-2"><FileText className="h-5 w-5 text-blue-500" />Détails de l’import</DialogTitle></DialogHeader>
{selectedLog && <div className="space-y-4"><div className="grid gap-3 sm:grid-cols-2"><div className="rounded-lg bg-gray-50 p-3"><p className="mb-1 text-xs font-medium text-gray-500">Fichier</p><p className="break-all text-sm font-semibold">{selectedLog.fileName}</p></div><div className="rounded-lg bg-gray-50 p-3"><p className="mb-1 text-xs font-medium text-gray-500">Date et heure d’import</p><p className="text-sm font-semibold">{new Date(selectedLog.importedAt).toLocaleString("fr-FR")}</p></div><div className="rounded-lg bg-gray-50 p-3"><p className="mb-1 text-xs font-medium text-gray-500">Compte responsable</p><p className="text-sm font-semibold">{selectedLog.userName?.trim() || selectedLog.userEmail || "Compte supprimé"}</p>{selectedLog.userName && selectedLog.userEmail && <p className="mt-0.5 text-xs text-gray-500">{selectedLog.userEmail}</p>}</div><div className="rounded-lg bg-gray-50 p-3"><p className="mb-1 text-xs font-medium text-gray-500">Origine</p><div className="flex items-center gap-2"><SourceBadge source={selectedLog.importSource} /><TriggerBadge trigger={selectedLog.importTrigger} /></div></div><div className="rounded-lg bg-gray-50 p-3"><p className="mb-1 text-xs font-medium text-gray-500">Fichier source</p><p className="text-sm font-semibold">ID {selectedLog.sourceFileId} · {selectedLog.sourceStatus || "indisponible"}</p>{selectedLog.sourceCreatedAt && <p className="mt-0.5 text-xs text-gray-500">Créé le {new Date(selectedLog.sourceCreatedAt).toLocaleString("fr-FR")}</p>}</div></div>
<div className="grid grid-cols-4 gap-2"><div className="rounded-lg bg-gray-50 p-3 text-center"><p className="text-2xl font-bold text-gray-700">{selectedLog.totalInvoicesDetected}</p><p className="mt-1 text-xs text-gray-500">Détectées</p></div><div className="rounded-lg bg-green-50 p-3 text-center"><p className="text-2xl font-bold text-green-700">{selectedLog.invoicesImported}</p><p className="mt-1 text-xs text-green-600">Importées</p></div><div className="rounded-lg bg-yellow-50 p-3 text-center"><p className="text-2xl font-bold text-yellow-700">{selectedLog.duplicatesIgnored}</p><p className="mt-1 text-xs text-yellow-600">Doublons</p></div><div className="rounded-lg bg-red-50 p-3 text-center"><p className="text-2xl font-bold text-red-600">{selectedLog.errors}</p><p className="mt-1 text-xs text-red-600">Erreurs</p></div></div>
{selectedLog.warningMessage && <div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"><strong>Avertissement :</strong> {selectedLog.warningMessage}</div>}
{selectedLog.duplicateDetails && <DetailsList title="Doublons ignorés" tone="yellow" value={selectedLog.duplicateDetails} icon={<RotateCcw className="h-4 w-4" />} />}
{selectedLog.errorDetails && <DetailsList title="Erreurs" tone="red" value={selectedLog.errorDetails} icon={<AlertTriangle className="h-4 w-4" />} />}
</div>}
</DialogContent></Dialog>
</DashboardLayout>
);
}
function DetailsList({ title, tone, value, icon }: { title: string; tone: "yellow" | "red"; value: string; icon: React.ReactNode }) {
try {
const details = JSON.parse(value);
if (!Array.isArray(details) || details.length === 0) return null;
const styles = tone === "yellow" ? "border-yellow-100 bg-yellow-50 text-yellow-800" : "border-red-100 bg-red-50 text-red-700";
const heading = tone === "yellow" ? "text-yellow-700" : "text-red-600";
return <div><h3 className={`mb-2 flex items-center gap-1 text-sm font-semibold ${heading}`}>{icon}{title} ({details.length})</h3><div className="max-h-40 space-y-1 overflow-y-auto">{details.map((detail: string | Record<string, unknown>, index: number) => <div key={index} className={`rounded border px-3 py-1.5 text-xs ${styles}`}>{typeof detail === "string" ? detail : Object.values(detail).filter(Boolean).join(" — ")}</div>)}</div></div>;
} catch {
return null;
}
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import { trpc } from "@/lib/trpc";
import AzureAdExportSettingsCard from "@/components/AzureAdExportSettingsCard";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -333,25 +334,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
@@ -361,6 +358,8 @@ export default function ImportSettings() {
{/* ===== ONGLET IMPORT ===== */}
<TabsContent value="import" className="space-y-6">
<AzureAdExportSettingsCard />
{/* Import manuel */}
<Card className="border-2 hover:border-primary/50 transition-colors">
<CardHeader className="bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 border-b">

View File

@@ -116,6 +116,9 @@ export default function InvoicesBAP() {
const [searchQuery, setSearchQuery] = useState(initialFilters.searchQuery);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [statusFilter, setStatusFilter] = useState<string>(initialFilters.statusFilter);
const [recipientFilter, setRecipientFilter] = useState<string>(initialFilters.recipientFilter);
const [entityFilter, setEntityFilter] = useState<string>(initialFilters.entityFilter);
const [ventilationFilter, setVentilationFilter] = useState<string>(initialFilters.ventilationFilter);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false);
@@ -143,6 +146,9 @@ export default function InvoicesBAP() {
statusFilter,
selectedYear,
selectedMonth,
recipientFilter,
entityFilter,
ventilationFilter,
sortField,
sortDir,
}));
@@ -169,6 +175,14 @@ export default function InvoicesBAP() {
const { data: allInvoices, isLoading } = trpc.invoices.list.useQuery();
// Filter for BAP invoices only (Abonnement = NON, isSubscription = 0)
const invoices = allInvoices?.filter(inv => inv.isSubscription === 0);
const uniqueRecipients = useMemo(
() => Array.from(new Set((invoices || []).map(inv => (inv as any).recipientName).filter(Boolean))).sort(),
[invoices],
);
const uniqueVentilations = useMemo(
() => Array.from(new Set((invoices || []).map(inv => (inv as any).ventilationComptable).filter(Boolean))).sort(),
[invoices],
);
// IDs des factures déjà validées BAP (pour charger leurs pdfUrl depuis bapHistory)
// Stabilisé avec useMemo pour éviter les re-renders infinis (anti-pattern tRPC)
const validatedInvoiceIds = useMemo(
@@ -440,6 +454,27 @@ export default function InvoicesBAP() {
if (statusFilter === "bap_pending" && inv.bapValidated === 1) return false;
if (statusFilter === "to_complete" && !isToComplete(inv)) return false;
}
// Même convention que la page Factures : valeur vide et entités déduites du service.
if (recipientFilter !== "all") {
if (recipientFilter === "__empty__") {
if ((inv as any).recipientName) return false;
} else if ((inv as any).recipientName !== recipientFilter) {
return false;
}
}
if (entityFilter !== "all") {
const service = ((inv as any).serviceConcerne || "").trim().toUpperCase();
if (entityFilter === "santinova" && service !== "DSI SANTINOVA") return false;
if (entityFilter === "itinova" && service === "DSI SANTINOVA") return false;
}
if (ventilationFilter !== "all") {
if (ventilationFilter === "__empty__") {
if ((inv as any).ventilationComptable) return false;
} else if ((inv as any).ventilationComptable !== ventilationFilter) {
return false;
}
}
return true;
});
@@ -778,6 +813,40 @@ export default function InvoicesBAP() {
))}
</SelectContent>
</Select>
<Select value={recipientFilter} onValueChange={setRecipientFilter}>
<SelectTrigger className="w-[180px] h-8 text-sm bg-white border-emerald-200">
<SelectValue placeholder="Destinataire" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous destinataires</SelectItem>
<SelectItem value="__empty__">Sans destinataire</SelectItem>
{uniqueRecipients.map((recipient) => (
<SelectItem key={recipient} value={recipient}>{recipient}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={entityFilter} onValueChange={setEntityFilter}>
<SelectTrigger className="w-[150px] h-8 text-sm bg-white border-emerald-200">
<SelectValue placeholder="Entité" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes entités</SelectItem>
<SelectItem value="santinova">SANTINOVA</SelectItem>
<SelectItem value="itinova">ITINOVA</SelectItem>
</SelectContent>
</Select>
<Select value={ventilationFilter} onValueChange={setVentilationFilter}>
<SelectTrigger className="w-[170px] h-8 text-sm bg-white border-emerald-200">
<SelectValue placeholder="Ventilation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes ventilations</SelectItem>
<SelectItem value="__empty__">Sans ventilation</SelectItem>
{uniqueVentilations.map((ventilation) => (
<SelectItem key={ventilation} value={ventilation}>{ventilation}</SelectItem>
))}
</SelectContent>
</Select>
{(selectedYear !== "all" || selectedMonth !== "all") && (
<button
onClick={() => { setSelectedYear(String(currentYear)); setSelectedMonth("all"); }}

View File

@@ -0,0 +1 @@
ALTER TABLE `importLogs` ADD `importTrigger` enum('manual','automatic','unknown') DEFAULT 'unknown' NOT NULL;

View 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`);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -267,6 +267,20 @@
"when": 1787394418464,
"tag": "0037_goofy_quentin_quire",
"breakpoints": true
},
{
"idx": 38,
"version": "5",
"when": 1788349114141,
"tag": "0038_late_thunderbolt",
"breakpoints": true
},
{
"idx": 39,
"version": "5",
"when": 1788350633569,
"tag": "0039_good_carmella_unuscione",
"breakpoints": true
}
]
}

View File

@@ -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.
@@ -231,6 +231,8 @@ export const importLogs = mysqlTable("importLogs", {
warningMessage: text("warningMessage"), // Warning message (e.g. quota exhausted)
/** Source du mode d'import : 'file' = upload manuel, 'folder' = dossier automatique, 'email' = import par email */
importSource: mysqlEnum("importSource", ["file", "folder", "email"]).default("file").notNull(),
/** Déclencheur : manuel, planifié automatiquement, ou non tracé pour les historiques antérieurs. */
importTrigger: mysqlEnum("importTrigger", ["manual", "automatic", "unknown"]).default("unknown").notNull(),
importedAt: timestamp("importedAt").defaultNow().notNull(),
});
@@ -322,6 +324,31 @@ export const automationRules = mysqlTable("automationRules", {
export type AutomationRule = typeof automationRules.$inferSelect;
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
* Stores configuration for each field used in invoice extraction

View File

@@ -12,27 +12,33 @@ describe("navigation Factures BAP", () => {
statusFilter: "bap_pending",
selectedYear: "2026",
selectedMonth: "05",
recipientFilter: "Direction générale",
entityFilter: "santinova",
ventilationFilter: "615200",
sortField: "invoiceDate",
sortDir: "asc",
});
expect(location).toBe(
"/invoices/42?returnTo=bap&q=microsoft+europe&status=bap_pending&year=2026&month=05&sort=invoiceDate&dir=asc"
"/invoices/42?returnTo=bap&q=microsoft+europe&status=bap_pending&year=2026&month=05&recipient=Direction+g%C3%A9n%C3%A9rale&entity=santinova&ventilation=615200&sort=invoiceDate&dir=asc"
);
});
it("restaure la liste BAP et les filtres de façon sûre", () => {
const detailSearch =
"?returnTo=bap&q=microsoft+europe&status=bap_pending&year=2026&month=05&sort=invoiceDate&dir=asc";
"?returnTo=bap&q=microsoft+europe&status=bap_pending&year=2026&month=05&recipient=Direction+g%C3%A9n%C3%A9rale&entity=santinova&ventilation=615200&sort=invoiceDate&dir=asc";
expect(getBapReturnLocation(detailSearch)).toBe(
"/invoices-bap?q=microsoft+europe&status=bap_pending&year=2026&month=05&sort=invoiceDate&dir=asc"
"/invoices-bap?q=microsoft+europe&status=bap_pending&year=2026&month=05&recipient=Direction+g%C3%A9n%C3%A9rale&entity=santinova&ventilation=615200&sort=invoiceDate&dir=asc"
);
expect(parseBapFilters(detailSearch, "2026")).toEqual({
searchQuery: "microsoft europe",
statusFilter: "bap_pending",
selectedYear: "2026",
selectedMonth: "05",
recipientFilter: "Direction générale",
entityFilter: "santinova",
ventilationFilter: "615200",
sortField: "invoiceDate",
sortDir: "asc",
});
@@ -44,6 +50,9 @@ describe("navigation Factures BAP", () => {
statusFilter: "all",
selectedYear: "2026",
selectedMonth: "all",
recipientFilter: "all",
entityFilter: "all",
ventilationFilter: "all",
sortField: "createdAt",
sortDir: "desc",
});

View File

@@ -1,4 +1,4 @@
import { eq, and, desc, sql, inArray } from "drizzle-orm";
import { eq, and, desc, sql, inArray, getTableColumns } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
InsertUser,
@@ -30,6 +30,9 @@ import {
automationRules,
InsertAutomationRule,
AutomationRule,
exportAutomationRules,
InsertExportAutomationRule,
ExportAutomationRule,
llmFieldsConfig,
InsertLlmFieldConfig,
LlmFieldConfig,
@@ -538,6 +541,44 @@ export async function getAllImportLogs(): Promise<ImportLog[]> {
return db.select().from(importLogs).orderBy(desc(importLogs.importedAt));
}
/**
* Retourne les informations nécessaires à l'audit d'un import sans exposer
* les paramètres sensibles de messagerie. L'administrateur peut ainsi relier
* chaque import à son compte, fichier source et déclencheur.
*/
export async function getImportLogsWithDetailsByUser(userId: number) {
const db = await getDb();
if (!db) return [];
return db.select({
...getTableColumns(importLogs),
userName: users.name,
userEmail: users.email,
sourceCreatedAt: sourceFiles.createdAt,
sourceStatus: sourceFiles.processingStatus,
})
.from(importLogs)
.leftJoin(users, eq(importLogs.userId, users.id))
.leftJoin(sourceFiles, eq(importLogs.sourceFileId, sourceFiles.id))
.where(eq(importLogs.userId, userId))
.orderBy(desc(importLogs.importedAt));
}
export async function getAllImportLogsWithDetails() {
const db = await getDb();
if (!db) return [];
return db.select({
...getTableColumns(importLogs),
userName: users.name,
userEmail: users.email,
sourceCreatedAt: sourceFiles.createdAt,
sourceStatus: sourceFiles.processingStatus,
})
.from(importLogs)
.leftJoin(users, eq(importLogs.userId, users.id))
.leftJoin(sourceFiles, eq(importLogs.sourceFileId, sourceFiles.id))
.orderBy(desc(importLogs.importedAt));
}
export async function deleteAllImportLogs(userId: number | null): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
@@ -611,6 +652,24 @@ export async function upsertImportSettings(data: InsertImportSettings): Promise<
}
}
/** Liste administrative des comptes e-mail configurés, sans secret IMAP ni Azure AD. */
export async function getEmailImportAccounts() {
const db = await getDb();
if (!db) return [];
return db.select({
userId: importSettings.userId,
userName: users.name,
userEmail: users.email,
emailAddress: importSettings.emailImportAddress,
authMode: importSettings.emailImportAuthMode,
automaticEnabled: importSettings.emailImportEnabled,
isConfigured: sql<number>`CASE WHEN ${importSettings.emailImportAddress} IS NOT NULL AND ${importSettings.emailImportAddress} <> '' AND ${importSettings.emailImportHost} IS NOT NULL AND ${importSettings.emailImportHost} <> '' THEN 1 ELSE 0 END`.as("isConfigured"),
})
.from(importSettings)
.innerJoin(users, eq(importSettings.userId, users.id))
.orderBy(users.name, users.email);
}
// ============= DEPARTMENT LIST OPERATIONS =============
export async function getDepartmentsByUser(_userId?: number): Promise<Department[]> {
@@ -691,6 +750,47 @@ export async function deleteAutomationRule(id: number): Promise<void> {
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 =============
export async function initializeDefaultLists(userId: number): Promise<void> {

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { createImapFlowOptions, type EmailImportConfig } from "./emailImportService";
import {
createImapFlowOptions,
type EmailImportConfig,
validateEmailImportConfiguration,
} from "./emailImportService";
const baseConfig: EmailImportConfig = {
userId: 2,
@@ -36,3 +40,39 @@ describe("createImapFlowOptions", () => {
expect(options.auth).not.toHaveProperty("accessToken");
});
});
describe("validateEmailImportConfiguration", () => {
it("accepte une boîte OAuth2 configurée même lorsque la planification automatique est désactivée", () => {
const error = validateEmailImportConfiguration({
emailImportEnabled: 0,
emailImportAddress: "compta@example.org",
emailImportPassword: null,
emailImportHost: "outlook.office365.com",
emailImportPort: 993,
emailImportSinceDate: null,
emailImportAuthMode: "oauth2",
azureTenantId: "tenant",
azureClientId: "client",
azureClientSecret: "secret",
});
expect(error).toBeNull();
});
it("refuse une boîte dont la configuration IMAP est incomplète", () => {
const error = validateEmailImportConfiguration({
emailImportEnabled: 0,
emailImportAddress: "compta@example.org",
emailImportPassword: null,
emailImportHost: null,
emailImportPort: 993,
emailImportSinceDate: null,
emailImportAuthMode: "oauth2",
azureTenantId: "tenant",
azureClientId: "client",
azureClientSecret: "secret",
});
expect(error).toBe("Configuration IMAP incomplète");
});
});

View File

@@ -32,6 +32,40 @@ export interface EmailImportConfig {
azureClientSecret?: string;
}
export type EmailImportTrigger = "manual" | "automatic";
type EmailImportSettingsSnapshot = {
emailImportEnabled: number;
emailImportAddress: string | null;
emailImportPassword: string | null;
emailImportHost: string | null;
emailImportPort: number | null;
emailImportSinceDate: number | null;
emailImportAuthMode: "basic" | "oauth2";
azureTenantId: string | null;
azureClientId: string | null;
azureClientSecret: string | null;
};
/** Vérifie une configuration IMAP sans tenir compte de l'activation de la planification. */
export function validateEmailImportConfiguration(
settings: EmailImportSettingsSnapshot | null | undefined,
): string | null {
if (!settings || !settings.emailImportAddress || !settings.emailImportHost) {
return "Configuration IMAP incomplète";
}
if (settings.emailImportAuthMode === "basic" && !settings.emailImportPassword) {
return "Mot de passe IMAP manquant";
}
if (
settings.emailImportAuthMode === "oauth2" &&
(!settings.azureTenantId || !settings.azureClientId || !settings.azureClientSecret)
) {
return "Configuration OAuth2 IMAP incomplète";
}
return null;
}
/**
* Construit les options ImapFlow sans effectuer d'appel réseau.
* ImapFlow reçoit le jeton brut et construit lui-même SASL XOAUTH2.
@@ -67,14 +101,17 @@ const activeIntervals = new Map<number, NodeJS.Timeout>();
// qu'un second cycle IMAP traite les mêmes messages avant la fin du premier.
const activeChecks = new Map<number, Promise<void>>();
function runEmailCheckExclusive(config: EmailImportConfig): Promise<void> {
function runEmailCheckExclusive(
config: EmailImportConfig,
trigger: EmailImportTrigger = "automatic",
): Promise<void> {
const runningCheck = activeChecks.get(config.userId);
if (runningCheck) {
console.log(`[EmailImport] Vérification déjà en cours pour user ${config.userId}, cycle ignoré`);
return runningCheck;
}
const check = checkEmailsForPDFs(config).finally(() => {
const check = checkEmailsForPDFs(config, trigger).finally(() => {
if (activeChecks.get(config.userId) === check) activeChecks.delete(config.userId);
});
activeChecks.set(config.userId, check);
@@ -88,7 +125,8 @@ function runEmailCheckExclusive(config: EmailImportConfig): Promise<void> {
async function processEmailAttachment(
userId: number,
attachment: Attachment,
emailSubject: string
emailSubject: string,
trigger: EmailImportTrigger,
): Promise<{ success: boolean; totalInvoices: number; imported: number; duplicates: number; errors: number; quotaError?: boolean }> {
const fileName = attachment.filename || `email-attachment-${Date.now()}.pdf`;
console.log(`[EmailImport] Processing attachment: ${fileName} from email: ${emailSubject}`);
@@ -323,6 +361,7 @@ async function processEmailAttachment(
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
warningMessage,
importSource: "email",
importTrigger: trigger,
});
console.log(`[EmailImport] Successfully processed attachment: ${fileName}`);
@@ -386,7 +425,10 @@ async function buildImapConfig(config: EmailImportConfig): Promise<ImapFlowOptio
/**
* Connect to IMAP and process unread emails with PDF attachments
*/
async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
async function checkEmailsForPDFs(
config: EmailImportConfig,
trigger: EmailImportTrigger,
): Promise<void> {
const imapConfig = await buildImapConfig(config);
const client = new ImapFlow(imapConfig);
client.on("error", (error) => {
@@ -450,6 +492,7 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
config.userId,
attachment,
parsed.subject || "No subject",
trigger,
);
allAttachmentsSucceeded = allAttachmentsSucceeded && result.success;
@@ -615,29 +658,28 @@ export function isEmailImportServiceRunning(userId: number): boolean {
return activeIntervals.has(userId);
}
/**
* Manually trigger an immediate email check for a user
*/
export async function triggerEmailCheck(userId: number): Promise<{ success: boolean; message: string }> {
async function triggerConfiguredEmailCheck(
userId: number,
requireAutomaticEnabled: boolean,
): Promise<{ success: boolean; message: string }> {
try {
// Get user's import settings
const settings = await getImportSettingsByUser(userId);
if (!settings || settings.emailImportEnabled !== 1) {
if (!settings || (requireAutomaticEnabled && settings.emailImportEnabled !== 1)) {
return { success: false, message: "Import par email non activé" };
}
if (!settings.emailImportAddress || !settings.emailImportHost) {
return { success: false, message: "Configuration IMAP incomplète" };
}
const validationError = validateEmailImportConfiguration(settings);
if (validationError) return { success: false, message: validationError };
const authMode = (settings as any).emailImportAuthMode as "basic" | "oauth2" || "basic";
const config: EmailImportConfig = {
userId,
emailAddress: settings.emailImportAddress,
// validateEmailImportConfiguration garantit ces deux valeurs avant ce point.
emailAddress: settings.emailImportAddress!,
password: settings.emailImportPassword || "",
host: settings.emailImportHost,
host: settings.emailImportHost!,
port: settings.emailImportPort || 993,
sinceDate: settings.emailImportSinceDate ?? undefined,
authMode,
@@ -647,7 +689,8 @@ export async function triggerEmailCheck(userId: number): Promise<{ success: bool
};
console.log(`[EmailImport] Manual check triggered for user ${userId}`);
await runEmailCheckExclusive(config);
// Cette voie exécute une vérification unique : elle ne crée pas de setInterval.
await runEmailCheckExclusive(config, "manual");
return { success: true, message: "Vérification terminée avec succès" };
} catch (error: any) {
@@ -656,6 +699,16 @@ export async function triggerEmailCheck(userId: number): Promise<{ success: bool
}
}
/** Déclenchement manuel réservé aux appels administrateurs, même si le planificateur est désactivé. */
export async function triggerManualEmailCheck(userId: number): Promise<{ success: boolean; message: string }> {
return triggerConfiguredEmailCheck(userId, false);
}
/** Compatibilité avec le bouton existant : la vérification personnelle exige toujours l’activation automatique. */
export async function triggerEmailCheck(userId: number): Promise<{ success: boolean; message: string }> {
return triggerConfiguredEmailCheck(userId, true);
}
/**
* Stop all email import services
*/

View 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();
});
});

View 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",
};
}

View File

@@ -211,6 +211,7 @@ async function processFolderFile(
duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null,
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
importSource: "folder",
importTrigger: "automatic",
});
// Move file to processed folder

View File

@@ -40,13 +40,16 @@ import {
isInvoiceBlacklisted,
getAllInvoices,
getAllImportLogs,
getAllImportLogsWithDetails,
getAllBapHistory,
createImportLog,
getImportLogsByUser,
getImportLogsWithDetailsByUser,
deleteAllImportLogs,
getLlmLogsBySourceFile,
getLlmLogsByInvoice,
getImportSettingsByUser,
getEmailImportAccounts,
upsertImportSettings,
getDepartmentsByUser,
createDepartment,
@@ -63,6 +66,11 @@ import {
createAutomationRule,
updateAutomationRule,
deleteAutomationRule,
getExportAutomationRulesByUser,
getExportAutomationRuleById,
createExportAutomationRule,
updateExportAutomationRule,
deleteExportAutomationRule,
getSignaturesByUser,
getSignatureById,
createSignature,
@@ -94,7 +102,8 @@ import { calculateFileSha256 } from "./fileFingerprint";
import { localStoragePut, generateStorageKey } from "./localStorage";
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
import { drawBapCartouche } from "./bapCartouche";
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, testImapConnection } from "./emailImportService";
import { resolveBapExportDestination } from "./exportDestinationResolver";
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, testImapConnection } from "./emailImportService";
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
import { TRPCError } from "@trpc/server";
import { processFreeproExcel } from "./freeproService";
@@ -409,6 +418,7 @@ export const appRouter = router({
duplicateDetails: JSON.stringify(duplicateDetails),
errorDetails: JSON.stringify(errorDetails),
importSource: "file",
importTrigger: "manual",
});
} catch (error: any) {
@@ -517,9 +527,10 @@ export const appRouter = router({
const { localStoragePut, generateStorageKey } = await import('./localStorage');
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 resolvedDestination = await resolveBapExportDestination(ctx.user.id, invoice, importSettings);
const bapExportMode = resolvedDestination.exportMode;
const exportFolder = resolvedDestination.destinationPath;
const exportFolderType = resolvedDestination.destinationType;
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
let pdfUrl: string | null = null;
@@ -605,11 +616,11 @@ export const appRouter = router({
const _bapNumber = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
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 (exportFolderType === 'sharepoint') {
// Mode SharePoint : upload via Microsoft Graph
console.log('[BAP] Démarrage upload SharePoint pour:', bapFilename);
if (exportFolderType === 'sharepoint' || exportFolderType === 'teams') {
// Les fichiers Teams sont déposés via le site SharePoint de l’équipe.
console.log('[BAP] Démarrage upload Microsoft 365 pour:', bapFilename);
const { uploadToSharePoint } = await import('./sharepoint');
const spResult = await uploadToSharePoint(
{
@@ -736,9 +747,6 @@ export const appRouter = router({
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
const { localStoragePut, generateStorageKey } = await import('./localStorage');
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 serviceSignaturesList = await getServiceSignaturesByUser(ctx.user.id);
const results: Array<{ id: number; success: boolean; pdfUrl?: string | null; exportPath?: string | null; error?: string }> = [];
@@ -752,6 +760,10 @@ export const appRouter = router({
let sharepointUploadStatus: 'success' | 'error' | 'skipped' | null = null;
let sharepointUploadPath: 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 {
// ─ Lecture du PDF source ─
let sourcePdfBytes: Buffer;
@@ -822,7 +834,7 @@ export const appRouter = router({
const _bapNumber2 = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
const bapFilename = [_bapDateStr2, _bapSupplier2, _bapNumber2].filter(Boolean).join(' - ') + '.pdf';
if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) {
if (exportFolderType === 'sharepoint') {
if (exportFolderType === 'sharepoint' || exportFolderType === 'teams') {
const { uploadToSharePoint } = await import('./sharepoint');
const spResult = await uploadToSharePoint(
{
@@ -1646,9 +1658,9 @@ export const appRouter = router({
getByUser: protectedProcedure.query(async ({ ctx }) => {
// Les admins voient tous les logs d'import
if (ctx.user.role === 'admin') {
return getAllImportLogs();
return getAllImportLogsWithDetails();
}
return getImportLogsByUser(ctx.user.id);
return getImportLogsWithDetailsByUser(ctx.user.id);
}),
deleteAll: protectedProcedure.mutation(async ({ ctx }) => {
@@ -1715,6 +1727,18 @@ export const appRouter = router({
const result = await triggerEmailCheck(ctx.user.id);
return result;
}),
/** Liste sans secrets les boîtes IMAP disponibles pour une action manuelle d’administrateur. */
getConfiguredAccounts: adminProcedure.query(async () => {
return getEmailImportAccounts();
}),
/** Déclenche une seule lecture IMAP sans activer ni planifier le service automatique. */
checkAccountNow: adminProcedure
.input(z.object({ userId: z.number().int().positive() }))
.mutation(async ({ input }) => {
return triggerManualEmailCheck(input.userId);
}),
}),
// ============= IMPORT SETTINGS ROUTES =============
@@ -1742,6 +1766,7 @@ export const appRouter = router({
azureTenantId: null,
azureClientId: null,
azureClientSecret: null,
azureSecretExpiresAt: null,
};
}
@@ -2260,6 +2285,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: router({
list: protectedProcedure.query(async ({ ctx }) => {

View 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);
});
}

52
todo.md
View File

@@ -825,3 +825,55 @@
- [x] Confirmer le tri décroissant par montant total dans le budget
- [x] Ajouter un graphique annuel BAP versus abonnements au tableau de bord
- [x] Ajouter les tests et valider TypeScript, build et rendu sandbox
## Déploiement et visibilité production — budget et graphique
- [x] Pousser les améliorations vers Gitea recette
- [x] Déployer et vérifier les améliorations en recette
- [x] Pousser la version validée vers Gitea production
- [x] Déployer et vérifier les améliorations en production
- [x] Contrôler le dashboard et le portail de production : aucun changement requis, le catalogue et les statuts sont déjà à jour
## Contrôle de l’import automatique e-mail
- [x] Vérifier en production les paramètres et traces de l’import e-mail automatique : un compte reste activé dans la configuration, sans trace récente de déclenchement
## Désactivation de l’import automatique e-mail
- [x] Désactiver la planification e-mail pour tous les comptes en recette
- [x] Désactiver la planification e-mail pour tous les comptes en production
- [x] Redémarrer uniquement les conteneurs applicatifs pour vider les planifications en mémoire
- [x] Vérifier les paramètres, la santé et l’absence de redémarrage automatique sur les deux environnements
## Investigation des factures production du 1er septembre 2026
- [x] Identifier les deux factures créées et leur source d’import sans modifier les données
- [x] Corréler les factures avec l’historique, les fichiers source et les journaux applicatifs
- [x] Vérifier l’état de l’import e-mail au moment de leur création et déterminer la cause : le compte utilisateur 2 était activé, avec une cadence de 30 minutes
## Historique détaillé et déclenchement manuel des imports
- [x] Analyser les données d’import, les routes et la page Historique existantes
- [x] Ajouter une vue détaillée de l’historique avec source, compte, date, résultats et erreurs
- [x] Ajouter une action administrateur d’import e-mail manuel par compte configuré
- [x] Garantir que le déclenchement manuel ne modifie jamais la planification automatique
- [x] Ajouter les tests de non-régression et valider le parcours en sandbox
## Alignement des filtres Factures BAP
- [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] 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
## Répartition des paramètres Azure AD
- [x] Rétablir dans Paramètres l’URL SharePoint, Tenant ID, Client ID, secret et date d’expiration
- [x] Rétablir les tests Azure AD et upload SharePoint depuis Paramètres
- [x] Retirer les accès Microsoft Azure AD des automatismes d’export
- [x] Valider les deux emplacements en sandbox
## Présentation des automatismes
- [x] Coloriser les onglets Import et Export
- [x] Simplifier les libellés de l’onglet Export comme demandé
- [x] Vérifier le rendu en sandbox

12
verification_notes.md Normal file
View File

@@ -0,0 +1,12 @@
# Vérification visuelle — Historique des imports
- Le 2 septembre 2026, la page `/history` a été vérifiée en format bureau et mobile.
- 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.
- 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.