466 lines
19 KiB
TypeScript
466 lines
19 KiB
TypeScript
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>
|
||
);
|
||
}
|