Compare commits

39 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
Manus
9997e383cc Checkpoint: Budget réel confirmé en ordre décroissant de montant total ; graphique en barres annuel comparant les montants BAP et abonnements ajouté au tableau de bord.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 3m19s
2026-08-30 08:56:27 +00:00
Manus
5128322a6d Checkpoint: Ajout du récapitulatif annuel BAP et abonnements au tableau de bord, et de Budget réel par fournisseur avec filtres Année/Mois et ventilation abonnements/hors abonnement. 2026-08-29 22:15:35 +00:00
Manus
43fc8d523c Checkpoint: Page Factures abonnements ajoutée après Factures BAP ; filtres Année/Mois et filtres de la page Factures disponibles sur les deux listes ; déploiements recette et production vérifiés. 2026-08-29 18:52:49 +00:00
Manus
cd73dd3d58 Checkpoint: Préparation du déploiement production de la page Factures abonnements et de ses filtres Année/Mois après validation de la recette. 2026-08-29 18:45:38 +00:00
Manus
28a00cc771 chore: préparer le déploiement recette des factures abonnements
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 2m58s
2026-08-29 18:33:00 +00:00
Manus
e2d067ff4b Checkpoint: Ajout de la page Factures abonnements, accessible après Factures BAP, partageant les recherches, filtres et actions de la page Factures. Filtres Année et Mois ajoutés aux deux listes, avec règle date de facture puis réception. 2026-08-29 18:32:12 +00:00
Manus
0b01ebe331 Checkpoint: Seuil BAP unifié à 90 % dans le serveur et l’interface, déployé en recette et production. Les deux environnements répondent HTTP 200, les conteneurs sont actifs et les bundles BAP correspondent au correctif. 2026-08-27 14:29:52 +00:00
Manus
a9a2a4b312 Checkpoint: Seuil BAP à 90 % validé en sandbox et recette. Le manifeste est rétabli vers le dépôt Gitea de production avant promotion ; le suivi du rétablissement recette est mis à jour. 2026-08-27 14:22:06 +00:00
Manus
deb5b5735c chore: préparer le déploiement recette du seuil BAP à 90
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 54m40s
2026-08-27 13:20:18 +00:00
Manus
a0c440dfae Checkpoint: Le seuil d’éligibilité et de validation BAP est centralisé à 90 % dans le serveur et l’interface. Les validations unitaires, TypeScript, build et rendu de la page Factures BAP sont réussis en sandbox. 2026-08-27 13:02:07 +00:00
Manus
a88b342a0d Checkpoint: Filtre par type d’action des automatismes déployé en recette puis production. Les catégories Type d’achat, Service, Ventilation, Abonnement, Abonnement Oui et Abonnement Non sont disponibles. Contrôles HTTP, conteneurs, commit et bundle réussis sur les deux environnements. 2026-08-27 10:16:24 +00:00
Manus
7e1253615b chore: suivre le déploiement du filtre par action 2026-08-27 10:09:52 +00:00
Manus
1a7d5cb64e chore: suivre le déploiement du filtre d actions
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 9m49s
2026-08-27 09:46:34 +00:00
Manus
0536035303 Checkpoint: Ajout d’un filtre par action dans la liste des automatismes, avec catégories Type d’achat, Service, Ventilation, Abonnement, Abonnement Oui et Abonnement Non. Tests, TypeScript, build et rendu visuel validés en sandbox. 2026-08-27 07:02:36 +00:00
Manus
d7677df41b Checkpoint: Correctif Abonnement visible dans les automatismes déployé en recette puis production. Production validée : commit d9c1130, conteneur sain, HTTP 200 et bundle AutomationRules vérifié. 2026-08-26 12:51:08 +00:00
Manus
d9c1130359 Checkpoint: Préparation du déploiement production du correctif de visibilité Abonnement : manifeste restauré vers le dépôt Gitea production et tâches de vérification tracées. 2026-08-26 12:41:50 +00:00
Manus
401659ec99 Checkpoint: Action Abonnement rendue immédiatement visible dans le bloc Actions des automatismes. Rebuild recette finalisé après arrêt autorisé du processus non applicatif saturant le serveur ; HTTP 200 et bundle validé. 2026-08-26 12:11:40 +00:00
Manus
3528fc6905 Checkpoint: L’option Abonnement est remontée en tête des actions du dialogue d’édition, dans un encadré explicite avec Oui, Non et Ne pas modifier. Tests, TypeScript et build validés.
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 3s
2026-08-26 11:49:09 +00:00
Manus
8b47a78e4a Checkpoint: Retour depuis le détail vers Factures BAP avec conservation des filtres et du tri ; automatismes enrichis avec Abonnement Oui/Non/Ne pas modifier. Tests, TypeScript et build validés ; recette HTTP 200 et conteneurs sains. 2026-08-26 10:35:50 +00:00
Manus
a281212bab chore: cible le dépôt Gitea recette
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 3m6s
2026-08-26 10:21:24 +00:00
Manus
b603541adf Checkpoint: Correctifs validés en sandbox : retour contextuel depuis le détail vers Factures BAP avec filtres et tri conservés, et nouvelle action Abonnement Oui/Non/Ne pas modifier dans les automatismes. Ajout de tests de non-régression. 2026-08-26 10:20:20 +00:00
Manus
1b3e3cdd67 Checkpoint: Migration ImapFlow déployée et validée sur recette et production. HTTP et conteneurs sains, OAuth2 production réussi, premier cycle email connecté sans message non lu, image Docker allégée et Vite exclu du runtime. 2026-08-22 19:12:43 +00:00
Manus
e4328ab652 Checkpoint: Préparation du déploiement production après validation recette : manifeste Gitea production restauré, image runtime allégée, Vite exclu du bundle serveur et diagnostic OAuth2 ImapFlow basé sur la configuration active ajouté. 2026-08-22 19:03:59 +00:00
Manus
692fbbe912 Checkpoint: Correction du runtime Docker : Vite et sa configuration sont maintenant chargés uniquement en développement via imports indirects. Le bundle serveur de production ne référence plus Vite ni ses plugins ; 31 tests, TypeScript et build validés.
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 3m22s
2026-08-22 18:22:46 +00:00
Manus
ece737e11d Checkpoint: Dockerfile final optimisé : les dépendances de développement restent dans le builder et seules les dépendances runtime sont installées dans l’image finale. Cette stratégie remplace le prune et la copie du node_modules complet qui bloquaient l’export sur le serveur de recette.
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 2m49s
2026-08-22 17:47:07 +00:00
Manus
75e7a9256c Checkpoint: Migration ImapFlow validée, historique recette fusionné sans réintroduire l’ancien client IMAP, et image Docker optimisée avec suppression des dépendances de développement après build pour réduire le runtime et fiabiliser l’export sur le serveur de recette.
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 38m5s
2026-08-22 17:03:36 +00:00
Manus
52b5e49082 Merge remote-tracking branch 'recette/server-state'
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 10m21s
# Conflicts:
#	app.json
#	server/emailImportService.ts
2026-08-22 16:09:35 +00:00
manus-admin
a1755e181c ci: trigger 2026-08-21 12:38:06 +02:00
Manus CI
5c8237d5c6 CI : workflow validate.yml, manifeste ci.required:true, garde VITEST, tests isolés 2026-08-21 12:38:06 +02:00
Manus Admin
a9cea0ecbb fix: empêcher la duplication massive de fichiers lors de l'import email
- Marquer les emails comme lus immédiatement (markSeen: true)
- Vérifier si le fichier existe déjà avant de le stocker (findSourceFileByFileName)
- Ajouter un verrou anti-concurrence par utilisateur (runningChecks)
- Empêche la création de ~570x doublons par facture
2026-08-21 12:38:06 +02:00
Manus
0612abf415 chore: versionner le manifeste de déploiement 2026-08-21 12:38:06 +02:00
43 changed files with 7066 additions and 519 deletions

View File

@@ -29,7 +29,8 @@ RUN pnpm install --frozen-lockfile
# Copy source code # Copy source code
COPY . . COPY . .
# Build frontend + backend # Build frontend + backend. Les dépendances de développement restent confinées
# au builder et ne sont jamais copiées dans l'image d'exécution.
RUN pnpm build RUN pnpm build
# ============================================================ # ============================================================
@@ -46,15 +47,19 @@ RUN apt-get update && apt-get install -y \
WORKDIR /app WORKDIR /app
# Copy node_modules from builder (already compiled, including sharp native binaries) ENV NODE_ENV=production
COPY --from=builder /app/node_modules ./node_modules
# Installer uniquement les dépendances d'exécution réduit fortement la taille
# de la couche exportée. Les binaires Sharp sont fournis par ses paquets
# optionnels de plateforme et ne nécessitent pas de script post-installation.
RUN npm install -g pnpm@10.4.1
COPY package.json pnpm-lock.yaml ./
COPY patches/ ./patches/
RUN pnpm install --prod --frozen-lockfile --ignore-scripts
# Copy built assets from builder (vite outputs to dist/public, esbuild to dist/) # Copy built assets from builder (vite outputs to dist/public, esbuild to dist/)
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
# Copy package.json (needed for module resolution)
COPY package.json ./
# Copy drizzle migrations # Copy drizzle migrations
COPY drizzle/ ./drizzle/ COPY drizzle/ ./drizzle/
COPY drizzle.config.ts ./ COPY drizzle.config.ts ./

View File

