feat(ui): lot 3 — toutes les pages lisibles, cohérentes et en français clair

Chaque page reprend le socle du lot 2 : en-tête avec une phrase
d'explication, couleurs du thème (plus de pages figées en blanc), textes
de 12 px minimum, noms de magasins, infobulles sur le jargon, retours par
toast / confirmation, états vides et d'erreur expliqués.

- Accueil : CA, tickets et panier moyen d'hier vs N-1, par magasin, top 10
  avec lien vers la fiche produit.
- Révision d'assortiment : un seul endroit pour enregistrer et exporter
  (menu Export unique, mêmes droits pour tous), annulation confirmée, sens
  des gammes affiché partout, magasin choisi en un clic, colonnes nommées
  en clair, bandeau de progression au lieu de la fenêtre floue, droits lus
  côté serveur, ligne sélectionnée opaque sur les colonnes figées.
- Recherche produit, Stocks à surveiller (export FF inchangé), Meilleures
  ventes, Ventes par mois (dernier mois complet par défaut), Publicités,
  Commandes fournisseurs, Historique, Paramètres (onglets), Synchronisation
  et Connexion migrés sur DataTable / Tabs / Card.
- Le nom de l'utilisateur est transmis à la session (affiché dans
  l'en-tête).
- Composants devenus inutiles supprimés (anciennes fenêtres modales,
  export-dropdown, financial-cell, supplier-selection).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
Claude committed 2026-10-03 21:05:46 +00:00
1 parent 0c890c84c5
commit 220ca2263a
61 files changed
+6101 -6864

No files matched your search

