import { useCallback, useEffect, useState, type ReactNode } from "react"; import { AlertCircle, CheckCircle2, Eye, EyeOff, FileText, KeyRound, Loader2, PlugZap, RefreshCw, RotateCcw, Save, Settings, Users, } from "lucide-react"; import { PageHeader } from "@/components/ui/page-header"; import { Tabs, useUrlTab, type TabItem } from "@/components/ui/tabs"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Input, Label } from "@/components/ui/form-controls"; import { Terme } from "@/components/ui/tooltip"; import { toast } from "@/components/ui/feedback"; import { fmtEntier } from "@/lib/format"; import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action"; import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store"; import { testDatabaseConnection, saveDatabaseSettings, getParametresAffichables, saveQlikSettings, testQlikConnection, saveFfApiSettings, testFfApiConnection, } from "@/features/settings/actions"; import { UserManagement } from "@/features/admin/components/user-management"; import { ApiKeyManagement } from "@/features/admin/components/api-key-management"; import { ApiConnectionInfo } from "@/features/admin/components/api-connection-info"; import { GridWarmup } from "@/features/admin/components/grid-warmup"; import { ServerLogs } from "@/features/settings/components/server-logs"; /** * Configuration enregistrée, lue UNE fois pour toute la page : trois sections * (connexion, API FF, Qlik) la demandaient chacune au montage, et les actions * serveur s'exécutent les unes après les autres. Elle ne contient aucun mot de * passe : seulement l'indication qu'il y en a un d'enregistré. */ let savedConfigPromise: ReturnType | null = null; function loadSavedConfig(force = false): ReturnType { if (force || !savedConfigPromise) { savedConfigPromise = getParametresAffichables().catch((e) => { savedConfigPromise = null; throw e; }); } return savedConfigPromise; } /** Message lisible d'une erreur de server action (onglet périmé compris). */ function messageErreur(e: unknown): string { if (isStaleServerActionError(e)) return STALE_ACTION_MESSAGE; return e instanceof Error ? e.message : String(e); } function fmtDateHeure(valeur: string | null | undefined): string { if (!valeur) return "—"; const d = new Date(valeur); return isNaN(d.getTime()) ? valeur : d.toLocaleString("fr-FR"); } // ── Onglets ────────────────────────────────────────────────────────────────── type Onglet = "connexions" | "utilisateurs" | "api" | "journal"; const ONGLETS: readonly TabItem[] = [ { value: "connexions", label: "Connexions", icon: PlugZap }, { value: "utilisateurs", label: "Utilisateurs", icon: Users }, { value: "api", label: "Accès API", icon: KeyRound }, { value: "journal", label: "Journal serveur", icon: FileText }, ]; function estOnglet(v: string | undefined): v is Onglet { return ONGLETS.some((o) => o.value === v); } /** * Contenu d'un onglet : monté à la première visite puis seulement masqué, pour * qu'une saisie non enregistrée survive au changement d'onglet et que rien ne * soit rechargé en revenant. */ function Panneau({ actif, visite, children }: { actif: boolean; visite: boolean; children: ReactNode }) { if (!visite) return null; return ( ); } // ── Éléments de formulaire ─────────────────────────────────────────────────── function Champ({ id, label, aide, children, className }: { id: string; label: ReactNode; aide?: ReactNode; children: ReactNode; className?: string; }) { return (
{children} {aide &&

{aide}

}
); } type EtatTest = { etat: "idle" } | { etat: "en-cours" } | { etat: "ok" } | { etat: "erreur"; message: string }; /** Résultat d'un test de connexion, affiché à côté des boutons. */ function ResultatTest({ test }: { test: EtatTest }) { if (test.etat === "ok") { return ( Connexion réussie ); } if (test.etat === "erreur") { return (

Connexion impossible.{" "} {test.message}

); } return null; } /** Texte indicatif d'un champ mot de passe, selon qu'un mot de passe est déjà enregistré. */ function placeholderMotDePasse(enregistre: boolean): string { return enregistre ? "•••••• (inchangé)" : "••••••••"; } const AIDE_MOT_DE_PASSE_ENREGISTRE = "Laissez vide pour conserver le mot de passe enregistré."; // ── PostgreSQL ─────────────────────────────────────────────────────────────── function SectionPostgres({ recharger }: { recharger: () => Promise }) { const { host, setHost, port, setPort, database, setDatabase, user, setUser, password, setPassword, ssl, setSsl, motDePasseEnregistre, setMotDePasseEnregistre, } = useDbSettingsStore(); // Mot de passe vide : le serveur reprend celui qui est enregistré. const champs = () => ({ host, port, database, user, password: password || undefined, ssl }); const [test, setTest] = useState({ etat: "idle" }); const [enregistrement, setEnregistrement] = useState(false); const [rechargement, setRechargement] = useState(false); const tester = async () => { setTest({ etat: "en-cours" }); try { const res = await testDatabaseConnection(champs()); setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Erreur de connexion inconnue." }); } catch (e) { setTest({ etat: "erreur", message: messageErreur(e) }); } }; const enregistrer = async () => { setEnregistrement(true); try { const res = await saveDatabaseSettings(champs()); if (res.success) { toast.succes("Connexion à la base PostgreSQL enregistrée."); if (password) { setPassword(""); setMotDePasseEnregistre(true); } } else toast.erreur(`Enregistrement impossible : ${res.error ?? "erreur inconnue"}`); } catch (e) { toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`); } finally { setEnregistrement(false); } }; const rechargerConfig = async () => { setRechargement(true); try { await recharger(); toast.info("Les valeurs enregistrées sur le serveur ont été rechargées."); } catch (e) { toast.erreur(`La configuration n'a pas pu être relue : ${messageErreur(e)}`); } finally { setRechargement(false); } }; return (
setHost(e.target.value)} /> setPort(e.target.value)} /> setDatabase(e.target.value)} /> setUser(e.target.value)} autoComplete="off" /> setPassword(e.target.value)} autoComplete="new-password" />

