Compare commits
5 Commits
5d18dfa468
...
4ce2d2877a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ce2d2877a | ||
|
|
711ce6b83a | ||
|
|
bcb307bde1 | ||
|
|
295ba29329 | ||
|
|
ac0594f5db |
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
404
client/src/pages/WebImportSources.tsx
Normal file
404
client/src/pages/WebImportSources.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
drizzle/0036_broken_rattler.sql
Normal file
18
drizzle/0036_broken_rattler.sql
Normal 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`)
|
||||
);
|
||||
2357
drizzle/meta/0036_snapshot.json
Normal file
2357
drizzle/meta/0036_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -253,6 +253,13 @@
|
||||
"when": 1785404752594,
|
||||
"tag": "0035_eminent_dreadnoughts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 36,
|
||||
"version": "5",
|
||||
"when": 1785419093588,
|
||||
"tag": "0036_broken_rattler",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
70
scripts/web-import/README.md
Normal file
70
scripts/web-import/README.md
Normal 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
|
||||
216
scripts/web-import/sfr-connector.mjs
Normal file
216
scripts/web-import/sfr-connector.mjs
Normal 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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
76
server/db.ts
76
server/db.ts
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
8
todo.md
8
todo.md
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user