+619
View File
@@ -0,0 +1,619 @@
"use client";
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,
getSavedDatabaseConfig,
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.
*/
let savedConfigPromise: ReturnType<typeof getSavedDatabaseConfig> | null = null;
function loadSavedConfig(force = false): ReturnType<typeof getSavedDatabaseConfig> {
if (force || !savedConfigPromise) {
savedConfigPromise = getSavedDatabaseConfig().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<Onglet>[] = [
{ 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 (
<div role="tabpanel" hidden={!actif} className="space-y-6">
{children}
</div>
);
}
// ── Éléments de formulaire ───────────────────────────────────────────────────
function Champ({ id, label, aide, children, className }: {
id: string;
label: ReactNode;
aide?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<div className={className}>
<Label htmlFor={id}>{label}</Label>
{children}
{aide && <p className="mt-1 text-xs text-[var(--text-muted)]">{aide}</p>}
</div>
);
}
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 (
<Badge ton="succes">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden /> Connexion réussie
</Badge>
);
}
if (test.etat === "erreur") {
return (
<p role="alert" className="flex items-start gap-1.5 text-[13px] text-[var(--accent-error)]">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
<span>
<span className="font-semibold">Connexion impossible.</span>{" "}
<span className="text-[var(--text-secondary)]">{test.message}</span>
</span>
</p>
);
}
return null;
}
// ── PostgreSQL ───────────────────────────────────────────────────────────────
function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
const {
host, setHost,
port, setPort,
database, setDatabase,
user, setUser,
password, setPassword,
ssl, setSsl,
getDatabaseUrl,
} = useDbSettingsStore();
const [test, setTest] = useState<EtatTest>({ etat: "idle" });
const [enregistrement, setEnregistrement] = useState(false);
const [rechargement, setRechargement] = useState(false);
const tester = async () => {
setTest({ etat: "en-cours" });
try {
const res = await testDatabaseConnection(getDatabaseUrl());
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(getDatabaseUrl());
if (res.success) toast.succes("Connexion à la base PostgreSQL enregistrée.");
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 (
<Card>
<CardHeader
title="Base de données de l'application (PostgreSQL)"
description="Contient les comptes utilisateurs, l'historique des sessions et les réglages. Les produits et les ventes, eux, viennent de l'API FF Nancy."
/>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Champ
id="pg-hote"
label="Serveur"
aide="Adresse du serveur : localhost, une adresse IP (192.168.1.10) ou le nom du service Docker (postgres, db)."
className="sm:col-span-2"
>
<Input id="pg-hote" className="w-full font-mono" placeholder="localhost" value={host}
onChange={(e) => setHost(e.target.value)} />
</Champ>
<Champ id="pg-port" label="Port" aide="5432 dans la plupart des installations.">
<Input id="pg-port" className="w-full font-mono" placeholder="5432" value={port}
onChange={(e) => setPort(e.target.value)} />
</Champ>
<Champ id="pg-base" label="Nom de la base">
<Input id="pg-base" className="w-full font-mono" placeholder="collectflow" value={database}
onChange={(e) => setDatabase(e.target.value)} />
</Champ>
<Champ id="pg-utilisateur" label="Utilisateur">
<Input id="pg-utilisateur" className="w-full font-mono" placeholder="postgres" value={user}
onChange={(e) => setUser(e.target.value)} autoComplete="off" />
</Champ>
<Champ id="pg-mdp" label="Mot de passe">
<Input id="pg-mdp" type="password" className="w-full font-mono" placeholder="••••••••"
value={password || ""} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
</Champ>
</div>
<div>
<label htmlFor="pg-ssl" className="inline-flex cursor-pointer items-center gap-2 text-sm text-[var(--text-primary)]">
<input
type="checkbox"
id="pg-ssl"
checked={ssl}
onChange={(e) => setSsl(e.target.checked)}
className="h-4 w-4 cursor-pointer accent-[var(--accent)]"
/>
Connexion chiffrée (SSL)
</label>
<p className="mt-1 text-xs text-[var(--text-muted)]">
À cocher si le serveur de base de données l&apos;exige, en général quand il est hébergé à distance.
</p>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-[var(--border)] pt-4">
<Button onClick={enregistrer} disabled={enregistrement}>
{enregistrement ? <Loader2 className="animate-spin" /> : <Save />}
Enregistrer
</Button>
<Button variant="outline" onClick={tester} disabled={test.etat === "en-cours"}>
{test.etat === "en-cours" ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Tester la connexion
</Button>
<Button variant="ghost" onClick={rechargerConfig} disabled={rechargement}>
{rechargement ? <Loader2 className="animate-spin" /> : <RotateCcw />}
Recharger les valeurs enregistrées
</Button>
<ResultatTest test={test} />
</div>
</CardContent>
</Card>
);
}
// ── 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<FfSyncStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [url, setUrl] = useState("");
const [savedUrl, setSavedUrl] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [testedUrl, setTestedUrl] = useState<string | null>(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 (
<Card>
<CardHeader
title="API FF Nancy"
description="Source des produits et des ventes de nos magasins. Ses données sont mises à jour chaque nuit depuis la base FF : elles s'arrêtent donc à la veille."
/>
<CardContent className="space-y-4">
<Champ
id="ff-url"
label="Adresse de l'API"
aide="Laissez vide pour utiliser l'adresse par défaut (https://api.ffnancy.fr)."
>
<Input
id="ff-url"
type="text"
placeholder="https://api.ffnancy.fr"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void save(); }}
className="w-full font-mono"
/>
</Champ>
<div className="flex flex-wrap items-center gap-3 border-t border-[var(--border)] pt-4">
<Button onClick={save} disabled={saving}>
{saving ? <Loader2 className="animate-spin" /> : <Save />}
Enregistrer
</Button>
<Button variant="outline" onClick={() => runTest(url)} disabled={loading}>
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Tester la connexion
</Button>
<ResultatTest test={test} />
</div>
{/* 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 && (
<p className="text-xs text-[var(--text-muted)]">
Adresse testée : <span className="font-mono">{testedUrl}</span>
{!savedUrl && <> (adresse par défaut, aucun réglage enregistré)</>}
</p>
)}
{status && (
<div className="space-y-2">
<p className="text-[13px] text-[var(--text-secondary)]">
Dernière mise à jour complète des données :{" "}
<span className="font-medium text-[var(--text-primary)]">{fmtDateHeure(status.lastSync)}</span>
</p>
{status.tables?.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full">
<thead className="bg-[var(--bg-elevated)] text-[13px] font-semibold text-[var(--text-secondary)]">
<tr>
<th scope="col" className="px-3 py-2 text-left">Table de données</th>
<th scope="col" className="px-3 py-2 text-right">Mise à jour</th>
<th scope="col" className="px-3 py-2 text-right">Lignes</th>
<th scope="col" className="px-3 py-2 text-right">État</th>
</tr>
</thead>
<tbody>
{status.tables.map((t) => (
<tr key={t.nom} className="border-t border-[var(--border)]">
<td className="px-3 py-2 text-sm font-mono text-[var(--text-primary)]">{t.nom}</td>
<td className="px-3 py-2 text-sm text-right text-[var(--text-secondary)]">{fmtDateHeure(t.derniereSync)}</td>
<td className="px-3 py-2 text-sm text-right tabular-nums text-[var(--text-secondary)]">
{t.nbLignes != null ? fmtEntier(t.nbLignes) : "—"}
</td>
{/* Une table en erreur est précisément ce qu'on vient chercher ici. */}
<td className="px-3 py-2 text-sm text-right">
{t.statut === "ok"
? <Badge ton="succes">OK</Badge>
: <Badge ton="erreur" title={t.erreur ?? undefined}>{t.statut ?? "—"}</Badge>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}
// ── Qlik Sense ───────────────────────────────────────────────────────────────
function SectionQlik() {
const [host, setHost] = useState("");
const [user, setUser] = useState("");
const [password, setPassword] = useState("");
const [showPwd, setShowPwd] = useState(false);
const [saving, setSaving] = useState(false);
const [test, setTest] = useState<EtatTest>({ etat: "idle" });
useEffect(() => {
loadSavedConfig()
.then((c) => {
if (!c) return;
setHost(c.qlikHost ?? "");
setUser(c.qlikUser ?? "");
setPassword(c.qlikPassword ?? "");
})
.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);
if (res.success) toast.succes("Identifiants Qlik enregistrés.");
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);
setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Échec de la connexion." });
} catch (e) {
setTest({ etat: "erreur", message: messageErreur(e) });
}
};
const testImpossible = !user || !password;
return (
<Card>
<CardHeader
title="Qlik Sense (données du réseau)"
description={
<>
Identifiants utilisés pour récupérer les ventes des magasins du{" "}
<Terme id="reseau">réseau</Terme> dans l&apos;outil de reporting Qlik Sense de La Foir&apos;Fouille.
</>
}
/>
<CardContent className="space-y-4">
<Champ
id="qlik-hote"
label="Serveur Qlik"
aide="Laissez vide pour utiliser le serveur habituel : reporting-magasins.lafoirfouille.fr."
>
<Input id="qlik-hote" type="text" placeholder="reporting-magasins.lafoirfouille.fr" value={host}
onChange={(e) => setHost(e.target.value)} className="w-full font-mono" />
</Champ>
<div className="grid gap-4 sm:grid-cols-2">
<Champ
id="qlik-utilisateur"
label="Identifiant"
aide="Identifiant Windows sans le domaine, par exemple FFSCH (connexion par authentification Windows, dite NTLM)."
>
<Input id="qlik-utilisateur" type="text" placeholder="FFSCH" value={user}
onChange={(e) => setUser(e.target.value)} className="w-full font-mono" autoComplete="off" />
</Champ>
<Champ id="qlik-mdp" label="Mot de passe">
<div className="relative">
<Input id="qlik-mdp" type={showPwd ? "text" : "password"} placeholder="••••••••" value={password}
onChange={(e) => setPassword(e.target.value)} className="w-full pr-10 font-mono" autoComplete="new-password" />
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setShowPwd((v) => !v)}
aria-label={showPwd ? "Masquer le mot de passe" : "Afficher le mot de passe"}
title={showPwd ? "Masquer le mot de passe" : "Afficher le mot de passe"}
className="absolute right-0.5 top-1/2 -translate-y-1/2 text-[var(--text-muted)]"
>
{showPwd ? <EyeOff /> : <Eye />}
</Button>
</div>
</Champ>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-[var(--border)] pt-4">
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="animate-spin" /> : <Save />}
Enregistrer
</Button>
<Button
variant="outline"
onClick={handleTest}
disabled={test.etat === "en-cours" || testImpossible}
title={testImpossible ? "Renseignez l'identifiant et le mot de passe pour tester" : undefined}
>
{test.etat === "en-cours" ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Tester la connexion
</Button>
<ResultatTest test={test} />
</div>
</CardContent>
</Card>
);
}
// ── Page ─────────────────────────────────────────────────────────────────────
export function ParametresClient({ onglet: ongletDemande }: { onglet?: string }) {
const initial: Onglet = estOnglet(ongletDemande) ? ongletDemande : "connexions";
const [onglet, setOnglet] = useUrlTab<Onglet>("onglet", initial);
const [visites, setVisites] = useState<ReadonlySet<Onglet>>(() => 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 reloadFromServer = useCallback(async (force = false) => {
const config = await loadSavedConfig(force);
if (config?.url) {
// Parser l'URL pour remettre dans le store
try {
const url = new URL(config.url.replace("postgres://", "http://")); // URL parser helper
setHost(url.hostname);
setPort(url.port || "5432");
setDatabase(url.pathname.slice(1).split("?")[0]);
setUser(url.username);
setPassword(decodeURIComponent(url.password));
setSsl(config.url.includes("sslmode=require"));
} catch (e) {
console.error("Failed to parse saved URL", e);
}
}
}, [setDatabase, setHost, 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 (
<div className="mx-auto w-full max-w-5xl">
<PageHeader
icon={Settings}
title="Paramètres"
description="Connexions aux sources de données, comptes des utilisateurs et clés d'accès à l'API (réservé aux administrateurs)."
/>
<Tabs items={ONGLETS} value={onglet} onChange={changerOnglet} className="mb-6" />
{isMounted && (
<>
<Panneau actif={onglet === "connexions"} visite={visites.has("connexions")}>
<SectionPostgres recharger={recharger} />
<SectionFfApi />
<SectionQlik />
</Panneau>
<Panneau actif={onglet === "utilisateurs"} visite={visites.has("utilisateurs")}>
<UserManagement />
</Panneau>
<Panneau actif={onglet === "api"} visite={visites.has("api")}>
<ApiConnectionInfo />
<ApiKeyManagement />
<GridWarmup />
</Panneau>
<Panneau actif={onglet === "journal"} visite={visites.has("journal")}>
<ServerLogs />
</Panneau>
</>
)}
</div>
);
}
+11 -539
View File
@@ -1,544 +1,16 @@
"use client";
import type { Metadata } from "next";
import { ParametresClient } from "./client";
import { useState, useCallback } from "react";
import { Save, Eye, EyeOff, CheckCircle, AlertCircle, Loader2, RefreshCw, Sun, Moon, RotateCcw } from "lucide-react";
import { useTheme } from "next-themes";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store";
import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig, saveQlikSettings, testQlikConnection, saveFfApiSettings, testFfApiConnection } from "@/features/settings/actions";
import { useEffect } from "react";
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";
export const metadata: Metadata = { title: "Paramètres" };
/**
* 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.
* Paramètres (administrateurs) : connexions aux sources de données, comptes
* utilisateurs, accès à l'API et journal serveur, rangés en onglets.
*
* Composant serveur minimal : il lit l'onglet demandé (`?onglet=`) pour que le
* lien se partage et survive au rechargement, puis rend la page cliente.
*/
let savedConfigPromise: ReturnType<typeof getSavedDatabaseConfig> | null = null;
function loadSavedConfig(force = false): ReturnType<typeof getSavedDatabaseConfig> {
if (force || !savedConfigPromise) {
savedConfigPromise = getSavedDatabaseConfig().catch((e) => {
savedConfigPromise = null;
throw e;
});
}
return savedConfigPromise;
}
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return (
<section className="apple-card">
<div className="apple-card-header">
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">{title}</h2>
{subtitle && <p className="text-[12px] mt-0.5 text-[var(--text-secondary)]">{subtitle}</p>}
</div>
<div className="apple-card-content space-y-4">
{children}
</div>
</section>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<label className="text-[12px] font-medium text-[var(--text-secondary)]">{label}</label>
{children}
{hint && <p className="text-[11px] text-[var(--text-muted)]">{hint}</p>}
</div>
);
}
interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; statut?: string; erreur?: string | null; }
interface FfSyncStatus { lastSync: string; tables: FfSyncTable[]; }
function FfApiStatusSection() {
const [status, setStatus] = useState<FfSyncStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [url, setUrl] = useState("");
const [savedUrl, setSavedUrl] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [testedUrl, setTestedUrl] = useState<string | null>(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 : on laisse le champ vide */ });
}, []);
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(e instanceof Error ? e.message : "Erreur inconnue"))
.finally(() => setLoading(false));
}, []);
useEffect(() => { runTest(); }, [runTest]);
const save = async () => {
setSaving(true);
setError(null);
try {
const res = await saveFfApiSettings(url);
if (!res.success) { setError(res.error ?? "Enregistrement impossible"); return; }
setSavedUrl(url.trim() || null);
runTest(url);
} finally {
setSaving(false);
}
};
return (
<Section title="API FF Nancy" subtitle="Source des données produits — synchronisation nuit depuis SQL Server (données J-1)">
<Field label="URL de l'API" hint="Laisser vide pour utiliser la valeur par défaut (https://api.ffnancy.fr)">
<input
type="text"
placeholder="https://api.ffnancy.fr"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void save(); }}
className="apple-input w-full"
/>
</Field>
<div className="flex items-center gap-3 flex-wrap">
<button onClick={save} disabled={saving} className="apple-btn h-9 px-4">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Enregistrer
</button>
<button onClick={() => runTest(url)} disabled={loading} className="apple-btn-secondary h-9 px-4">
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
Tester
</button>
{status && <span className="text-[12px] text-emerald-400 flex items-center gap-1"><CheckCircle className="w-3.5 h-3.5" /> Connexion OK</span>}
{error && <span className="text-[12px] text-red-400 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" /> {error}</span>}
</div>
{/* 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 && (
<p className="text-[11px] text-[var(--text-muted)]">
Adresse testée : <span className="font-mono">{testedUrl}</span>
{!savedUrl && <> — valeur par défaut, aucun réglage enregistré</>}
</p>
)}
{status && (
<div className="space-y-2 mt-2">
<p className="text-[12px] text-[var(--text-secondary)]">
Dernière sync globale : <span className="font-mono font-medium">{(() => { const d = new Date(status.lastSync); return isNaN(d.getTime()) ? (status.lastSync ?? "—") : d.toLocaleString("fr-FR"); })()}</span>
</p>
{status.tables?.length > 0 && (
<div className="rounded-lg overflow-hidden border border-[var(--border-subtle)]">
<table className="w-full text-[11px]">
<thead>
<tr className="bg-[var(--surface-tertiary)]">
<th className="text-left px-3 py-2 font-medium text-[var(--text-secondary)]">Table</th>
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Sync</th>
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Lignes</th>
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">État</th>
</tr>
</thead>
<tbody>
{status.tables.map((t) => (
<tr key={t.nom} className="border-t border-[var(--border-subtle)]">
<td className="px-3 py-2 font-mono text-[var(--text-primary)]">{t.nom}</td>
<td className="px-3 py-2 text-right text-[var(--text-secondary)]">
{(() => { const d = new Date(t.derniereSync); return isNaN(d.getTime()) ? (t.derniereSync ?? "—") : d.toLocaleString("fr-FR"); })()}
</td>
<td className="px-3 py-2 text-right text-[var(--text-muted)]">
{t.nbLignes?.toLocaleString("fr-FR") ?? "—"}
</td>
{/* Une table en erreur est précisément ce qu'on vient chercher ici. */}
<td className="px-3 py-2 text-right">
{t.statut === "ok"
? <span className="text-emerald-400">ok</span>
: <span className="text-red-400" title={t.erreur ?? undefined}>{t.statut ?? "—"}</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</Section>
);
}
function QlikSettingsSection() {
const [host, setHost] = useState("");
const [user, setUser] = useState("");
const [password, setPassword] = useState("");
const [showPwd, setShowPwd] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [status, setStatus] = useState<{ type: "ok" | "err"; msg: string } | null>(null);
useEffect(() => {
loadSavedConfig().then((c) => {
if (!c) return;
setHost(c.qlikHost ?? "");
setUser(c.qlikUser ?? "");
setPassword(c.qlikPassword ?? "");
});
}, []);
const handleSave = async () => {
setSaving(true);
setStatus(null);
const res = await saveQlikSettings(host.trim(), user.trim(), password);
setSaving(false);
setStatus(res.success ? { type: "ok", msg: "Réglages Qlik enregistrés" } : { type: "err", msg: res.error || "Erreur" });
};
const handleTest = async () => {
setTesting(true);
setStatus(null);
const res = await testQlikConnection(host.trim(), user.trim(), password);
setTesting(false);
setStatus(res.success ? { type: "ok", msg: "Connexion Qlik OK" } : { type: "err", msg: res.error || "Échec connexion" });
};
return (
<Section title="Qlik Sense — Données réseau" subtitle="Identifiants pour la synchro des données réseau (~270 magasins). Auth NTLM ; laisser l'hôte vide pour le défaut.">
<Field label="Hôte" hint="Défaut : reporting-magasins.lafoirfouille.fr (laisser vide pour ce défaut)">
<input type="text" placeholder="reporting-magasins.lafoirfouille.fr" value={host}
onChange={(e) => setHost(e.target.value)} className="apple-input font-mono" />
</Field>
<Field label="Utilisateur" hint="Ex : FFSCH (sans domaine)">
<input type="text" placeholder="FFSCH" value={user}
onChange={(e) => setUser(e.target.value)} className="apple-input font-mono" />
</Field>
<Field label="Mot de passe">
<div className="relative">
<input type={showPwd ? "text" : "password"} placeholder="••••••••" value={password}
onChange={(e) => setPassword(e.target.value)} className="apple-input font-mono pr-10" />
<button type="button" onClick={() => setShowPwd((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-muted)]">
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</Field>
{status && (
<div className={`flex items-center gap-1.5 text-[12px] ${status.type === "ok" ? "text-emerald-600" : "text-rose-600"}`}>
{status.type === "ok" ? <CheckCircle className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
{status.msg}
</div>
)}
<div className="flex gap-2">
<button onClick={handleSave} disabled={saving}
className="btn-action btn-action-primary flex items-center gap-1.5 disabled:opacity-60">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Enregistrer
</button>
<button onClick={handleTest} disabled={testing || !user || !password}
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60">
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
Tester
</button>
</div>
</Section>
);
}
export default function SettingsPage() {
const { resolvedTheme, setTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const displayDensity = useGridStore((s) => s.displayDensity);
const setDisplayDensity = useGridStore((s) => s.setDisplayDensity);
const [dbStatus, setDbStatus] = useState<"idle" | "testing" | "ok" | "error">("idle");
const [dbError, setDbError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
const [isMounted, setIsMounted] = useState(false);
const {
host, setHost,
port, setPort,
database, setDatabase,
user, setUser,
password, setPassword,
ssl, setSsl,
getDatabaseUrl
} = useDbSettingsStore();
const reloadFromServer = useCallback(async (force = false) => {
const config = await loadSavedConfig(force);
if (config) {
if (config.url) {
// Parser l'URL pour remettre dans le store
try {
const url = new URL(config.url.replace("postgres://", "http://")); // URL parser helper
setHost(url.hostname);
setPort(url.port || "5432");
setDatabase(url.pathname.slice(1).split("?")[0]);
setUser(url.username);
setPassword(decodeURIComponent(url.password));
setSsl(config.url.includes("sslmode=require"));
} catch (e) {
console.error("Failed to parse saved URL", e);
}
}
}
}, [setDatabase, setHost, setPassword, setPort, setSsl, setUser]);
useEffect(() => {
// `isMounted` sert à éviter un écart d'hydratation : 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);
}, [reloadFromServer]);
const testDb = async () => {
setDbStatus("testing");
setDbError(null);
const url = getDatabaseUrl();
const res = await testDatabaseConnection(url);
if (res.success) {
setDbStatus("ok");
} else {
setDbStatus("error");
setDbError(res.error || "Erreur de connexion inconnue");
}
};
const handleSave = async () => {
setSaveStatus("saving");
const url = getDatabaseUrl();
const res = await saveDatabaseSettings(url);
if (res.success) {
setSaveStatus("saved");
setTimeout(() => setSaveStatus("idle"), 2500);
} else {
setSaveStatus("idle");
alert("Erreur lors de la sauvegarde : " + res.error);
}
};
if (!isMounted) {
return null;
}
return (
<div className="space-y-6 max-w-xl">
<div>
<h1 className="text-[22px] font-bold tracking-[-0.4px] text-[var(--text-primary)]">Paramètres</h1>
<p className="text-[13px] mt-1 text-[var(--text-secondary)]">Configuration de la connexion et des services.</p>
</div>
{/* PostgreSQL */}
<Section title="PostgreSQL — Auth & Historique" subtitle="Connexion pour les comptes utilisateurs, snapshots et contexte IA. Les données produits proviennent de l'API FF Nancy.">
<Field label="Hôte / Nom du container" hint="Ex: localhost, 192.168.1.10, ou nom du service Docker (ex: postgres, db)">
<input
type="text"
placeholder="localhost"
value={host}
onChange={(e) => setHost(e.target.value)}
className="apple-input font-mono"
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Port">
<input
type="text"
placeholder="5432"
value={port}
onChange={(e) => setPort(e.target.value)}
className="apple-input font-mono"
/>
</Field>
<Field label="Base de données">
<input
type="text"
placeholder="collectflow"
value={database}
onChange={(e) => setDatabase(e.target.value)}
className="apple-input font-mono"
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Utilisateur">
<input
type="text"
placeholder="postgres"
value={user}
onChange={(e) => setUser(e.target.value)}
className="apple-input font-mono"
/>
</Field>
<Field label="Mot de passe">
<input
type="password"
placeholder="••••••••"
value={password || ""}
onChange={(e) => setPassword(e.target.value)}
className="apple-input font-mono"
/>
</Field>
</div>
<div className="flex items-center gap-2.5">
<input
type="checkbox"
id="ssl"
checked={ssl}
onChange={(e) => setSsl(e.target.checked)}
className="w-3.5 h-3.5 rounded accent-teal-500 cursor-pointer"
/>
<label htmlFor="ssl" className="text-[12px] cursor-pointer text-[var(--text-secondary)]">Connexion SSL</label>
</div>
<div className="flex items-center gap-3">
<button onClick={testDb} disabled={dbStatus === "testing"} className="apple-btn-secondary h-9 px-4">
{dbStatus === "testing" && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{dbStatus === "ok" && <CheckCircle className="w-3.5 h-3.5 text-teal-500" />}
{dbStatus === "error" && <AlertCircle className="w-3.5 h-3.5 text-red-400" />}
Tester la connexion
</button>
{dbStatus === "error" && (
<div className="flex flex-col">
<span className="text-[12px] text-red-400 font-medium">Connexion refusée</span>
{dbError && <span className="text-[11px]" style={{ color: "var(--text-muted)" }}>{dbError}</span>}
</div>
)}
{dbStatus === "ok" && (
<span className="text-[12px] text-teal-500 font-medium">Connexion réussie !</span>
)}
</div>
<div className="pt-2">
<button
onClick={() => reloadFromServer(true)}
className="apple-btn-secondary h-8 px-3 text-[11px] opacity-80 hover:opacity-100"
>
<RotateCcw className="w-3 h-3" />
Recharger la configuration enregistrée sur le serveur
</button>
</div>
</Section>
{/* API FF Nancy */}
<FfApiStatusSection />
<QlikSettingsSection />
{/* Apparence */}
<Section title="Apparence">
<div className="flex items-center justify-between">
<div>
<p className="text-[13px] font-medium text-[var(--text-primary)]">Thème</p>
<p className="text-[12px] text-[var(--text-secondary)]">Interface claire ou sombre</p>
</div>
{/* Segment control */}
<div className="segment-control">
<button onClick={() => setTheme("light")} className={`segment-btn ${!isDark ? "active" : ""}`}>
<Sun className="w-3.5 h-3.5" /> Clair
</button>
<button onClick={() => setTheme("dark")} className={`segment-btn ${isDark ? "active" : ""}`}>
<Moon className="w-3.5 h-3.5" /> Sombre
</button>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-[13px] font-medium text-[var(--text-primary)]">Densité de la grille</p>
<p className="text-[12px] text-[var(--text-secondary)]">Hauteur de ligne par défaut</p>
</div>
<select
className="apple-input w-auto"
value={displayDensity}
onChange={(e) => setDisplayDensity(e.target.value as "compact" | "normal" | "comfortable")}
>
<option value="compact">Compact (32px)</option>
<option value="normal">Normal (40px)</option>
<option value="comfortable">Spacieux (48px)</option>
</select>
</div>
</Section>
{/* Journaux serveur — diagnostic des extractions Qlik */}
<Section
title="Journaux serveur"
subtitle="Journal complet des synchronisations Qlik, à télécharger pour diagnostic"
>
<ServerLogs />
</Section>
{/* Gestion des Utilisateurs */}
<Section
title="Gestion des Utilisateurs"
subtitle="Administration des accès et des rôles"
>
<UserManagement />
</Section>
{/* API publique /api/v1 — connexion puis gestion des clés */}
<Section
title="API CollectFlow — Connexion"
subtitle="Tout ce qu'il faut pour appeler l'API depuis un script ou un outil externe"
>
<ApiConnectionInfo />
</Section>
<Section
title="Clés d'API"
subtitle="Créer et révoquer les clés d'accès à /api/v1"
>
<ApiKeyManagement />
</Section>
<Section
title="Données exposées à l'API"
subtitle="Calculer l'instantané de tous les fournisseurs pour qu'une IA externe y ait accès"
>
<GridWarmup />
</Section>
{/* Save Button — Floating/Sticky style at bottom */}
<div className="sticky bottom-6 flex justify-end pt-4 pb-2">
<button
onClick={handleSave}
disabled={saveStatus === "saving"}
className={`apple-btn-primary min-w-[240px] shadow-lg ${saveStatus === "saving" ? "animate-pulse" : saveStatus === "saved" ? "animate-apple-save" : ""}`}
style={{ background: saveStatus === "saved" ? "var(--accent-success)" : "var(--brand-solid)" }}
>
{saveStatus === "saving" ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : saveStatus === "saved" ? (
<CheckCircle className="w-4 h-4" />
) : (
<Save className="w-4 h-4" />
)}
<span className="ml-1">
{saveStatus === "saving"
? "Enregistrement en cours..."
: saveStatus === "saved"
? "Configuration enregistrée !"
: "Sauvegarder la configuration"}
</span>
</button>
</div>
</div>
);
export default async function SettingsPage(props: { searchParams: Promise<{ onglet?: string }> }) {
const { onglet } = await props.searchParams;
return <ParametresClient onglet={onglet} />;
}