À cocher si le serveur de base de données l'exige, en général quand il est hébergé à distance.

); } // ── API FF Nancy ───────────────────────────────────────────────────────────── interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; statut?: string; erreur?: string | null; } interface FfSyncStatus { lastSync: string; tables: FfSyncTable[]; } function SectionFfApi() { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [url, setUrl] = useState(""); const [savedUrl, setSavedUrl] = useState(null); const [saving, setSaving] = useState(false); const [testedUrl, setTestedUrl] = useState(null); // Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut). useEffect(() => { loadSavedConfig() .then((cfg) => { setSavedUrl(cfg.ffApiBaseUrl || null); if (cfg.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl); }) .catch(() => { /* réglage optionnel ; l'échec de lecture est signalé par la page */ }); }, []); const runTest = useCallback((candidate?: string) => { setLoading(true); setError(null); setStatus(null); testFfApiConnection(candidate) .then((res) => { setTestedUrl(res.url); if (res.success) setStatus(res.status as FfSyncStatus); else setError(res.error ?? "Échec inconnu."); }) .catch((e) => setError(messageErreur(e))) .finally(() => setLoading(false)); }, []); useEffect(() => { runTest(); }, [runTest]); const save = async () => { setSaving(true); try { const res = await saveFfApiSettings(url); if (!res.success) { toast.erreur(res.error ?? "Enregistrement impossible."); return; } setSavedUrl(url.trim() || null); toast.succes(url.trim() ? "Adresse de l'API FF enregistrée." : "L'adresse par défaut de l'API FF sera utilisée."); runTest(url); } catch (e) { toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`); } finally { setSaving(false); } }; const test: EtatTest = loading ? { etat: "en-cours" } : status ? { etat: "ok" } : error ? { etat: "erreur", message: error } : { etat: "idle" }; return ( setUrl(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void save(); }} className="w-full font-mono" />
{/* L'URL réellement appelée : évite de croire qu'on teste celle du champ alors que le réglage enregistré ou la variable d'environnement prime. */} {testedUrl && (

Adresse testée : {testedUrl} {!savedUrl && <> (adresse par défaut, aucun réglage enregistré)}

)} {status && (

Dernière mise à jour complète des données :{" "} {fmtDateHeure(status.lastSync)}

{status.tables?.length > 0 && (
{status.tables.map((t) => ( {/* Une table en erreur est précisément ce qu'on vient chercher ici. */} ))}
Table de données Mise à jour Lignes État
{t.nom} {fmtDateHeure(t.derniereSync)} {t.nbLignes != null ? fmtEntier(t.nbLignes) : "—"} {t.statut === "ok" ? OK : {t.statut ?? "—"}}
)}
)}
); } // ── Qlik Sense ─────────────────────────────────────────────────────────────── function SectionQlik() { const [host, setHost] = useState(""); const [user, setUser] = useState(""); // Toujours vide au chargement : le mot de passe enregistré ne quitte pas le serveur. const [password, setPassword] = useState(""); const [motDePasseEnregistre, setMotDePasseEnregistre] = useState(false); const [showPwd, setShowPwd] = useState(false); const [saving, setSaving] = useState(false); const [test, setTest] = useState({ etat: "idle" }); useEffect(() => { loadSavedConfig() .then((c) => { setHost(c.qlik.host); setUser(c.qlik.user); setMotDePasseEnregistre(c.qlik.motDePasseEnregistre); }) .catch(() => { /* l'échec de lecture est signalé par la page */ }); }, []); const handleSave = async () => { setSaving(true); try { const res = await saveQlikSettings(host.trim(), user.trim(), password || undefined); if (res.success) { toast.succes("Identifiants Qlik enregistrés."); if (password) { setPassword(""); setMotDePasseEnregistre(true); } } else toast.erreur(`Enregistrement impossible : ${res.error || "erreur inconnue"}`); } catch (e) { toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`); } finally { setSaving(false); } }; const handleTest = async () => { setTest({ etat: "en-cours" }); try { const res = await testQlikConnection(host.trim(), user.trim(), password || undefined); setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Échec de la connexion." }); } catch (e) { setTest({ etat: "erreur", message: messageErreur(e) }); } }; // Mot de passe vide accepté s'il y en a un d'enregistré : le serveur le reprend. const testImpossible = !user || (!password && !motDePasseEnregistre); return ( Identifiants utilisés pour récupérer les ventes des magasins du{" "} réseau dans l'outil de reporting Qlik Sense de La Foir'Fouille. } /> setHost(e.target.value)} className="w-full font-mono" />
setUser(e.target.value)} className="w-full font-mono" autoComplete="off" />
setPassword(e.target.value)} className="w-full pr-10 font-mono" autoComplete="new-password" />
); } // ── Page ───────────────────────────────────────────────────────────────────── export function ParametresClient({ onglet: ongletDemande }: { onglet?: string }) { const initial: Onglet = estOnglet(ongletDemande) ? ongletDemande : "connexions"; const [onglet, setOnglet] = useUrlTab("onglet", initial); const [visites, setVisites] = useState>(() => new Set([initial])); const [isMounted, setIsMounted] = useState(false); const setHost = useDbSettingsStore((s) => s.setHost); const setPort = useDbSettingsStore((s) => s.setPort); const setDatabase = useDbSettingsStore((s) => s.setDatabase); const setUser = useDbSettingsStore((s) => s.setUser); const setPassword = useDbSettingsStore((s) => s.setPassword); const setSsl = useDbSettingsStore((s) => s.setSsl); const setMotDePasseEnregistre = useDbSettingsStore((s) => s.setMotDePasseEnregistre); const reloadFromServer = useCallback(async (force = false) => { const { db } = await loadSavedConfig(force); // Le mot de passe n'est jamais renvoyé : champ vide = conserver celui enregistré. setPassword(""); setMotDePasseEnregistre(db?.motDePasseEnregistre ?? false); if (db) { setHost(db.host); setPort(db.port); setDatabase(db.database); setUser(db.user); setSsl(db.ssl); } }, [setDatabase, setHost, setMotDePasseEnregistre, setPassword, setPort, setSsl, setUser]); useEffect(() => { // `isMounted` sert à éviter un écart d'hydratation (réglages PostgreSQL // lus dans le localStorage) : il doit donc être posé au montage, ce que // la règle déconseille en général mais qui est ici le but. // eslint-disable-next-line react-hooks/set-state-in-effect setIsMounted(true); // Lecture fraîche à chaque ouverture de la page ; les sections, montées // juste après (cf. `isMounted`), réutilisent cette même requête. reloadFromServer(true).catch((e) => { toast.erreur(`La configuration enregistrée n'a pas pu être lue : ${messageErreur(e)}`); }); }, [reloadFromServer]); const changerOnglet = (suivant: Onglet) => { setOnglet(suivant); setVisites((prev) => (prev.has(suivant) ? prev : new Set([...prev, suivant]))); }; const recharger = useCallback(() => reloadFromServer(true), [reloadFromServer]); return (
{isMounted && ( <> )}
); }