Compare commits

...

5 Commits

16 changed files with 3855 additions and 267 deletions

View File

@@ -21,6 +21,7 @@ import BapHistory from "./pages/BapHistory";
import ImportReport from "./pages/ImportReport";
import LearningSettings from "./pages/LearningSettings";
import VentilationFreePro from "./pages/VentilationFreePro";
import WebImportSources from "./pages/WebImportSources";
function Router() {
return (
@@ -42,6 +43,7 @@ function Router() {
<Route path="/import-report" component={ImportReport} />
<Route path="/learning-settings" component={LearningSettings} />
<Route path="/ventilation-freepro" component={VentilationFreePro} />
<Route path="/web-import-sources" component={WebImportSources} />
<Route path="/404" component={NotFound} />
<Route component={NotFound} />
</Switch>

View File

@@ -25,7 +25,7 @@ import {
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile";
import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List, Zap, ChevronDown, Receipt, Cog, ClipboardList, CheckSquare, Brain, BarChart2, BarChart3 } from "lucide-react";
import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List, Zap, ChevronDown, Receipt, Cog, ClipboardList, CheckSquare, Brain, BarChart2, BarChart3, Globe } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
@@ -75,6 +75,7 @@ const menuStructure: MenuItem[] = [
{ icon: List, label: "Administration des listes", path: "/lists-admin" },
{ icon: Zap, label: "Automatismes", path: "/automation-rules" },
{ icon: Brain, label: "Apprentissages IA", path: "/learning-settings" },
{ icon: Globe, label: "Connecteurs web", path: "/web-import-sources" },
{ icon: Users, label: "Utilisateurs", path: "/users", adminOnly: true },
],
},

View File

@@ -11,10 +11,146 @@ import { toast } from "sonner";
import {
Loader2, Save, Upload, FolderOpen, Mail, Play, Square, Download,
Inbox, CheckCircle2, Monitor, FolderOutput, Wifi, AlertTriangle, Calendar,
ArrowDownToLine, Share2
ArrowDownToLine, Share2, DatabaseBackup, HardDrive, CheckCircle, Clock
} from "lucide-react";
import DashboardLayout from "@/components/DashboardLayout";
// ============= COMPOSANT SAUVEGARDE DB =============
function BackupSection() {
const [isGenerating, setIsGenerating] = useState(false);
const { data: backupList, refetch: refetchList } = trpc.backup.list.useQuery();
const deleteBackupMutation = trpc.backup.delete.useMutation({
onSuccess: () => { refetchList(); toast.success("Sauvegarde supprimée"); },
onError: (e) => toast.error("Erreur : " + e.message),
});
const handleGenerateBackup = async () => {
setIsGenerating(true);
try {
const response = await fetch("/api/db-backup", { method: "POST", credentials: "include" });
if (!response.ok) {
const err = await response.json().catch(() => ({ error: "Erreur inconnue" }));
toast.error("Erreur : " + (err.error || response.statusText));
return;
}
// Déclencher le téléchargement
const blob = await response.blob();
const contentDisposition = response.headers.get("Content-Disposition") || "";
const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)/i);
const fileName = match ? decodeURIComponent(match[1]) : `backup-${new Date().toISOString().slice(0,10)}.sql`;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
toast.success("Sauvegarde générée et téléchargée");
refetchList();
} catch (e: any) {
toast.error("Erreur : " + e.message);
} finally {
setIsGenerating(false);
}
};
const formatSize = (bytes: number) => {
if (bytes < 1024) return bytes + " o";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " Ko";
return (bytes / (1024 * 1024)).toFixed(1) + " Mo";
};
return (
<Card className="border-2 hover:border-primary/50 transition-colors">
<CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 dark:from-slate-950/20 dark:to-gray-950/20 border-b">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-600 rounded-lg">
<DatabaseBackup className="w-6 h-6 text-white" />
</div>
<div>
<CardTitle className="text-xl">Sauvegarde de la base de données</CardTitle>
<CardDescription className="mt-1">
Générer un dump SQL de la base de données et l'enregistrer localement dans le dossier <code className="bg-muted px-1 rounded text-xs">backups/</code>
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6 space-y-6">
{/* Bouton générer */}
<div className="flex items-center gap-4">
<Button
onClick={handleGenerateBackup}
disabled={isGenerating}
className="gap-2 bg-slate-700 hover:bg-slate-800 text-white"
size="lg"
>
{isGenerating ? (
<><Loader2 className="h-5 w-5 animate-spin" />Génération en cours...</>
) : (
<><HardDrive className="h-5 w-5" />Générer une sauvegarde</>
)}
</Button>
<p className="text-sm text-muted-foreground">
Le dump SQL sera généré, enregistré dans <code className="bg-muted px-1 rounded text-xs">backups/</code> et téléchargé automatiquement.
</p>
</div>
{/* Liste des sauvegardes existantes */}
{backupList && backupList.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Clock className="h-4 w-4" />
Sauvegardes enregistrées ({backupList.length})
</div>
<div className="border rounded-lg divide-y">
{backupList.map((backup) => (
<div key={backup.name} className="flex items-center justify-between px-4 py-3 hover:bg-muted/30">
<div className="flex items-center gap-3">
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
<div>
<p className="text-sm font-medium font-mono">{backup.name}</p>
<p className="text-xs text-muted-foreground">
{new Date(backup.createdAt).toLocaleString("fr-FR")} — {formatSize(backup.size)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="gap-1 text-xs"
onClick={() => { window.open(`/api/db-backup/${encodeURIComponent(backup.name)}`, "_blank"); }}
>
<Download className="h-3 w-3" />
Télécharger
</Button>
<Button
variant="outline"
size="sm"
className="gap-1 text-xs text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => deleteBackupMutation.mutate({ name: backup.name })}
disabled={deleteBackupMutation.isPending}
>
Supprimer
</Button>
</div>
</div>
))}
</div>
</div>
)}
{backupList && backupList.length === 0 && (
<div className="text-center py-8 text-muted-foreground text-sm">
<HardDrive className="h-8 w-8 mx-auto mb-2 opacity-30" />
Aucune sauvegarde enregistrée
</div>
)}
</CardContent>
</Card>
);
}
export default function ImportSettings() {
// Chargement différé : on ne charge les statuts de services qu'après le montage
const { data: settings, isLoading } = trpc.importSettings.get.useQuery(undefined, {
@@ -207,7 +343,7 @@ export default function ImportSettings() {
{/* Onglets Import / Export */}
<Tabs defaultValue="import" className="w-full">
<TabsList className="grid w-full grid-cols-2 h-12 mb-6">
<TabsList className="grid w-full grid-cols-3 h-12 mb-6">
<TabsTrigger value="import" className="flex items-center gap-2 text-base">
<ArrowDownToLine className="w-4 h-4" />
Paramètres d'import
@@ -216,6 +352,10 @@ export default function ImportSettings() {
<Share2 className="w-4 h-4" />
Paramètres d'export
</TabsTrigger>
<TabsTrigger value="backup" className="flex items-center gap-2 text-base">
<DatabaseBackup className="w-4 h-4" />
Sauvegarde DB
</TabsTrigger>
</TabsList>
{/* ===== ONGLET IMPORT ===== */}
@@ -955,6 +1095,10 @@ export default function ImportSettings() {
</Button>
</div>
</TabsContent>
{/* ===== ONGLET SAUVEGARDE ===== */}
<TabsContent value="backup" className="space-y-6">
<BackupSection />
</TabsContent>
</Tabs>
</div>
</DashboardLayout>

View File

@@ -54,6 +54,7 @@ export default function Invoices() {
const [recipientFilter, setRecipientFilter] = useState<string>("all");
const [subscriptionFilter, setSubscriptionFilter] = useState<string>("all"); // all | yes | no
const [entityFilter, setEntityFilter] = useState<string>("all"); // all | santinova | itinova
const [ventilationFilter, setVentilationFilter] = useState<string>("all");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false);
@@ -199,6 +200,12 @@ export default function Invoices() {
)
).sort();
const uniqueVentilations = Array.from(
new Set(
(invoices || []).map(inv => (inv as any).ventilationComptable).filter(Boolean)
)
).sort();
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDir(d => d === "asc" ? "desc" : "asc");
@@ -250,6 +257,14 @@ export default function Invoices() {
if (entityFilter === "santinova" && service !== "DSI SANTINOVA") return false;
if (entityFilter === "itinova" && service === "DSI SANTINOVA") return false;
}
// Filter by ventilation comptable
if (ventilationFilter !== "all") {
if (ventilationFilter === "__empty__") {
if ((inv as any).ventilationComptable) return false;
} else {
if ((inv as any).ventilationComptable !== ventilationFilter) return false;
}
}
return true;
});
@@ -405,135 +420,150 @@ export default function Invoices() {
</div>
</div>
<Card>
<CardContent className="pt-6">
{/* Search + Recipient Filter */}
<div className="mb-4 flex gap-3 flex-wrap">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Rechercher par fournisseur, destinataire ou numéro..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex items-center gap-2 min-w-[220px]">
<Filter className="w-4 h-4 text-gray-400 shrink-0" />
<Select value={recipientFilter} onValueChange={setRecipientFilter}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Filtrer par destinataire" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les destinataires</SelectItem>
<SelectItem value="__empty__">Sans destinataire</SelectItem>
{uniqueRecipients.map((r) => (
<SelectItem key={r} value={r}>{r}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Filtre Abonnement */}
<div className="flex items-center gap-2 min-w-[180px]">
<Select value={subscriptionFilter} onValueChange={setSubscriptionFilter}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Abonnement" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous (abonnement)</SelectItem>
<SelectItem value="yes">Abonnement : OUI</SelectItem>
<SelectItem value="no">Abonnement : NON</SelectItem>
</SelectContent>
</Select>
</div>
{/* Filtre Entité */}
<div className="flex items-center gap-2 min-w-[180px]">
<Select value={entityFilter} onValueChange={setEntityFilter}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Entité" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes entités</SelectItem>
<SelectItem value="santinova">SANTINOVA</SelectItem>
<SelectItem value="itinova">ITINOVA</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Mode compact/détail + Tri */}
<div className="flex gap-2 mb-4 flex-wrap items-center justify-between">
<div className="flex gap-2">
<Button
variant={compactMode ? "default" : "outline"}
size="sm"
onClick={() => setCompactMode(true)}
title="Mode compact"
>
<LayoutList className="w-4 h-4 mr-1" /> Compact
</Button>
<Button
variant={!compactMode ? "default" : "outline"}
size="sm"
onClick={() => setCompactMode(false)}
title="Mode détail"
>
<LayoutGrid className="w-4 h-4 mr-1" /> Détail
</Button>
</div>
<div className="flex gap-2 items-center">
<span className="text-sm text-gray-500">Trier par :</span>
<Button
variant={sortField === "createdAt" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("createdAt")}
>
Date réception <SortIcon field="createdAt" />
</Button>
<Button
variant={sortField === "invoiceDate" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("invoiceDate")}
>
Date facture <SortIcon field="invoiceDate" />
</Button>
</div>
</div>
{/* ===== CARTOUCHE FILTRES ===== */}
<div className="rounded-xl border border-blue-100 bg-blue-50/60 px-4 py-3 mb-4 shadow-sm">
{/* Status Filters */}
<div className="flex gap-2 mb-4">
{/* Ligne 1 : Recherche + affichage compact/détail */}
<div className="flex gap-3 items-center flex-wrap mb-3">
<div className="relative flex-1 min-w-[220px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-blue-400 w-4 h-4" />
<Input
placeholder="Rechercher par fournisseur, destinataire ou numéro..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-white border-blue-200 focus:border-blue-400"
/>
</div>
<div className="flex gap-1 shrink-0">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
onClick={() => setStatusFilter("all")}
variant={compactMode ? "default" : "outline"}
size="sm"
onClick={() => setCompactMode(true)}
title="Mode compact"
className={compactMode ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
>
Tous ({statusCounts.all})
<LayoutList className="w-4 h-4 mr-1" /> Compact
</Button>
<Button
variant={statusFilter === "exported" ? "default" : "outline"}
onClick={() => setStatusFilter("exported")}
variant={!compactMode ? "default" : "outline"}
size="sm"
className={statusFilter === "exported" ? "bg-green-600 hover:bg-green-700" : ""}
onClick={() => setCompactMode(false)}
title="Mode détail"
className={!compactMode ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
>
Exportés ({statusCounts.exported})
</Button>
<Button
variant={statusFilter === "not_exported" ? "default" : "outline"}
onClick={() => setStatusFilter("not_exported")}
size="sm"
className={statusFilter === "not_exported" ? "bg-blue-600 hover:bg-blue-700" : ""}
>
Non exportés ({statusCounts.not_exported})
</Button>
<Button
variant={statusFilter === "export_error" ? "default" : "outline"}
onClick={() => setStatusFilter("export_error")}
size="sm"
className={statusFilter === "export_error" ? "bg-red-600 hover:bg-red-700" : ""}
>
Erreurs ({statusCounts.export_error})
<LayoutGrid className="w-4 h-4 mr-1" /> Détail
</Button>
</div>
</div>
{/* Ligne 2 : Filtres destinataire, abonnement, entité, ventilation */}
<div className="flex gap-2 items-center flex-wrap mb-3">
<Filter className="w-4 h-4 text-blue-400 shrink-0" />
<Select value={recipientFilter} onValueChange={setRecipientFilter}>
<SelectTrigger className="w-[180px] bg-white border-blue-200 text-sm">
<SelectValue placeholder="Destinataire" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous destinataires</SelectItem>
<SelectItem value="__empty__">Sans destinataire</SelectItem>
{uniqueRecipients.map((r) => (
<SelectItem key={r} value={r}>{r}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={subscriptionFilter} onValueChange={setSubscriptionFilter}>
<SelectTrigger className="w-[170px] bg-white border-blue-200 text-sm">
<SelectValue placeholder="Abonnement" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Abonnement : tous</SelectItem>
<SelectItem value="yes">Abonnement : OUI</SelectItem>
<SelectItem value="no">Abonnement : NON</SelectItem>
</SelectContent>
</Select>
<Select value={entityFilter} onValueChange={setEntityFilter}>
<SelectTrigger className="w-[150px] bg-white border-blue-200 text-sm">
<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] bg-white border-blue-200 text-sm">
<SelectValue placeholder="Ventilation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes ventilations</SelectItem>
<SelectItem value="__empty__">Sans ventilation</SelectItem>
{uniqueVentilations.map((v) => (
<SelectItem key={v} value={v}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Ligne 3 : Boutons statut export + tri */}
<div className="flex gap-2 items-center flex-wrap">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
onClick={() => setStatusFilter("all")}
size="sm"
className={statusFilter === "all" ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
>
Tous ({statusCounts.all})
</Button>
<Button
variant={statusFilter === "exported" ? "default" : "outline"}
onClick={() => setStatusFilter("exported")}
size="sm"
className={statusFilter === "exported" ? "bg-green-600 hover:bg-green-700 text-white" : "bg-white border-green-200 text-green-700 hover:bg-green-50"}
>
Exportés ({statusCounts.exported})
</Button>
<Button
variant={statusFilter === "not_exported" ? "default" : "outline"}
onClick={() => setStatusFilter("not_exported")}
size="sm"
className={statusFilter === "not_exported" ? "bg-indigo-600 hover:bg-indigo-700 text-white" : "bg-white border-indigo-200 text-indigo-700 hover:bg-indigo-50"}
>
Non exportés ({statusCounts.not_exported})
</Button>
<Button
variant={statusFilter === "export_error" ? "default" : "outline"}
onClick={() => setStatusFilter("export_error")}
size="sm"
className={statusFilter === "export_error" ? "bg-red-600 hover:bg-red-700 text-white" : "bg-white border-red-200 text-red-700 hover:bg-red-50"}
>
Erreurs ({statusCounts.export_error})
</Button>
<div className="ml-auto flex gap-1 items-center">
<span className="text-xs text-blue-500 font-medium mr-1">Trier :</span>
<Button
variant={sortField === "createdAt" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("createdAt")}
className={sortField === "createdAt" ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
>
Réception <SortIcon field="createdAt" />
</Button>
<Button
variant={sortField === "invoiceDate" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("invoiceDate")}
className={sortField === "invoiceDate" ? "bg-blue-600 hover:bg-blue-700" : "bg-white border-blue-200 text-blue-700 hover:bg-blue-50"}
>
Facture <SortIcon field="invoiceDate" />
</Button>
</div>
</div>
</div>
{/* ===== FIN CARTOUCHE ===== */}
<Card>
<CardContent className="pt-4">
{/* Table */}
{isLoading ? (

View File

@@ -587,17 +587,19 @@ export default function InvoicesBAP() {
<h1 className="text-3xl font-bold">Factures BAP</h1>
<p className="text-gray-500 mt-1">Factures non-abonnement (Abonnement = NON)</p>
</div>
{/* Barre d'actions : 2 lignes */}
<div className="space-y-2">
{/* Ligne 1 : Excel, ZIP, Valider tout en BAP, Importer */}
<div className="flex flex-wrap gap-2">
{/* ===== CARTOUCHE FILTRES + ACTIONS BAP ===== */}
<div className="rounded-xl border border-emerald-100 bg-emerald-50/60 px-4 py-3 mb-4 shadow-sm">
{/* Ligne 1 : Boutons d'action + Recherche */}
<div className="flex gap-2 items-center flex-wrap mb-3">
<Button
onClick={handleExportExcel}
disabled={selectedIds.length === 0 || exportExcelMutation.isPending}
variant="outline"
className="border-green-600 text-green-600 hover:bg-green-50"
size="sm"
className="bg-white border-green-300 text-green-700 hover:bg-green-50"
>
<FileSpreadsheet className="w-4 h-4 mr-2" />
<FileSpreadsheet className="w-4 h-4 mr-1" />
Excel ({selectedIds.length})
</Button>
<Button
@@ -631,9 +633,10 @@ export default function InvoicesBAP() {
}}
disabled={selectedIds.length === 0 || isZipDownloading}
variant="outline"
className="border-indigo-500 text-indigo-600 hover:bg-indigo-50"
size="sm"
className="bg-white border-indigo-300 text-indigo-700 hover:bg-indigo-50"
>
<FolderDown className={`w-4 h-4 mr-2 ${isZipDownloading ? 'animate-bounce' : ''}`} />
<FolderDown className={`w-4 h-4 mr-1 ${isZipDownloading ? 'animate-bounce' : ''}`} />
{isZipDownloading ? 'ZIP...' : `ZIP (${selectedIds.length})`}
</Button>
<Button
@@ -643,18 +646,16 @@ export default function InvoicesBAP() {
}
}}
disabled={validateBAPBulkMutation.isPending}
size="sm"
className="bg-green-700 hover:bg-green-800 text-white"
>
<ShieldCheck className="w-4 h-4 mr-2" />
{validateBAPBulkMutation.isPending ? "Validation en cours..." : "Valider tout en BAP"}
<ShieldCheck className="w-4 h-4 mr-1" />
{validateBAPBulkMutation.isPending ? "Validation..." : "Valider tout BAP"}
</Button>
<Button onClick={() => setLocation("/upload")}>
<FileText className="w-4 h-4 mr-2" />
<Button onClick={() => setLocation("/upload")} size="sm" className="bg-white border-emerald-300 text-emerald-700 hover:bg-emerald-50" variant="outline">
<FileText className="w-4 h-4 mr-1" />
Importer
</Button>
</div>
{/* Ligne 2 : Supprimer, Dévalider BAP, Relancer */}
<div className="flex flex-wrap gap-2">
<Button
onClick={async () => {
if (confirm(`Voulez-vous vraiment supprimer ${selectedIds.length} facture(s) ?`)) {
@@ -672,8 +673,9 @@ export default function InvoicesBAP() {
}}
disabled={selectedIds.length === 0}
variant="destructive"
size="sm"
>
<Trash2 className="w-4 h-4 mr-2" />
<Trash2 className="w-4 h-4 mr-1" />
Supprimer ({selectedIds.length})
</Button>
<Button
@@ -689,9 +691,10 @@ export default function InvoicesBAP() {
}}
disabled={selectedIds.length === 0 || devalidateBAPMutation.isPending}
variant="outline"
className="border-orange-500 text-orange-600 hover:bg-orange-50"
size="sm"
className="bg-white border-orange-300 text-orange-700 hover:bg-orange-50"
>
<ShieldCheck className="w-4 h-4 mr-2 rotate-180" />
<ShieldCheck className="w-4 h-4 mr-1 rotate-180" />
{devalidateBAPMutation.isPending ? "Dévalidation..." : `Dévalider BAP (${selectedIds.length})`}
</Button>
<Button
@@ -706,154 +709,153 @@ export default function InvoicesBAP() {
}}
disabled={selectedIds.length === 0 || reprocessMutation.isPending}
variant="outline"
className="border-purple-500 text-purple-600 hover:bg-purple-50"
size="sm"
className="bg-white border-purple-300 text-purple-700 hover:bg-purple-50"
>
<RefreshCw className={`w-4 h-4 mr-2 ${reprocessMutation.isPending ? 'animate-spin' : ''}`} />
<RefreshCw className={`w-4 h-4 mr-1 ${reprocessMutation.isPending ? 'animate-spin' : ''}`} />
{reprocessMutation.isPending ? `Retraitement...` : `Relancer (${selectedIds.length})`}
</Button>
{/* Recherche à droite */}
<div className="relative flex-1 min-w-[200px] ml-auto">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-emerald-400 w-4 h-4" />
<Input
placeholder="Rechercher par fournisseur ou numéro..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-white border-emerald-200 focus:border-emerald-400 h-8 text-sm"
/>
</div>
</div>
</div>
<Card>
<CardContent className="pt-6">
{/* Search */}
<div className="mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Rechercher par fournisseur ou numéro..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{/* Ligne 2 : Filtres période (année + mois) */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<div className="flex items-center gap-1.5 text-sm font-medium text-emerald-600">
<CalendarDays className="w-4 h-4" />
Période :
</div>
{/* Period Filter + Status Filters */}
<div className="flex flex-wrap items-center gap-2 mb-3 p-3 bg-slate-50 dark:bg-slate-900/50 rounded-lg border border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-1.5 text-sm font-medium text-slate-600 dark:text-slate-400">
<CalendarDays className="w-4 h-4" />
Période :
</div>
{/* Year selector */}
<Select value={selectedYear} onValueChange={(v) => { setSelectedYear(v); if (v === "all") setSelectedMonth("all"); }}>
<SelectTrigger className="w-28 h-8 text-sm">
<SelectValue placeholder="Année" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toute année</SelectItem>
{Array.from({ length: 5 }, (_, i) => currentYear - i).map(y => (
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
))}
</SelectContent>
</Select>
{/* Month selector — disabled when year = all */}
<Select
value={selectedMonth}
onValueChange={setSelectedMonth}
disabled={selectedYear === "all"}
<Select value={selectedYear} onValueChange={(v) => { setSelectedYear(v); if (v === "all") setSelectedMonth("all"); }}>
<SelectTrigger className="w-28 h-8 text-sm bg-white border-emerald-200">
<SelectValue placeholder="Année" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toute année</SelectItem>
{Array.from({ length: 5 }, (_, i) => currentYear - i).map(y => (
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedMonth}
onValueChange={setSelectedMonth}
disabled={selectedYear === "all"}
>
<SelectTrigger className="w-36 h-8 text-sm bg-white border-emerald-200">
<SelectValue placeholder="Mois" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les mois</SelectItem>
{[
["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"]
].map(([v, label]) => (
<SelectItem key={v} value={v}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{(selectedYear !== "all" || selectedMonth !== "all") && (
<button
onClick={() => { setSelectedYear(String(currentYear)); setSelectedMonth("all"); }}
className="text-xs text-emerald-600 hover:text-emerald-800 underline"
>
<SelectTrigger className="w-36 h-8 text-sm">
<SelectValue placeholder="Mois" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les mois</SelectItem>
{[
["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"]
].map(([v, label]) => (
<SelectItem key={v} value={v}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{(selectedYear !== "all" || selectedMonth !== "all") && (
<button
onClick={() => { setSelectedYear(String(currentYear)); setSelectedMonth("all"); }}
className="text-xs text-slate-500 hover:text-slate-700 underline"
>
Réinitialiser
</button>
)}
</div>
Réinitialiser
</button>
)}
</div>
{/* Tri */}
<div className="flex gap-2 items-center mb-3">
<span className="text-sm text-gray-500">Trier par :</span>
{/* Ligne 3 : Boutons statut + tri */}
<div className="flex gap-2 items-center flex-wrap">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
onClick={() => setStatusFilter("all")}
size="sm"
className={statusFilter === "all" ? "bg-emerald-600 hover:bg-emerald-700" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
>
Tous ({statusCounts.all})
</Button>
<Button
variant={statusFilter === "exported" ? "default" : "outline"}
onClick={() => setStatusFilter("exported")}
size="sm"
className={statusFilter === "exported" ? "bg-green-600 hover:bg-green-700 text-white" : "bg-white border-green-200 text-green-700 hover:bg-green-50"}
>
Exportés ({statusCounts.exported})
</Button>
<Button
variant={statusFilter === "not_exported" ? "default" : "outline"}
onClick={() => setStatusFilter("not_exported")}
size="sm"
className={statusFilter === "not_exported" ? "bg-indigo-600 hover:bg-indigo-700 text-white" : "bg-white border-indigo-200 text-indigo-700 hover:bg-indigo-50"}
>
Non exportés ({statusCounts.not_exported})
</Button>
<Button
variant={statusFilter === "export_error" ? "default" : "outline"}
onClick={() => setStatusFilter("export_error")}
size="sm"
className={statusFilter === "export_error" ? "bg-red-600 hover:bg-red-700 text-white" : "bg-white border-red-200 text-red-700 hover:bg-red-50"}
>
Erreurs ({statusCounts.export_error})
</Button>
<Button
variant={statusFilter === "bap_validated" ? "default" : "outline"}
onClick={() => setStatusFilter("bap_validated")}
size="sm"
className={statusFilter === "bap_validated" ? "bg-emerald-600 hover:bg-emerald-700 text-white" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
>
Validées BAP ({statusCounts.bap_validated})
</Button>
<Button
variant={statusFilter === "bap_pending" ? "default" : "outline"}
onClick={() => setStatusFilter("bap_pending")}
size="sm"
className={statusFilter === "bap_pending" ? "bg-orange-600 hover:bg-orange-700 text-white" : "bg-white border-orange-200 text-orange-700 hover:bg-orange-50"}
>
En attente BAP ({statusCounts.bap_pending})
</Button>
<Button
variant={statusFilter === "to_complete" ? "default" : "outline"}
onClick={() => setStatusFilter("to_complete")}
size="sm"
className={statusFilter === "to_complete" ? "bg-amber-600 hover:bg-amber-700 text-white" : "bg-white border-amber-200 text-amber-700 hover:bg-amber-50"}
>
À compléter ({statusCounts.to_complete})
</Button>
<div className="ml-auto flex gap-1 items-center">
<span className="text-xs text-emerald-500 font-medium mr-1">Trier :</span>
<Button
variant={sortField === "createdAt" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("createdAt")}
className={sortField === "createdAt" ? "bg-emerald-600 hover:bg-emerald-700" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
>
Date réception <SortIcon field="createdAt" />
Réception <SortIcon field="createdAt" />
</Button>
<Button
variant={sortField === "invoiceDate" ? "default" : "outline"}
size="sm"
onClick={() => handleSort("invoiceDate")}
className={sortField === "invoiceDate" ? "bg-emerald-600 hover:bg-emerald-700" : "bg-white border-emerald-200 text-emerald-700 hover:bg-emerald-50"}
>
Date facture <SortIcon field="invoiceDate" />
Facture <SortIcon field="invoiceDate" />
</Button>
</div>
</div>
</div>
{/* ===== FIN CARTOUCHE BAP ===== */}
{/* Status Filters */}
<div className="flex flex-wrap gap-2 mb-4">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
onClick={() => setStatusFilter("all")}
size="sm"
>
Tous ({statusCounts.all})
</Button>
<Button
variant={statusFilter === "exported" ? "default" : "outline"}
onClick={() => setStatusFilter("exported")}
size="sm"
className={statusFilter === "exported" ? "bg-green-600 hover:bg-green-700" : ""}
>
Exportés ({statusCounts.exported})
</Button>
<Button
variant={statusFilter === "not_exported" ? "default" : "outline"}
onClick={() => setStatusFilter("not_exported")}
size="sm"
className={statusFilter === "not_exported" ? "bg-blue-600 hover:bg-blue-700" : ""}
>
Non exportés ({statusCounts.not_exported})
</Button>
<Button
variant={statusFilter === "export_error" ? "default" : "outline"}
onClick={() => setStatusFilter("export_error")}
size="sm"
className={statusFilter === "export_error" ? "bg-red-600 hover:bg-red-700" : ""}
>
Erreurs ({statusCounts.export_error})
</Button>
<Button
variant={statusFilter === "bap_validated" ? "default" : "outline"}
onClick={() => setStatusFilter("bap_validated")}
size="sm"
className={statusFilter === "bap_validated" ? "bg-emerald-600 hover:bg-emerald-700" : ""}
>
Validées BAP ({statusCounts.bap_validated})
</Button>
<Button
variant={statusFilter === "bap_pending" ? "default" : "outline"}
onClick={() => setStatusFilter("bap_pending")}
size="sm"
className={statusFilter === "bap_pending" ? "bg-orange-600 hover:bg-orange-700" : ""}
>
En attente BAP ({statusCounts.bap_pending})
</Button>
<Button
variant={statusFilter === "to_complete" ? "default" : "outline"}
onClick={() => setStatusFilter("to_complete")}
size="sm"
className={statusFilter === "to_complete" ? "bg-amber-600 hover:bg-amber-700" : "border-amber-400 text-amber-700 hover:bg-amber-50"}
>
À compléter ({statusCounts.to_complete})
</Button>
</div>
<Card>
<CardContent className="pt-4">
{/* Table */}
{isLoading ? (

View File

@@ -0,0 +1,404 @@
import { useState } from "react";
import { trpc } from "@/lib/trpc";
import DashboardLayout from "@/components/DashboardLayout";
import { Button } from "@/components/ui/button";
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 { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Globe, Plus, Pencil, Trash2, Key, CheckCircle, XCircle, Clock, RefreshCw } from "lucide-react";
import { toast } from "sonner";
const CONNECTOR_TYPES = [
{ value: "sfr", label: "SFR Pro", url: "https://www.sfr.fr/mon-espace-client/" },
{ value: "orange", label: "Orange Pro", url: "https://espaceclient.orange.fr/" },
{ value: "bouygues", label: "Bouygues Telecom", url: "https://www.bouyguestelecom.fr/mon-compte/" },
{ value: "free", label: "Free Pro", url: "https://pro.free.fr/" },
{ value: "starlink", label: "Starlink", url: "https://www.starlink.com/account/" },
{ value: "custom", label: "Autre (personnalisé)", url: "" },
];
const FREQUENCY_LABELS: Record<string, string> = {
manual: "Manuel",
daily: "Quotidien",
weekly: "Hebdomadaire",
monthly: "Mensuel",
};
type Source = {
id: number;
name: string;
connectorType: string;
portalUrl: string;
loginEmail: string;
loginPassword: string;
frequency: "manual" | "daily" | "weekly" | "monthly";
autoEnabled: number;
lastSuccessAt: Date | null;
lastStatus: string | null;
lastImportCount: number | null;
apiToken: string;
createdAt: Date;
updatedAt: Date;
};
type FormData = {
name: string;
connectorType: string;
portalUrl: string;
loginEmail: string;
loginPassword: string;
frequency: "manual" | "daily" | "weekly" | "monthly";
autoEnabled: number;
};
const emptyForm: FormData = {
name: "",
connectorType: "sfr",
portalUrl: "https://www.sfr.fr/mon-espace-client/",
loginEmail: "",
loginPassword: "",
frequency: "monthly",
autoEnabled: 0,
};
export default function WebImportSources() {
const [showForm, setShowForm] = useState(false);
const [editSource, setEditSource] = useState<Source | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [showToken, setShowToken] = useState<number | null>(null);
const [form, setForm] = useState<FormData>(emptyForm);
const { data: sources = [], refetch } = trpc.webImportSources.list.useQuery();
const { data: tokenData } = trpc.webImportSources.getToken.useQuery(
{ id: showToken! },
{ enabled: showToken !== null }
);
const createMutation = trpc.webImportSources.create.useMutation({
onSuccess: () => {
toast.success("Source créée", { description: "Le connecteur web a été ajouté." });
setShowForm(false);
setForm(emptyForm);
refetch();
},
onError: (e) => toast.error("Erreur", { description: e.message }),
});
const updateMutation = trpc.webImportSources.update.useMutation({
onSuccess: () => {
toast.success("Source mise à jour");
setEditSource(null);
setForm(emptyForm);
refetch();
},
onError: (e) => toast.error("Erreur", { description: e.message }),
});
const deleteMutation = trpc.webImportSources.delete.useMutation({
onSuccess: () => {
toast.success("Source supprimée");
setDeleteId(null);
refetch();
},
onError: (e) => toast.error("Erreur", { description: e.message }),
});
function openCreate() {
setForm(emptyForm);
setEditSource(null);
setShowForm(true);
}
function openEdit(source: Source) {
setForm({
name: source.name,
connectorType: source.connectorType,
portalUrl: source.portalUrl,
loginEmail: source.loginEmail,
loginPassword: "", // Ne pas pré-remplir le mot de passe
frequency: source.frequency,
autoEnabled: source.autoEnabled,
});
setEditSource(source);
setShowForm(true);
}
function handleConnectorTypeChange(value: string) {
const connector = CONNECTOR_TYPES.find(c => c.value === value);
setForm(f => ({
...f,
connectorType: value,
name: f.name || connector?.label || "",
portalUrl: connector?.url || f.portalUrl,
}));
}
function handleSubmit() {
if (!form.name || !form.loginEmail || (!editSource && !form.loginPassword)) {
toast.error("Champs requis", { description: "Nom, identifiant et mot de passe sont obligatoires." });
return;
}
if (editSource) {
const updateData: any = { id: editSource.id, ...form };
if (!form.loginPassword) delete updateData.loginPassword; // Ne pas écraser si vide
updateMutation.mutate(updateData);
} else {
createMutation.mutate(form);
}
}
return (
<DashboardLayout>
<div className="p-6 max-w-5xl mx-auto">
{/* En-tête */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-100 rounded-lg">
<Globe className="w-6 h-6 text-indigo-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Connecteurs web</h1>
<p className="text-sm text-gray-500">Import automatique de factures depuis des espaces clients</p>
</div>
</div>
<Button onClick={openCreate} className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2">
<Plus className="w-4 h-4" />
Ajouter un connecteur
</Button>
</div>
{/* Info technique */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6 text-sm text-amber-800">
<strong>Fonctionnement :</strong> Un script cron tourne sur le serveur LWS et se connecte automatiquement aux espaces clients configurés pour télécharger les nouvelles factures. Le token API affiché ci-dessous est utilisé par ce script pour s'authentifier auprès de l'application.
</div>
{/* Liste des sources */}
{sources.length === 0 ? (
<div className="text-center py-16 text-gray-400">
<Globe className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-lg font-medium">Aucun connecteur configuré</p>
<p className="text-sm mt-1">Ajoutez un connecteur pour importer automatiquement des factures depuis un espace client web.</p>
</div>
) : (
<div className="space-y-3">
{(sources as Source[]).map((source) => (
<div key={source.id} className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1 min-w-0">
<div className="p-2 bg-indigo-50 rounded-lg shrink-0">
<Globe className="w-5 h-5 text-indigo-500" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-gray-900">{source.name}</span>
<Badge variant="outline" className="text-xs capitalize">{source.connectorType}</Badge>
<Badge variant={source.autoEnabled ? "default" : "secondary"} className="text-xs">
{source.autoEnabled ? "Auto activé" : "Manuel"}
</Badge>
<Badge variant="outline" className="text-xs">{FREQUENCY_LABELS[source.frequency]}</Badge>
</div>
<p className="text-sm text-gray-500 mt-1 truncate">{source.portalUrl}</p>
<p className="text-sm text-gray-600 mt-0.5">
<span className="font-medium">Identifiant :</span> {source.loginEmail}
</p>
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
{source.lastSuccessAt ? (
<span className="flex items-center gap-1 text-green-600">
<CheckCircle className="w-3 h-3" />
Dernier import : {new Date(source.lastSuccessAt).toLocaleDateString("fr-FR")}
{source.lastImportCount !== null && ` (${source.lastImportCount} facture(s))`}
</span>
) : (
<span className="flex items-center gap-1 text-gray-400">
<Clock className="w-3 h-3" />
Jamais importé
</span>
)}
{source.lastStatus && !source.lastSuccessAt && (
<span className="flex items-center gap-1 text-red-500">
<XCircle className="w-3 h-3" />
{source.lastStatus}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="sm"
className="gap-1 text-xs"
onClick={() => setShowToken(showToken === source.id ? null : source.id)}
>
<Key className="w-3 h-3" />
Token
</Button>
<Button variant="outline" size="sm" onClick={() => openEdit(source)}>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
className="text-red-500 hover:text-red-700 hover:border-red-300"
onClick={() => setDeleteId(source.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{/* Token API affiché inline */}
{showToken === source.id && tokenData && (
<div className="mt-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
<p className="text-xs text-gray-500 mb-1 font-medium">Token API (à configurer dans le script cron) :</p>
<code className="text-xs font-mono text-indigo-700 break-all select-all">{tokenData.apiToken}</code>
</div>
)}
</div>
))}
</div>
)}
{/* Dialog création / édition */}
<Dialog open={showForm} onOpenChange={(open) => { if (!open) { setShowForm(false); setEditSource(null); } }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editSource ? "Modifier le connecteur" : "Nouveau connecteur web"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label>Type de connecteur</Label>
<Select value={form.connectorType} onValueChange={handleConnectorTypeChange}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONNECTOR_TYPES.map(c => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Nom affiché</Label>
<Input
className="mt-1"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder="Ex : SFR Pro - Itinova"
/>
</div>
<div>
<Label>URL de l'espace client</Label>
<Input
className="mt-1"
value={form.portalUrl}
onChange={e => setForm(f => ({ ...f, portalUrl: e.target.value }))}
placeholder="https://..."
/>
</div>
<div>
<Label>Identifiant (email ou login)</Label>
<Input
className="mt-1"
value={form.loginEmail}
onChange={e => setForm(f => ({ ...f, loginEmail: e.target.value }))}
placeholder="votre@email.com"
/>
</div>
<div>
<Label>{editSource ? "Nouveau mot de passe (laisser vide pour ne pas changer)" : "Mot de passe"}</Label>
<Input
className="mt-1"
type="password"
value={form.loginPassword}
onChange={e => setForm(f => ({ ...f, loginPassword: e.target.value }))}
placeholder={editSource ? "••••••••" : "Mot de passe"}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Fréquence</Label>
<Select value={form.frequency} onValueChange={(v: any) => setForm(f => ({ ...f, frequency: v }))}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manuel</SelectItem>
<SelectItem value="daily">Quotidien</SelectItem>
<SelectItem value="weekly">Hebdomadaire</SelectItem>
<SelectItem value="monthly">Mensuel</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col justify-end pb-1">
<div className="flex items-center gap-2">
<Switch
checked={form.autoEnabled === 1}
onCheckedChange={v => setForm(f => ({ ...f, autoEnabled: v ? 1 : 0 }))}
/>
<Label>Import auto activé</Label>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setShowForm(false); setEditSource(null); }}>Annuler</Button>
<Button
onClick={handleSubmit}
disabled={createMutation.isPending || updateMutation.isPending}
className="bg-indigo-600 hover:bg-indigo-700 text-white"
>
{createMutation.isPending || updateMutation.isPending ? (
<RefreshCw className="w-4 h-4 animate-spin mr-2" />
) : null}
{editSource ? "Enregistrer" : "Créer"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Confirmation suppression */}
<AlertDialog open={deleteId !== null} onOpenChange={(open) => { if (!open) setDeleteId(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer ce connecteur ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action est irréversible. Le connecteur et son token API seront supprimés.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={() => deleteId !== null && deleteMutation.mutate({ id: deleteId })}
>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,18 @@
CREATE TABLE `webImportSources` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`name` varchar(100) NOT NULL,
`connectorType` varchar(50) NOT NULL,
`portalUrl` varchar(500) NOT NULL,
`loginEmail` varchar(320) NOT NULL,
`loginPassword` text NOT NULL,
`frequency` enum('manual','daily','weekly','monthly') NOT NULL DEFAULT 'monthly',
`autoEnabled` int NOT NULL DEFAULT 0,
`lastSuccessAt` timestamp,
`lastStatus` text,
`lastImportCount` int DEFAULT 0,
`apiToken` varchar(128) NOT NULL,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `webImportSources_id` PRIMARY KEY(`id`)
);

File diff suppressed because it is too large Load Diff

View File

@@ -253,6 +253,13 @@
"when": 1785404752594,
"tag": "0035_eminent_dreadnoughts",
"breakpoints": true
},
{
"idx": 36,
"version": "5",
"when": 1785419093588,
"tag": "0036_broken_rattler",
"breakpoints": true
}
]
}

View File

@@ -524,3 +524,39 @@ export const deletedInvoices = mysqlTable("deletedInvoices", {
});
export type DeletedInvoice = typeof deletedInvoices.$inferSelect;
export type InsertDeletedInvoice = typeof deletedInvoices.$inferInsert;
/**
* Web import sources — connecteurs web pour scraper des factures depuis des sites
* (ex: espace client SFR, Starlink, Orange...) avec login/mot de passe.
* Le scraping est exécuté par un script cron externe (Node.js + Playwright) sur LWS.
*/
export const webImportSources = mysqlTable("webImportSources", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(),
/** Nom affiché (ex: "SFR Pro", "Starlink") */
name: varchar("name", { length: 100 }).notNull(),
/** Type de connecteur — détermine le script Playwright à utiliser */
connectorType: varchar("connectorType", { length: 50 }).notNull(), // ex: "sfr", "starlink", "orange"
/** URL de l'espace client */
portalUrl: varchar("portalUrl", { length: 500 }).notNull(),
/** Identifiant de connexion (email ou login) */
loginEmail: varchar("loginEmail", { length: 320 }).notNull(),
/** Mot de passe chiffré (AES-256) */
loginPassword: text("loginPassword").notNull(),
/** Fréquence de vérification automatique */
frequency: mysqlEnum("frequency", ["manual", "daily", "weekly", "monthly"]).default("monthly").notNull(),
/** Activation de l'import automatique */
autoEnabled: int("autoEnabled").default(0).notNull(), // 0 = désactivé, 1 = activé
/** Date du dernier import réussi */
lastSuccessAt: timestamp("lastSuccessAt"),
/** Statut du dernier import */
lastStatus: text("lastStatus"),
/** Nombre de factures importées lors du dernier run */
lastImportCount: int("lastImportCount").default(0),
/** Token d'API pour que le script externe puisse s'authentifier */
apiToken: varchar("apiToken", { length: 128 }).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type WebImportSource = typeof webImportSources.$inferSelect;
export type InsertWebImportSource = typeof webImportSources.$inferInsert;

View File

@@ -0,0 +1,70 @@
# Connecteurs Web - Scripts d'import automatique
## Prérequis sur le serveur LWS
```bash
cd /opt/web-import
npm install playwright
npx playwright install chromium --with-deps
```
## Configuration
1. Depuis l'application : **Configuration > Connecteurs web** → créer une source SFR → copier le token API
2. Créer un fichier de configuration :
```bash
cp config.example.json config.json
# Éditer config.json avec vos valeurs
```
Contenu de `config.json` :
```json
{
"sfrLogin": "votre-login@sfr.fr",
"sfrPassword": "votre-mot-de-passe",
"appUrl": "https://demat-facturation.santinova-soft.org",
"apiToken": "votre-token-api-copié-depuis-lappli",
"downloadDir": "/tmp/sfr-invoices",
"processedFile": "/tmp/sfr-processed.json"
}
```
Ou utiliser des variables d'environnement :
```bash
export SFR_LOGIN=votre-login@sfr.fr
export SFR_PASSWORD=votre-mot-de-passe
export APP_URL=https://demat-facturation.santinova-soft.org
export API_TOKEN=votre-token-api
```
## Exécution manuelle
```bash
node sfr-connector.mjs
```
## Planification (cron)
Ajouter dans le crontab (`crontab -e`) :
```
# Import SFR le 5 de chaque mois à 8h00
0 8 5 * * /usr/bin/node /opt/web-import/sfr-connector.mjs >> /var/log/sfr-import.log 2>&1
```
## Ajouter un nouveau connecteur
Dupliquer `sfr-connector.mjs` et adapter :
1. L'URL du portail (`portalUrl`)
2. Les sélecteurs CSS pour le login et les liens de factures
3. Le nom du fichier de suivi (`processedFile`)
## Fonctionnement
1. Le script se connecte au site SFR avec les identifiants fournis
2. Il navigue vers la section factures
3. Il télécharge les PDFs non encore traités
4. Il les envoie à l'application via l'endpoint `/api/web-import/push-invoice`
5. L'application extrait les données avec l'IA et crée les factures
6. Le script marque les factures comme traitées pour éviter les doublons

View File

@@ -0,0 +1,216 @@
/**
* Connecteur SFR Pro - Script cron pour import automatique de factures
*
* Prérequis sur le serveur LWS :
* npm install playwright @playwright/test
* npx playwright install chromium
*
* Configuration :
* Copier .env.example en .env et remplir les variables
*
* Utilisation :
* node sfr-connector.mjs
*
* Cron (mensuel le 5 du mois à 8h) :
* 0 8 5 * * /usr/bin/node /opt/web-import/sfr-connector.mjs >> /var/log/sfr-import.log 2>&1
*/
import { chromium } from 'playwright';
import fs from 'fs';
import path from 'path';
import https from 'https';
import http from 'http';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ============ CONFIGURATION ============
// Ces variables peuvent être définies dans un fichier .env ou directement ici
const CONFIG = {
// URL de l'espace client SFR Pro
portalUrl: process.env.SFR_PORTAL_URL || 'https://www.sfr-business.fr/espace-client/',
// Identifiants SFR
login: process.env.SFR_LOGIN || '',
password: process.env.SFR_PASSWORD || '',
// URL de l'application de dématérialisation
appUrl: process.env.APP_URL || 'https://demat-facturation.santinova-soft.org',
// Token API de la source web (récupéré depuis l'interface Connecteurs web)
apiToken: process.env.API_TOKEN || '',
// Dossier temporaire pour les PDFs téléchargés
downloadDir: process.env.DOWNLOAD_DIR || '/tmp/sfr-invoices',
// Ne pas réimporter les factures déjà traitées (fichier de suivi)
processedFile: process.env.PROCESSED_FILE || '/tmp/sfr-processed.json',
};
// ============ HELPERS ============
function log(msg) {
console.log(`[${new Date().toISOString()}] [SFR] ${msg}`);
}
function loadProcessed() {
try {
if (fs.existsSync(CONFIG.processedFile)) {
return JSON.parse(fs.readFileSync(CONFIG.processedFile, 'utf8'));
}
} catch {}
return [];
}
function saveProcessed(list) {
fs.writeFileSync(CONFIG.processedFile, JSON.stringify(list, null, 2));
}
async function pushInvoiceToApp(filePath, fileName) {
const fileBuffer = fs.readFileSync(filePath);
const fileBase64 = fileBuffer.toString('base64');
const body = JSON.stringify({
apiToken: CONFIG.apiToken,
fileName,
fileBase64,
mimeType: 'application/pdf',
});
return new Promise((resolve, reject) => {
const url = new URL(`${CONFIG.appUrl}/api/web-import/push-invoice`);
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
};
const lib = url.protocol === 'https:' ? https : http;
const req = lib.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve({ status: res.statusCode, body: JSON.parse(data) });
} catch {
resolve({ status: res.statusCode, body: data });
}
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
// ============ CONNECTEUR SFR ============
async function runSfrConnector() {
log('Démarrage du connecteur SFR Pro');
if (!CONFIG.login || !CONFIG.password || !CONFIG.apiToken) {
log('ERREUR : SFR_LOGIN, SFR_PASSWORD et API_TOKEN sont requis');
process.exit(1);
}
// Créer le dossier de téléchargement
if (!fs.existsSync(CONFIG.downloadDir)) {
fs.mkdirSync(CONFIG.downloadDir, { recursive: true });
}
const processed = loadProcessed();
let newInvoices = 0;
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
acceptDownloads: true,
});
const page = await context.newPage();
try {
// 1. Naviguer vers l'espace client SFR
log(`Navigation vers ${CONFIG.portalUrl}`);
await page.goto(CONFIG.portalUrl, { waitUntil: 'networkidle', timeout: 30000 });
// 2. Accepter les cookies si présent
try {
await page.click('[id*="accept"], [class*="accept-cookie"], #didomi-notice-agree-button', { timeout: 3000 });
log('Cookies acceptés');
} catch {}
// 3. Remplir le formulaire de connexion
log('Connexion en cours...');
await page.fill('input[type="email"], input[name="login"], input[id*="login"], input[id*="email"]', CONFIG.login);
await page.fill('input[type="password"], input[name="password"], input[id*="password"]', CONFIG.password);
await page.click('button[type="submit"], input[type="submit"], button:has-text("Connexion"), button:has-text("Se connecter")');
await page.waitForNavigation({ waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
log('Connecté');
// 4. Naviguer vers la section factures
// Adapter selon la structure réelle du site SFR Pro
await page.goto(`${CONFIG.portalUrl}factures`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
// Chercher les liens de factures PDF
const invoiceLinks = await page.$$eval(
'a[href*=".pdf"], a[href*="facture"], a[href*="invoice"], a[download]',
links => links.map(a => ({
href: a.href,
text: a.textContent?.trim() || '',
download: a.getAttribute('download') || '',
}))
);
log(`${invoiceLinks.length} lien(s) de facture trouvé(s)`);
// 5. Télécharger et envoyer chaque facture
for (const link of invoiceLinks) {
const invoiceId = link.href || link.text;
if (processed.includes(invoiceId)) {
log(`Déjà traité : ${link.text}`);
continue;
}
try {
// Télécharger le PDF
const [download] = await Promise.all([
context.waitForEvent('download', { timeout: 15000 }),
page.click(`a[href="${link.href}"]`).catch(() => page.goto(link.href)),
]);
const fileName = download?.suggestedFilename() || `sfr-facture-${Date.now()}.pdf`;
const filePath = path.join(CONFIG.downloadDir, fileName);
await download?.saveAs(filePath);
log(`Téléchargé : ${fileName}`);
// Envoyer à l'application
const result = await pushInvoiceToApp(filePath, fileName);
log(`Envoyé : ${fileName}${JSON.stringify(result.body)}`);
// Marquer comme traité
processed.push(invoiceId);
saveProcessed(processed);
newInvoices++;
// Nettoyer le fichier temporaire
fs.unlinkSync(filePath);
} catch (err) {
log(`ERREUR sur ${link.text} : ${err.message}`);
}
}
} catch (err) {
log(`ERREUR FATALE : ${err.message}`);
await page.screenshot({ path: path.join(CONFIG.downloadDir, 'error-screenshot.png') }).catch(() => {});
throw err;
} finally {
await browser.close();
}
log(`Terminé : ${newInvoices} nouvelle(s) facture(s) importée(s)`);
return newInvoices;
}
// ============ POINT D'ENTRÉE ============
runSfrConnector().catch(err => {
console.error(`[FATAL] ${err.message}`);
process.exit(1);
});

View File

@@ -5,6 +5,9 @@ import net from "net";
import path from "path";
import fs from "fs";
import archiver from "archiver";
import { exec as execCb } from "child_process";
import { promisify } from "util";
const execAsync = promisify(execCb);
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { registerOAuthRoutes } from "./oauth";
import { appRouter } from "../routers";
@@ -224,6 +227,120 @@ async function startServer() {
}
});
// ============= WEB IMPORT SOURCES - Endpoint pour script cron externe =============
// ============= DB BACKUP - Génération et téléchargement dump MySQL =============
app.post("/api/db-backup", async (req, res) => {
// Vérifier l'auth JWT
const { verifyToken } = await import("../auth");
const token = req.cookies?.auth_token;
if (!token) { res.status(401).json({ error: "Non authentifié" }); return; }
const user = verifyToken(token);
if (!user || user.role !== "admin") { res.status(403).json({ error: "Accès réservé aux admins" }); return; }
try {
const dbUrl = new URL(process.env.DATABASE_URL || "");
const host = dbUrl.hostname;
const port = dbUrl.port || "3306";
const username = dbUrl.username;
const password = dbUrl.password;
const database = dbUrl.pathname.slice(1);
// Créer le dossier backups/
const backupDir = path.resolve("backups");
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const fileName = `backup-${database}-${timestamp}.sql`;
const filePath = path.join(backupDir, fileName);
// Construire la commande mysqldump
const sslFlag = dbUrl.searchParams.get("ssl-mode") === "DISABLED" ? "" : "--ssl-mode=REQUIRED";
const cmd = `mysqldump ${sslFlag} -h "${host}" -P ${port} -u "${username}" --password="${password}" "${database}" > "${filePath}"`;
console.log(`[Backup] Generating dump for database ${database}...`);
await execAsync(cmd);
console.log(`[Backup] Dump saved to ${filePath}`);
// Retourner le fichier en téléchargement
const encodedName = encodeURIComponent(fileName);
res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
res.setHeader("Content-Type", "application/sql");
res.sendFile(filePath, (err) => {
if (err) console.error("[Backup] Error sending file:", err);
});
} catch (err: any) {
console.error("[Backup] Error:", err.message);
res.status(500).json({ error: "Erreur lors de la génération du dump : " + err.message });
}
});
// Télécharger une sauvegarde existante
app.get("/api/db-backup/:filename", async (req, res) => {
const { verifyToken } = await import("../auth");
const token = req.cookies?.auth_token;
if (!token) { res.status(401).json({ error: "Non authentifié" }); return; }
const user = verifyToken(token);
if (!user || user.role !== "admin") { res.status(403).json({ error: "Accès réservé aux admins" }); return; }
const fileName = path.basename(req.params.filename);
const filePath = path.join(path.resolve("backups"), fileName);
if (!fs.existsSync(filePath)) { res.status(404).json({ error: "Fichier introuvable" }); return; }
const encodedName = encodeURIComponent(fileName);
res.setHeader("Content-Disposition", `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
res.setHeader("Content-Type", "application/sql");
res.sendFile(filePath);
});
app.post("/api/web-import/push-invoice", async (req, res) => {
try {
const { apiToken, fileName, fileBase64, mimeType } = req.body;
if (!apiToken || !fileName || !fileBase64) {
res.status(400).json({ error: "apiToken, fileName et fileBase64 sont requis" });
return;
}
const { getWebImportSourceByToken, getImportSettingsByUser, createInvoice, findDuplicateInvoice, isInvoiceBlacklisted, updateWebImportSourceStatus, createSourceFile } = await import('../db');
const source = await getWebImportSourceByToken(apiToken);
if (!source) {
res.status(401).json({ error: "Token invalide" });
return;
}
const pdfBuffer = Buffer.from(fileBase64, 'base64');
const fileMime = mimeType || 'application/pdf';
// Stocker le fichier source en DB
const sourceFile = await createSourceFile({
userId: source.userId,
fileName,
fileKey: `web-import/${source.userId}/${Date.now()}-${fileName}`,
fileUrl: '',
});
const importSettings = await getImportSettingsByUser(source.userId);
const aiSettings = {
aiProvider: importSettings?.aiProvider || 'manus',
mistralApiKey: importSettings?.mistralApiKey || undefined,
manusForgeApiUrl: importSettings?.manusForgeApiUrl || undefined,
manusForgeApiKey: importSettings?.manusForgeApiKey || undefined,
};
const { extractInvoicesWithMistral } = await import('../invoiceExtractor');
const extractResult = await extractInvoicesWithMistral(pdfBuffer, source.userId, sourceFile.id, 'mistral-large-latest', undefined, aiSettings);
let imported = 0;
let duplicates = 0;
for (const inv of extractResult.invoices || []) {
const blacklisted = await isInvoiceBlacklisted(inv.invoiceNumber || null, source.userId);
if (blacklisted) { duplicates++; continue; }
const dup = await findDuplicateInvoice(inv.invoiceNumber || null, String(inv.totalAmount ?? ''), source.userId);
if (dup) { duplicates++; continue; }
await createInvoice({ ...inv, userId: source.userId, sourceFileId: sourceFile.id } as any);
imported++;
}
await updateWebImportSourceStatus(source.id, 'success', imported, true);
res.json({ success: true, imported, duplicates, total: (extractResult.invoices || []).length });
} catch (err: any) {
console.error('[WebImport] Erreur push-invoice:', err.message);
res.status(500).json({ error: err.message });
}
});
// tRPC API
app.use(
"/api/trpc",

View File

@@ -47,7 +47,10 @@ import {
InvoiceLearning,
deletedInvoices,
InsertDeletedInvoice,
DeletedInvoice
DeletedInvoice,
webImportSources,
InsertWebImportSource,
WebImportSource
} from "../drizzle/schema";
import { ENV } from './_core/env';
@@ -1147,3 +1150,74 @@ export async function updateFreeproLastRun(
.set(update)
.where(eq(freeproSettings.userId, userId));
}
// ============= WEB IMPORT SOURCES =============
/** Génère un token API aléatoire de 64 caractères */
function generateApiToken(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let token = '';
for (let i = 0; i < 64; i++) {
token += chars.charAt(Math.floor(Math.random() * chars.length));
}
return token;
}
export async function getWebImportSourcesByUser(userId: number): Promise<WebImportSource[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(webImportSources).where(eq(webImportSources.userId, userId)).orderBy(desc(webImportSources.createdAt));
}
export async function getWebImportSourceById(id: number): Promise<WebImportSource | undefined> {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(webImportSources).where(eq(webImportSources.id, id)).limit(1);
return result[0];
}
export async function getWebImportSourceByToken(token: string): Promise<WebImportSource | undefined> {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(webImportSources).where(eq(webImportSources.apiToken, token)).limit(1);
return result[0];
}
export async function createWebImportSource(data: Omit<InsertWebImportSource, 'apiToken'>): Promise<WebImportSource> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const apiToken = generateApiToken();
const result = await db.insert(webImportSources).values({ ...data, apiToken });
const insertedId = Number(result[0].insertId);
const inserted = await db.select().from(webImportSources).where(eq(webImportSources.id, insertedId)).limit(1);
return inserted[0]!;
}
export async function updateWebImportSource(id: number, data: Partial<InsertWebImportSource>): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(webImportSources).set({ ...data, updatedAt: new Date() }).where(eq(webImportSources.id, id));
}
export async function deleteWebImportSource(id: number): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(webImportSources).where(eq(webImportSources.id, id));
}
export async function updateWebImportSourceStatus(
id: number,
status: string,
importCount: number,
success: boolean
): Promise<void> {
const db = await getDb();
if (!db) return;
const update: Partial<InsertWebImportSource> = {
lastStatus: status,
lastImportCount: importCount,
updatedAt: new Date(),
};
if (success) update.lastSuccessAt = new Date();
await db.update(webImportSources).set(update).where(eq(webImportSources.id, id));
}

View File

@@ -77,8 +77,19 @@ import {
deleteLearning,
deleteAllLearnings,
getBapPdfUrlsByInvoiceIds,
getWebImportSourcesByUser,
getWebImportSourceById,
createWebImportSource,
updateWebImportSource,
deleteWebImportSource,
updateWebImportSourceStatus,
} from "./db";
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
import { exec as execCb } from "child_process";
import { promisify } from "util";
import fsSync from "fs";
import pathSync from "path";
const execAsync = promisify(execCb);
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
import { localStoragePut, generateStorageKey } from "./localStorage";
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
@@ -2609,5 +2620,96 @@ export const appRouter = router({
return { success: true, webUrl: result.webUrl, fileName };
}),
}),
// ============= WEB IMPORT SOURCES =============
webImportSources: router({
list: protectedProcedure.query(async ({ ctx }) => {
return getWebImportSourcesByUser(ctx.user.id);
}),
create: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
connectorType: z.string().min(1).max(50),
portalUrl: z.string().url(),
loginEmail: z.string().min(1),
loginPassword: z.string().min(1),
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).default('monthly'),
autoEnabled: z.number().min(0).max(1).default(0),
}))
.mutation(async ({ input, ctx }) => {
return createWebImportSource({ ...input, userId: ctx.user.id });
}),
update: protectedProcedure
.input(z.object({
id: z.number(),
name: z.string().min(1).max(100).optional(),
connectorType: z.string().min(1).max(50).optional(),
portalUrl: z.string().url().optional(),
loginEmail: z.string().min(1).optional(),
loginPassword: z.string().optional(),
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(),
autoEnabled: z.number().min(0).max(1).optional(),
}))
.mutation(async ({ input, ctx }) => {
const source = await getWebImportSourceById(input.id);
if (!source || source.userId !== ctx.user.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Source introuvable' });
}
const { id, ...data } = input;
await updateWebImportSource(id, data);
return { success: true };
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const source = await getWebImportSourceById(input.id);
if (!source || source.userId !== ctx.user.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Source introuvable' });
}
await deleteWebImportSource(input.id);
return { success: true };
}),
getToken: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input, ctx }) => {
const source = await getWebImportSourceById(input.id);
if (!source || source.userId !== ctx.user.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Source introuvable' });
}
return { apiToken: source.apiToken };
}),
}),
// ============= BACKUP ROUTES =============
backup: router({
// Liste les sauvegardes existantes dans le dossier backups/
list: adminProcedure.query(async () => {
const backupDir = pathSync.resolve("backups");
if (!fsSync.existsSync(backupDir)) return [];
const files = fsSync.readdirSync(backupDir)
.filter(f => f.endsWith(".sql") || f.endsWith(".sql.gz"))
.map(f => {
const stat = fsSync.statSync(pathSync.join(backupDir, f));
return { name: f, size: stat.size, createdAt: stat.mtime };
})
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return files;
}),
// Supprime une sauvegarde
delete: adminProcedure
.input(z.object({ name: z.string() }))
.mutation(async ({ input }) => {
const backupDir = pathSync.resolve("backups");
const filePath = pathSync.join(backupDir, pathSync.basename(input.name));
if (!fsSync.existsSync(filePath)) throw new TRPCError({ code: 'NOT_FOUND', message: 'Fichier introuvable' });
fsSync.unlinkSync(filePath);
return { success: true };
}),
}),
});
export type AppRouter = typeof appRouter;

View File

@@ -684,3 +684,11 @@
- [ ] Modifier getServiceSignaturesByUser → retourner toutes les signatures de service (sans filtre userId)
- [ ] Migrer les données en production : dédoublonner les listes fusionnées
- [ ] Déployer en production
## Connecteurs web (scraping login/mdp)
- [ ] Table webImportSources dans le schéma DB
- [ ] Procédures tRPC CRUD pour webImportSources
- [ ] Interface de gestion des sources web dans les paramètres
- [ ] Endpoint API sécurisé pour déclencher l'import et recevoir les PDFs
- [ ] Script cron externe Node.js + Playwright pour SFR
- [ ] Framework connecteur générique extensible