Files
demat-facturation/client/src/pages/WebImportSources.tsx
2026-09-30 16:42:27 +02:00

466 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { isValidWebPortalUrl, normalizeWebPortalUrl } from "@shared/webPortalUrl";
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, Play } 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 [manualImportId, setManualImportId] = 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 }),
});
const runManualMutation = trpc.webImportSources.runManual.useMutation({
onSuccess: (result) => {
toast.success("Import manuel terminé", {
description: `${result.imported} facture(s) importée(s), ${result.duplicates} doublon(s) ignoré(s).`,
});
setManualImportId(null);
refetch();
},
onError: (e) => {
toast.error("Import manuel en erreur", { description: e.message });
setManualImportId(null);
refetch();
},
});
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;
}
const portalUrl = normalizeWebPortalUrl(form.portalUrl);
if (!isValidWebPortalUrl(portalUrl)) {
toast.error("URL de portail invalide", {
description: "Saisissez une adresse HTTP(S), par exemple https://espaceclient.exemple.fr.",
});
return;
}
if (editSource) {
const updateData: any = { id: editSource.id, ...form, portalUrl };
if (!form.loginPassword) delete updateData.loginPassword; // Ne pas écraser si vide
updateMutation.mutate(updateData);
} else {
createMutation.mutate({ ...form, portalUrl });
}
}
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"
disabled={source.connectorType !== "sfr" || runManualMutation.isPending}
title={source.connectorType === "sfr" ? "Lancer une lecture ponctuelle" : "Ce connecteur ne dispose pas encore d’un moteur d’import manuel"}
onClick={() => setManualImportId(source.id)}
>
{runManualMutation.isPending && manualImportId === source.id ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<Play className="w-3 h-3" />
)}
Importer maintenant
</Button>
<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://..."
/>
<p className="mt-1 text-xs text-muted-foreground">Le préfixe https:// est ajouté automatiquement si nécessaire.</p>
</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>
<AlertDialog open={manualImportId !== null} onOpenChange={(open) => { if (!open && !runManualMutation.isPending) setManualImportId(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Lancer l’import manuel ?</AlertDialogTitle>
<AlertDialogDescription>
L’application se connectera ponctuellement à l’espace client SFR, téléchargera les nouvelles factures puis les analysera via le flux normal. Cette action ne crée aucune planification automatique.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={runManualMutation.isPending}>Annuler</AlertDialogCancel>
<AlertDialogAction
disabled={runManualMutation.isPending}
onClick={() => manualImportId !== null && runManualMutation.mutate({ id: manualImportId })}
>
{runManualMutation.isPending ? "Import en cours…" : "Lancer l’import"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 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>
);
}