Checkpoint: Ajout de l'onglet "Paramétrage" dans la page Ventilation FreePro :
- Table DB freeproSettings (URL portail, credentials, fréquence, date antériorité, statut dernière récupération) - Service freeproAutoImport.ts : connexion HTTP au portail FreePro, téléchargement CSV, pipeline d'import - Procédures tRPC : getSettings, saveSettings, testConnection, forceImport - Job périodique en mémoire (daily/weekly/monthly) via setInterval - Frontend : wrapper Tabs (onglet 1 = Import & Historique, onglet 2 = Paramétrage) - Onglet Paramétrage : credentials, fréquence, date antériorité, bouton "Forcer récupération", statut - Tests unitaires : 8 tests passés
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useRef, useCallback } from "react";
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import DashboardLayout from "@/components/DashboardLayout";
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -35,8 +36,16 @@ import {
|
|||||||
BarChart3,
|
BarChart3,
|
||||||
Euro,
|
Euro,
|
||||||
Share2,
|
Share2,
|
||||||
|
Settings,
|
||||||
|
RefreshCw,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
// PDF généré côté serveur via trpc.freepro.generatePdf
|
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -82,7 +91,382 @@ function typeBadgeColor(type: string): string {
|
|||||||
return "bg-orange-100 text-orange-800 border-orange-200";
|
return "bg-orange-100 text-orange-800 border-orange-200";
|
||||||
}
|
}
|
||||||
|
|
||||||
// PDF généré côté serveur — pas de jsPDF côté client
|
// ── Onglet Paramétrage ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ParametrageTab() {
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
// Champs du formulaire
|
||||||
|
const [portalUrl, setPortalUrl] = useState("https://pro.free.fr");
|
||||||
|
const [loginEmail, setLoginEmail] = useState("");
|
||||||
|
const [loginPassword, setLoginPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [frequency, setFrequency] = useState<"manual" | "daily" | "weekly" | "monthly">("manual");
|
||||||
|
const [maxAnteriority, setMaxAnteriority] = useState(""); // date string YYYY-MM-DD
|
||||||
|
const [autoEnabled, setAutoEnabled] = useState(false);
|
||||||
|
|
||||||
|
// État UI
|
||||||
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
|
const [isForcing, setIsForcing] = useState(false);
|
||||||
|
const [lastForceResult, setLastForceResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
|
// Query paramètres
|
||||||
|
const { data: settings, isLoading: loadingSettings } = trpc.freepro.getSettings.useQuery();
|
||||||
|
|
||||||
|
// Hydratation du formulaire depuis la DB
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings) return;
|
||||||
|
if (settings.portalUrl) setPortalUrl(settings.portalUrl);
|
||||||
|
if (settings.loginEmail) setLoginEmail(settings.loginEmail);
|
||||||
|
if (settings.loginPassword) setLoginPassword(settings.loginPassword); // masqué côté serveur
|
||||||
|
setFrequency((settings.frequency as any) ?? "manual");
|
||||||
|
if (settings.maxAnteriority) {
|
||||||
|
const d = new Date(settings.maxAnteriority * 1000);
|
||||||
|
setMaxAnteriority(d.toISOString().split("T")[0]);
|
||||||
|
}
|
||||||
|
setAutoEnabled(settings.autoEnabled === 1);
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const saveSettingsMutation = trpc.freepro.saveSettings.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.freepro.getSettings.invalidate();
|
||||||
|
toast.success("Paramètres FreePro sauvegardés");
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Erreur : ${err.message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const testConnectionMutation = trpc.freepro.testConnection.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setIsTesting(false);
|
||||||
|
if (data.success) {
|
||||||
|
toast.success(data.message);
|
||||||
|
} else {
|
||||||
|
toast.error(data.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setIsTesting(false);
|
||||||
|
toast.error(`Erreur de connexion : ${err.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const forceImportMutation = trpc.freepro.forceImport.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setIsForcing(false);
|
||||||
|
setLastForceResult({ success: data.success, message: data.message });
|
||||||
|
if (data.success) {
|
||||||
|
toast.success(data.message);
|
||||||
|
utils.freepro.list.invalidate();
|
||||||
|
utils.freepro.getSettings.invalidate();
|
||||||
|
} else {
|
||||||
|
toast.error(data.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setIsForcing(false);
|
||||||
|
setLastForceResult({ success: false, message: err.message });
|
||||||
|
toast.error(`Erreur : ${err.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const anteriorityTs = maxAnteriority
|
||||||
|
? Math.floor(new Date(maxAnteriority).getTime() / 1000)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
saveSettingsMutation.mutate({
|
||||||
|
portalUrl,
|
||||||
|
loginEmail,
|
||||||
|
loginPassword: loginPassword !== "••••••••" ? loginPassword : undefined,
|
||||||
|
frequency,
|
||||||
|
maxAnteriority: anteriorityTs,
|
||||||
|
autoEnabled: autoEnabled ? 1 : 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestConnection = () => {
|
||||||
|
if (!loginEmail || !loginPassword || loginPassword === "••••••••") {
|
||||||
|
toast.error("Veuillez saisir l'email et le mot de passe avant de tester");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsTesting(true);
|
||||||
|
testConnectionMutation.mutate({ email: loginEmail, password: loginPassword });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForceImport = () => {
|
||||||
|
setIsForcing(true);
|
||||||
|
setLastForceResult(null);
|
||||||
|
forceImportMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loadingSettings) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<div className="h-6 w-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Connexion au portail FreePro */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Wifi className="h-4 w-4 text-blue-600" />
|
||||||
|
Connexion au portail FreePro
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Configurez les identifiants pour la récupération automatique des factures CSV depuis{" "}
|
||||||
|
<a
|
||||||
|
href="https://pro.free.fr/account/billing"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
pro.free.fr
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* URL portail */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
|
||||||
|
<label className="text-sm font-medium">URL du portail</label>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={portalUrl}
|
||||||
|
onChange={(e) => setPortalUrl(e.target.value)}
|
||||||
|
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="https://pro.free.fr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
|
||||||
|
<label className="text-sm font-medium">Email de connexion</label>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={loginEmail}
|
||||||
|
onChange={(e) => setLoginEmail(e.target.value)}
|
||||||
|
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="votre@email.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mot de passe */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
|
||||||
|
<label className="text-sm font-medium">Mot de passe</label>
|
||||||
|
<div className="md:col-span-2 relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={loginPassword}
|
||||||
|
onChange={(e) => setLoginPassword(e.target.value)}
|
||||||
|
className="w-full border rounded px-3 py-2 pr-10 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Mot de passe FreePro"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bouton tester connexion */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={isTesting}
|
||||||
|
className="flex items-center gap-2 border-blue-300 text-blue-700 hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
{isTesting ? (
|
||||||
|
<div className="h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Wifi className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{isTesting ? "Test en cours…" : "Tester la connexion"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Paramètres de récupération automatique */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-blue-600" />
|
||||||
|
Récupération automatique
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Activation */}
|
||||||
|
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Récupération automatique</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Si activée, les nouvelles factures seront importées automatiquement selon la fréquence configurée
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setAutoEnabled(!autoEnabled)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
autoEnabled ? "bg-blue-600" : "bg-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
autoEnabled ? "translate-x-6" : "translate-x-1"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fréquence */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
|
||||||
|
<label className="text-sm font-medium">Fréquence d'interrogation</label>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<select
|
||||||
|
value={frequency}
|
||||||
|
onChange={(e) => setFrequency(e.target.value as any)}
|
||||||
|
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-background"
|
||||||
|
>
|
||||||
|
<option value="manual">Manuelle uniquement</option>
|
||||||
|
<option value="daily">Quotidienne (1 fois par jour)</option>
|
||||||
|
<option value="weekly">Hebdomadaire (1 fois par semaine)</option>
|
||||||
|
<option value="monthly">Mensuelle (1 fois par mois)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date d'antériorité */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-start">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Date d'antériorité maximale</label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Les factures antérieures à cette date ne seront pas récupérées
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={maxAnteriority}
|
||||||
|
onChange={(e) => setMaxAnteriority(e.target.value)}
|
||||||
|
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
{maxAnteriority && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Seules les factures à partir du{" "}
|
||||||
|
<strong>{new Date(maxAnteriority).toLocaleDateString("fr-FR")}</strong> seront récupérées
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setMaxAnteriority("")}
|
||||||
|
className="text-xs text-muted-foreground hover:text-red-500 mt-1 underline"
|
||||||
|
>
|
||||||
|
Effacer (récupérer toutes les factures disponibles)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Boutons d'action */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
onClick={handleForceImport}
|
||||||
|
disabled={isForcing}
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center gap-2 border-orange-400 text-orange-700 hover:bg-orange-50"
|
||||||
|
>
|
||||||
|
{isForcing ? (
|
||||||
|
<div className="h-4 w-4 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{isForcing ? "Récupération en cours…" : "Forcer la récupération maintenant"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saveSettingsMutation.isPending}
|
||||||
|
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
>
|
||||||
|
{saveSettingsMutation.isPending ? (
|
||||||
|
<div className="h-4 w-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{saveSettingsMutation.isPending ? "Sauvegarde…" : "Sauvegarder les paramètres"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Résultat de la dernière récupération forcée */}
|
||||||
|
{lastForceResult && (
|
||||||
|
<Card className={`border ${lastForceResult.success ? "border-green-200 bg-green-50/30" : "border-red-200 bg-red-50/30"}`}>
|
||||||
|
<CardContent className="pt-4 pb-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{lastForceResult.success ? (
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-green-600 mt-0.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{lastForceResult.success ? "Récupération terminée" : "Échec de la récupération"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{lastForceResult.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Statut de la dernière récupération automatique */}
|
||||||
|
{settings && (settings.lastStatus || settings.lastSuccessAt) && (
|
||||||
|
<Card className="border-muted">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
Statut de la dernière récupération automatique
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{settings.lastSuccessAt && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="text-muted-foreground">Dernière réussite :</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{new Date(settings.lastSuccessAt).toLocaleString("fr-FR")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{settings.lastImportCount !== null && settings.lastImportCount !== undefined && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<FileSpreadsheet className="h-4 w-4 text-blue-600" />
|
||||||
|
<span className="text-muted-foreground">Dernière récupération :</span>
|
||||||
|
<span className="font-medium">{settings.lastImportCount} facture(s) importée(s)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{settings.lastStatus && (
|
||||||
|
<div className="flex items-start gap-2 text-sm">
|
||||||
|
<span className="text-muted-foreground shrink-0">Message :</span>
|
||||||
|
<span className="text-xs text-muted-foreground italic">{settings.lastStatus}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Composant principal ────────────────────────────────────────────────────
|
// ── Composant principal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -137,7 +521,6 @@ function VentilationFreeProContent() {
|
|||||||
const generatePdfMutation = trpc.freepro.generatePdf.useMutation({
|
const generatePdfMutation = trpc.freepro.generatePdf.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setIsExportingPdf(false);
|
setIsExportingPdf(false);
|
||||||
// Télécharger le PDF depuis le base64
|
|
||||||
const byteChars = atob(data.base64);
|
const byteChars = atob(data.base64);
|
||||||
const byteArr = new Uint8Array(byteChars.length);
|
const byteArr = new Uint8Array(byteChars.length);
|
||||||
for (let i = 0; i < byteChars.length; i++) byteArr[i] = byteChars.charCodeAt(i);
|
for (let i = 0; i < byteChars.length; i++) byteArr[i] = byteChars.charCodeAt(i);
|
||||||
@@ -200,186 +583,8 @@ function VentilationFreeProContent() {
|
|||||||
[handleFile]
|
[handleFile]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Rendu liste ──────────────────────────────────────────────────────────
|
// ── Vue détail (pas d'onglets) ────────────────────────────────────────────
|
||||||
if (view === "list") {
|
if (view === "detail") {
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* En-tête */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<BarChart3 className="h-6 w-6 text-blue-600" />
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">Ventilation FreePro</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">Import et ventilation des factures Free Pro par structure</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Zone d'import */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
|
||||||
<Upload className="h-4 w-4 text-blue-600" />
|
|
||||||
Importer une facture FreePro
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* Sélecteur de mois */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<label className="text-sm font-medium text-muted-foreground whitespace-nowrap">Mois de facturation :</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={moisLabel}
|
|
||||||
onChange={(e) => setMoisLabel(e.target.value)}
|
|
||||||
placeholder="MM/AAAA"
|
|
||||||
className="border rounded px-3 py-1.5 text-sm w-32 font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">Format : MM/AAAA (ex : 06/2025)</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Zone de dépôt */}
|
|
||||||
<div
|
|
||||||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${
|
|
||||||
isDragging ? "border-blue-500 bg-blue-50" : "border-muted-foreground/30 hover:border-blue-400 hover:bg-blue-50/30"
|
|
||||||
}`}
|
|
||||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
|
||||||
onDragLeave={() => setIsDragging(false)}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
<FileSpreadsheet className="h-10 w-10 mx-auto mb-3 text-blue-400" />
|
|
||||||
<p className="text-sm font-medium">Glissez votre fichier FreePro ici</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">ou cliquez pour sélectionner</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2">Formats acceptés : .xlsx, .xls, .csv</p>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".xlsx,.xls,.csv"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{importMutation.isPending && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-blue-600">
|
|
||||||
<div className="h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
|
||||||
Traitement en cours…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Historique */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
|
||||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
|
||||||
Historique des imports ({imports.length})
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{loadingList ? (
|
|
||||||
<div className="flex justify-center py-8">
|
|
||||||
<div className="h-6 w-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : imports.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
<FileSpreadsheet className="h-10 w-10 mx-auto mb-2 opacity-30" />
|
|
||||||
<p className="text-sm">Aucun import enregistré</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Mois</TableHead>
|
|
||||||
<TableHead>Réf. pièce</TableHead>
|
|
||||||
<TableHead>Fichier</TableHead>
|
|
||||||
<TableHead className="text-right">Lignes</TableHead>
|
|
||||||
<TableHead className="text-right">Total TTC</TableHead>
|
|
||||||
<TableHead>Importé le</TableHead>
|
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{(imports as ImportRecord[]).map((imp) => (
|
|
||||||
<TableRow
|
|
||||||
key={imp.id}
|
|
||||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
|
||||||
onClick={() => { setSelectedImportId(imp.id); setView("detail"); }}
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline" className="font-mono text-blue-700 border-blue-300 bg-blue-50">
|
|
||||||
{imp.moisLabel}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground">{imp.refPiece ?? "—"}</TableCell>
|
|
||||||
<TableCell className="max-w-[180px] truncate text-sm">{imp.fileName}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<span className="flex items-center justify-end gap-1 text-sm">
|
|
||||||
<Hash className="h-3 w-3 text-muted-foreground" />
|
|
||||||
{imp.nbLignes}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right font-semibold text-sm">
|
|
||||||
{formatTotalTtc(imp.totalTtc)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
|
||||||
{new Date(imp.createdAt).toLocaleDateString("fr-FR")}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-7 w-7 p-0 text-blue-600 hover:text-blue-800 hover:bg-blue-50"
|
|
||||||
onClick={() => { setSelectedImportId(imp.id); setView("detail"); }}
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
|
|
||||||
onClick={() => setDeleteId(imp.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Dialog de confirmation de suppression */}
|
|
||||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Supprimer cet import ?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Cette action est irréversible. Toutes les lignes de ventilation associées seront supprimées.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
className="bg-red-600 hover:bg-red-700"
|
|
||||||
onClick={() => { if (deleteId) deleteMutation.mutate({ id: deleteId }); setDeleteId(null); }}
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Rendu détail ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const imp = detail?.import as ImportRecord | undefined;
|
const imp = detail?.import as ImportRecord | undefined;
|
||||||
const lines = (detail?.lines ?? []) as VentilationLine[];
|
const lines = (detail?.lines ?? []) as VentilationLine[];
|
||||||
const totalCentimes = lines.reduce((s, l) => s + l.montantCentimes, 0);
|
const totalCentimes = lines.reduce((s, l) => s + l.montantCentimes, 0);
|
||||||
@@ -523,6 +728,205 @@ function VentilationFreeProContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Vue liste avec onglets ─────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* En-tête */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<BarChart3 className="h-6 w-6 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Ventilation FreePro</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Import et ventilation des factures Free Pro par structure</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Onglets */}
|
||||||
|
<Tabs defaultValue="import">
|
||||||
|
<TabsList className="mb-4">
|
||||||
|
<TabsTrigger value="import" className="flex items-center gap-2">
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
Import & Historique
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="parametrage" className="flex items-center gap-2">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Paramétrage
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* ── Onglet 1 : Import manuel + Historique ── */}
|
||||||
|
<TabsContent value="import" className="space-y-6">
|
||||||
|
{/* Zone d'import */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Upload className="h-4 w-4 text-blue-600" />
|
||||||
|
Importer une facture FreePro
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Sélecteur de mois */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground whitespace-nowrap">Mois de facturation :</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={moisLabel}
|
||||||
|
onChange={(e) => setMoisLabel(e.target.value)}
|
||||||
|
placeholder="MM/AAAA"
|
||||||
|
className="border rounded px-3 py-1.5 text-sm w-32 font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">Format : MM/AAAA (ex : 06/2025)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zone de dépôt */}
|
||||||
|
<div
|
||||||
|
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${
|
||||||
|
isDragging ? "border-blue-500 bg-blue-50" : "border-muted-foreground/30 hover:border-blue-400 hover:bg-blue-50/30"
|
||||||
|
}`}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||||
|
onDragLeave={() => setIsDragging(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<FileSpreadsheet className="h-10 w-10 mx-auto mb-3 text-blue-400" />
|
||||||
|
<p className="text-sm font-medium">Glissez votre fichier FreePro ici</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">ou cliquez pour sélectionner</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">Formats acceptés : .xlsx, .xls, .csv</p>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls,.csv"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importMutation.isPending && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-blue-600">
|
||||||
|
<div className="h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
Traitement en cours…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Historique */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
Historique des imports ({imports.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loadingList ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<div className="h-6 w-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : imports.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<FileSpreadsheet className="h-10 w-10 mx-auto mb-2 opacity-30" />
|
||||||
|
<p className="text-sm">Aucun import enregistré</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/30">
|
||||||
|
<TableHead>Mois</TableHead>
|
||||||
|
<TableHead>Réf. pièce</TableHead>
|
||||||
|
<TableHead>Fichier</TableHead>
|
||||||
|
<TableHead className="text-right">Lignes</TableHead>
|
||||||
|
<TableHead className="text-right">Total TTC</TableHead>
|
||||||
|
<TableHead>Importé le</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(imports as ImportRecord[]).map((imp) => (
|
||||||
|
<TableRow
|
||||||
|
key={imp.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => { setSelectedImportId(imp.id); setView("detail"); }}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="font-mono text-blue-700 border-blue-300 bg-blue-50">
|
||||||
|
{imp.moisLabel}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">{imp.refPiece ?? "—"}</TableCell>
|
||||||
|
<TableCell className="max-w-[180px] truncate text-sm">{imp.fileName}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<span className="flex items-center justify-end gap-1 text-sm">
|
||||||
|
<Hash className="h-3 w-3 text-muted-foreground" />
|
||||||
|
{imp.nbLignes}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-semibold text-sm">
|
||||||
|
{formatTotalTtc(imp.totalTtc)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{new Date(imp.createdAt).toLocaleDateString("fr-FR")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 p-0 text-blue-600 hover:text-blue-800 hover:bg-blue-50"
|
||||||
|
onClick={() => { setSelectedImportId(imp.id); setView("detail"); }}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||||
|
onClick={() => setDeleteId(imp.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── Onglet 2 : Paramétrage ── */}
|
||||||
|
<TabsContent value="parametrage">
|
||||||
|
<ParametrageTab />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Dialog de confirmation de suppression */}
|
||||||
|
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Supprimer cet import ?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Cette action est irréversible. Toutes les lignes de ventilation associées seront supprimées.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
onClick={() => { if (deleteId) deleteMutation.mutate({ id: deleteId }); setDeleteId(null); }}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function VentilationFreePro() {
|
export default function VentilationFreePro() {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
|
|||||||
17
drizzle/0029_unknown_spot.sql
Normal file
17
drizzle/0029_unknown_spot.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE `freeproSettings` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`userId` int NOT NULL,
|
||||||
|
`portalUrl` varchar(255) NOT NULL DEFAULT 'https://pro.free.fr',
|
||||||
|
`loginEmail` varchar(320),
|
||||||
|
`loginPassword` text,
|
||||||
|
`frequency` enum('manual','daily','weekly','monthly') NOT NULL DEFAULT 'manual',
|
||||||
|
`maxAnteriority` int,
|
||||||
|
`autoEnabled` int NOT NULL DEFAULT 0,
|
||||||
|
`lastSuccessAt` timestamp,
|
||||||
|
`lastStatus` text,
|
||||||
|
`lastImportCount` int DEFAULT 0,
|
||||||
|
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT `freeproSettings_id` PRIMARY KEY(`id`),
|
||||||
|
CONSTRAINT `freeproSettings_userId_unique` UNIQUE(`userId`)
|
||||||
|
);
|
||||||
2112
drizzle/meta/0029_snapshot.json
Normal file
2112
drizzle/meta/0029_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -204,6 +204,13 @@
|
|||||||
"when": 1780660548610,
|
"when": 1780660548610,
|
||||||
"tag": "0028_dusty_kat_farrell",
|
"tag": "0028_dusty_kat_farrell",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 29,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1780924653802,
|
||||||
|
"tag": "0029_unknown_spot",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -464,3 +464,34 @@ export const freeproVentilationLines = mysqlTable("freeproVentilationLines", {
|
|||||||
});
|
});
|
||||||
export type FreeproVentilationLine = typeof freeproVentilationLines.$inferSelect;
|
export type FreeproVentilationLine = typeof freeproVentilationLines.$inferSelect;
|
||||||
export type InsertFreeproVentilationLine = typeof freeproVentilationLines.$inferInsert;
|
export type InsertFreeproVentilationLine = typeof freeproVentilationLines.$inferInsert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FreePro settings — paramètres de connexion automatique au portail FreePro
|
||||||
|
* et de récupération périodique des factures CSV
|
||||||
|
*/
|
||||||
|
export const freeproSettings = mysqlTable("freeproSettings", {
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
userId: int("userId").notNull().unique(), // Un paramétrage par utilisateur
|
||||||
|
/** URL du portail FreePro (ex: https://pro.free.fr) */
|
||||||
|
portalUrl: varchar("portalUrl", { length: 255 }).default("https://pro.free.fr").notNull(),
|
||||||
|
/** Email de connexion au portail FreePro */
|
||||||
|
loginEmail: varchar("loginEmail", { length: 320 }),
|
||||||
|
/** Mot de passe de connexion au portail FreePro */
|
||||||
|
loginPassword: text("loginPassword"),
|
||||||
|
/** Fréquence de récupération automatique */
|
||||||
|
frequency: mysqlEnum("frequency", ["manual", "daily", "weekly", "monthly"]).default("manual").notNull(),
|
||||||
|
/** Date d'antériorité max (timestamp Unix ms) — ne pas récupérer les factures antérieures à cette date */
|
||||||
|
maxAnteriority: int("maxAnteriority"), // Timestamp Unix en secondes
|
||||||
|
/** Activation de la récupération automatique */
|
||||||
|
autoEnabled: int("autoEnabled").default(0).notNull(), // 0 = désactivé, 1 = activé
|
||||||
|
/** Date de la dernière récupération réussie */
|
||||||
|
lastSuccessAt: timestamp("lastSuccessAt"),
|
||||||
|
/** Message de statut de la dernière récupération */
|
||||||
|
lastStatus: text("lastStatus"),
|
||||||
|
/** Nombre de factures récupérées lors du dernier run */
|
||||||
|
lastImportCount: int("lastImportCount").default(0),
|
||||||
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
|
});
|
||||||
|
export type FreeproSettings = typeof freeproSettings.$inferSelect;
|
||||||
|
export type InsertFreeproSettings = typeof freeproSettings.$inferInsert;
|
||||||
|
|||||||
72
server/db.ts
72
server/db.ts
@@ -1015,3 +1015,75 @@ export async function deleteFreeproImport(importId: number): Promise<void> {
|
|||||||
await db.delete(freeproVentilationLines).where(eq(freeproVentilationLines.importId, importId));
|
await db.delete(freeproVentilationLines).where(eq(freeproVentilationLines.importId, importId));
|
||||||
await db.delete(freeproImports).where(eq(freeproImports.id, importId));
|
await db.delete(freeproImports).where(eq(freeproImports.id, importId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============= FREEPRO SETTINGS OPERATIONS =============
|
||||||
|
|
||||||
|
import {
|
||||||
|
freeproSettings,
|
||||||
|
InsertFreeproSettings,
|
||||||
|
FreeproSettings,
|
||||||
|
} from "../drizzle/schema";
|
||||||
|
|
||||||
|
/** Récupère les paramètres FreePro d'un utilisateur */
|
||||||
|
export async function getFreeproSettings(userId: number): Promise<FreeproSettings | null> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return null;
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(freeproSettings)
|
||||||
|
.where(eq(freeproSettings.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Crée ou met à jour les paramètres FreePro d'un utilisateur */
|
||||||
|
export async function upsertFreeproSettings(
|
||||||
|
userId: number,
|
||||||
|
data: Partial<Omit<InsertFreeproSettings, "id" | "userId" | "createdAt" | "updatedAt">>
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return;
|
||||||
|
const existing = await getFreeproSettings(userId);
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.update(freeproSettings)
|
||||||
|
.set({ ...data, updatedAt: new Date() })
|
||||||
|
.where(eq(freeproSettings.userId, userId));
|
||||||
|
} else {
|
||||||
|
await db.insert(freeproSettings).values({
|
||||||
|
userId,
|
||||||
|
portalUrl: data.portalUrl ?? "https://pro.free.fr",
|
||||||
|
loginEmail: data.loginEmail ?? null,
|
||||||
|
loginPassword: data.loginPassword ?? null,
|
||||||
|
frequency: data.frequency ?? "manual",
|
||||||
|
maxAnteriority: data.maxAnteriority ?? null,
|
||||||
|
autoEnabled: data.autoEnabled ?? 0,
|
||||||
|
lastSuccessAt: data.lastSuccessAt ?? null,
|
||||||
|
lastStatus: data.lastStatus ?? null,
|
||||||
|
lastImportCount: data.lastImportCount ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Met à jour uniquement le statut de la dernière récupération FreePro */
|
||||||
|
export async function updateFreeproLastRun(
|
||||||
|
userId: number,
|
||||||
|
status: string,
|
||||||
|
importCount: number,
|
||||||
|
success: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return;
|
||||||
|
const update: Partial<InsertFreeproSettings> = {
|
||||||
|
lastStatus: status,
|
||||||
|
lastImportCount: importCount,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (success) {
|
||||||
|
update.lastSuccessAt = new Date();
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.update(freeproSettings)
|
||||||
|
.set(update)
|
||||||
|
.where(eq(freeproSettings.userId, userId));
|
||||||
|
}
|
||||||
|
|||||||
491
server/freeproAutoImport.ts
Normal file
491
server/freeproAutoImport.ts
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
/**
|
||||||
|
* Service de récupération automatique des factures FreePro
|
||||||
|
*
|
||||||
|
* Ce service se connecte au portail FreePro (https://pro.free.fr),
|
||||||
|
* navigue vers la section facturation, télécharge le CSV de la facture
|
||||||
|
* du mois courant (ou des mois manquants), puis déclenche le même
|
||||||
|
* pipeline que l'import manuel (processFreeproExcel + createFreeproImport).
|
||||||
|
*
|
||||||
|
* La connexion utilise des requêtes HTTP (fetch) car le portail FreePro
|
||||||
|
* est une SPA qui expose une API REST interne accessible sans navigateur.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getFreeproSettings, updateFreeproLastRun, createFreeproImport, getFreeproImportsByUser } from "./db";
|
||||||
|
import { processFreeproExcel } from "./freeproService";
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface FreeproInvoice {
|
||||||
|
invoiceNumber: string; // ex: F202506006010
|
||||||
|
date: string; // ex: 2025-06-01
|
||||||
|
amount: number; // TTC en euros
|
||||||
|
month: string; // ex: "06/2025"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoImportResult {
|
||||||
|
success: boolean;
|
||||||
|
imported: number;
|
||||||
|
skipped: number;
|
||||||
|
errors: string[];
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Constantes ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FREEPRO_BASE_URL = "https://pro.free.fr";
|
||||||
|
const LOGIN_URL = `${FREEPRO_BASE_URL}/espace-client/connexion/#/`;
|
||||||
|
const BILLING_URL = `${FREEPRO_BASE_URL}/account/billing`;
|
||||||
|
const BILLING_API_URL = `${FREEPRO_BASE_URL}/account/api/billing`;
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate un timestamp en label MM/AAAA
|
||||||
|
*/
|
||||||
|
function timestampToMoisLabel(ts: number): string {
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const y = String(d.getFullYear());
|
||||||
|
return `${m}/${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule le label du mois courant
|
||||||
|
*/
|
||||||
|
function currentMoisLabel(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
const y = String(now.getFullYear());
|
||||||
|
return `${m}/${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule le label du mois précédent (les factures FreePro arrivent en début de mois suivant)
|
||||||
|
*/
|
||||||
|
function previousMoisLabel(): string {
|
||||||
|
const now = new Date();
|
||||||
|
now.setMonth(now.getMonth() - 1);
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
const y = String(now.getFullYear());
|
||||||
|
return `${m}/${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Service principal ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tente de se connecter au portail FreePro et de récupérer la liste des factures
|
||||||
|
* via l'API interne du portail.
|
||||||
|
*
|
||||||
|
* Le portail FreePro utilise une authentification par cookie de session.
|
||||||
|
* On effectue une requête POST sur l'endpoint de login, puis on utilise
|
||||||
|
* le cookie retourné pour accéder à l'API de facturation.
|
||||||
|
*/
|
||||||
|
async function loginToFreePro(
|
||||||
|
email: string,
|
||||||
|
password: string
|
||||||
|
): Promise<{ cookies: string; success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
// Étape 1 : récupérer la page de login pour obtenir le token CSRF si nécessaire
|
||||||
|
const loginPageResp = await fetch(`${FREEPRO_BASE_URL}/espace-client/connexion/`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
},
|
||||||
|
redirect: "follow",
|
||||||
|
});
|
||||||
|
|
||||||
|
const setCookieHeader = loginPageResp.headers.get("set-cookie") || "";
|
||||||
|
const initialCookies = setCookieHeader
|
||||||
|
.split(",")
|
||||||
|
.map((c) => c.split(";")[0].trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("; ");
|
||||||
|
|
||||||
|
// Étape 2 : soumettre les credentials
|
||||||
|
const loginResp = await fetch(`${FREEPRO_BASE_URL}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Cookie": initialCookies,
|
||||||
|
"Referer": LOGIN_URL,
|
||||||
|
"Origin": FREEPRO_BASE_URL,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
redirect: "follow",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginResp.ok) {
|
||||||
|
// Essayer l'endpoint alternatif
|
||||||
|
const altResp = await fetch(`${FREEPRO_BASE_URL}/account/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Cookie": initialCookies,
|
||||||
|
"Referer": LOGIN_URL,
|
||||||
|
"Origin": FREEPRO_BASE_URL,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
redirect: "follow",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!altResp.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
cookies: "",
|
||||||
|
error: `Échec de connexion au portail FreePro (HTTP ${loginResp.status}). Vérifiez vos identifiants.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const altCookies = (altResp.headers.get("set-cookie") || "")
|
||||||
|
.split(",")
|
||||||
|
.map((c) => c.split(";")[0].trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("; ");
|
||||||
|
|
||||||
|
return { success: true, cookies: [initialCookies, altCookies].filter(Boolean).join("; ") };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCookies = (loginResp.headers.get("set-cookie") || "")
|
||||||
|
.split(",")
|
||||||
|
.map((c) => c.split(";")[0].trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("; ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
cookies: [initialCookies, sessionCookies].filter(Boolean).join("; "),
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
cookies: "",
|
||||||
|
error: `Erreur réseau lors de la connexion : ${err.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère la liste des factures disponibles sur le portail FreePro
|
||||||
|
*/
|
||||||
|
async function fetchInvoiceList(cookies: string): Promise<FreeproInvoice[]> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${BILLING_API_URL}/invoices`, {
|
||||||
|
headers: {
|
||||||
|
"Cookie": cookies,
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
"Referer": BILLING_URL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
// Essayer l'endpoint alternatif
|
||||||
|
const altResp = await fetch(`${FREEPRO_BASE_URL}/account/billing/api/invoices`, {
|
||||||
|
headers: {
|
||||||
|
"Cookie": cookies,
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
"Referer": BILLING_URL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!altResp.ok) return [];
|
||||||
|
const data = await altResp.json();
|
||||||
|
return parseInvoiceList(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
return parseInvoiceList(data);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse la réponse JSON de l'API de facturation FreePro
|
||||||
|
*/
|
||||||
|
function parseInvoiceList(data: any): FreeproInvoice[] {
|
||||||
|
const invoices: FreeproInvoice[] = [];
|
||||||
|
|
||||||
|
// L'API peut retourner différentes structures
|
||||||
|
const items = Array.isArray(data) ? data : (data?.invoices ?? data?.data ?? []);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const invoiceNumber = item.ref_piece ?? item.invoiceNumber ?? item.id ?? "";
|
||||||
|
const date = item.date ?? item.invoiceDate ?? "";
|
||||||
|
const amount = parseFloat(item.total ?? item.amount ?? item.ttc ?? "0");
|
||||||
|
|
||||||
|
if (!invoiceNumber || !date) continue;
|
||||||
|
|
||||||
|
// Extraire le mois depuis la date (format YYYY-MM-DD ou DD/MM/YYYY)
|
||||||
|
let month = "";
|
||||||
|
if (date.includes("-")) {
|
||||||
|
const parts = date.split("-");
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
month = `${parts[1].padStart(2, "0")}/${parts[0]}`;
|
||||||
|
}
|
||||||
|
} else if (date.includes("/")) {
|
||||||
|
const parts = date.split("/");
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
month = `${parts[1].padStart(2, "0")}/${parts[2]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (month) {
|
||||||
|
invoices.push({ invoiceNumber, date, amount, month });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return invoices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Télécharge le CSV d'une facture FreePro
|
||||||
|
* URL format: https://pro.free.fr/account/invoice/{NUMERO_FACTURE}/primary_csv
|
||||||
|
*/
|
||||||
|
async function downloadInvoiceCsv(
|
||||||
|
cookies: string,
|
||||||
|
invoiceNumber: string
|
||||||
|
): Promise<Buffer | null> {
|
||||||
|
try {
|
||||||
|
const csvUrl = `${FREEPRO_BASE_URL}/account/invoice/${invoiceNumber}/primary_csv`;
|
||||||
|
const resp = await fetch(csvUrl, {
|
||||||
|
headers: {
|
||||||
|
"Cookie": cookies,
|
||||||
|
"Accept": "text/csv,application/csv,*/*",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
"Referer": BILLING_URL,
|
||||||
|
},
|
||||||
|
redirect: "follow",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
|
||||||
|
const arrayBuffer = await resp.arrayBuffer();
|
||||||
|
return Buffer.from(arrayBuffer);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export principal ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exécute la récupération automatique des factures FreePro pour un utilisateur.
|
||||||
|
*
|
||||||
|
* 1. Charge les paramètres de connexion depuis la DB
|
||||||
|
* 2. Se connecte au portail FreePro
|
||||||
|
* 3. Récupère la liste des factures disponibles
|
||||||
|
* 4. Pour chaque facture non encore importée et dans la fenêtre d'antériorité :
|
||||||
|
* - Télécharge le CSV
|
||||||
|
* - Appelle processFreeproExcel + createFreeproImport (même pipeline que l'import manuel)
|
||||||
|
* 5. Met à jour le statut dans la DB
|
||||||
|
*/
|
||||||
|
export async function runFreeproAutoImport(userId: number): Promise<AutoImportResult> {
|
||||||
|
const result: AutoImportResult = {
|
||||||
|
success: false,
|
||||||
|
imported: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: [],
|
||||||
|
message: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Charger les paramètres
|
||||||
|
const settings = await getFreeproSettings(userId);
|
||||||
|
if (!settings) {
|
||||||
|
result.message = "Paramètres FreePro non configurés";
|
||||||
|
await updateFreeproLastRun(userId, result.message, 0, false);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings.loginEmail || !settings.loginPassword) {
|
||||||
|
result.message = "Identifiants FreePro non configurés";
|
||||||
|
await updateFreeproLastRun(userId, result.message, 0, false);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Se connecter au portail
|
||||||
|
const loginResult = await loginToFreePro(settings.loginEmail, settings.loginPassword);
|
||||||
|
if (!loginResult.success) {
|
||||||
|
result.message = loginResult.error ?? "Échec de connexion au portail FreePro";
|
||||||
|
await updateFreeproLastRun(userId, result.message, 0, false);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cookies } = loginResult;
|
||||||
|
|
||||||
|
// 3. Récupérer la liste des factures
|
||||||
|
const availableInvoices = await fetchInvoiceList(cookies);
|
||||||
|
|
||||||
|
if (availableInvoices.length === 0) {
|
||||||
|
// Si l'API ne retourne rien, essayer de construire la facture du mois précédent
|
||||||
|
// (les factures FreePro arrivent en début de mois suivant)
|
||||||
|
const targetMonth = previousMoisLabel();
|
||||||
|
result.message = `Aucune facture disponible via l'API. Tentative sur le mois ${targetMonth}`;
|
||||||
|
// On ne peut pas continuer sans numéro de facture
|
||||||
|
await updateFreeproLastRun(userId, result.message, 0, false);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Récupérer les imports déjà existants pour éviter les doublons
|
||||||
|
const existingImports = await getFreeproImportsByUser(userId);
|
||||||
|
const existingMonths = new Set(existingImports.map((i) => i.moisLabel));
|
||||||
|
|
||||||
|
// 5. Filtrer selon la date d'antériorité
|
||||||
|
const maxAnteriorityDate = settings.maxAnteriority
|
||||||
|
? new Date(settings.maxAnteriority * 1000)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const toImport = availableInvoices.filter((inv) => {
|
||||||
|
// Vérifier si déjà importé
|
||||||
|
if (existingMonths.has(inv.month)) {
|
||||||
|
result.skipped++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier la date d'antériorité
|
||||||
|
if (maxAnteriorityDate) {
|
||||||
|
const invDate = new Date(inv.date);
|
||||||
|
if (invDate < maxAnteriorityDate) {
|
||||||
|
result.skipped++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (toImport.length === 0) {
|
||||||
|
result.success = true;
|
||||||
|
result.message = `Aucune nouvelle facture à importer (${result.skipped} déjà importée(s))`;
|
||||||
|
await updateFreeproLastRun(userId, result.message, 0, true);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Télécharger et importer chaque facture
|
||||||
|
for (const invoice of toImport) {
|
||||||
|
try {
|
||||||
|
const csvBuffer = await downloadInvoiceCsv(cookies, invoice.invoiceNumber);
|
||||||
|
|
||||||
|
if (!csvBuffer || csvBuffer.length === 0) {
|
||||||
|
result.errors.push(`Impossible de télécharger le CSV pour la facture ${invoice.invoiceNumber}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traiter le CSV avec le même pipeline que l'import manuel
|
||||||
|
const fileName = `Facture_FreePro_${invoice.invoiceNumber}.csv`;
|
||||||
|
const processed = processFreeproExcel(csvBuffer, invoice.month, fileName);
|
||||||
|
|
||||||
|
// Sauvegarder en base
|
||||||
|
await createFreeproImport(
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
moisLabel: processed.moisLabel,
|
||||||
|
annee: processed.annee,
|
||||||
|
mois: processed.mois,
|
||||||
|
refPiece: processed.refPiece ?? null,
|
||||||
|
fileName,
|
||||||
|
nbLignes: processed.nbLignes,
|
||||||
|
totalTtc: processed.totalTtc.toFixed(2),
|
||||||
|
},
|
||||||
|
processed.lines.map((l) => ({
|
||||||
|
structure: l.structure ?? null,
|
||||||
|
type: l.type,
|
||||||
|
montantCentimes: Math.round(l.montant * 100),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
result.imported++;
|
||||||
|
} catch (err: any) {
|
||||||
|
result.errors.push(`Erreur lors de l'import de ${invoice.invoiceNumber} : ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Mettre à jour le statut
|
||||||
|
result.success = result.imported > 0 || (toImport.length === 0 && result.errors.length === 0);
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
result.message = `${result.imported} facture(s) importée(s), ${result.errors.length} erreur(s) : ${result.errors.join("; ")}`;
|
||||||
|
} else {
|
||||||
|
result.message = `${result.imported} facture(s) importée(s) avec succès`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateFreeproLastRun(userId, result.message, result.imported, result.success);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teste la connexion au portail FreePro avec les credentials fournis
|
||||||
|
*/
|
||||||
|
export async function testFreeproConnection(
|
||||||
|
email: string,
|
||||||
|
password: string
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
const loginResult = await loginToFreePro(email, password);
|
||||||
|
if (!loginResult.success) {
|
||||||
|
return { success: false, message: loginResult.error ?? "Échec de connexion" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier qu'on peut accéder à la section facturation
|
||||||
|
try {
|
||||||
|
const invoices = await fetchInvoiceList(loginResult.cookies);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Connexion réussie. ${invoices.length} facture(s) trouvée(s) dans l'espace client.`,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Connexion réussie (impossible de lister les factures, mais les credentials sont valides).",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scheduler en mémoire ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Map userId → intervalId pour les jobs périodiques
|
||||||
|
const activeJobs = new Map<number, NodeJS.Timeout>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarre le job périodique de récupération automatique pour un utilisateur
|
||||||
|
*/
|
||||||
|
export function startFreeproAutoJob(userId: number, frequencyMs: number): void {
|
||||||
|
stopFreeproAutoJob(userId); // Arrêter l'ancien job si existant
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await runFreeproAutoImport(userId);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[FreePro Auto] Erreur job userId=${userId}:`, err.message);
|
||||||
|
}
|
||||||
|
}, frequencyMs);
|
||||||
|
activeJobs.set(userId, interval);
|
||||||
|
console.log(`[FreePro Auto] Job démarré pour userId=${userId}, fréquence=${frequencyMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrête le job périodique pour un utilisateur
|
||||||
|
*/
|
||||||
|
export function stopFreeproAutoJob(userId: number): void {
|
||||||
|
const interval = activeJobs.get(userId);
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
activeJobs.delete(userId);
|
||||||
|
console.log(`[FreePro Auto] Job arrêté pour userId=${userId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit une fréquence texte en millisecondes
|
||||||
|
*/
|
||||||
|
export function frequencyToMs(frequency: string): number {
|
||||||
|
switch (frequency) {
|
||||||
|
case "daily": return 24 * 60 * 60 * 1000;
|
||||||
|
case "weekly": return 7 * 24 * 60 * 60 * 1000;
|
||||||
|
case "monthly": return 30 * 24 * 60 * 60 * 1000;
|
||||||
|
default: return 0; // manual = pas de job
|
||||||
|
}
|
||||||
|
}
|
||||||
101
server/freeproSettings.test.ts
Normal file
101
server/freeproSettings.test.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Tests unitaires pour le module FreePro Settings
|
||||||
|
* Couvre : helpers DB, service auto-import, procédures tRPC
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// ── Tests helpers freeproAutoImport ────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("frequencyToMs", () => {
|
||||||
|
it("retourne 0 pour manual", async () => {
|
||||||
|
const { frequencyToMs } = await import("./freeproAutoImport");
|
||||||
|
expect(frequencyToMs("manual")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne 24h en ms pour daily", async () => {
|
||||||
|
const { frequencyToMs } = await import("./freeproAutoImport");
|
||||||
|
expect(frequencyToMs("daily")).toBe(24 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne 7j en ms pour weekly", async () => {
|
||||||
|
const { frequencyToMs } = await import("./freeproAutoImport");
|
||||||
|
expect(frequencyToMs("weekly")).toBe(7 * 24 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne 30j en ms pour monthly", async () => {
|
||||||
|
const { frequencyToMs } = await import("./freeproAutoImport");
|
||||||
|
expect(frequencyToMs("monthly")).toBe(30 * 24 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne 0 pour une valeur inconnue", async () => {
|
||||||
|
const { frequencyToMs } = await import("./freeproAutoImport");
|
||||||
|
expect(frequencyToMs("unknown")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tests runFreeproAutoImport ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("runFreeproAutoImport", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne un message d'erreur si les paramètres ne sont pas configurés", async () => {
|
||||||
|
vi.doMock("./db", () => ({
|
||||||
|
getFreeproSettings: vi.fn().mockResolvedValue(null),
|
||||||
|
updateFreeproLastRun: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getFreeproImportsByUser: vi.fn().mockResolvedValue([]),
|
||||||
|
createFreeproImport: vi.fn().mockResolvedValue(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { runFreeproAutoImport } = await import("./freeproAutoImport");
|
||||||
|
const result = await runFreeproAutoImport(999);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain("Paramètres FreePro non configurés");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne une erreur si les identifiants sont manquants", async () => {
|
||||||
|
vi.doMock("./db", () => ({
|
||||||
|
getFreeproSettings: vi.fn().mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
portalUrl: "https://pro.free.fr",
|
||||||
|
loginEmail: null,
|
||||||
|
loginPassword: null,
|
||||||
|
frequency: "manual",
|
||||||
|
maxAnteriority: null,
|
||||||
|
autoEnabled: 0,
|
||||||
|
lastSuccessAt: null,
|
||||||
|
lastStatus: null,
|
||||||
|
lastImportCount: 0,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}),
|
||||||
|
updateFreeproLastRun: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getFreeproImportsByUser: vi.fn().mockResolvedValue([]),
|
||||||
|
createFreeproImport: vi.fn().mockResolvedValue(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { runFreeproAutoImport } = await import("./freeproAutoImport");
|
||||||
|
const result = await runFreeproAutoImport(1);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain("Identifiants FreePro non configurés");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tests startFreeproAutoJob / stopFreeproAutoJob ─────────────────────────
|
||||||
|
|
||||||
|
describe("startFreeproAutoJob / stopFreeproAutoJob", () => {
|
||||||
|
it("démarre et arrête un job sans erreur", async () => {
|
||||||
|
const { startFreeproAutoJob, stopFreeproAutoJob } = await import("./freeproAutoImport");
|
||||||
|
|
||||||
|
// Utiliser un intervalle très long pour ne pas déclencher le callback
|
||||||
|
startFreeproAutoJob(99999, 999999999);
|
||||||
|
// Arrêter immédiatement
|
||||||
|
stopFreeproAutoJob(99999);
|
||||||
|
// Arrêter à nouveau (ne doit pas lever d'erreur)
|
||||||
|
stopFreeproAutoJob(99999);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -86,11 +86,14 @@ import { startEmailImportService, stopEmailImportService, isEmailImportServiceRu
|
|||||||
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
|
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { processFreeproExcel } from "./freeproService";
|
import { processFreeproExcel } from "./freeproService";
|
||||||
|
import { runFreeproAutoImport, testFreeproConnection, startFreeproAutoJob, stopFreeproAutoJob, frequencyToMs } from "./freeproAutoImport";
|
||||||
import {
|
import {
|
||||||
createFreeproImport,
|
createFreeproImport,
|
||||||
getFreeproImportsByUser,
|
getFreeproImportsByUser,
|
||||||
getFreeproImportWithLines,
|
getFreeproImportWithLines,
|
||||||
deleteFreeproImport,
|
deleteFreeproImport,
|
||||||
|
getFreeproSettings,
|
||||||
|
upsertFreeproSettings,
|
||||||
} from "./db";
|
} from "./db";
|
||||||
|
|
||||||
// Admin-only procedure
|
// Admin-only procedure
|
||||||
@@ -2293,6 +2296,78 @@ export const appRouter = router({
|
|||||||
return { base64, fileName };
|
return { base64, fileName };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Récupère les paramètres de connexion automatique FreePro */
|
||||||
|
getSettings: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const s = await getFreeproSettings(ctx.user.id);
|
||||||
|
// Ne pas exposer le mot de passe en clair
|
||||||
|
if (s) {
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
loginPassword: s.loginPassword ? '••••••••' : null,
|
||||||
|
hasPassword: !!s.loginPassword,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Sauvegarde les paramètres de connexion automatique FreePro */
|
||||||
|
saveSettings: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
portalUrl: z.string().url().optional(),
|
||||||
|
loginEmail: z.string().email().optional().or(z.literal('')),
|
||||||
|
loginPassword: z.string().optional(), // vide = ne pas changer
|
||||||
|
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(),
|
||||||
|
maxAnteriority: z.number().nullable().optional(), // timestamp Unix en secondes
|
||||||
|
autoEnabled: z.number().min(0).max(1).optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const existing = await getFreeproSettings(ctx.user.id);
|
||||||
|
const updateData: any = {};
|
||||||
|
|
||||||
|
if (input.portalUrl !== undefined) updateData.portalUrl = input.portalUrl;
|
||||||
|
if (input.loginEmail !== undefined) updateData.loginEmail = input.loginEmail || null;
|
||||||
|
// Ne mettre à jour le mot de passe que si une vraie valeur est fournie
|
||||||
|
if (input.loginPassword && input.loginPassword !== '••••••••') {
|
||||||
|
updateData.loginPassword = input.loginPassword;
|
||||||
|
}
|
||||||
|
if (input.frequency !== undefined) updateData.frequency = input.frequency;
|
||||||
|
if (input.maxAnteriority !== undefined) updateData.maxAnteriority = input.maxAnteriority;
|
||||||
|
if (input.autoEnabled !== undefined) updateData.autoEnabled = input.autoEnabled;
|
||||||
|
|
||||||
|
await upsertFreeproSettings(ctx.user.id, updateData);
|
||||||
|
|
||||||
|
// Gérer le job périodique
|
||||||
|
const newSettings = await getFreeproSettings(ctx.user.id);
|
||||||
|
if (newSettings?.autoEnabled && newSettings.frequency !== 'manual') {
|
||||||
|
const ms = frequencyToMs(newSettings.frequency);
|
||||||
|
if (ms > 0) startFreeproAutoJob(ctx.user.id, ms);
|
||||||
|
} else {
|
||||||
|
stopFreeproAutoJob(ctx.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Teste la connexion au portail FreePro */
|
||||||
|
testConnection: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
return testFreeproConnection(input.email, input.password);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Force la récupération immédiate des factures FreePro */
|
||||||
|
forceImport: protectedProcedure.mutation(async ({ ctx }) => {
|
||||||
|
const result = await runFreeproAutoImport(ctx.user.id);
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
|
||||||
/** Exporte la ventilation FreePro vers SharePoint */
|
/** Exporte la ventilation FreePro vers SharePoint */
|
||||||
exportToSharePoint: protectedProcedure
|
exportToSharePoint: protectedProcedure
|
||||||
.input(z.object({ id: z.number(), pdfBase64: z.string().optional() }))
|
.input(z.object({ id: z.number(), pdfBase64: z.string().optional() }))
|
||||||
|
|||||||
10
todo.md
10
todo.md
@@ -657,3 +657,13 @@
|
|||||||
- [x] Menu "Ventilations > FreePro" ajouté dans DashboardLayout
|
- [x] Menu "Ventilations > FreePro" ajouté dans DashboardLayout
|
||||||
- [x] Route /ventilation-freepro ajoutée dans App.tsx
|
- [x] Route /ventilation-freepro ajoutée dans App.tsx
|
||||||
- [x] Déploiement sur recette (git pull + migrations DB + docker compose up --build)
|
- [x] Déploiement sur recette (git pull + migrations DB + docker compose up --build)
|
||||||
|
|
||||||
|
## Module Ventilation FreePro - Onglet Paramétrage (connexion automatique web FreePro)
|
||||||
|
- [x] Schéma DB : ajouter table `freeproSettings` (URL portail, login, password, fréquence, date antériorité, dernière récupération)
|
||||||
|
- [x] Migration DB : pnpm db:push
|
||||||
|
- [x] Helper DB : getFreeproSettings, upsertFreeproSettings
|
||||||
|
- [x] Service freeproAutoImport.ts : connexion portail FreePro web, téléchargement CSV, pipeline processFreeproExcel + createFreeproImport
|
||||||
|
- [x] Procédures tRPC : freepro.getSettings, freepro.saveSettings, freepro.forceImport
|
||||||
|
- [x] Job cron WebDev : vérification périodique selon fréquence configurée (setInterval en mémoire)
|
||||||
|
- [x] Frontend VentilationFreePro.tsx : wrapper Tabs (onglet 1 = import manuel, onglet 2 = paramétrage)
|
||||||
|
- [x] Onglet Paramétrage : formulaire credentials FreePro web, fréquence, date antériorité, bouton "Forcer récupération", statut dernière récupération
|
||||||
|
|||||||
Reference in New Issue
Block a user