@@ -26,6 +26,15 @@ const ImportReport = lazy(() => import("./pages/ImportReport"));
const LearningSettings = lazy(() => import("./pages/LearningSettings")); const LearningSettings = lazy(() => import("./pages/LearningSettings"));
const VentilationFreePro = lazy(() => import("./pages/VentilationFreePro")); const VentilationFreePro = lazy(() => import("./pages/VentilationFreePro"));
const WebImportSources = lazy(() => import("./pages/WebImportSources")); const WebImportSources = lazy(() => import("./pages/WebImportSources"));
const RealBudget = lazy(() => import("./pages/RealBudget"));
function SubscriptionInvoices() {
return <Invoices scope="subscriptions" />;
}
function AllInvoices() {
return <Invoices />;
}
function RouteFallback() { function RouteFallback() {
return <div className="min-h-screen bg-background" aria-busy="true" aria-label="Chargement" />; return <div className="min-h-screen bg-background" aria-busy="true" aria-label="Chargement" />;
@@ -38,8 +47,10 @@ function Router() {
<Route path="/login" component={Login} /> <Route path="/login" component={Login} />
<Route path="/dashboard" component={Dashboard} /> <Route path="/dashboard" component={Dashboard} />
<Route path="/upload" component={Upload} /> <Route path="/upload" component={Upload} />
<Route path="/invoices" component={Invoices} /> <Route path="/invoices" component={AllInvoices} />
<Route path="/invoices-bap" component={InvoicesBAP} /> <Route path="/invoices-bap" component={InvoicesBAP} />
<Route path="/invoices-subscriptions" component={SubscriptionInvoices} />
<Route path="/real-budget" component={RealBudget} />
<Route path="/invoices/:id" component={InvoiceDetail} /> <Route path="/invoices/:id" component={InvoiceDetail} />
<Route path="/settings" component={Settings} /> <Route path="/settings" component={Settings} />
<Route path="/import-settings" component={ImportSettings} /> <Route path="/import-settings" component={ImportSettings} />

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 denregistrer 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 dexport.</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 lURL 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 lannuaire (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 dapplication (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 dexpiration 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 lupload 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

@@ -53,6 +53,8 @@ const menuStructure: MenuItem[] = [
{ icon: Upload, label: "Import", path: "/upload" }, { icon: Upload, label: "Import", path: "/upload" },
{ icon: FileText, label: "Factures", path: "/invoices" }, { icon: FileText, label: "Factures", path: "/invoices" },
{ icon: FileText, label: "Factures BAP", path: "/invoices-bap" }, { icon: FileText, label: "Factures BAP", path: "/invoices-bap" },
{ icon: FileText, label: "Factures abonnements", path: "/invoices-subscriptions" },
{ icon: BarChart2, label: "Budget réel", path: "/real-budget" },
], ],
}, },
{ {
@@ -69,7 +71,7 @@ const menuStructure: MenuItem[] = [
color: "from-orange-500 to-amber-500", color: "from-orange-500 to-amber-500",
children: [ children: [
{ icon: Settings, label: "Paramètres IA et Signatures", path: "/settings" }, { icon: Settings, label: "Paramètres IA et Signatures", path: "/settings" },
{ icon: Download, label: "Paramètres import / export", path: "/import-settings" }, { icon: Download, label: "Paramètres", path: "/import-settings" },
{ icon: List, label: "Administration des listes", path: "/lists-admin" }, { icon: List, label: "Administration des listes", path: "/lists-admin" },
{ icon: Zap, label: "Automatismes", path: "/automation-rules" }, { icon: Zap, label: "Automatismes", path: "/automation-rules" },
{ icon: Brain, label: "Apprentissages IA", path: "/learning-settings" }, { icon: Brain, label: "Apprentissages IA", path: "/learning-settings" },

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 dexport 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 dexport 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 dexport 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 dexport enregistrés");
utils.importSettings.get.invalidate();
},
onError: (error) => toast.error(error.message || "Impossible denregistrer les paramètres dexport"),
});
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 lexport 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 dexport par défaut</CardTitle><CardDescription>Ils sappliquent lorsquaucun automatisme dexport 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 dexport</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 lURL 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 dexport"}</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 lURL 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 lapplication."}</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 dexport</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

@@ -0,0 +1,96 @@
/**
* Paramètres daffichage qui doivent survivre au passage par le détail dune
* facture BAP. Seules ces clés sont sérialisées afin déviter de propager des
* paramètres de navigation arbitraires.
*/
export type BapFilters = {
searchQuery: string;
statusFilter: string;
selectedYear: string;
selectedMonth: string;
recipientFilter: string;
entityFilter: string;
ventilationFilter: string;
sortField: "invoiceDate" | "createdAt";
sortDir: "asc" | "desc";
};
const BAP_STATUSES = new Set([
"all",
"exported",
"not_exported",
"export_error",
"bap_validated",
"bap_pending",
"to_complete",
]);
function asSearchParams(search: string): URLSearchParams {
return new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
}
function isMonth(value: string | null): value is string {
return value !== null && /^(0[1-9]|1[0-2])$/.test(value);
}
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 lURL. */
export function parseBapFilters(search: string, defaultYear: string): BapFilters {
const params = asSearchParams(search);
const status = params.get("status");
const year = params.get("year");
const month = params.get("month");
const sort = params.get("sort");
const direction = params.get("dir");
return {
searchQuery: params.get("q") || "",
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",
};
}
/** Construit une URL de détail qui conserve explicitement le contexte BAP. */
export function buildBapDetailLocation(invoiceId: number, filters: BapFilters): string {
const params = new URLSearchParams({
returnTo: "bap",
q: filters.searchQuery,
status: filters.statusFilter,
year: filters.selectedYear,
month: filters.selectedMonth,
recipient: filters.recipientFilter,
entity: filters.entityFilter,
ventilation: filters.ventilationFilter,
sort: filters.sortField,
dir: filters.sortDir,
});
return `/invoices/${invoiceId}?${params.toString()}`;
}
/** Retourne la liste dorigine. Sans contexte BAP, le comportement historique est conservé. */
export function getBapReturnLocation(search: string): string {
const params = asSearchParams(search);
if (params.get("returnTo") !== "bap") return "/invoices";
const allowed = new URLSearchParams();
for (const key of ["q", "status", "year", "month", "recipient", "entity", "ventilation", "sort", "dir"]) {
const value = params.get(key);
if (value) allowed.set(key, value);
}
const query = allowed.toString();
return query ? `/invoices-bap?${query}` : "/invoices-bap";
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import DashboardLayout from "@/components/DashboardLayout"; import DashboardLayout from "@/components/DashboardLayout";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -33,6 +33,13 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import ExportAutomationRulesPanel from "@/components/ExportAutomationRulesPanel";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
AUTOMATION_ACTION_FILTERS,
type AutomationActionFilter,
matchesAutomationActionFilter,
} from "@shared/automationActions";
import { toast } from "sonner"; import { toast } from "sonner";
import { Plus, Edit, Trash2, Power, PowerOff, Copy } from "lucide-react"; import { Plus, Edit, Trash2, Power, PowerOff, Copy } from "lucide-react";
@@ -46,6 +53,7 @@ interface Actions {
typeAchat?: string; typeAchat?: string;
serviceConcerne?: string; serviceConcerne?: string;
ventilationComptable?: string; ventilationComptable?: string;
isSubscription?: 0 | 1;
} }
export default function AutomationRules() { export default function AutomationRules() {
@@ -62,6 +70,7 @@ export default function AutomationRules() {
const [wizardStep, setWizardStep] = useState(1); const [wizardStep, setWizardStep] = useState(1);
const [testResultsOpen, setTestResultsOpen] = useState(false); const [testResultsOpen, setTestResultsOpen] = useState(false);
const [testResults, setTestResults] = useState<any>(null); const [testResults, setTestResults] = useState<any>(null);
const [actionFilter, setActionFilter] = useState<AutomationActionFilter>("all");
// Form state // Form state
const [ruleName, setRuleName] = useState(""); const [ruleName, setRuleName] = useState("");
@@ -84,6 +93,12 @@ export default function AutomationRules() {
return val; return val;
}; };
const normalizeSubscriptionAction = (value: unknown): 0 | 1 | undefined => {
if (value === 1 || value === "1" || value === "OUI") return 1;
if (value === 0 || value === "0" || value === "NON") return 0;
return undefined;
};
const createMutation = trpc.automationRules.create.useMutation({ const createMutation = trpc.automationRules.create.useMutation({
onSuccess: (rule) => { onSuccess: (rule) => {
if (rule.isActive === 1) { if (rule.isActive === 1) {
@@ -204,7 +219,7 @@ export default function AutomationRules() {
} else { } else {
setCustomVentilation(""); setCustomVentilation("");
} }
setActions(parsedActions); setActions({ ...parsedActions, isSubscription: normalizeSubscriptionAction(parsedActions.isSubscription) });
} catch { } catch {
setActions({}); setActions({});
setCustomTypeAchat(""); setCustomTypeAchat("");
@@ -231,11 +246,13 @@ export default function AutomationRules() {
typeAchat: resolveAction(actions.typeAchat, customTypeAchat), typeAchat: resolveAction(actions.typeAchat, customTypeAchat),
serviceConcerne: resolveAction(actions.serviceConcerne, customServiceConcerne), serviceConcerne: resolveAction(actions.serviceConcerne, customServiceConcerne),
ventilationComptable: resolveAction(actions.ventilationComptable, customVentilation), ventilationComptable: resolveAction(actions.ventilationComptable, customVentilation),
isSubscription: actions.isSubscription,
}; };
// Supprimer les clés undefined // Supprimer les clés undefined
if (!resolvedActions.typeAchat) delete resolvedActions.typeAchat; if (!resolvedActions.typeAchat) delete resolvedActions.typeAchat;
if (!resolvedActions.serviceConcerne) delete resolvedActions.serviceConcerne; if (!resolvedActions.serviceConcerne) delete resolvedActions.serviceConcerne;
if (!resolvedActions.ventilationComptable) delete resolvedActions.ventilationComptable; if (!resolvedActions.ventilationComptable) delete resolvedActions.ventilationComptable;
if (resolvedActions.isSubscription === undefined) delete resolvedActions.isSubscription;
const actionsJSON = JSON.stringify(resolvedActions); const actionsJSON = JSON.stringify(resolvedActions);
if (editingRule) { if (editingRule) {
@@ -311,13 +328,18 @@ export default function AutomationRules() {
{ value: "<=", label: "inférieur ou égal à" }, { value: "<=", label: "inférieur ou égal à" },
]; ];
const filteredRules = useMemo(
() => rules.filter((rule) => matchesAutomationActionFilter(rule.actions, actionFilter)),
[rules, actionFilter],
);
return ( return (
<DashboardLayout> <DashboardLayout>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold">Automatismes</h1> <h1 className="text-3xl font-bold">Automatismes</h1>
<p className="text-gray-500 mt-1">Gérez les règles de remplissage automatique des champs</p> <p className="text-gray-500 mt-1">Gérez séparément le classement à limport et les destinations dexport</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={() => { setWizardMode(true); setWizardStep(1); openCreateDialog(); }}> <Button onClick={() => { setWizardMode(true); setWizardStep(1); openCreateDialog(); }}>
@@ -331,6 +353,12 @@ export default function AutomationRules() {
</div> </div>
</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 dimport</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 dexport</TabsTrigger>
</TabsList>
<TabsContent value="import" className="space-y-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Règles d'automatisme</CardTitle> <CardTitle>Règles d'automatisme</CardTitle>
@@ -339,12 +367,36 @@ export default function AutomationRules() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
<Label htmlFor="automation-action-filter" className="font-medium text-slate-700">
Filtrer par action
</Label>
<Select value={actionFilter} onValueChange={(value) => setActionFilter(value as AutomationActionFilter)}>
<SelectTrigger id="automation-action-filter" className="w-[260px] bg-white">
<SelectValue placeholder="Toutes les actions" />
</SelectTrigger>
<SelectContent>
{AUTOMATION_ACTION_FILTERS.map((filter) => (
<SelectItem key={filter.value} value={filter.value}>
{filter.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-slate-500">
{filteredRules.length} règle{filteredRules.length > 1 ? "s" : ""} affichée{filteredRules.length > 1 ? "s" : ""}
</span>
</div>
{isLoading ? ( {isLoading ? (
<div className="text-center py-8 text-gray-500">Chargement...</div> <div className="text-center py-8 text-gray-500">Chargement...</div>
) : rules.length === 0 ? ( ) : rules.length === 0 ? (
<div className="text-center py-8 text-gray-500"> <div className="text-center py-8 text-gray-500">
Aucune règle d'automatisme configurée Aucune règle d'automatisme configurée
</div> </div>
) : filteredRules.length === 0 ? (
<div className="text-center py-8 text-gray-500">
Aucune règle ne correspond à cette action
</div>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
@@ -357,7 +409,7 @@ export default function AutomationRules() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{rules.map((rule) => { {filteredRules.map((rule) => {
let conditionsDisplay = ""; let conditionsDisplay = "";
let actionsDisplay = ""; let actionsDisplay = "";
try { try {
@@ -372,6 +424,7 @@ export default function AutomationRules() {
if (acts.typeAchat) actionsArr.push(`Type: ${acts.typeAchat}`); if (acts.typeAchat) actionsArr.push(`Type: ${acts.typeAchat}`);
if (acts.serviceConcerne) actionsArr.push(`Service: ${acts.serviceConcerne}`); if (acts.serviceConcerne) actionsArr.push(`Service: ${acts.serviceConcerne}`);
if (acts.ventilationComptable) actionsArr.push(`Ventilation: ${acts.ventilationComptable}`); if (acts.ventilationComptable) actionsArr.push(`Ventilation: ${acts.ventilationComptable}`);
if (acts.isSubscription !== undefined) actionsArr.push(`Abonnement: ${normalizeSubscriptionAction(acts.isSubscription) === 1 ? "Oui" : "Non"}`);
actionsDisplay = actionsArr.join(", "); actionsDisplay = actionsArr.join(", ");
} catch {} } catch {}
@@ -435,6 +488,11 @@ export default function AutomationRules() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent>
<TabsContent value="export">
<ExportAutomationRulesPanel />
</TabsContent>
</Tabs>
{/* Create/Edit Dialog */} {/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
@@ -610,6 +668,23 @@ export default function AutomationRules() {
/> />
)} )}
</div> </div>
<div className="space-y-2">
<Label htmlFor="isSubscription">Abonnement</Label>
<Select
value={actions.isSubscription === undefined ? "__NONE__" : String(actions.isSubscription)}
onValueChange={(value) => setActions({ ...actions, isSubscription: value === "__NONE__" ? undefined : Number(value) as 0 | 1 })}
>
<SelectTrigger>
<SelectValue placeholder="Ne pas modifier" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__NONE__">Ne pas modifier</SelectItem>
<SelectItem value="1">Oui</SelectItem>
<SelectItem value="0">Non</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
</div> </div>
)} )}
@@ -642,7 +717,8 @@ export default function AutomationRules() {
{actions.typeAchat && <div>• Type d'achat : {actions.typeAchat}</div>} {actions.typeAchat && <div>• Type d'achat : {actions.typeAchat}</div>}
{actions.serviceConcerne && <div> Service : {actions.serviceConcerne}</div>} {actions.serviceConcerne && <div> Service : {actions.serviceConcerne}</div>}
{actions.ventilationComptable && <div> Ventilation : {actions.ventilationComptable}</div>} {actions.ventilationComptable && <div> Ventilation : {actions.ventilationComptable}</div>}
{!actions.typeAchat && !actions.serviceConcerne && !actions.ventilationComptable && ( {actions.isSubscription !== undefined && <div> Abonnement : {actions.isSubscription === 1 ? "Oui" : "Non"}</div>}
{!actions.typeAchat && !actions.serviceConcerne && !actions.ventilationComptable && actions.isSubscription === undefined && (
<div className="text-gray-500 italic">Aucune action définie</div> <div className="text-gray-500 italic">Aucune action définie</div>
)} )}
</div> </div>
@@ -745,6 +821,24 @@ export default function AutomationRules() {
<div className="space-y-2"> <div className="space-y-2">
<Label>Actions (ALORS)</Label> <Label>Actions (ALORS)</Label>
<div className="space-y-2"> <div className="space-y-2">
<div className="space-y-2 rounded-md border border-emerald-200 bg-emerald-50 p-3">
<Label htmlFor="isSubscription" className="font-medium text-emerald-950">Abonnement</Label>
<Select
value={actions.isSubscription === undefined ? "__NONE__" : String(actions.isSubscription)}
onValueChange={(value) => setActions({ ...actions, isSubscription: value === "__NONE__" ? undefined : Number(value) as 0 | 1 })}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="Ne pas modifier" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__NONE__">Ne pas modifier</SelectItem>
<SelectItem value="1">Oui</SelectItem>
<SelectItem value="0">Non</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-emerald-800">Définit si les factures correspondant à la règle sont des abonnements.</p>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="typeAchat">Type d'achat</Label> <Label htmlFor="typeAchat">Type d'achat</Label>
<Select <Select
@@ -829,6 +923,7 @@ export default function AutomationRules() {
/> />
)} )}
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -148,6 +148,38 @@ export default function Dashboard() {
</Card> </Card>
</div> </div>
<Card>
<CardHeader>
<CardTitle>Récapitulatif annuel</CardTitle>
<CardDescription>Volumes et montants des factures finalisées, séparés entre BAP (hors abonnement) et abonnements.</CardDescription>
</CardHeader>
<CardContent>
{stats?.annualSummary?.length ? (
<div className="space-y-6">
<div className="h-[300px] rounded-lg border bg-white p-4">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={[...stats.annualSummary].reverse()} margin={{ top: 8, right: 16, left: 12, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="year" />
<YAxis tickFormatter={(value) => new Intl.NumberFormat("fr-FR", { notation: "compact", maximumFractionDigits: 1 }).format(value)} />
<Tooltip formatter={(value: number) => formatCurrency(Number(value))} labelFormatter={(year) => `Année ${year}`} />
<Legend />
<Bar dataKey="bapAmount" name="Montant BAP" fill="#2563eb" radius={[4, 4, 0, 0]} />
<Bar dataKey="subscriptionAmount" name="Montant abonnements" fill="#7c3aed" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="overflow-x-auto rounded-lg border">
<table className="w-full min-w-[780px] text-sm">
<thead className="bg-muted/60 text-left text-muted-foreground"><tr><th className="px-4 py-3 font-medium">Année</th><th className="px-4 py-3 text-right font-medium">Factures BAP</th><th className="px-4 py-3 text-right font-medium">Montant BAP</th><th className="px-4 py-3 text-right font-medium">Abonnements</th><th className="px-4 py-3 text-right font-medium">Montant abonnements</th><th className="px-4 py-3 text-right font-medium">Total annuel</th></tr></thead>
<tbody>{stats.annualSummary.map((row) => <tr key={row.year} className="border-t hover:bg-muted/30"><td className="px-4 py-3 font-semibold">{row.year}</td><td className="px-4 py-3 text-right">{row.bapCount}</td><td className="px-4 py-3 text-right text-blue-700">{formatCurrency(row.bapAmount)}</td><td className="px-4 py-3 text-right">{row.subscriptionCount}</td><td className="px-4 py-3 text-right text-violet-700">{formatCurrency(row.subscriptionAmount)}</td><td className="px-4 py-3 text-right font-semibold text-emerald-700">{formatCurrency(row.totalAmount)}</td></tr>)}</tbody>
</table>
</div>
</div>
) : <div className="py-8 text-center text-muted-foreground">Aucune facture finalisée avec une date disponible.</div>}
</CardContent>
</Card>
{/* Detailed Stats */} {/* Detailed Stats */}
{showDetailedStats && ( {showDetailedStats && (
<> <>

View File

@@ -1,6 +1,7 @@
import { useState, useMemo } from "react"; import { useMemo, useState } from "react";
import DashboardLayout from "@/components/DashboardLayout"; 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 { import {
Table, Table,
TableBody, TableBody,
@@ -12,20 +13,24 @@ import {
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { import {
History as HistoryIcon,
FileText,
Trash2,
Eye,
CheckCircle2,
XCircle,
AlertTriangle, AlertTriangle,
CalendarDays, CalendarDays,
RotateCcw, CheckCircle2,
Package, Clock3,
X, Eye,
Upload, FileText,
FolderOpen, FolderOpen,
History as HistoryIcon,
Loader2,
Mail, Mail,
Package,
Play,
RotateCcw,
Trash2,
Upload,
UserRound,
X,
XCircle,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -55,6 +60,39 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
type StatusFilter = "all" | "imported" | "not_imported" | "duplicates" | "errors"; 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 = [ const MONTHS = [
{ value: "1", label: "Janvier" }, { value: "1", label: "Janvier" },
@@ -71,111 +109,149 @@ const MONTHS = [
{ value: "12", label: "Décembre" }, { 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() { export default function History() {
const { user } = useAuth();
const isAdmin = user?.role === "admin";
const { data: logs, isLoading } = trpc.importLogs.getByUser.useQuery(); 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 deleteAllMutation = trpc.importLogs.deleteAll.useMutation();
const checkAccountNowMutation = trpc.emailImportService.checkAccountNow.useMutation();
const utils = trpc.useUtils(); 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 [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 [yearFilter, setYearFilter] = useState<string>(String(new Date().getFullYear()));
const [monthFilter, setMonthFilter] = useState<string>("all"); const [monthFilter, setMonthFilter] = useState<string>("all");
// Dialog visualisation détails
const [detailDialogOpen, setDetailDialogOpen] = useState(false); 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 () => { const handleDeleteAll = async () => {
try { try {
await deleteAllMutation.mutateAsync(); await deleteAllMutation.mutateAsync();
await utils.importLogs.getByUser.invalidate(); await utils.importLogs.getByUser.invalidate();
toast.success("Logs supprimés", { toast.success("Historique supprimé", { description: "Les enregistrements visibles pour votre rôle ont été supprimés." });
description: "Tous les logs d'import ont été supprimés avec succès.",
});
} catch { } catch {
toast.error("Erreur", { toast.error("Impossible de supprimer lhistorique des imports.");
description: "Impossible de supprimer les logs d'import.",
});
} }
}; };
// Filtrage par période const handleManualEmailCheck = async () => {
const periodFiltered = useMemo(() => { if (!selectedAccount) return;
if (!logs) return []; try {
return logs.filter((log) => { toast.info("Lecture ponctuelle de la boîte e-mail en cours…");
const d = new Date(log.importedAt); const result = await checkAccountNowMutation.mutateAsync({ userId: selectedAccount.userId });
if (yearFilter !== "all" && d.getFullYear() !== Number(yearFilter)) return false; if (!result.success) {
if (monthFilter !== "all" && (d.getMonth() + 1) !== Number(monthFilter)) return false; toast.error(result.message);
return true; return;
}); }
}, [logs, yearFilter, monthFilter]); 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 counts = useMemo(() => {
const totalImported = periodFiltered.reduce((s, l) => s + l.invoicesImported, 0); const totalImported = periodFiltered.reduce((sum, log) => sum + log.invoicesImported, 0);
const totalDuplicates = periodFiltered.reduce((s, l) => s + l.duplicatesIgnored, 0); const totalDuplicates = periodFiltered.reduce((sum, log) => sum + log.duplicatesIgnored, 0);
const totalErrors = periodFiltered.reduce((s, l) => s + l.errors, 0); const totalErrors = periodFiltered.reduce((sum, log) => sum + log.errors, 0);
const totalDetected = periodFiltered.reduce((s, l) => s + l.totalInvoicesDetected, 0); const totalDetected = periodFiltered.reduce((sum, log) => sum + log.totalInvoicesDetected, 0);
const totalNotImported = totalDuplicates + totalErrors; return { totalImported, totalDuplicates, totalErrors, totalDetected, totalNotImported: totalDuplicates + totalErrors };
return { totalImported, totalDuplicates, totalErrors, totalDetected, totalNotImported };
}, [periodFiltered]); }, [periodFiltered]);
// Filtrage par statut const filteredLogs = useMemo(() => periodFiltered.filter((log) => {
const filteredLogs = useMemo(() => { if (sourceFilter !== "all" && log.importSource !== sourceFilter) return false;
return periodFiltered.filter((log) => { if (triggerFilter !== "all" && log.importTrigger !== triggerFilter) return false;
if (statusFilter === "all") return true; if (statusFilter === "imported") return log.invoicesImported > 0;
if (statusFilter === "imported") return log.invoicesImported > 0; if (statusFilter === "not_imported") return log.duplicatesIgnored > 0 || log.errors > 0;
if (statusFilter === "not_imported") return log.duplicatesIgnored > 0 || log.errors > 0; if (statusFilter === "duplicates") return log.duplicatesIgnored > 0;
if (statusFilter === "duplicates") return log.duplicatesIgnored > 0; if (statusFilter === "errors") return log.errors > 0;
if (statusFilter === "errors") return log.errors > 0; return true;
return true; }), [periodFiltered, sourceFilter, statusFilter, triggerFilter]);
});
}, [periodFiltered, statusFilter]);
const hasPeriodFilter = yearFilter !== "all" || monthFilter !== "all"; 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" },
const filterButtons: { key: StatusFilter; label: string; count: number; color: string; activeColor: string }[] = [ { 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: "all", label: "Tous", count: periodFiltered.length, color: "bg-gray-100 text-gray-700 hover:bg-gray-200", activeColor: "bg-gray-700 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: "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: "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: "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: "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" },
{ 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 hasFilters = yearFilter !== "all" || monthFilter !== "all" || sourceFilter !== "all" || triggerFilter !== "all";
return ( return (
<DashboardLayout> <DashboardLayout>
<div className="space-y-6"> <div className="space-y-6">
{/* En-tête */} <div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-start">
<div className="flex justify-between items-start">
<div> <div>
<h1 className="text-3xl font-bold">Historique des imports</h1> <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> </div>
{logs && logs.length > 0 && ( {detailedLogs.length > 0 && (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="destructive" size="sm"> <Button variant="destructive" size="sm"><Trash2 className="mr-2 h-4 w-4" />Effacer lhistorique</Button>
<Trash2 className="mr-2 h-4 w-4" />
Effacer les logs
</Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle> <AlertDialogTitle>Supprimer lhistorique ?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>Cette action est irréversible et supprime les enregistrements accessibles avec votre rôle.</AlertDialogDescription>
Cette action supprimera définitivement tous les logs d'import. Cette opération est irréversible.
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel> <AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction onClick={handleDeleteAll} disabled={deleteAllMutation.isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
onClick={handleDeleteAll} {deleteAllMutation.isPending ? "Suppression…" : "Supprimer"}
disabled={deleteAllMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteAllMutation.isPending ? "Suppression..." : "Supprimer"}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -183,340 +259,108 @@ export default function History() {
)} )}
</div> </div>
{/* Compteurs */} {isAdmin && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <Card className="border-teal-200 bg-gradient-to-r from-teal-50 to-cyan-50">
<Card className="border-green-200 bg-green-50"> <CardHeader className="pb-4">
<CardContent className="p-4 flex items-center gap-3"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<CheckCircle2 className="h-8 w-8 text-green-600 shrink-0" /> <div className="flex gap-3">
<div> <div className="rounded-lg bg-teal-600 p-2 text-white"><Mail className="h-5 w-5" /></div>
<p className="text-2xl font-bold text-green-700">{counts.totalImported}</p> <div>
<p className="text-xs text-green-600 font-medium">Factures importées</p> <CardTitle className="text-lg">Lecture e-mail ponctuelle</CardTitle>
</div> <CardDescription>Déclenchez une seule vérification pour une boîte configurée, sans réactiver le traitement automatique.</CardDescription>
</CardContent> </div>
</Card> </div>
<Card className="border-orange-200 bg-orange-50"> <Badge variant="outline" className="w-fit border-teal-200 bg-white text-teal-700">Aucune planification créée</Badge>
<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>
</div> </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 limport 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> </CardContent>
</Card> </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> </div>
{/* Filtres */} <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-3 items-center">
{/* Filtres statut */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{filterButtons.map((btn) => ( {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 {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>
key={btn.key} </button>)}
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>
))}
</div> </div>
<div className="hidden h-6 w-px bg-blue-200 sm:block" />
{/* Séparateur */} <div className="flex flex-wrap items-center gap-2">
<div className="h-6 w-px bg-gray-200" />
{/* Filtre Période */}
<div className="flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-gray-500" /> <CalendarDays className="h-4 w-4 text-gray-500" />
<Select value={yearFilter} onValueChange={(v) => { setYearFilter(v); if (v === "all") setMonthFilter("all"); }}> <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>
<SelectTrigger className="h-8 w-28 text-sm"> <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>
<SelectValue placeholder="Année" /> <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>
</SelectTrigger> <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>
<SelectContent> {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>}
<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>
)}
</div> </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é */} <Card><CardContent className="overflow-x-auto p-0">
<span className="text-sm text-gray-500 ml-auto"> {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 lhistorique</p></div> : filteredLogs.length > 0 ? (
{filteredLogs.length} import{filteredLogs.length > 1 ? "s" : ""} {counts.totalDetected} facture{counts.totalDetected > 1 ? "s" : ""} détectée{counts.totalDetected > 1 ? "s" : ""} <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>
</span> <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>
</div> </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>}
{/* Tableau */} </CardContent></Card>
<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>
</div> </div>
{/* Dialog visualisation détails */} <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 limport</DialogTitle></DialogHeader>
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}> {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 dimport</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>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> <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>
<DialogHeader> {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>}
<DialogTitle className="flex items-center gap-2"> {selectedLog.duplicateDetails && <DetailsList title="Doublons ignorés" tone="yellow" value={selectedLog.duplicateDetails} icon={<RotateCcw className="h-4 w-4" />} />}
<FileText className="h-5 w-5 text-blue-500" /> {selectedLog.errorDetails && <DetailsList title="Erreurs" tone="red" value={selectedLog.errorDetails} icon={<AlertTriangle className="h-4 w-4" />} />}
Détails de l'import </div>}
</DialogTitle> </DialogContent></Dialog>
</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>
</DashboardLayout> </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 { useState, useEffect } from "react";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import AzureAdExportSettingsCard from "@/components/AzureAdExportSettingsCard";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -333,25 +334,21 @@ export default function ImportSettings() {
</div> </div>
<div> <div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent"> <h1 className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">
Paramètres import / export Paramètres
</h1> </h1>
<p className="text-muted-foreground mt-0.5 text-sm"> <p className="text-muted-foreground mt-0.5 text-sm">
Configurez les méthodes d'importation et les options d'export des factures BAP Configurez les méthodes dimportation et la sauvegarde de la base de données
</p> </p>
</div> </div>
</div> </div>
{/* Onglets Import / Export */} {/* Les destinations dexport sont administrées dans Automatismes dexport. */}
<Tabs defaultValue="import" className="w-full"> <Tabs defaultValue="import" className="w-full">
<TabsList className="grid w-full grid-cols-3 h-12 mb-6"> <TabsList className="grid w-full grid-cols-2 h-12 mb-6">
<TabsTrigger value="import" className="flex items-center gap-2 text-base"> <TabsTrigger value="import" className="flex items-center gap-2 text-base">
<ArrowDownToLine className="w-4 h-4" /> <Inbox className="w-4 h-4" />
Paramètres d'import Paramètres d'import
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="export" className="flex items-center gap-2 text-base">
<Share2 className="w-4 h-4" />
Paramètres d'export
</TabsTrigger>
<TabsTrigger value="backup" className="flex items-center gap-2 text-base"> <TabsTrigger value="backup" className="flex items-center gap-2 text-base">
<DatabaseBackup className="w-4 h-4" /> <DatabaseBackup className="w-4 h-4" />
Sauvegarde DB Sauvegarde DB
@@ -361,6 +358,8 @@ export default function ImportSettings() {
{/* ===== ONGLET IMPORT ===== */} {/* ===== ONGLET IMPORT ===== */}
<TabsContent value="import" className="space-y-6"> <TabsContent value="import" className="space-y-6">
<AzureAdExportSettingsCard />
{/* Import manuel */} {/* Import manuel */}
<Card className="border-2 hover:border-primary/50 transition-colors"> <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"> <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

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useParams, useLocation } from "wouter"; import { useParams, useLocation, useSearch } from "wouter";
import DashboardLayout from "@/components/DashboardLayout"; import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -9,10 +9,13 @@ import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { ArrowLeft, Save, FileText } from "lucide-react"; import { ArrowLeft, Save, FileText } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getBapReturnLocation } from "@/lib/bapNavigation";
export default function InvoiceDetail() { export default function InvoiceDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
const detailSearch = useSearch();
const returnLocation = getBapReturnLocation(detailSearch);
const invoiceId = parseInt(id || "0"); const invoiceId = parseInt(id || "0");
const { data: invoice, isLoading } = trpc.invoices.getById.useQuery({ id: invoiceId }); const { data: invoice, isLoading } = trpc.invoices.getById.useQuery({ id: invoiceId });
@@ -172,7 +175,7 @@ export default function InvoiceDetail() {
<FileText className="w-16 h-16 text-gray-300 mb-4" /> <FileText className="w-16 h-16 text-gray-300 mb-4" />
<h2 className="text-2xl font-bold mb-2">Facture introuvable</h2> <h2 className="text-2xl font-bold mb-2">Facture introuvable</h2>
<p className="text-gray-500 mb-4">Cette facture n'existe pas ou a été supprimée</p> <p className="text-gray-500 mb-4">Cette facture n'existe pas ou a été supprimée</p>
<Button onClick={() => setLocation("/invoices")}> <Button onClick={() => setLocation(returnLocation)}>
<ArrowLeft className="w-4 h-4 mr-2" /> <ArrowLeft className="w-4 h-4 mr-2" />
Retour aux factures Retour aux factures
</Button> </Button>
@@ -187,7 +190,7 @@ export default function InvoiceDetail() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => setLocation("/invoices")}> <Button variant="ghost" onClick={() => setLocation(returnLocation)}>
<ArrowLeft className="w-4 h-4 mr-2" /> <ArrowLeft className="w-4 h-4 mr-2" />
Retour Retour
</Button> </Button>

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import DashboardLayout from "@/components/DashboardLayout"; import DashboardLayout from "@/components/DashboardLayout";
import { useAuth } from "@/_core/hooks/useAuth"; import { useAuth } from "@/_core/hooks/useAuth";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@@ -34,17 +34,28 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search, FileText, Download, FileSpreadsheet, Trash2, Edit, Trash, Filter, LayoutList, LayoutGrid, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react"; import { Search, FileText, Download, FileSpreadsheet, Trash2, Edit, Trash, Filter, LayoutList, LayoutGrid, ArrowUpDown, ArrowUp, ArrowDown, CalendarDays } from "lucide-react";
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { toast } from "sonner"; import { toast } from "sonner";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { matchesInvoicePeriod } from "@shared/invoicePeriod";
type SortField = "invoiceDate" | "createdAt"; type SortField = "invoiceDate" | "createdAt";
type SortDir = "asc" | "desc"; type SortDir = "asc" | "desc";
export default function Invoices() { type InvoiceScope = "all" | "subscriptions";
const MONTHS = [
["01", "Janvier"], ["02", "Février"], ["03", "Mars"], ["04", "Avril"],
["05", "Mai"], ["06", "Juin"], ["07", "Août"], ["08", "Septembre"],
["09", "Septembre"], ["10", "Octobre"], ["11", "Novembre"], ["12", "Décembre"],
] as const;
export default function Invoices({ scope = "all" }: { scope?: InvoiceScope }) {
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
const currentYear = new Date().getFullYear();
const isSubscriptionPage = scope === "subscriptions";
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [compactMode, setCompactMode] = useState(true); const [compactMode, setCompactMode] = useState(true);
const [sortField, setSortField] = useState<SortField>("createdAt"); const [sortField, setSortField] = useState<SortField>("createdAt");
@@ -52,9 +63,11 @@ export default function Invoices() {
const [selectedIds, setSelectedIds] = useState<number[]>([]); const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [statusFilter, setStatusFilter] = useState<string>("all"); const [statusFilter, setStatusFilter] = useState<string>("all");
const [recipientFilter, setRecipientFilter] = useState<string>("all"); const [recipientFilter, setRecipientFilter] = useState<string>("all");
const [subscriptionFilter, setSubscriptionFilter] = useState<string>("all"); // all | yes | no const [subscriptionFilter, setSubscriptionFilter] = useState<string>(isSubscriptionPage ? "yes" : "all"); // all | yes | no
const [entityFilter, setEntityFilter] = useState<string>("all"); // all | santinova | itinova const [entityFilter, setEntityFilter] = useState<string>("all"); // all | santinova | itinova
const [ventilationFilter, setVentilationFilter] = useState<string>("all"); const [ventilationFilter, setVentilationFilter] = useState<string>("all");
const [selectedYear, setSelectedYear] = useState<string>(String(currentYear));
const [selectedMonth, setSelectedMonth] = useState<string>("all");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null); const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false); const [addDialogOpen, setAddDialogOpen] = useState(false);
@@ -194,17 +207,20 @@ export default function Invoices() {
}; };
// Collect unique recipients for the filter dropdown // Collect unique recipients for the filter dropdown
const uniqueRecipients = Array.from( const scopedInvoices = useMemo(
new Set( () => (invoices || []).filter(inv => !isSubscriptionPage || inv.isSubscription === 1),
(invoices || []).map(inv => (inv as any).recipientName).filter(Boolean) [invoices, isSubscriptionPage],
) );
).sort();
const uniqueVentilations = Array.from( const uniqueRecipients = useMemo(
new Set( () => Array.from(new Set(scopedInvoices.map(inv => (inv as any).recipientName).filter(Boolean))).sort(),
(invoices || []).map(inv => (inv as any).ventilationComptable).filter(Boolean) [scopedInvoices],
) );
).sort();
const uniqueVentilations = useMemo(
() => Array.from(new Set(scopedInvoices.map(inv => (inv as any).ventilationComptable).filter(Boolean))).sort(),
[scopedInvoices],
);
const handleSort = (field: SortField) => { const handleSort = (field: SortField) => {
if (sortField === field) { if (sortField === field) {
@@ -220,7 +236,7 @@ export default function Invoices() {
return sortDir === "asc" ? <ArrowUp className="w-3 h-3 ml-1" /> : <ArrowDown className="w-3 h-3 ml-1" />; return sortDir === "asc" ? <ArrowUp className="w-3 h-3 ml-1" /> : <ArrowDown className="w-3 h-3 ml-1" />;
}; };
const filteredInvoices = invoices?.filter((inv) => { const filteredInvoices = scopedInvoices.filter((inv) => {
// Filter by search query // Filter by search query
if (searchQuery) { if (searchQuery) {
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
@@ -230,6 +246,9 @@ export default function Invoices() {
if (!matchesSearch) return false; if (!matchesSearch) return false;
} }
// Même convention que Factures BAP : date de facture, sinon réception.
if (!matchesInvoicePeriod(inv, selectedYear, selectedMonth)) return false;
// Filter by export status // Filter by export status
if (statusFilter !== "all") { if (statusFilter !== "all") {
if (statusFilter === "exported" && inv.exportStatus !== "exported") return false; if (statusFilter === "exported" && inv.exportStatus !== "exported") return false;
@@ -268,22 +287,17 @@ export default function Invoices() {
return true; return true;
}); });
const statusCounts = { const invoicesInPeriod = useMemo(
all: invoices?.length || 0, () => scopedInvoices.filter(inv => matchesInvoicePeriod(inv, selectedYear, selectedMonth)),
exported: invoices?.filter(inv => inv.exportStatus === "exported").length || 0, [scopedInvoices, selectedYear, selectedMonth],
not_exported: invoices?.filter(inv => inv.exportStatus === "not_exported").length || 0, );
export_error: invoices?.filter(inv => inv.exportStatus === "export_error").length || 0,
};
// Old filter logic (to be removed) const statusCounts = {
const _oldFilteredInvoices = invoices?.filter((inv) => { all: invoicesInPeriod.length,
if (!searchQuery) return true; exported: invoicesInPeriod.filter(inv => inv.exportStatus === "exported").length,
const query = searchQuery.toLowerCase(); not_exported: invoicesInPeriod.filter(inv => inv.exportStatus === "not_exported").length,
return ( export_error: invoicesInPeriod.filter(inv => inv.exportStatus === "export_error").length,
inv.supplierName?.toLowerCase().includes(query) || };
inv.invoiceNumber?.toLowerCase().includes(query)
);
});
const handleSelectAll = (checked: boolean) => { const handleSelectAll = (checked: boolean) => {
if (checked) { if (checked) {
@@ -370,8 +384,10 @@ export default function Invoices() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold">Factures</h1> <h1 className="text-3xl font-bold">{isSubscriptionPage ? "Factures abonnements" : "Factures"}</h1>
<p className="text-gray-500 mt-1">Gérez toutes vos factures importées</p> <p className="text-gray-500 mt-1">
{isSubscriptionPage ? "Gérez les factures identifiées comme abonnements" : "Gérez toutes vos factures importées"}
</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@@ -456,9 +472,30 @@ export default function Invoices() {
</div> </div>
</div> </div>
{/* Ligne 2 : Filtres destinataire, abonnement, entité, ventilation */} {/* Ligne 2 : Filtres de période, destinataire, abonnement, entité, ventilation */}
<div className="flex gap-2 items-center flex-wrap mb-3"> <div className="flex gap-2 items-center flex-wrap mb-3">
<Filter className="w-4 h-4 text-blue-400 shrink-0" /> <Filter className="w-4 h-4 text-blue-400 shrink-0" />
<div className="flex items-center gap-1.5 text-sm font-medium text-blue-600">
<CalendarDays className="w-4 h-4" />
Période :
</div>
<Select value={selectedYear} onValueChange={(value) => { setSelectedYear(value); if (value === "all") setSelectedMonth("all"); }}>
<SelectTrigger className="w-28 h-8 bg-white border-blue-200 text-sm"><SelectValue placeholder="Année" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Toute année</SelectItem>
{Array.from({ length: 5 }, (_, index) => currentYear - index).map(year => <SelectItem key={year} value={String(year)}>{year}</SelectItem>)}
</SelectContent>
</Select>
<Select value={selectedMonth} onValueChange={setSelectedMonth} disabled={selectedYear === "all"}>
<SelectTrigger className="w-36 h-8 bg-white border-blue-200 text-sm"><SelectValue placeholder="Mois" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les mois</SelectItem>
{MONTHS.map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}
</SelectContent>
</Select>
{(selectedYear !== String(currentYear) || selectedMonth !== "all") && (
<button onClick={() => { setSelectedYear(String(currentYear)); setSelectedMonth("all"); }} className="text-xs text-blue-600 hover:text-blue-800 underline">Réinitialiser</button>
)}
<Select value={recipientFilter} onValueChange={setRecipientFilter}> <Select value={recipientFilter} onValueChange={setRecipientFilter}>
<SelectTrigger className="w-[180px] bg-white border-blue-200 text-sm"> <SelectTrigger className="w-[180px] bg-white border-blue-200 text-sm">
<SelectValue placeholder="Destinataire" /> <SelectValue placeholder="Destinataire" />
@@ -471,7 +508,7 @@ export default function Invoices() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={subscriptionFilter} onValueChange={setSubscriptionFilter}> <Select value={isSubscriptionPage ? "yes" : subscriptionFilter} onValueChange={setSubscriptionFilter} disabled={isSubscriptionPage}>
<SelectTrigger className="w-[170px] bg-white border-blue-200 text-sm"> <SelectTrigger className="w-[170px] bg-white border-blue-200 text-sm">
<SelectValue placeholder="Abonnement" /> <SelectValue placeholder="Abonnement" />
</SelectTrigger> </SelectTrigger>

View File

@@ -32,6 +32,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
import { Search, FileText, Download, FileSpreadsheet, Trash2, Edit, Trash, CheckCircle, CheckCircle2, ShieldCheck, RefreshCw, FolderDown, ChevronDown, ChevronRight, ChevronsUpDown, CalendarDays, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react"; import { Search, FileText, Download, FileSpreadsheet, Trash2, Edit, Trash, CheckCircle, CheckCircle2, ShieldCheck, RefreshCw, FolderDown, ChevronDown, ChevronRight, ChevronsUpDown, CalendarDays, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -87,7 +88,8 @@ function downloadBapPdf(
} }
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { toast } from "sonner"; import { toast } from "sonner";
import { useLocation } from "wouter"; import { useLocation, useSearch } from "wouter";
import { buildBapDetailLocation, parseBapFilters } from "@/lib/bapNavigation";
// Helper function to determine field color based on origin // Helper function to determine field color based on origin
const getFieldColor = (invoice: any, fieldName: string): string => { const getFieldColor = (invoice: any, fieldName: string): string => {
@@ -108,9 +110,15 @@ const getFieldColor = (invoice: any, fieldName: string): string => {
export default function InvoicesBAP() { export default function InvoicesBAP() {
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
const [searchQuery, setSearchQuery] = useState(""); const queryString = useSearch();
const currentYear = new Date().getFullYear();
const initialFilters = parseBapFilters(queryString, String(currentYear));
const [searchQuery, setSearchQuery] = useState(initialFilters.searchQuery);
const [selectedIds, setSelectedIds] = useState<number[]>([]); const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [statusFilter, setStatusFilter] = useState<string>("all"); 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 [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null); const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false); const [addDialogOpen, setAddDialogOpen] = useState(false);
@@ -126,12 +134,25 @@ export default function InvoicesBAP() {
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set()); const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const [allExpanded, setAllExpanded] = useState(false); const [allExpanded, setAllExpanded] = useState(false);
// Filtre par période // Filtre par période
const currentYear = new Date().getFullYear(); const [selectedYear, setSelectedYear] = useState<string>(initialFilters.selectedYear);
const [selectedYear, setSelectedYear] = useState<string>(String(currentYear)); const [selectedMonth, setSelectedMonth] = useState<string>(initialFilters.selectedMonth); // "all" ou "01".."12"
const [selectedMonth, setSelectedMonth] = useState<string>("all"); // "all" ou "01".."12"
// Tri // Tri
const [sortField, setSortField] = useState<"invoiceDate" | "createdAt">("createdAt"); const [sortField, setSortField] = useState<"invoiceDate" | "createdAt">(initialFilters.sortField);
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); const [sortDir, setSortDir] = useState<"asc" | "desc">(initialFilters.sortDir);
const openInvoiceDetail = (invoiceId: number) => {
setLocation(buildBapDetailLocation(invoiceId, {
searchQuery,
statusFilter,
selectedYear,
selectedMonth,
recipientFilter,
entityFilter,
ventilationFilter,
sortField,
sortDir,
}));
};
const toggleRow = (id: number) => { const toggleRow = (id: number) => {
setExpandedIds(prev => { setExpandedIds(prev => {
@@ -154,6 +175,14 @@ export default function InvoicesBAP() {
const { data: allInvoices, isLoading } = trpc.invoices.list.useQuery(); const { data: allInvoices, isLoading } = trpc.invoices.list.useQuery();
// Filter for BAP invoices only (Abonnement = NON, isSubscription = 0) // Filter for BAP invoices only (Abonnement = NON, isSubscription = 0)
const invoices = allInvoices?.filter(inv => inv.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) // 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) // Stabilisé avec useMemo pour éviter les re-renders infinis (anti-pattern tRPC)
const validatedInvoiceIds = useMemo( const validatedInvoiceIds = useMemo(
@@ -380,7 +409,7 @@ export default function InvoicesBAP() {
// Déclarée ici (avant filteredInvoices et statusCounts) pour éviter le hoisting error // Déclarée ici (avant filteredInvoices et statusCounts) pour éviter le hoisting error
const isEligibleForBAPValidation = (invoice: any) => { const isEligibleForBAPValidation = (invoice: any) => {
return ( return (
(invoice.qualityScore || 0) === 100 && meetsBapQualityThreshold(invoice.qualityScore) &&
invoice.exportStatus !== "exported" && invoice.exportStatus !== "exported" &&
invoice.isSubscription === 0 && invoice.isSubscription === 0 &&
invoice.serviceConcerne && invoice.serviceConcerne &&
@@ -426,6 +455,27 @@ export default function InvoicesBAP() {
if (statusFilter === "to_complete" && !isToComplete(inv)) 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; return true;
}); });
@@ -512,19 +562,19 @@ export default function InvoicesBAP() {
const getQualityBadge = (score: number | null) => { const getQualityBadge = (score: number | null) => {
if (score === null) return <Badge variant="outline">-</Badge>; if (score === null) return <Badge variant="outline">-</Badge>;
if (score === 100) return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">{score}%</Badge>; if (meetsBapQualityThreshold(score)) return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">{score}%</Badge>;
if (score >= 80) return <Badge className="bg-yellow-100 text-yellow-800 hover:bg-yellow-100">{score}%</Badge>; if (score >= 80) return <Badge className="bg-yellow-100 text-yellow-800 hover:bg-yellow-100">{score}%</Badge>;
return <Badge className="bg-red-100 text-red-800 hover:bg-red-100">{score}%</Badge>; return <Badge className="bg-red-100 text-red-800 hover:bg-red-100">{score}%</Badge>;
}; };
const isEligibleForExport = (invoice: any) => { const isEligibleForExport = (invoice: any) => {
// Une facture BAP est exportable si : // Une facture BAP est exportable si :
// 1. Score = 100% // 1. Score >= 90 % (seuil BAP)
// 2. Type d'achat rempli // 2. Type d'achat rempli
// 3. Service concerné rempli // 3. Service concerné rempli
// 4. Ventilation comptable remplie // 4. Ventilation comptable remplie
return ( return (
(invoice.qualityScore || 0) === 100 && meetsBapQualityThreshold(invoice.qualityScore) &&
invoice.typeAchat && invoice.typeAchat &&
invoice.serviceConcerne && invoice.serviceConcerne &&
invoice.ventilationComptable invoice.ventilationComptable
@@ -535,7 +585,7 @@ export default function InvoicesBAP() {
const getBAPValidationTooltip = (invoice: any): string => { const getBAPValidationTooltip = (invoice: any): string => {
const reasons: string[] = []; const reasons: string[] = [];
if ((invoice.qualityScore || 0) < 100) reasons.push("Score < 100%"); if (!meetsBapQualityThreshold(invoice.qualityScore)) reasons.push(`Score < ${BAP_MIN_QUALITY_SCORE}%`);
if (invoice.exportStatus === "exported") reasons.push("Déjà exportée"); if (invoice.exportStatus === "exported") reasons.push("Déjà exportée");
if (invoice.isSubscription !== 0) reasons.push("Marquée comme abonnement"); if (invoice.isSubscription !== 0) reasons.push("Marquée comme abonnement");
if (!invoice.serviceConcerne) reasons.push("Service manquant"); if (!invoice.serviceConcerne) reasons.push("Service manquant");
@@ -641,7 +691,7 @@ export default function InvoicesBAP() {
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
if (confirm("Valider en BAP toutes les factures éligibles (score 100%, champs remplis, non abonnement) ? Les PDFs annotés seront générés automatiquement.")) { if (confirm(`Valider en BAP toutes les factures éligibles (score ${BAP_MIN_QUALITY_SCORE}%, champs remplis, non abonnement) ? Les PDFs annotés seront générés automatiquement.`)) {
validateBAPBulkMutation.mutate(); validateBAPBulkMutation.mutate();
} }
}} }}
@@ -763,6 +813,40 @@ export default function InvoicesBAP() {
))} ))}
</SelectContent> </SelectContent>
</Select> </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") && ( {(selectedYear !== "all" || selectedMonth !== "all") && (
<button <button
onClick={() => { setSelectedYear(String(currentYear)); setSelectedMonth("all"); }} onClick={() => { setSelectedYear(String(currentYear)); setSelectedMonth("all"); }}
@@ -926,7 +1010,7 @@ export default function InvoicesBAP() {
</TableCell> </TableCell>
<TableCell className="font-medium"> <TableCell className="font-medium">
<button <button
onClick={() => setLocation(`/invoices/${invoice.id}`)} onClick={() => openInvoiceDetail(invoice.id)}
className="text-blue-600 hover:text-blue-800 hover:underline" className="text-blue-600 hover:text-blue-800 hover:underline"
> >
{invoice.supplierName || "Inconnu"} {invoice.supplierName || "Inconnu"}
@@ -976,7 +1060,7 @@ export default function InvoicesBAP() {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => setLocation(`/invoices/${invoice.id}`)} onClick={() => openInvoiceDetail(invoice.id)}
className="h-8 px-2" className="h-8 px-2"
title="Modifier" title="Modifier"
> >

View File

@@ -0,0 +1,90 @@
import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { trpc } from "@/lib/trpc";
import { buildSupplierBudgetSummary } from "@shared/invoiceAnalytics";
import { CalendarDays, Euro, FileText } from "lucide-react";
import { useMemo, useState } from "react";
const MONTHS = [
["01", "Janvier"], ["02", "Février"], ["03", "Mars"], ["04", "Avril"],
["05", "Mai"], ["06", "Juin"], ["07", "Juillet"], ["08", "Août"],
["09", "Septembre"], ["10", "Octobre"], ["11", "Novembre"], ["12", "Décembre"],
] as const;
function formatCurrency(value: number) {
return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(value);
}
export default function RealBudget() {
const currentYear = new Date().getFullYear();
const [selectedYear, setSelectedYear] = useState(String(currentYear));
const [selectedMonth, setSelectedMonth] = useState("all");
const { data: invoices, isLoading } = trpc.invoices.list.useQuery();
const availableYears = useMemo(() => {
const years = new Set<number>([currentYear]);
(invoices || []).forEach((invoice) => {
const value = invoice.invoiceDate ?? invoice.createdAt;
if (!value) return;
const date = new Date(value);
if (!Number.isNaN(date.getTime())) years.add(date.getFullYear());
});
return Array.from(years).sort((a, b) => b - a);
}, [invoices, currentYear]);
const rows = useMemo(
() => buildSupplierBudgetSummary(invoices || [], selectedYear, selectedMonth),
[invoices, selectedYear, selectedMonth],
);
const totals = useMemo(() => rows.reduce((total, row) => ({
subscriptionAmount: total.subscriptionAmount + row.subscriptionAmount,
nonSubscriptionAmount: total.nonSubscriptionAmount + row.nonSubscriptionAmount,
totalAmount: total.totalAmount + row.totalAmount,
}), { subscriptionAmount: 0, nonSubscriptionAmount: 0, totalAmount: 0 }), [rows]);
return (
<DashboardLayout>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Budget réel</h1>
<p className="mt-1 text-muted-foreground">Montants réellement facturés, regroupés par fournisseur.</p>
</div>
<Card className="border-blue-100 bg-blue-50/70">
<CardContent className="flex flex-wrap items-center gap-3 py-4">
<div className="flex items-center gap-1.5 text-sm font-medium text-blue-700"><CalendarDays className="h-4 w-4" /> Période :</div>
<Select value={selectedYear} onValueChange={(value) => { setSelectedYear(value); if (value === "all") setSelectedMonth("all"); }}>
<SelectTrigger className="w-28 bg-white"><SelectValue placeholder="Année" /></SelectTrigger>
<SelectContent><SelectItem value="all">Toute année</SelectItem>{availableYears.map((year) => <SelectItem key={year} value={String(year)}>{year}</SelectItem>)}</SelectContent>
</Select>
<Select value={selectedMonth} onValueChange={setSelectedMonth} disabled={selectedYear === "all"}>
<SelectTrigger className="w-40 bg-white"><SelectValue placeholder="Mois" /></SelectTrigger>
<SelectContent><SelectItem value="all">Tous les mois</SelectItem>{MONTHS.map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent>
</Select>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<Card><CardHeader className="pb-2"><CardTitle className="text-sm font-medium">Abonnements</CardTitle></CardHeader><CardContent><div className="text-2xl font-bold text-violet-700">{formatCurrency(totals.subscriptionAmount)}</div></CardContent></Card>
<Card><CardHeader className="pb-2"><CardTitle className="text-sm font-medium">Hors abonnement</CardTitle></CardHeader><CardContent><div className="text-2xl font-bold text-blue-700">{formatCurrency(totals.nonSubscriptionAmount)}</div></CardContent></Card>
<Card><CardHeader className="pb-2"><CardTitle className="flex items-center gap-2 text-sm font-medium"><Euro className="h-4 w-4" /> Total facturé</CardTitle></CardHeader><CardContent><div className="text-2xl font-bold text-emerald-700">{formatCurrency(totals.totalAmount)}</div></CardContent></Card>
</div>
<Card>
<CardHeader><CardTitle>Budget réel par fournisseur</CardTitle><CardDescription>Les factures finalisées sont séparées entre abonnements et hors abonnement.</CardDescription></CardHeader>
<CardContent>
{isLoading ? <div className="py-12 text-center text-muted-foreground">Chargement du budget</div> : rows.length === 0 ? <div className="flex flex-col items-center gap-2 py-12 text-muted-foreground"><FileText className="h-10 w-10" />Aucune facture finalisée pour cette période.</div> : (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full min-w-[850px] text-sm"><thead className="bg-muted/60 text-left text-muted-foreground"><tr><th className="px-4 py-3 font-medium">Fournisseur</th><th className="px-4 py-3 text-right font-medium">Abonnements</th><th className="px-4 py-3 text-right font-medium">Montant abonnements</th><th className="px-4 py-3 text-right font-medium">Hors abonnement</th><th className="px-4 py-3 text-right font-medium">Montant hors abonnement</th><th className="px-4 py-3 text-right font-medium">Total</th></tr></thead>
<tbody>{rows.map((row) => <tr key={row.supplierName} className="border-t hover:bg-muted/30"><td className="px-4 py-3 font-medium">{row.supplierName}</td><td className="px-4 py-3 text-right">{row.subscriptionCount}</td><td className="px-4 py-3 text-right text-violet-700">{formatCurrency(row.subscriptionAmount)}</td><td className="px-4 py-3 text-right">{row.nonSubscriptionCount}</td><td className="px-4 py-3 text-right text-blue-700">{formatCurrency(row.nonSubscriptionAmount)}</td><td className="px-4 py-3 text-right font-semibold">{formatCurrency(row.totalAmount)}</td></tr>)}</tbody>
<tfoot className="border-t-2 bg-muted/50 font-semibold"><tr><td className="px-4 py-3">Total</td><td colSpan={2} className="px-4 py-3 text-right text-violet-700">{formatCurrency(totals.subscriptionAmount)}</td><td colSpan={2} className="px-4 py-3 text-right text-blue-700">{formatCurrency(totals.nonSubscriptionAmount)}</td><td className="px-4 py-3 text-right text-emerald-700">{formatCurrency(totals.totalAmount)}</td></tr></tfoot>
</table>
</div>
)}
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}

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, "when": 1787394418464,
"tag": "0037_goofy_quentin_quire", "tag": "0037_goofy_quentin_quire",
"breakpoints": true "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. * Core user table backing auth flow.
@@ -231,6 +231,8 @@ export const importLogs = mysqlTable("importLogs", {
warningMessage: text("warningMessage"), // Warning message (e.g. quota exhausted) warningMessage: text("warningMessage"), // Warning message (e.g. quota exhausted)
/** Source du mode d'import : 'file' = upload manuel, 'folder' = dossier automatique, 'email' = import par email */ /** Source du mode d'import : 'file' = upload manuel, 'folder' = dossier automatique, 'email' = import par email */
importSource: mysqlEnum("importSource", ["file", "folder", "email"]).default("file").notNull(), 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(), importedAt: timestamp("importedAt").defaultNow().notNull(),
}); });
@@ -322,6 +324,31 @@ export const automationRules = mysqlTable("automationRules", {
export type AutomationRule = typeof automationRules.$inferSelect; export type AutomationRule = typeof automationRules.$inferSelect;
export type InsertAutomationRule = typeof automationRules.$inferInsert; export type InsertAutomationRule = typeof automationRules.$inferInsert;
/**
* Règles de destination appliquées lors de la validation BAP.
* Elles sont séparées des règles dimport, afin de ne jamais modifier les
* champs dune facture au moment de son export.
*/
export const exportAutomationRules = mysqlTable("exportAutomationRules", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(),
name: varchar("name", { length: 255 }).notNull(),
isActive: int("isActive").default(1).notNull(),
priority: int("priority").default(0).notNull(),
conditionField: mysqlEnum("conditionField", ["recipientName", "ventilationComptable"]).notNull(),
conditionValue: varchar("conditionValue", { length: 255 }).notNull(),
destinationType: mysqlEnum("destinationType", ["local", "teams", "sharepoint"]).notNull(),
destinationPath: text("destinationPath").notNull(),
openInBrowser: int("openInBrowser").default(0).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
userPriorityIdx: index("export_rule_user_priority_idx").on(table.userId, table.priority),
}));
export type ExportAutomationRule = typeof exportAutomationRules.$inferSelect;
export type InsertExportAutomationRule = typeof exportAutomationRules.$inferInsert;
/** /**
* LLM Fields Configuration table * LLM Fields Configuration table
* Stores configuration for each field used in invoice extraction * Stores configuration for each field used in invoice extraction

View File

@@ -0,0 +1,70 @@
import { ImapFlow } from "imapflow";
import mysql from "mysql2/promise";
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error("Variable DATABASE_URL manquante");
const connection = await mysql.createConnection(databaseUrl);
try {
const [rows] = await connection.query(`
SELECT emailImportAddress, emailImportHost, emailImportPort,
azureTenantId, azureClientId, azureClientSecret
FROM importSettings
WHERE emailImportEnabled = 1
AND emailImportAuthMode = 'oauth2'
ORDER BY id
LIMIT 1
`);
const settings = rows[0];
if (!settings) throw new Error("Aucune configuration OAuth2 IMAP active");
const tenantId = settings.azureTenantId || process.env.AZURE_AD_TENANT_ID;
const clientId = settings.azureClientId || process.env.AZURE_AD_CLIENT_ID;
const clientSecret = settings.azureClientSecret || process.env.AZURE_AD_CLIENT_SECRET;
if (!tenantId || !clientId || !clientSecret) {
throw new Error("Configuration Azure AD incomplète");
}
const response = await fetch(
`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
scope: "https://outlook.office365.com/.default",
grant_type: "client_credentials",
}),
},
);
const tokenResponse = await response.json();
if (!response.ok || !tokenResponse.access_token) {
throw new Error(`Échec OAuth2 : ${tokenResponse.error || response.status}`);
}
const host = settings.emailImportHost || "outlook.office365.com";
const client = new ImapFlow({
host,
port: settings.emailImportPort || 993,
secure: true,
auth: {
user: settings.emailImportAddress,
accessToken: tokenResponse.access_token,
},
tls: { servername: host, rejectUnauthorized: true },
verifyOnly: true,
logger: false,
});
try {
await client.connect();
console.log(`Authentification ImapFlow OAuth2 réussie pour ${settings.emailImportAddress}`);
} finally {
if (client.usable) await client.logout().catch(() => client.close());
else client.close();
}
} finally {
await connection.end();
}

View File

@@ -3,10 +3,18 @@ import fs from "fs";
import { type Server } from "http"; import { type Server } from "http";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import path from "path"; import path from "path";
import { createServer as createViteServer } from "vite";
import viteConfig from "../../vite.config";
export async function setupVite(app: Express, server: Server) { export async function setupVite(app: Express, server: Server) {
// Vite et sa configuration sont des dépendances de développement. Les imports
// indirects empêchent esbuild de les intégrer au bundle serveur de production.
const vitePackageName = "vite";
const viteConfigPath = "../../vite.config";
const [viteModule, viteConfigModule] = await Promise.all([
import(vitePackageName),
import(viteConfigPath),
]);
const createViteServer = viteModule.createServer as typeof import("vite").createServer;
const viteConfig = viteConfigModule.default;
const serverOptions = { const serverOptions = {
middlewareMode: true, middlewareMode: true,
hmr: { server }, hmr: { server },

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { matchesAutomationActionFilter } from "@shared/automationActions";
describe("matchesAutomationActionFilter", () => {
it("filtre chaque type daction métier", () => {
const actions = JSON.stringify({
typeAchat: "OPEX",
serviceConcerne: "DSI SANTINOVA",
ventilationComptable: "SANTINOVA",
});
expect(matchesAutomationActionFilter(actions, "typeAchat")).toBe(true);
expect(matchesAutomationActionFilter(actions, "serviceConcerne")).toBe(true);
expect(matchesAutomationActionFilter(actions, "ventilationComptable")).toBe(true);
expect(matchesAutomationActionFilter(actions, "subscription")).toBe(false);
});
it("distingue les règles Abonnement Oui et Non", () => {
expect(matchesAutomationActionFilter('{"isSubscription":1}', "subscription")).toBe(true);
expect(matchesAutomationActionFilter('{"isSubscription":1}', "subscriptionYes")).toBe(true);
expect(matchesAutomationActionFilter('{"isSubscription":1}', "subscriptionNo")).toBe(false);
expect(matchesAutomationActionFilter('{"isSubscription":0}', "subscriptionYes")).toBe(false);
expect(matchesAutomationActionFilter('{"isSubscription":0}', "subscriptionNo")).toBe(true);
});
it("tolère les règles historiques invalides sans masquer le filtre Toutes les actions", () => {
expect(matchesAutomationActionFilter("json-invalide", "all")).toBe(true);
expect(matchesAutomationActionFilter("json-invalide", "subscription")).toBe(false);
});
});

View File

@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Invoice } from "../drizzle/schema";
import { getAutomationRulesByUser } from "./db";
import { applyAutomationRules } from "./automationEngine";
vi.mock("./db", () => ({
getAutomationRulesByUser: vi.fn(),
}));
const mockedGetAutomationRules = vi.mocked(getAutomationRulesByUser);
const invoice = {
id: 1,
supplierName: "Microsoft Ireland Operations Ltd",
isSubscription: 0,
} as Invoice;
describe("applyAutomationRules", () => {
beforeEach(() => {
mockedGetAutomationRules.mockReset();
});
it("applique Abonnement = Oui et trace le champ automatisé", async () => {
mockedGetAutomationRules.mockResolvedValue([
{
id: 1,
userId: 1,
name: "Microsoft est un abonnement",
isActive: 1,
priority: 1,
conditions: JSON.stringify([{ field: "supplierName", operator: "contains", value: "microsoft" }]),
conditionsLogic: "AND",
actions: JSON.stringify({ isSubscription: 1 }),
createdAt: new Date(),
updatedAt: new Date(),
},
] as never);
await expect(applyAutomationRules(1, invoice)).resolves.toMatchObject({
isSubscription: 1,
autoFilledFields: JSON.stringify(["isSubscription"]),
});
});
it("autorise Abonnement = Non sans le confondre avec une absence daction", async () => {
mockedGetAutomationRules.mockResolvedValue([
{
id: 2,
userId: 1,
name: "Microsoft nest pas un abonnement",
isActive: 1,
priority: 1,
conditions: JSON.stringify([{ field: "supplierName", operator: "contains", value: "microsoft" }]),
conditionsLogic: "AND",
actions: JSON.stringify({ isSubscription: 0 }),
createdAt: new Date(),
updatedAt: new Date(),
},
] as never);
await expect(applyAutomationRules(1, invoice)).resolves.toMatchObject({
isSubscription: 0,
autoFilledFields: JSON.stringify(["isSubscription"]),
});
});
});

View File

@@ -11,6 +11,7 @@ interface Actions {
typeAchat?: string; typeAchat?: string;
serviceConcerne?: string; serviceConcerne?: string;
ventilationComptable?: string; ventilationComptable?: string;
isSubscription?: 0 | 1;
} }
/** /**
@@ -111,6 +112,11 @@ export async function applyAutomationRules(
updates.ventilationComptable = actions.ventilationComptable; updates.ventilationComptable = actions.ventilationComptable;
autoFilledFieldsList.push("ventilationComptable"); autoFilledFieldsList.push("ventilationComptable");
} }
// 0 est une valeur métier valide : ne jamais la tester par vérité.
if (actions.isSubscription !== undefined && updates.isSubscription === undefined) {
updates.isSubscription = actions.isSubscription;
autoFilledFieldsList.push("isSubscription");
}
} }
} catch (error) { } catch (error) {
console.error(`[AutomationEngine] Error processing rule ${rule.id}:`, error); console.error(`[AutomationEngine] Error processing rule ${rule.id}:`, error);

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
describe("meetsBapQualityThreshold", () => {
it("autorise exactement le seuil de 90 %", () => {
expect(BAP_MIN_QUALITY_SCORE).toBe(90);
expect(meetsBapQualityThreshold(90)).toBe(true);
expect(meetsBapQualityThreshold(100)).toBe(true);
});
it("refuse les scores inférieurs ou absents", () => {
expect(meetsBapQualityThreshold(89)).toBe(false);
expect(meetsBapQualityThreshold(null)).toBe(false);
expect(meetsBapQualityThreshold(undefined)).toBe(false);
});
});

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
buildBapDetailLocation,
getBapReturnLocation,
parseBapFilters,
} from "../client/src/lib/bapNavigation";
describe("navigation Factures BAP", () => {
it("transmet tous les filtres et le tri à la page de détail", () => {
const location = buildBapDetailLocation(42, {
searchQuery: "microsoft europe",
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&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&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&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",
});
});
it("retombe sur les valeurs BAP sûres si les paramètres sont absents ou invalides", () => {
expect(parseBapFilters("?returnTo=bap&status=invalid&year=x&sort=nope&dir=down", "2026")).toEqual({
searchQuery: "",
statusFilter: "all",
selectedYear: "2026",
selectedMonth: "all",
recipientFilter: "all",
entityFilter: "all",
ventilationFilter: "all",
sortField: "createdAt",
sortDir: "desc",
});
expect(getBapReturnLocation("?returnTo=invoices")).toBe("/invoices");
});
});

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 { drizzle } from "drizzle-orm/mysql2";
import { import {
InsertUser, InsertUser,
@@ -30,6 +30,9 @@ import {
automationRules, automationRules,
InsertAutomationRule, InsertAutomationRule,
AutomationRule, AutomationRule,
exportAutomationRules,
InsertExportAutomationRule,
ExportAutomationRule,
llmFieldsConfig, llmFieldsConfig,
InsertLlmFieldConfig, InsertLlmFieldConfig,
LlmFieldConfig, LlmFieldConfig,
@@ -50,6 +53,7 @@ import {
WebImportSource WebImportSource
} from "../drizzle/schema"; } from "../drizzle/schema";
import { ENV } from './_core/env'; import { ENV } from './_core/env';
import { buildAnnualInvoiceSummary } from "@shared/invoiceAnalytics";
let _db: ReturnType<typeof drizzle> | null = null; let _db: ReturnType<typeof drizzle> | null = null;
@@ -332,7 +336,7 @@ export async function searchInvoices(userId: number | null, query: string): Prom
.orderBy(desc(invoices.createdAt)); .orderBy(desc(invoices.createdAt));
} }
export async function getInvoiceStats(userId: number) { export async function getInvoiceStats(userId?: number) {
const db = await getDb(); const db = await getDb();
if (!db) return { if (!db) return {
total: 0, total: 0,
@@ -346,10 +350,12 @@ export async function getInvoiceStats(userId: number) {
topSuppliers: [], topSuppliers: [],
suppliersList: [], suppliersList: [],
topRecipients: [], topRecipients: [],
recipientsList: [] recipientsList: [],
annualSummary: [],
}; };
const allInvoices = await getInvoicesByUserId(userId); // Le tableau de bord est global pour les administrateurs, comme les listes Factures.
const allInvoices = userId ? await getInvoicesByUserId(userId) : await getAllInvoices();
// Calculate basic stats // Calculate basic stats
const completed = allInvoices.filter(i => i.status === "completed"); const completed = allInvoices.filter(i => i.status === "completed");
@@ -458,7 +464,8 @@ export async function getInvoiceStats(userId: number) {
topSuppliers, topSuppliers,
suppliersList, suppliersList,
topRecipients, topRecipients,
recipientsList recipientsList,
annualSummary: buildAnnualInvoiceSummary(allInvoices),
}; };
} }
@@ -534,6 +541,44 @@ export async function getAllImportLogs(): Promise<ImportLog[]> {
return db.select().from(importLogs).orderBy(desc(importLogs.importedAt)); 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> { export async function deleteAllImportLogs(userId: number | null): Promise<void> {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
@@ -607,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 ============= // ============= DEPARTMENT LIST OPERATIONS =============
export async function getDepartmentsByUser(_userId?: number): Promise<Department[]> { export async function getDepartmentsByUser(_userId?: number): Promise<Department[]> {
@@ -687,6 +750,47 @@ export async function deleteAutomationRule(id: number): Promise<void> {
await db.delete(automationRules).where(eq(automationRules.id, id)); await db.delete(automationRules).where(eq(automationRules.id, id));
} }
// ============= EXPORT AUTOMATION RULES OPERATIONS =============
export async function getExportAutomationRulesByUser(userId: number): Promise<ExportAutomationRule[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(exportAutomationRules)
.where(eq(exportAutomationRules.userId, userId))
.orderBy(exportAutomationRules.priority, exportAutomationRules.id);
}
export async function getExportAutomationRuleById(id: number): Promise<ExportAutomationRule | null> {
const db = await getDb();
if (!db) return null;
const [rule] = await db.select().from(exportAutomationRules).where(eq(exportAutomationRules.id, id));
return rule || null;
}
export async function createExportAutomationRule(data: InsertExportAutomationRule): Promise<ExportAutomationRule> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(exportAutomationRules).values(data);
const rule = await getExportAutomationRuleById(result[0].insertId);
if (!rule) throw new Error("Export automation rule could not be created");
return rule;
}
export async function updateExportAutomationRule(id: number, data: Partial<InsertExportAutomationRule>): Promise<ExportAutomationRule> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(exportAutomationRules).set(data).where(eq(exportAutomationRules.id, id));
const rule = await getExportAutomationRuleById(id);
if (!rule) throw new Error("Export automation rule not found");
return rule;
}
export async function deleteExportAutomationRule(id: number): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(exportAutomationRules).where(eq(exportAutomationRules.id, id));
}
// ============= INITIALIZE DEFAULT VALUES ============= // ============= INITIALIZE DEFAULT VALUES =============
export async function initializeDefaultLists(userId: number): Promise<void> { export async function initializeDefaultLists(userId: number): Promise<void> {

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createImapFlowOptions, type EmailImportConfig } from "./emailImportService"; import {
createImapFlowOptions,
type EmailImportConfig,
validateEmailImportConfiguration,
} from "./emailImportService";
const baseConfig: EmailImportConfig = { const baseConfig: EmailImportConfig = {
userId: 2, userId: 2,
@@ -36,3 +40,39 @@ describe("createImapFlowOptions", () => {
expect(options.auth).not.toHaveProperty("accessToken"); 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; 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. * Construit les options ImapFlow sans effectuer d'appel réseau.
* ImapFlow reçoit le jeton brut et construit lui-même SASL XOAUTH2. * 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. // qu'un second cycle IMAP traite les mêmes messages avant la fin du premier.
const activeChecks = new Map<number, Promise<void>>(); 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); const runningCheck = activeChecks.get(config.userId);
if (runningCheck) { if (runningCheck) {
console.log(`[EmailImport] Vérification déjà en cours pour user ${config.userId}, cycle ignoré`); console.log(`[EmailImport] Vérification déjà en cours pour user ${config.userId}, cycle ignoré`);
return runningCheck; return runningCheck;
} }
const check = checkEmailsForPDFs(config).finally(() => { const check = checkEmailsForPDFs(config, trigger).finally(() => {
if (activeChecks.get(config.userId) === check) activeChecks.delete(config.userId); if (activeChecks.get(config.userId) === check) activeChecks.delete(config.userId);
}); });
activeChecks.set(config.userId, check); activeChecks.set(config.userId, check);
@@ -88,7 +125,8 @@ function runEmailCheckExclusive(config: EmailImportConfig): Promise<void> {
async function processEmailAttachment( async function processEmailAttachment(
userId: number, userId: number,
attachment: Attachment, attachment: Attachment,
emailSubject: string emailSubject: string,
trigger: EmailImportTrigger,
): Promise<{ success: boolean; totalInvoices: number; imported: number; duplicates: number; errors: number; quotaError?: boolean }> { ): Promise<{ success: boolean; totalInvoices: number; imported: number; duplicates: number; errors: number; quotaError?: boolean }> {
const fileName = attachment.filename || `email-attachment-${Date.now()}.pdf`; const fileName = attachment.filename || `email-attachment-${Date.now()}.pdf`;
console.log(`[EmailImport] Processing attachment: ${fileName} from email: ${emailSubject}`); console.log(`[EmailImport] Processing attachment: ${fileName} from email: ${emailSubject}`);
@@ -323,6 +361,7 @@ async function processEmailAttachment(
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null, errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
warningMessage, warningMessage,
importSource: "email", importSource: "email",
importTrigger: trigger,
}); });
console.log(`[EmailImport] Successfully processed attachment: ${fileName}`); 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 * 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 imapConfig = await buildImapConfig(config);
const client = new ImapFlow(imapConfig); const client = new ImapFlow(imapConfig);
client.on("error", (error) => { client.on("error", (error) => {
@@ -450,6 +492,7 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
config.userId, config.userId,
attachment, attachment,
parsed.subject || "No subject", parsed.subject || "No subject",
trigger,
); );
allAttachmentsSucceeded = allAttachmentsSucceeded && result.success; allAttachmentsSucceeded = allAttachmentsSucceeded && result.success;
@@ -615,29 +658,28 @@ export function isEmailImportServiceRunning(userId: number): boolean {
return activeIntervals.has(userId); return activeIntervals.has(userId);
} }
/** async function triggerConfiguredEmailCheck(
* Manually trigger an immediate email check for a user userId: number,
*/ requireAutomaticEnabled: boolean,
export async function triggerEmailCheck(userId: number): Promise<{ success: boolean; message: string }> { ): Promise<{ success: boolean; message: string }> {
try { try {
// Get user's import settings
const settings = await getImportSettingsByUser(userId); 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é" }; return { success: false, message: "Import par email non activé" };
} }
if (!settings.emailImportAddress || !settings.emailImportHost) { const validationError = validateEmailImportConfiguration(settings);
return { success: false, message: "Configuration IMAP incomplète" }; if (validationError) return { success: false, message: validationError };
}
const authMode = (settings as any).emailImportAuthMode as "basic" | "oauth2" || "basic"; const authMode = (settings as any).emailImportAuthMode as "basic" | "oauth2" || "basic";
const config: EmailImportConfig = { const config: EmailImportConfig = {
userId, userId,
emailAddress: settings.emailImportAddress, // validateEmailImportConfiguration garantit ces deux valeurs avant ce point.
emailAddress: settings.emailImportAddress!,
password: settings.emailImportPassword || "", password: settings.emailImportPassword || "",
host: settings.emailImportHost, host: settings.emailImportHost!,
port: settings.emailImportPort || 993, port: settings.emailImportPort || 993,
sinceDate: settings.emailImportSinceDate ?? undefined, sinceDate: settings.emailImportSinceDate ?? undefined,
authMode, authMode,
@@ -647,7 +689,8 @@ export async function triggerEmailCheck(userId: number): Promise<{ success: bool
}; };
console.log(`[EmailImport] Manual check triggered for user ${userId}`); 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" }; return { success: true, message: "Vérification terminée avec succès" };
} catch (error: any) { } 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 lactivation automatique. */
export async function triggerEmailCheck(userId: number): Promise<{ success: boolean; message: string }> {
return triggerConfiguredEmailCheck(userId, true);
}
/** /**
* Stop all email import services * 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 dexport", () => {
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 lexistant.
*/
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, duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null,
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null, errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
importSource: "folder", importSource: "folder",
importTrigger: "automatic",
}); });
// Move file to processed folder // Move file to processed folder

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { buildAnnualInvoiceSummary, buildSupplierBudgetSummary } from "@shared/invoiceAnalytics";
const invoices = [
{ invoiceDate: "2026-05-15", createdAt: "2026-05-16", status: "completed", isSubscription: 0, supplierName: "SFR", totalAmount: "100" },
{ invoiceDate: "2026-05-20", createdAt: "2026-05-21", status: "completed", isSubscription: 1, supplierName: "SFR", totalAmount: "20" },
{ invoiceDate: "2025-03-10", createdAt: "2025-03-11", status: "completed", isSubscription: 1, supplierName: "Microsoft", totalAmount: "50" },
{ invoiceDate: "2026-05-01", createdAt: "2026-05-01", status: "error", isSubscription: 0, supplierName: "Ignorée", totalAmount: "999" },
];
describe("agrégats de facturation", () => {
it("calcule les volumes et montants BAP et abonnements par année", () => {
expect(buildAnnualInvoiceSummary(invoices)).toEqual([
{ year: 2026, bapCount: 1, bapAmount: 100, subscriptionCount: 1, subscriptionAmount: 20, totalAmount: 120 },
{ year: 2025, bapCount: 0, bapAmount: 0, subscriptionCount: 1, subscriptionAmount: 50, totalAmount: 50 },
]);
});
it("filtre le budget par période et ventile chaque fournisseur par type", () => {
expect(buildSupplierBudgetSummary(invoices, "2026", "05")).toEqual([
{ supplierName: "SFR", subscriptionCount: 1, subscriptionAmount: 20, nonSubscriptionCount: 1, nonSubscriptionAmount: 100, totalAmount: 120 },
]);
});
it("classe le budget du fournisseur le plus facturé au moins facturé", () => {
const rows = buildSupplierBudgetSummary([
...invoices,
{ invoiceDate: "2026-05-22", createdAt: "2026-05-22", status: "completed", isSubscription: 0, supplierName: "Orange", totalAmount: "300" },
], "2026", "05");
expect(rows.map((row) => row.supplierName)).toEqual(["Orange", "SFR"]);
});
});

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { getInvoicePeriodDate, matchesInvoicePeriod } from "@shared/invoicePeriod";
describe("filtres de période des factures", () => {
it("privilégie la date de facture et accepte le mois demandé", () => {
const invoice = { invoiceDate: "2026-05-11T00:00:00.000Z", createdAt: "2026-06-02T00:00:00.000Z" };
expect(getInvoicePeriodDate(invoice)?.getFullYear()).toBe(2026);
expect(matchesInvoicePeriod(invoice, "2026", "05")).toBe(true);
expect(matchesInvoicePeriod(invoice, "2026", "06")).toBe(false);
});
it("utilise la date de réception lorsque la date de facture est absente", () => {
const invoice = { invoiceDate: null, createdAt: "2025-01-31T00:00:00.000Z" };
expect(matchesInvoicePeriod(invoice, "2025", "01")).toBe(true);
expect(matchesInvoicePeriod(invoice, "2026", "all")).toBe(false);
});
});

View File

@@ -1,5 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { COOKIE_NAME } from "@shared/const"; import { COOKIE_NAME } from "@shared/const";
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
interface Condition { interface Condition {
field: string; field: string;
@@ -11,6 +12,7 @@ interface Actions {
typeAchat?: string; typeAchat?: string;
serviceConcerne?: string; serviceConcerne?: string;
ventilationComptable?: string; ventilationComptable?: string;
isSubscription?: 0 | 1;
} }
import { getSessionCookieOptions } from "./_core/cookies"; import { getSessionCookieOptions } from "./_core/cookies";
import { systemRouter } from "./_core/systemRouter"; import { systemRouter } from "./_core/systemRouter";
@@ -38,13 +40,16 @@ import {
isInvoiceBlacklisted, isInvoiceBlacklisted,
getAllInvoices, getAllInvoices,
getAllImportLogs, getAllImportLogs,
getAllImportLogsWithDetails,
getAllBapHistory, getAllBapHistory,
createImportLog, createImportLog,
getImportLogsByUser, getImportLogsByUser,
getImportLogsWithDetailsByUser,
deleteAllImportLogs, deleteAllImportLogs,
getLlmLogsBySourceFile, getLlmLogsBySourceFile,
getLlmLogsByInvoice, getLlmLogsByInvoice,
getImportSettingsByUser, getImportSettingsByUser,
getEmailImportAccounts,
upsertImportSettings, upsertImportSettings,
getDepartmentsByUser, getDepartmentsByUser,
createDepartment, createDepartment,
@@ -61,6 +66,11 @@ import {
createAutomationRule, createAutomationRule,
updateAutomationRule, updateAutomationRule,
deleteAutomationRule, deleteAutomationRule,
getExportAutomationRulesByUser,
getExportAutomationRuleById,
createExportAutomationRule,
updateExportAutomationRule,
deleteExportAutomationRule,
getSignaturesByUser, getSignaturesByUser,
getSignatureById, getSignatureById,
createSignature, createSignature,
@@ -92,7 +102,8 @@ import { calculateFileSha256 } from "./fileFingerprint";
import { localStoragePut, generateStorageKey } from "./localStorage"; import { localStoragePut, generateStorageKey } from "./localStorage";
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport"; import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
import { drawBapCartouche } from "./bapCartouche"; import { drawBapCartouche } from "./bapCartouche";
import { 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 { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { processFreeproExcel } from "./freeproService"; import { processFreeproExcel } from "./freeproService";
@@ -407,6 +418,7 @@ export const appRouter = router({
duplicateDetails: JSON.stringify(duplicateDetails), duplicateDetails: JSON.stringify(duplicateDetails),
errorDetails: JSON.stringify(errorDetails), errorDetails: JSON.stringify(errorDetails),
importSource: "file", importSource: "file",
importTrigger: "manual",
}); });
} catch (error: any) { } catch (error: any) {
@@ -498,8 +510,8 @@ export const appRouter = router({
const hasService = !!invoice.serviceConcerne; const hasService = !!invoice.serviceConcerne;
const hasTypeAchat = !!invoice.typeAchat; const hasTypeAchat = !!invoice.typeAchat;
const hasVentilation = !!invoice.ventilationComptable; const hasVentilation = !!invoice.ventilationComptable;
if (score < 100) { if (!meetsBapQualityThreshold(score)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le score de qualité doit être à 100% pour valider" }); throw new TRPCError({ code: "BAD_REQUEST", message: `Le score de qualité doit être au moins de ${BAP_MIN_QUALITY_SCORE}% pour valider` });
} }
if (!isNotSubscription) { if (!isNotSubscription) {
throw new TRPCError({ code: "BAD_REQUEST", message: "La facture est marquée comme abonnement" }); throw new TRPCError({ code: "BAD_REQUEST", message: "La facture est marquée comme abonnement" });
@@ -515,9 +527,10 @@ export const appRouter = router({
const { localStoragePut, generateStorageKey } = await import('./localStorage'); const { localStoragePut, generateStorageKey } = await import('./localStorage');
const importSettings = await getImportSettingsByUser(ctx.user.id); const importSettings = await getImportSettingsByUser(ctx.user.id);
const bapExportMode = importSettings?.bapExportMode || 'browser'; const resolvedDestination = await resolveBapExportDestination(ctx.user.id, invoice, importSettings);
const exportFolder = importSettings?.exportFolder || null; const bapExportMode = resolvedDestination.exportMode;
const exportFolderType = (importSettings as any)?.exportFolderType || 'local'; const exportFolder = resolvedDestination.destinationPath;
const exportFolderType = resolvedDestination.destinationType;
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage'); const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
let pdfUrl: string | null = null; let pdfUrl: string | null = null;
@@ -603,11 +616,11 @@ export const appRouter = router({
const _bapNumber = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim(); const _bapNumber = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
const bapFilename = [_bapDateStr, _bapSupplier, _bapNumber].filter(Boolean).join(' - ') + '.pdf'; const bapFilename = [_bapDateStr, _bapSupplier, _bapNumber].filter(Boolean).join(' - ') + '.pdf';
console.log(`[BAP] Mode export: ${bapExportMode}, type: ${exportFolderType}, dossier: ${exportFolder ? 'configuré' : 'non configuré'}`); console.log(`[BAP] Mode export: ${bapExportMode}, type: ${exportFolderType}, source: ${resolvedDestination.source}, dossier: ${exportFolder ? 'configuré' : 'non configuré'}`);
if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) { if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) {
if (exportFolderType === 'sharepoint') { if (exportFolderType === 'sharepoint' || exportFolderType === 'teams') {
// Mode SharePoint : upload via Microsoft Graph // Les fichiers Teams sont déposés via le site SharePoint de léquipe.
console.log('[BAP] Démarrage upload SharePoint pour:', bapFilename); console.log('[BAP] Démarrage upload Microsoft 365 pour:', bapFilename);
const { uploadToSharePoint } = await import('./sharepoint'); const { uploadToSharePoint } = await import('./sharepoint');
const spResult = await uploadToSharePoint( const spResult = await uploadToSharePoint(
{ {
@@ -719,7 +732,7 @@ export const appRouter = router({
const allInvoices = ctx.user.role === 'admin' ? await getAllInvoices() : await getInvoicesByUser(ctx.user.id); const allInvoices = ctx.user.role === 'admin' ? await getAllInvoices() : await getInvoicesByUser(ctx.user.id);
// Filtrer les factures éligibles (non déjà validées) // Filtrer les factures éligibles (non déjà validées)
const eligible = allInvoices.filter((inv: any) => const eligible = allInvoices.filter((inv: any) =>
(inv.qualityScore || 0) >= 100 && meetsBapQualityThreshold(inv.qualityScore) &&
inv.isSubscription === 0 && inv.isSubscription === 0 &&
!!inv.serviceConcerne && !!inv.serviceConcerne &&
!!inv.typeAchat && !!inv.typeAchat &&
@@ -734,9 +747,6 @@ export const appRouter = router({
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib'); const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
const { localStoragePut, generateStorageKey } = await import('./localStorage'); const { localStoragePut, generateStorageKey } = await import('./localStorage');
const importSettings = await getImportSettingsByUser(ctx.user.id); const importSettings = await getImportSettingsByUser(ctx.user.id);
const bapExportMode = importSettings?.bapExportMode || 'browser';
const exportFolder = importSettings?.exportFolder || null;
const exportFolderType = (importSettings as any)?.exportFolderType || 'local';
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage'); const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
const serviceSignaturesList = await getServiceSignaturesByUser(ctx.user.id); const serviceSignaturesList = await getServiceSignaturesByUser(ctx.user.id);
const results: Array<{ id: number; success: boolean; pdfUrl?: string | null; exportPath?: string | null; error?: string }> = []; const results: Array<{ id: number; success: boolean; pdfUrl?: string | null; exportPath?: string | null; error?: string }> = [];
@@ -750,6 +760,10 @@ export const appRouter = router({
let sharepointUploadStatus: 'success' | 'error' | 'skipped' | null = null; let sharepointUploadStatus: 'success' | 'error' | 'skipped' | null = null;
let sharepointUploadPath: string | null = null; let sharepointUploadPath: string | null = null;
let sharepointUploadError: string | null = null; let sharepointUploadError: string | null = null;
const resolvedDestination = await resolveBapExportDestination(ctx.user.id, invoice, importSettings);
const bapExportMode = resolvedDestination.exportMode;
const exportFolder = resolvedDestination.destinationPath;
const exportFolderType = resolvedDestination.destinationType;
try { try {
// ─ Lecture du PDF source ─ // ─ Lecture du PDF source ─
let sourcePdfBytes: Buffer; let sourcePdfBytes: Buffer;
@@ -820,7 +834,7 @@ export const appRouter = router({
const _bapNumber2 = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim(); const _bapNumber2 = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
const bapFilename = [_bapDateStr2, _bapSupplier2, _bapNumber2].filter(Boolean).join(' - ') + '.pdf'; const bapFilename = [_bapDateStr2, _bapSupplier2, _bapNumber2].filter(Boolean).join(' - ') + '.pdf';
if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) { if ((bapExportMode === 'folder' || bapExportMode === 'both') && exportFolder) {
if (exportFolderType === 'sharepoint') { if (exportFolderType === 'sharepoint' || exportFolderType === 'teams') {
const { uploadToSharePoint } = await import('./sharepoint'); const { uploadToSharePoint } = await import('./sharepoint');
const spResult = await uploadToSharePoint( const spResult = await uploadToSharePoint(
{ {
@@ -1043,7 +1057,8 @@ export const appRouter = router({
}), }),
getStats: protectedProcedure.query(async ({ ctx }) => { getStats: protectedProcedure.query(async ({ ctx }) => {
return getInvoiceStats(ctx.user.id); // Les administrateurs consultent les mêmes données globales que les listes Factures.
return getInvoiceStats(ctx.user.role === "admin" ? undefined : ctx.user.id);
}), }),
}), }),
@@ -1643,9 +1658,9 @@ export const appRouter = router({
getByUser: protectedProcedure.query(async ({ ctx }) => { getByUser: protectedProcedure.query(async ({ ctx }) => {
// Les admins voient tous les logs d'import // Les admins voient tous les logs d'import
if (ctx.user.role === 'admin') { if (ctx.user.role === 'admin') {
return getAllImportLogs(); return getAllImportLogsWithDetails();
} }
return getImportLogsByUser(ctx.user.id); return getImportLogsWithDetailsByUser(ctx.user.id);
}), }),
deleteAll: protectedProcedure.mutation(async ({ ctx }) => { deleteAll: protectedProcedure.mutation(async ({ ctx }) => {
@@ -1712,6 +1727,18 @@ export const appRouter = router({
const result = await triggerEmailCheck(ctx.user.id); const result = await triggerEmailCheck(ctx.user.id);
return result; return result;
}), }),
/** Liste sans secrets les boîtes IMAP disponibles pour une action manuelle dadministrateur. */
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 ============= // ============= IMPORT SETTINGS ROUTES =============
@@ -1739,6 +1766,7 @@ export const appRouter = router({
azureTenantId: null, azureTenantId: null,
azureClientId: null, azureClientId: null,
azureClientSecret: null, azureClientSecret: null,
azureSecretExpiresAt: null,
}; };
} }
@@ -2028,7 +2056,7 @@ export const appRouter = router({
for (const invoice of bapInvoices) { for (const invoice of bapInvoices) {
const updates = await applyAutomationRules(ctx.user.id, invoice); const updates = await applyAutomationRules(ctx.user.id, invoice);
if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.autoFilledFields) { if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.isSubscription !== undefined || updates.autoFilledFields) {
await updateInvoice(invoice.id, updates); await updateInvoice(invoice.id, updates);
} }
} }
@@ -2059,7 +2087,7 @@ export const appRouter = router({
for (const invoice of bapInvoices) { for (const invoice of bapInvoices) {
const updates = await applyAutomationRules(ctx.user.id, invoice); const updates = await applyAutomationRules(ctx.user.id, invoice);
if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.autoFilledFields) { if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.isSubscription !== undefined || updates.autoFilledFields) {
await updateInvoice(invoice.id, updates); await updateInvoice(invoice.id, updates);
} }
} }
@@ -2257,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 ROUTES =============
signatures: router({ signatures: router({
list: protectedProcedure.query(async ({ ctx }) => { list: protectedProcedure.query(async ({ ctx }) => {

View File

@@ -0,0 +1,51 @@
/** Les catégories dactions proposées dans les règles dautomatisme. */
export const AUTOMATION_ACTION_FILTERS = [
{ value: "all", label: "Toutes les actions" },
{ value: "typeAchat", label: "Type dachat" },
{ value: "serviceConcerne", label: "Service concerné" },
{ value: "ventilationComptable", label: "Ventilation comptable" },
{ value: "subscription", label: "Abonnement (Oui ou Non)" },
{ value: "subscriptionYes", label: "Abonnement : Oui" },
{ value: "subscriptionNo", label: "Abonnement : Non" },
] as const;
export type AutomationActionFilter = (typeof AUTOMATION_ACTION_FILTERS)[number]["value"];
type RuleActions = Record<string, unknown>;
/**
* Lit défensivement le JSON stocké en base : une règle historique invalide ne
* doit jamais empêcher laffichage ni le filtrage de la liste complète.
*/
function parseRuleActions(actionsJson: string): RuleActions {
try {
const parsed: unknown = JSON.parse(actionsJson);
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
? parsed as RuleActions
: {};
} catch {
return {};
}
}
function getSubscriptionValue(actions: RuleActions): 0 | 1 | undefined {
const value = actions.isSubscription;
if (value === 1 || value === "1" || value === "OUI") return 1;
if (value === 0 || value === "0" || value === "NON") return 0;
return undefined;
}
/** Retourne si les actions JSON dune règle correspondent au filtre choisi. */
export function matchesAutomationActionFilter(
actionsJson: string,
filter: AutomationActionFilter,
): boolean {
if (filter === "all") return true;
const actions = parseRuleActions(actionsJson);
if (filter === "subscription") return Object.hasOwn(actions, "isSubscription");
if (filter === "subscriptionYes") return getSubscriptionValue(actions) === 1;
if (filter === "subscriptionNo") return getSubscriptionValue(actions) === 0;
return typeof actions[filter] === "string" && actions[filter].trim().length > 0;
}

10
shared/bapEligibility.ts Normal file
View File

@@ -0,0 +1,10 @@
/** Score minimal requis pour générer et valider un BAP. */
export const BAP_MIN_QUALITY_SCORE = 90;
/**
* Centralise le seuil BAP afin que linterface et le serveur appliquent la
* même règle métier, y compris pour les scores nuls ou absents.
*/
export function meetsBapQualityThreshold(score: number | null | undefined): boolean {
return typeof score === "number" && Number.isFinite(score) && score >= BAP_MIN_QUALITY_SCORE;
}

View File

@@ -0,0 +1,32 @@
/** Valeurs nécessaires pour déterminer une destination dexport. */
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);
});
}

108
shared/invoiceAnalytics.ts Normal file
View File

@@ -0,0 +1,108 @@
import { getInvoicePeriodDate, matchesInvoicePeriod } from "./invoicePeriod";
export type AnalyticsInvoice = {
invoiceDate?: Date | string | number | null;
createdAt?: Date | string | number | null;
isSubscription?: number | null;
status?: string | null;
supplierName?: string | null;
totalAmount?: number | string | null;
};
export type AnnualInvoiceSummary = {
year: number;
bapCount: number;
bapAmount: number;
subscriptionCount: number;
subscriptionAmount: number;
totalAmount: number;
};
export type SupplierBudgetSummary = {
supplierName: string;
subscriptionCount: number;
subscriptionAmount: number;
nonSubscriptionCount: number;
nonSubscriptionAmount: number;
totalAmount: number;
};
function getAmount(value: AnalyticsInvoice["totalAmount"]): number {
const amount = Number(value);
return Number.isFinite(amount) ? amount : 0;
}
/**
* Agrège les factures finalisées par année métier. « BAP » désigne ici les
* factures hors abonnement, éligibles au circuit BAP, validées ou non.
*/
export function buildAnnualInvoiceSummary(invoices: AnalyticsInvoice[]): AnnualInvoiceSummary[] {
const summaryByYear = new Map<number, AnnualInvoiceSummary>();
for (const invoice of invoices) {
if (invoice.status !== "completed") continue;
const date = getInvoicePeriodDate(invoice);
if (!date) continue;
const year = date.getFullYear();
const current = summaryByYear.get(year) ?? {
year,
bapCount: 0,
bapAmount: 0,
subscriptionCount: 0,
subscriptionAmount: 0,
totalAmount: 0,
};
const amount = getAmount(invoice.totalAmount);
if (invoice.isSubscription === 1) {
current.subscriptionCount += 1;
current.subscriptionAmount += amount;
} else {
current.bapCount += 1;
current.bapAmount += amount;
}
current.totalAmount += amount;
summaryByYear.set(year, current);
}
return Array.from(summaryByYear.values()).sort((a, b) => b.year - a.year);
}
/** Agrège le budget réellement facturé par fournisseur sur la période choisie. */
export function buildSupplierBudgetSummary(
invoices: AnalyticsInvoice[],
year: string,
month: string,
): SupplierBudgetSummary[] {
const summaryBySupplier = new Map<string, SupplierBudgetSummary>();
for (const invoice of invoices) {
if (invoice.status !== "completed" || !matchesInvoicePeriod(invoice, year, month)) continue;
const supplierName = invoice.supplierName?.trim() || "Fournisseur inconnu";
const current = summaryBySupplier.get(supplierName) ?? {
supplierName,
subscriptionCount: 0,
subscriptionAmount: 0,
nonSubscriptionCount: 0,
nonSubscriptionAmount: 0,
totalAmount: 0,
};
const amount = getAmount(invoice.totalAmount);
if (invoice.isSubscription === 1) {
current.subscriptionCount += 1;
current.subscriptionAmount += amount;
} else {
current.nonSubscriptionCount += 1;
current.nonSubscriptionAmount += amount;
}
current.totalAmount += amount;
summaryBySupplier.set(supplierName, current);
}
return Array.from(summaryBySupplier.values()).sort((a, b) =>
b.totalAmount - a.totalAmount || a.supplierName.localeCompare(b.supplierName, "fr"),
);
}

27
shared/invoicePeriod.ts Normal file
View File

@@ -0,0 +1,27 @@
/**
* Date métier utilisée pour les listes de factures : date de facture si elle
* est connue, sinon date de réception. Cette règle est partagée avec BAP.
*/
export function getInvoicePeriodDate(invoice: {
invoiceDate?: Date | string | number | null;
createdAt?: Date | string | number | null;
}): Date | null {
const value = invoice.invoiceDate ?? invoice.createdAt;
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
/** Indique si une facture correspond au filtre Année/Mois sélectionné. */
export function matchesInvoicePeriod(
invoice: Parameters<typeof getInvoicePeriodDate>[0],
year: string,
month: string,
): boolean {
if (year === "all") return true;
const date = getInvoicePeriodDate(invoice);
if (!date || date.getFullYear() !== Number(year)) return false;
return month === "all" || date.getMonth() + 1 === Number(month);
}

146
todo.md
View File

@@ -731,3 +731,149 @@
- [x] Adapter le test de connexion IMAP et les messages derreur - [x] Adapter le test de connexion IMAP et les messages derreur
- [x] Ajouter des tests de non-régression du flux ImapFlow - [x] Ajouter des tests de non-régression du flux ImapFlow
- [x] Vérifier TypeScript, tests, build et authentification OAuth2 réelle - [x] Vérifier TypeScript, tests, build et authentification OAuth2 réelle
## Déploiement ImapFlow — recette et production
- [x] Pousser la version ImapFlow vers le dépôt Gitea de recette
- [x] Réduire limage runtime Docker pour fiabiliser le build sur le serveur de recette
- [x] Charger Vite uniquement en développement pour lexclure de limage runtime
- [x] Déployer et valider HTTP et conteneurs ImapFlow en recette (aucune source OAuth2 active à tester)
- [x] Corriger les règles Docker orphelines qui bloquaient MySQL en recette
- [x] Pousser la version validée vers le dépôt Gitea de production
- [x] Déployer et valider HTTP, conteneurs et OAuth2 ImapFlow en production
## Navigation depuis lédition des factures BAP
- [x] Reproduire et identifier la perte du contexte de navigation depuis Factures BAP
- [x] Restaurer la page dorigine après fermeture de lédition
- [x] Conserver les filtres et tris sélectionnés sur Factures BAP
- [x] Ajouter des tests de non-régression du retour contextuel
- [x] Vérifier TypeScript, tests, build et parcours en sandbox
- [x] Pousser le correctif vers Gitea recette et redéployer
- [x] Vérifier le parcours et la santé de la recette
## Automatisation de lindicateur Abonnement
- [x] Analyser le modèle, le formulaire et lexécution actuelle des automatismes
- [x] Ajouter loption Abonnement : Oui, Non ou Ne pas modifier aux règles
- [x] Appliquer loption Abonnement lors du traitement automatique des factures
- [x] Ajouter des tests de non-régression des règles dabonnement
- [x] Valider les deux correctifs en sandbox
## Déploiement recette — navigation BAP et abonnement
- [x] Pousser les deux correctifs vers Gitea recette
- [x] Déployer, vérifier HTTP et valider le parcours de retour en recette
## Correctif recette — dialogue dautomatisme
- [x] Vérifier le commit et localiser lemplacement absent de loption Abonnement
- [x] Corriger le dialogue dédition et le valider en recette
- [x] Arrêter le processus non applicatif autorisé qui bloque le build Docker
## Déploiement production — dialogue Abonnement
- [x] Pousser le correctif vers Gitea production
- [x] Construire et redémarrer uniquement le conteneur applicatif
- [x] Vérifier le commit, la santé, HTTP et le bundle de production
## Filtre par action des automatismes
- [x] Identifier les types daction disponibles et leurs règles de détection
- [x] Ajouter un filtre daction dans la liste des automatismes
- [x] Permettre disoler les règles Abonnement, Oui ou Non
- [x] Ajouter les tests et valider TypeScript, tests et build
## Déploiement — filtre par action des automatismes
- [x] Pousser le filtre par action vers Gitea recette
- [x] Déployer et vérifier le filtre par action en recette
- [x] Pousser la version validée vers Gitea production
- [x] Déployer et vérifier le filtre par action en production
## Seuil déligibilité BAP à 90 %
- [x] Identifier les validations BAP fondées sur le score de qualité
- [x] Abaisser le seuil de 100 % à 90 % pour les opérations BAP
- [x] Ajouter les tests de seuil et valider TypeScript, tests et build
## Déploiement — seuil BAP à 90 %
- [x] Pousser le correctif vers Gitea recette
- [x] Déployer et vérifier le seuil BAP à 90 % en recette
- [x] Pousser la version validée vers Gitea production
- [x] Déployer et vérifier le seuil BAP à 90 % en production
## Rétablissement du serveur de recette
- [x] Redémarrer le serveur de recette autorisé par lutilisateur
- [x] Rétablir le conteneur applicatif et terminer le déploiement BAP à 90 %
## Factures abonnements et filtres de période
- [x] Analyser les listes Factures, Factures BAP et le menu Facturation
- [x] Ajouter les filtres Année et Mois à la page Factures
- [x] Créer la page Factures abonnements affichant uniquement les abonnements
- [x] Reprendre les recherches, filtres et actions de la page Factures
- [x] Ajouter lentrée Factures abonnements après Factures BAP dans le menu
- [x] Ajouter les tests et valider TypeScript, build et affichage en sandbox
- [x] Déployer et vérifier le correctif en recette
- [x] Déployer et vérifier le correctif en production
## Synthèse annuelle du tableau de bord
- [x] Analyser les données et le tableau de bord existant
- [x] Calculer par année les volumes et montants BAP et abonnements
- [x] Afficher le nombre de factures et les montants par type, puis le total
- [x] Ajouter les tests et valider TypeScript, build et affichage en sandbox
## Budget réel par fournisseur
- [x] Définir lagrégation des montants par fournisseur et statut Abonnement
- [x] Ajouter les filtres Année et Mois à lécran Budget réel
- [x] Créer lécran Budget réel à la fin du menu Facturation
- [x] Afficher par fournisseur les montants Abonnement, Hors abonnement et le total
- [x] Ajouter les tests et valider TypeScript, build et affichage en sandbox
## Améliorations Budget réel et tableau de bord
- [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 limport automatique e-mail
- [x] Vérifier en production les paramètres et traces de limport e-mail automatique : un compte reste activé dans la configuration, sans trace récente de déclenchement
## Désactivation de limport 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 labsence 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 dimport sans modifier les données
- [x] Corréler les factures avec lhistorique, les fichiers source et les journaux applicatifs
- [x] Vérifier létat de limport 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 dimport, les routes et la page Historique existantes
- [x] Ajouter une vue détaillée de lhistorique avec source, compte, date, résultats et erreurs
- [x] Ajouter une action administrateur dimport 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 laffichage de Factures BAP en sandbox
## Automatismes dexport et simplification des Paramètres
- [x] Ajouter les onglets Automatismes dimport et Automatismes dexport
- [x] Créer des règles de destination dexport 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 dexport 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 lURL SharePoint, Tenant ID, Client ID, secret et date dexpiration
- [x] Rétablir les tests Azure AD et upload SharePoint depuis Paramètres
- [x] Retirer les accès Microsoft Azure AD des automatismes dexport
- [x] Valider les deux emplacements en sandbox
## Présentation des automatismes
- [x] Coloriser les onglets Import et Export
- [x] Simplifier les libellés de longlet 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 labsence 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 dimport » et « Automatismes dexport » sont visibles dans la page Automatismes.
- La navigation et le titre affichent désormais « Paramètres » ; seuls les onglets Paramètres dimport et Sauvegarde DB restent accessibles depuis cette page.