mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
Lot 5 : changement de magasin instantané et contrôle des droits
Grille - Corrige le lot 4 : au changement de magasin, les chiffres et le tri affichés suivent tout de suite (nouvelle liste), la sélection est gardée. - Lignes toujours chargées « Nos 2 magasins » ; changement de magasin sans aller-retour serveur (URL mise à jour par l'historique du navigateur). - Complément de l'API FF par magasin servi à part (/api/grid/rows/store-patch), appliqué à son arrivée, calcul identique à l'ancien (lib/store-patch.ts). - La recherche ne reconstruit plus toutes les lignes à chaque frappe ; la sélection n'est remise à zéro qu'au changement de fournisseur. Sécurité - lib/authz.ts : session relue en base (cache 1 min), rôle de la base prioritaire ; variantes pour actions, routes et pages. - Paramètres réservés aux administrateurs ; session vérifiée dans les actions Grille, Commandes, Historique et les routes ; diagnostics réservés admin. - Mots de passe et clés d'IA plus envoyés au navigateur ; champ vide = mot de passe conservé ; plus de mot de passe dans le localStorage ; l'enregistrement de la base n'efface plus les clés d'IA. - admin/admin créé seulement si aucun utilisateur ; fichier de secours utilisé uniquement si la base est injoignable ; data/ ignoré par git. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
44 files changed
+1133
-509
No files matched your search
@@ -41,3 +41,6 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
.playwright-mcp/
|
||||
|
||||
# données locales (configuration avec mots de passe, comptes de secours)
|
||||
/data/
|
||||
@@ -376,3 +376,36 @@ Autres changements :
|
||||
- Première ouverture d'un gros fournisseur après la synchro de la nuit : elle doit être quasi immédiate.
|
||||
- Changement de magasin : la Grille reste affichée, puis les chiffres se mettent à jour.
|
||||
- Enregistrer, quitter la Grille, puis y revenir : les gammes enregistrées sont bien là.
|
||||
|
||||
## Suivi — Lot 5 réalisé (correctif Grille et sécurité)
|
||||
|
||||
**Grille : changement de magasin**
|
||||
- **Corrige une régression du lot 4** : les lignes déjà affichées gardaient les chiffres de l'ancien magasin, et un tri actif n'était pas réappliqué, jusqu'à la fin du rechargement. Désormais, la liste est reconstruite à chaque changement de magasin : les chiffres et le tri sont justes tout de suite, et la sélection est conservée.
|
||||
- **Plus d'aller-retour serveur** : la Grille charge toujours « Nos 2 magasins », dont les lignes portent déjà le détail de chaque magasin. Changer de magasin met seulement à jour l'URL, qui reste partageable.
|
||||
- **Complément de l'API FF demandé à part** (`/api/grid/rows/store-patch`) et appliqué à son arrivée. Il couvre les articles sans vente SQL dans le magasin, comme avant.
|
||||
- Le calcul est identique à l'ancien : comparaison automatique sur un jeu d'essai.
|
||||
- Il ne modifie que les chiffres du magasin, pas les totaux « Nos 2 magasins ».
|
||||
- Il n'est demandé qu'une fois par magasin et par chargement.
|
||||
- **Sélection** : remise à zéro seulement au changement de fournisseur.
|
||||
- **La recherche ne reconstruit plus toutes les lignes** à chaque frappe.
|
||||
|
||||
**Sécurité**
|
||||
- **Contrôle commun `lib/authz.ts`** :
|
||||
- le compte est relu en base, avec un cache d'une minute ; un compte supprimé ou rétrogradé perd ses droits sans attendre l'expiration de sa session ;
|
||||
- si la base est injoignable, le jeton de session fait foi.
|
||||
- **Paramètres** : actions réservées aux administrateurs, avec la même vérification sur les pages Paramètres et Synchronisation.
|
||||
- **Toutes les autres actions et routes vérifient la session** : Grille, Commandes, Historique, exports, statut FF.
|
||||
- **Routes de diagnostic** (`/api/diag*`, `/api/test-db`) : réservées aux administrateurs, sans trace d'erreur technique.
|
||||
- **Mots de passe et clés d'IA** :
|
||||
- ils ne sont plus envoyés au navigateur ; un champ laissé vide garde le mot de passe enregistré ;
|
||||
- le mot de passe PostgreSQL n'est plus conservé dans le `localStorage`, et l'ancien est effacé ;
|
||||
- enregistrer la connexion PostgreSQL n'efface plus les clés d'IA.
|
||||
- **Compte admin/admin** :
|
||||
- il n'est créé qu'au premier démarrage (aucun utilisateur en base) ;
|
||||
- le fichier de secours `data/users.json` ne sert que si la base est injoignable ;
|
||||
- `data/` est ignoré par git.
|
||||
|
||||
**À savoir après la mise en ligne**
|
||||
- Un utilisateur promu administrateur doit se reconnecter pour accéder à Paramètres.
|
||||
- Après avoir configuré la base depuis le compte de secours, il faut se reconnecter.
|
||||
- Un mot de passe enregistré ne peut plus être effacé depuis l'écran ; seul son remplacement est possible.
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { verifierAdmin } from "@/lib/authz";
|
||||
import { SynchronisationClient } from "./client";
|
||||
|
||||
export const metadata: Metadata = { title: "Synchronisation" };
|
||||
@@ -6,9 +8,10 @@ export const metadata: Metadata = { title: "Synchronisation" };
|
||||
/**
|
||||
* Paramétrage de la synchronisation nocturne des fournisseurs.
|
||||
*
|
||||
* L'accès admin est déjà imposé par le middleware (préfixe /admin) ; les routes
|
||||
* /api/admin/sync revérifient le rôle côté serveur.
|
||||
* L'accès admin est déjà imposé par le middleware (préfixe /admin), sur le rôle
|
||||
* du jeton ; la page le revérifie en base, comme les routes /api/admin/sync.
|
||||
*/
|
||||
export default function SynchronisationPage() {
|
||||
export default async function SynchronisationPage() {
|
||||
if (!(await verifierAdmin()).ok) redirect("/dashboard");
|
||||
return <SynchronisationClient />;
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-st
|
||||
import {
|
||||
testDatabaseConnection,
|
||||
saveDatabaseSettings,
|
||||
getSavedDatabaseConfig,
|
||||
getParametresAffichables,
|
||||
saveQlikSettings,
|
||||
testQlikConnection,
|
||||
saveFfApiSettings,
|
||||
@@ -45,12 +45,13 @@ 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.
|
||||
* 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<typeof getSavedDatabaseConfig> | null = null;
|
||||
function loadSavedConfig(force = false): ReturnType<typeof getSavedDatabaseConfig> {
|
||||
let savedConfigPromise: ReturnType<typeof getParametresAffichables> | null = null;
|
||||
function loadSavedConfig(force = false): ReturnType<typeof getParametresAffichables> {
|
||||
if (force || !savedConfigPromise) {
|
||||
savedConfigPromise = getSavedDatabaseConfig().catch((e) => {
|
||||
savedConfigPromise = getParametresAffichables().catch((e) => {
|
||||
savedConfigPromise = null;
|
||||
throw e;
|
||||
});
|
||||
@@ -142,6 +143,13 @@ function ResultatTest({ test }: { test: EtatTest }) {
|
||||
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<void> }) {
|
||||
@@ -152,9 +160,12 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
|
||||
user, setUser,
|
||||
password, setPassword,
|
||||
ssl, setSsl,
|
||||
getDatabaseUrl,
|
||||
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<EtatTest>({ etat: "idle" });
|
||||
const [enregistrement, setEnregistrement] = useState(false);
|
||||
const [rechargement, setRechargement] = useState(false);
|
||||
@@ -162,7 +173,7 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
|
||||
const tester = async () => {
|
||||
setTest({ etat: "en-cours" });
|
||||
try {
|
||||
const res = await testDatabaseConnection(getDatabaseUrl());
|
||||
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) });
|
||||
@@ -172,9 +183,14 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
|
||||
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"}`);
|
||||
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 {
|
||||
@@ -223,8 +239,8 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
|
||||
<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="••••••••"
|
||||
<Champ id="pg-mdp" label="Mot de passe" aide={motDePasseEnregistre ? AIDE_MOT_DE_PASSE_ENREGISTRE : undefined}>
|
||||
<Input id="pg-mdp" type="password" className="w-full font-mono" placeholder={placeholderMotDePasse(motDePasseEnregistre)}
|
||||
value={password || ""} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
|
||||
</Champ>
|
||||
</div>
|
||||
@@ -283,8 +299,8 @@ function SectionFfApi() {
|
||||
useEffect(() => {
|
||||
loadSavedConfig()
|
||||
.then((cfg) => {
|
||||
setSavedUrl(cfg?.ffApiBaseUrl ?? null);
|
||||
if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
|
||||
setSavedUrl(cfg.ffApiBaseUrl || null);
|
||||
if (cfg.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
|
||||
})
|
||||
.catch(() => { /* réglage optionnel ; l'échec de lecture est signalé par la page */ });
|
||||
}, []);
|
||||
@@ -418,7 +434,9 @@ function SectionFfApi() {
|
||||
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<EtatTest>({ etat: "idle" });
|
||||
@@ -426,10 +444,9 @@ function SectionQlik() {
|
||||
useEffect(() => {
|
||||
loadSavedConfig()
|
||||
.then((c) => {
|
||||
if (!c) return;
|
||||
setHost(c.qlikHost ?? "");
|
||||
setUser(c.qlikUser ?? "");
|
||||
setPassword(c.qlikPassword ?? "");
|
||||
setHost(c.qlik.host);
|
||||
setUser(c.qlik.user);
|
||||
setMotDePasseEnregistre(c.qlik.motDePasseEnregistre);
|
||||
})
|
||||
.catch(() => { /* l'échec de lecture est signalé par la page */ });
|
||||
}, []);
|
||||
@@ -437,9 +454,14 @@ function SectionQlik() {
|
||||
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"}`);
|
||||
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 {
|
||||
@@ -450,14 +472,15 @@ function SectionQlik() {
|
||||
const handleTest = async () => {
|
||||
setTest({ etat: "en-cours" });
|
||||
try {
|
||||
const res = await testQlikConnection(host.trim(), user.trim(), password);
|
||||
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) });
|
||||
}
|
||||
};
|
||||
|
||||
const testImpossible = !user || !password;
|
||||
// Mot de passe vide accepté s'il y en a un d'enregistré : le serveur le reprend.
|
||||
const testImpossible = !user || (!password && !motDePasseEnregistre);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -488,9 +511,9 @@ function SectionQlik() {
|
||||
<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">
|
||||
<Champ id="qlik-mdp" label="Mot de passe" aide={motDePasseEnregistre ? AIDE_MOT_DE_PASSE_ENREGISTRE : undefined}>
|
||||
<div className="relative">
|
||||
<Input id="qlik-mdp" type={showPwd ? "text" : "password"} placeholder="••••••••" value={password}
|
||||
<Input id="qlik-mdp" type={showPwd ? "text" : "password"} placeholder={placeholderMotDePasse(motDePasseEnregistre)} value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} className="w-full pr-10 font-mono" autoComplete="new-password" />
|
||||
<Button
|
||||
type="button"
|
||||
@@ -542,24 +565,21 @@ export function ParametresClient({ onglet: ongletDemande }: { onglet?: string })
|
||||
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 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);
|
||||
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, setPassword, setPort, setSsl, setUser]);
|
||||
}, [setDatabase, setHost, setMotDePasseEnregistre, setPassword, setPort, setSsl, setUser]);
|
||||
|
||||
useEffect(() => {
|
||||
// `isMounted` sert à éviter un écart d'hydratation (réglages PostgreSQL
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { verifierAdmin } from "@/lib/authz";
|
||||
import { ParametresClient } from "./client";
|
||||
|
||||
export const metadata: Metadata = { title: "Paramètres" };
|
||||
@@ -11,6 +13,9 @@ export const metadata: Metadata = { title: "Paramètres" };
|
||||
* lien se partage et survive au rechargement, puis rend la page cliente.
|
||||
*/
|
||||
export default async function SettingsPage(props: { searchParams: Promise<{ onglet?: string }> }) {
|
||||
// Le middleware filtre déjà sur le rôle du jeton ; ici, rôle relu en base.
|
||||
if (!(await verifierAdmin()).ok) redirect("/dashboard");
|
||||
|
||||
const { onglet } = await props.searchParams;
|
||||
return <ParametresClient onglet={onglet} />;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { adminOuReponse } from "@/lib/authz";
|
||||
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
|
||||
import { getProductRows } from "@/features/grid/api/get-product-rows";
|
||||
import { listGridSuppliers } from "@/lib/grid-store";
|
||||
@@ -45,15 +45,6 @@ function publicJob(j: WarmupJob | null) {
|
||||
return j;
|
||||
}
|
||||
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function runWarmup(j: WarmupJob, staleHours: number): Promise<void> {
|
||||
try {
|
||||
const fournisseurs = await pgGetFournisseurs();
|
||||
@@ -102,8 +93,8 @@ async function runWarmup(j: WarmupJob, staleHours: number): Promise<void> {
|
||||
|
||||
/** GET /api/admin/grid-warmup — avancement du préchauffage. */
|
||||
export async function GET() {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
}
|
||||
|
||||
@@ -112,8 +103,8 @@ export async function GET() {
|
||||
* Démarre le préchauffage en arrière-plan et répond immédiatement.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
if (job?.status === "running") {
|
||||
return NextResponse.json({ success: true, ...publicJob(job) });
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { asc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { adminOuReponse } from "@/lib/authz";
|
||||
import { db } from "@/db";
|
||||
import { syncFournisseurs } from "@/db/schema";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET — la liste complète des fournisseurs paramétrés, avec l'état de leur
|
||||
* dernière synchronisation.
|
||||
@@ -25,8 +16,8 @@ async function requireAdmin(): Promise<NextResponse | null> {
|
||||
* besoin de la vue d'ensemble pour décider qui cadencer.
|
||||
*/
|
||||
export async function GET() {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
@@ -65,8 +56,8 @@ const patchSchema = z.object({
|
||||
* trompeur, alors même qu'il vient d'être rouvert volontairement.
|
||||
*/
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { adminOuReponse } from "@/lib/authz";
|
||||
import { readSyncSettings, saveSyncSettings } from "@/features/admin/api/sync-settings";
|
||||
import {
|
||||
getSyncSchedulerState,
|
||||
@@ -15,19 +15,10 @@ export const dynamic = "force-dynamic";
|
||||
// Un lancement manuel peut enchaîner plusieurs fournisseurs.
|
||||
export const maxDuration = 300;
|
||||
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** GET — réglages, état courant, et prochaine ouverture de fenêtre. */
|
||||
export async function GET() {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
const settings = await readSyncSettings();
|
||||
const maintenant = new Date();
|
||||
@@ -55,8 +46,8 @@ const settingsSchema = z.object({
|
||||
|
||||
/** PATCH — met à jour les réglages. */
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
@@ -87,8 +78,8 @@ const actionSchema = z.object({
|
||||
|
||||
/** POST — démarre un round manuellement, l'arrête, ou resynchronise la liste. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
|
||||
@@ -9,6 +10,9 @@ const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
* Transfère les query params site et codefou si présents.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
try {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { adminOuReponse } from "@/lib/authz";
|
||||
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
|
||||
@@ -31,6 +32,9 @@ async function probe(url: string) {
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const acces = await adminOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
|
||||
// Date range: 12 derniers mois
|
||||
@@ -55,7 +59,7 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
if (!firstCodein) {
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=1`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=1`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
|
||||
if (list.length > 0) firstCodein = (list[0] as Record<string, unknown>).codein as string ?? null;
|
||||
@@ -80,13 +84,13 @@ export async function GET(req: NextRequest) {
|
||||
referentiel_forced,
|
||||
] = await Promise.all([
|
||||
probe(`${FF_API_BASE}/api/fournisseurs?limit=2`),
|
||||
probe(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=2`),
|
||||
probe(`${FF_API_BASE}/api/mouvements/articles?codefou=${codefou}&dateDebut=${dateDebut}&dateFin=${dateFin}&limit=2`),
|
||||
probe(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=2`),
|
||||
probe(`${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&dateDebut=${dateDebut}&dateFin=${dateFin}&limit=2`),
|
||||
// Test mensuel avec no_id auto-résolu
|
||||
firstCodein
|
||||
? (async () => {
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=1`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=1`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
|
||||
const noid = list[0] ? (list[0] as Record<string, unknown>).no_id as string : null;
|
||||
@@ -99,7 +103,7 @@ export async function GET(req: NextRequest) {
|
||||
firstCodein
|
||||
? (async () => {
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${codefou}&limit=5`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=5`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []);
|
||||
const art = (list[4] ?? list[0]) as Record<string, unknown> | undefined;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { pgDiagFournisseurDerniereEntree } from "@/lib/pg-ff-client";
|
||||
import { adminOuReponse } from "@/lib/authz";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,6 +14,9 @@ export const dynamic = "force-dynamic";
|
||||
* blanche `MVTART_FOU_CANDIDATES` si le nom diffère.
|
||||
*/
|
||||
export async function GET() {
|
||||
const acces = await adminOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
try {
|
||||
return NextResponse.json(await pgDiagFournisseurDerniereEntree());
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import ExcelJS from "exceljs";
|
||||
import { z } from "zod";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
|
||||
const ExportSchema = z.object({
|
||||
nomFournisseur: z.string(),
|
||||
@@ -19,6 +20,9 @@ const ExportSchema = z.object({
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const body = await req.json();
|
||||
const parsed = ExportSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import ExcelJS from "exceljs";
|
||||
import { z } from "zod";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
|
||||
const ExportModifiedGammesSchema = z.object({
|
||||
nomFournisseur: z.string(),
|
||||
@@ -13,6 +14,9 @@ const ExportModifiedGammesSchema = z.object({
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const body = await req.json();
|
||||
const parsed = ExportModifiedGammesSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { testFfApiConnection } from "@/features/settings/actions";
|
||||
import { diagnostiquerApiFf } from "@/features/settings/ff-api-diagnostic";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -13,7 +14,10 @@ export const dynamic = "force-dynamic";
|
||||
* détail, impossible à distinguer d'une simple erreur d'URL.
|
||||
*/
|
||||
export async function GET() {
|
||||
const res = await testFfApiConnection();
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const res = await diagnostiquerApiFf();
|
||||
if (!res.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "API FF Nancy non disponible", url: res.url, detail: res.error },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getProductRows } from "@/features/grid/api/get-product-rows";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
import type { GridFilters } from "@/types/grid";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -11,6 +12,9 @@ function writeNdjson(controller: ReadableStreamDefaultController<Uint8Array>, va
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const codeFournisseur = searchParams.get("fournisseur");
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getStorePatch } from "@/features/grid/api/get-product-rows";
|
||||
import { MAGASINS } from "@/lib/magasins";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/grid/rows/store-patch?fournisseur=…&magasin=…
|
||||
*
|
||||
* Complément de l'API FF pour un magasin (cf. `features/grid/lib/store-patch.ts`).
|
||||
* La Grille charge toujours les lignes « tous magasins » et applique ce
|
||||
* complément à son arrivée : le changement de magasin n'attend plus rien.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const codeFournisseur = request.nextUrl.searchParams.get("fournisseur");
|
||||
const magasin = request.nextUrl.searchParams.get("magasin") ?? "";
|
||||
// Un code inconnu ferait de chaque article un candidat : autant d'appels
|
||||
// inutiles à l'API FF. « Tous magasins » n'a pas de complément.
|
||||
if (!codeFournisseur || !MAGASINS.some((m) => m.code === magasin)) {
|
||||
return Response.json({ error: "Paramètres fournisseur et magasin requis." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const patch = await getStorePatch(codeFournisseur, magasin);
|
||||
return Response.json(patch, { headers: { "Cache-Control": "no-store" } });
|
||||
} catch (error) {
|
||||
console.error(`[store-patch] ${codeFournisseur}/${magasin} KO:`, (error as Error).message?.slice(0, 200));
|
||||
return Response.json({ error: "Complément indisponible." }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -11,24 +11,15 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { adminOuReponse } from "@/lib/authz";
|
||||
import { listCaptures, readCapture } from "@/lib/log-capture";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const refus = await requireAdmin();
|
||||
if (refus) return refus;
|
||||
const refus = await adminOuReponse();
|
||||
if (refus instanceof Response) return refus;
|
||||
|
||||
const id = req.nextUrl.searchParams.get("id");
|
||||
if (!id) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
import { pgGetOpportunitesFamille } from "@/lib/pg-ff-client";
|
||||
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
|
||||
|
||||
@@ -12,14 +12,10 @@ export const dynamic = "force-dynamic";
|
||||
* et performance locale (quantités par magasin). Chargé à la demande depuis la
|
||||
* fiche produit : la requête agrège `mvtart` sur 12 mois pour toute la famille,
|
||||
* on ne veut pas la payer au chargement de la page.
|
||||
*
|
||||
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const acces = await sessionOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
const raw = req.nextUrl.searchParams.get("nomNoId");
|
||||
const nomNoId = Number(raw);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sessionOuReponse } from "@/lib/authz";
|
||||
import { demarrerRecherche, etatRecherche } from "@/features/produits/api/search-produits";
|
||||
|
||||
// Les réponses sont immédiates (le travail Qlik tourne en tâche de fond), mais on
|
||||
@@ -21,23 +21,18 @@ export const dynamic = "force-dynamic";
|
||||
* d'erreur **HTML** — le client échouait alors sur « Unexpected token '<' … is
|
||||
* not valid JSON ». Même schéma que `POST /api/qlik/sync`.
|
||||
*
|
||||
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
|
||||
* Contrôle de session explicite en plus du middleware : il répond ici en JSON,
|
||||
* et relit le compte en base (cf. lib/authz).
|
||||
*/
|
||||
|
||||
async function requireSession(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return null;
|
||||
}
|
||||
|
||||
function lireTerme(req: NextRequest): string | null {
|
||||
const q = (req.nextUrl.searchParams.get("q") ?? "").trim();
|
||||
return q.length >= 3 ? q : null;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
const denied = await sessionOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
const q = lireTerme(req);
|
||||
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
|
||||
@@ -54,8 +49,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
const denied = await sessionOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
const q = lireTerme(req);
|
||||
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { adminOuReponse, sessionOuReponse } from "@/lib/authz";
|
||||
import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
|
||||
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
||||
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
|
||||
@@ -121,34 +121,6 @@ function idleJob(fournisseur: string, codeCentrale?: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie l'auth admin. Renvoie une réponse 403/401 si refusée, sinon null.
|
||||
*/
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie qu'une session existe (sans exiger le rôle admin).
|
||||
*
|
||||
* Utilisé par le mode produit : le middleware Next ne couvre PAS les routes
|
||||
* `/api/*`, le contrôle doit donc être fait ici explicitement.
|
||||
*/
|
||||
async function requireSession(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex de validation d'un code centrale "raisonnable".
|
||||
*
|
||||
@@ -337,8 +309,8 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
// Mode produit : ouvert à tout utilisateur connecté.
|
||||
if (codeCentrale) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
const denied = await sessionOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
const job = jobs.get(jobKeyForProduct(codeCentrale));
|
||||
if (!job) {
|
||||
return NextResponse.json({ success: true, ...idleJob("", codeCentrale) });
|
||||
@@ -347,8 +319,8 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
// Mode fournisseur : admin uniquement (inchangé).
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
if (!fournisseur) {
|
||||
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
|
||||
@@ -371,8 +343,8 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// ─── Mode produit : 1 code centrale, ouvert à tout utilisateur connecté ───
|
||||
if (codeCentraleParam) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
const denied = await sessionOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
// Même validation que la sync fournisseur (trim, rejet des vides / "-" /
|
||||
// codes hors charset) pour ne jamais envoyer de saleté à l'Engine Qlik.
|
||||
@@ -414,8 +386,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// ─── Mode fournisseur : admin uniquement (inchangé) ──────────────────────
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
const denied = await adminOuReponse();
|
||||
if (denied instanceof Response) return denied;
|
||||
|
||||
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
|
||||
if (!fournisseur) {
|
||||
|
||||
@@ -2,8 +2,12 @@ import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { pgGetStockForCodeins } from '@/lib/pg-ff-client';
|
||||
import { adminOuReponse } from '@/lib/authz';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const acces = await adminOuReponse();
|
||||
if (acces instanceof Response) return acces;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const codein = searchParams.get('codein') || '334152';
|
||||
@@ -24,7 +28,9 @@ export async function GET(request: Request) {
|
||||
stock: Object.fromEntries(map),
|
||||
fou: fouResult.rows,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e.message, stack: e.stack }, { status: 500 });
|
||||
} catch (e) {
|
||||
// Pas de pile d'appels dans la réponse : elle reste dans les journaux du serveur.
|
||||
console.error('[api/test-db]', e);
|
||||
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -11,18 +11,10 @@
|
||||
import { db } from "@/db";
|
||||
import { apiKeys } from "@/db/schema";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { requireAdmin } from "@/lib/authz";
|
||||
import { generateApiKey } from "@/lib/api-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
async function ensureAdmin() {
|
||||
const session = await auth();
|
||||
if ((session?.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
throw new Error("Accès refusé : Droits administrateur requis.");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export interface ApiKeyRow {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -36,7 +28,7 @@ export interface ApiKeyRow {
|
||||
|
||||
/** Liste les clés. Ne renvoie jamais de secret — seulement le préfixe lisible. */
|
||||
export async function getApiKeys(): Promise<ApiKeyRow[]> {
|
||||
await ensureAdmin();
|
||||
await requireAdmin();
|
||||
const rows = await db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
@@ -67,7 +59,7 @@ export async function createApiKey(
|
||||
name: string,
|
||||
role: "admin" | "user" = "user",
|
||||
): Promise<{ success: true; key: string; keyPrefix: string } | { success: false; error: string }> {
|
||||
const session = await ensureAdmin();
|
||||
const moi = await requireAdmin();
|
||||
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return { success: false, error: "Le nom de la clé est obligatoire." };
|
||||
@@ -80,7 +72,7 @@ export async function createApiKey(
|
||||
keyPrefix,
|
||||
keyHash,
|
||||
role,
|
||||
createdBy: (session?.user as { name?: string | null } | undefined)?.name ?? null,
|
||||
createdBy: moi.username || null,
|
||||
});
|
||||
revalidatePath("/settings");
|
||||
return { success: true, key, keyPrefix };
|
||||
@@ -92,7 +84,7 @@ export async function createApiKey(
|
||||
|
||||
/** Révoque une clé : elle est refusée dès l'appel suivant, mais reste listée. */
|
||||
export async function revokeApiKey(id: number): Promise<{ success: boolean; error?: string }> {
|
||||
await ensureAdmin();
|
||||
await requireAdmin();
|
||||
try {
|
||||
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id));
|
||||
revalidatePath("/settings");
|
||||
@@ -105,7 +97,7 @@ export async function revokeApiKey(id: number): Promise<{ success: boolean; erro
|
||||
|
||||
/** Supprime définitivement une clé révoquée (nettoyage de la liste). */
|
||||
export async function deleteApiKey(id: number): Promise<{ success: boolean; error?: string }> {
|
||||
await ensureAdmin();
|
||||
await requireAdmin();
|
||||
try {
|
||||
await db.delete(apiKeys).where(eq(apiKeys.id, id));
|
||||
revalidatePath("/settings");
|
||||
|
||||
@@ -4,24 +4,14 @@ import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { hashPassword } from "@/features/auth/logic/auth-logic";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { requireAdmin } from "@/lib/authz";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur actuel est un administrateur.
|
||||
*/
|
||||
async function ensureAdmin() {
|
||||
const session = await auth();
|
||||
if ((session?.user as any)?.role !== "admin") {
|
||||
throw new Error("Accès refusé : Droits administrateur requis.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère tous les utilisateurs (Admin seulement).
|
||||
*/
|
||||
export async function getUsers() {
|
||||
await ensureAdmin();
|
||||
await requireAdmin();
|
||||
return db.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
@@ -34,7 +24,7 @@ export async function getUsers() {
|
||||
* Crée un nouvel utilisateur.
|
||||
*/
|
||||
export async function createUser(username: string, password: string, role: "admin" | "user" = "user") {
|
||||
await ensureAdmin();
|
||||
await requireAdmin();
|
||||
|
||||
try {
|
||||
await db.insert(users).values({
|
||||
@@ -54,10 +44,8 @@ export async function createUser(username: string, password: string, role: "admi
|
||||
* Supprime un utilisateur.
|
||||
*/
|
||||
export async function deleteUser(id: number) {
|
||||
await ensureAdmin();
|
||||
|
||||
const session = await auth();
|
||||
if (Number((session?.user as any)?.id) === id) {
|
||||
const moi = await requireAdmin();
|
||||
if (Number(moi.id) === id) {
|
||||
return { success: false, error: "Vous ne pouvez pas supprimer votre propre compte." };
|
||||
}
|
||||
|
||||
@@ -65,7 +53,7 @@ export async function deleteUser(id: number) {
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
revalidatePath("/settings");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return { success: false, error: "Erreur lors de la suppression." };
|
||||
}
|
||||
}
|
||||
@@ -74,13 +62,13 @@ export async function deleteUser(id: number) {
|
||||
* Met à jour le mot de passe d'un utilisateur.
|
||||
*/
|
||||
export async function updatePassword(id: number, newPassword: string) {
|
||||
await ensureAdmin();
|
||||
await requireAdmin();
|
||||
try {
|
||||
await db.update(users)
|
||||
.set({ passwordHash: hashPassword(newPassword) })
|
||||
.where(eq(users.id, id));
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return { success: false, error: "Erreur lors de la mise à jour du mot de passe." };
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,46 @@
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { hashPassword, getFallbackUsers, saveFallbackUsers } from "./auth-logic";
|
||||
import { count, eq, sql } from "drizzle-orm";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
/** Amorçage en cours ou réussi pour ce processus (remis à null en cas d'échec). */
|
||||
let amorcage: Promise<boolean> | null = null;
|
||||
|
||||
/**
|
||||
* Checks if the users table is empty.
|
||||
* If so, creates a default admin account (admin/admin).
|
||||
* Also ensures the table exists (auto-repair).
|
||||
* Prépare les comptes au premier démarrage, une seule fois par processus.
|
||||
*
|
||||
* - Base joignable : crée la table `users` si besoin (auto-réparation), puis le
|
||||
* compte admin/admin **uniquement si la table est vide**. Un admin supprimé ou
|
||||
* renommé n'est donc pas recréé à chaque connexion. Le fichier de secours
|
||||
* `data/users.json` n'est pas touché.
|
||||
* - Base injoignable (première installation, base pas encore configurée) : on
|
||||
* s'assure que le fichier de secours contient un administrateur, et on
|
||||
* retentera la base à la prochaine connexion.
|
||||
*
|
||||
* Renvoie true si le compte admin/admin vient d'être créé en base.
|
||||
*/
|
||||
export async function ensureAdminExists() {
|
||||
// 1. Always ensure local JSON fallback has at least one admin
|
||||
const fallbackUsers = getFallbackUsers();
|
||||
if (fallbackUsers.length === 0) {
|
||||
console.log("[AUTH] Initializing local JSON fallback with admin/admin...");
|
||||
saveFallbackUsers([{
|
||||
id: "0",
|
||||
username: "admin",
|
||||
passwordHash: hashPassword("admin"),
|
||||
role: "admin"
|
||||
}]);
|
||||
export function ensureAdminExists(): Promise<boolean> {
|
||||
if (!amorcage) {
|
||||
amorcage = amorcer().catch((err: unknown) => {
|
||||
// Échec : on retentera à la prochaine connexion.
|
||||
amorcage = null;
|
||||
console.warn("[AUTH] Database seeding failed (expected if DB not configured yet):", (err as Error)?.message ?? err);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return amorcage;
|
||||
}
|
||||
|
||||
try {
|
||||
async function amorcer(): Promise<boolean> {
|
||||
console.log("[AUTH] Testing database connection for seeding...");
|
||||
try {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
} catch (err) {
|
||||
assurerAdminDeSecours();
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 2. Auto-repair: Ensure table exists
|
||||
// Auto-repair: Ensure table exists
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "users" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -36,23 +51,30 @@ export async function ensureAdminExists() {
|
||||
);
|
||||
`);
|
||||
|
||||
// 3. Sync admin to DB
|
||||
const adminUser = await db.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, "admin"))
|
||||
.limit(1);
|
||||
const [{ nb }] = await db.select({ nb: sql<number>`count(*)::int` }).from(users);
|
||||
if (nb > 0) return false;
|
||||
|
||||
if (adminUser.length === 0) {
|
||||
console.log("[AUTH] Seeding admin to DB...");
|
||||
await db.insert(users).values({
|
||||
console.log("[AUTH] Table users vide : création du compte admin/admin.");
|
||||
// Si un autre processus l'a créé entre-temps, l'unicité du nom fait échouer
|
||||
// l'insertion : on la laisse sans effet plutôt que de relancer l'amorçage.
|
||||
const crees = await db.insert(users).values({
|
||||
username: "admin",
|
||||
passwordHash: hashPassword("admin"),
|
||||
role: "admin",
|
||||
}).onConflictDoNothing().returning({ id: users.id });
|
||||
return crees.length > 0;
|
||||
}
|
||||
|
||||
/** Base injoignable : le fichier de secours doit contenir au moins un administrateur. */
|
||||
function assurerAdminDeSecours() {
|
||||
const fallbackUsers = getFallbackUsers();
|
||||
if (fallbackUsers.length === 0) {
|
||||
console.log("[AUTH] Initializing local JSON fallback with admin/admin...");
|
||||
saveFallbackUsers([{
|
||||
id: "0",
|
||||
username: "admin",
|
||||
passwordHash: hashPassword("admin"),
|
||||
role: "admin"
|
||||
});
|
||||
return true;
|
||||
}]);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn("[AUTH] Database seeding failed (expected if DB not configured yet):", err.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { and, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { commandeCadences } from "@/db/schema";
|
||||
import { getDerniereReceptionCached, getFournisseursCached } from "@/lib/ff-cache";
|
||||
import { requireSession, verifierSession } from "@/lib/authz";
|
||||
|
||||
export type CadenceStatut = "a_commander" | "bientot" | "ok" | "inconnu";
|
||||
|
||||
@@ -37,6 +38,7 @@ function calcStatut(joursRestants: number | null): CadenceStatut {
|
||||
|
||||
/** Liste des fournisseurs (référentiel) pour le sélecteur de cadence. */
|
||||
export async function getFournisseursPourCadence(): Promise<{ code: string; nom: string }[]> {
|
||||
await requireSession();
|
||||
return getFournisseursCached();
|
||||
}
|
||||
|
||||
@@ -45,6 +47,7 @@ export async function getFournisseursPourCadence(): Promise<{ code: string; nom:
|
||||
* de la dernière réception (FF Nancy).
|
||||
*/
|
||||
export async function listCadences(): Promise<CadenceView[]> {
|
||||
await requireSession();
|
||||
let rows: typeof commandeCadences.$inferSelect[] = [];
|
||||
let receptions = new Map<string, string>();
|
||||
try {
|
||||
@@ -95,6 +98,9 @@ export interface UpsertCadenceInput {
|
||||
|
||||
/** Crée ou met à jour la cadence d'un fournisseur sur un site. */
|
||||
export async function upsertCadence(input: UpsertCadenceInput): Promise<{ ok: boolean; error?: string }> {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { ok: false, error: acces.message };
|
||||
|
||||
const codefou = input.codefou?.trim();
|
||||
const site = input.site?.trim();
|
||||
const intervalle = Math.round(Number(input.intervalleSemaines));
|
||||
@@ -132,7 +138,10 @@ export async function upsertCadence(input: UpsertCadenceInput): Promise<{ ok: bo
|
||||
}
|
||||
|
||||
/** Active / désactive une cadence sans la supprimer. */
|
||||
export async function toggleCadence(codefou: string, site: string, actif: boolean): Promise<{ ok: boolean }> {
|
||||
export async function toggleCadence(codefou: string, site: string, actif: boolean): Promise<{ ok: boolean; error?: string }> {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { ok: false, error: acces.message };
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(commandeCadences)
|
||||
@@ -147,7 +156,10 @@ export async function toggleCadence(codefou: string, site: string, actif: boolea
|
||||
}
|
||||
|
||||
/** Supprime définitivement une cadence. */
|
||||
export async function removeCadence(codefou: string, site: string): Promise<{ ok: boolean }> {
|
||||
export async function removeCadence(codefou: string, site: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { ok: false, error: acces.message };
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(commandeCadences)
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import { getSitesFromApi } from "@/lib/api-ff-client";
|
||||
import { getFournisseursCached } from "@/lib/ff-cache";
|
||||
import { getProductRows } from "./api/get-product-rows";
|
||||
import { requireSession } from "@/lib/authz";
|
||||
import type { ProductRow, GridFilters } from "@/types/grid";
|
||||
|
||||
/**
|
||||
* Get the list of all suppliers from PostgreSQL (fouadr1).
|
||||
*/
|
||||
export async function getFournisseurs() {
|
||||
await requireSession();
|
||||
// Relu à chaque navigation dans la Grille (changement de fournisseur, de
|
||||
// magasin…) : le référentiel ne change qu'avec la recopie nocturne.
|
||||
return getFournisseursCached();
|
||||
@@ -18,6 +20,7 @@ export async function getFournisseurs() {
|
||||
* Get the list of all stores (sites) from the FF Nancy API.
|
||||
*/
|
||||
export async function getMagasins() {
|
||||
await requireSession();
|
||||
return getSitesFromApi();
|
||||
}
|
||||
|
||||
@@ -26,6 +29,7 @@ export async function getMagasins() {
|
||||
* Returns an empty hierarchy so the sidebar filter is hidden gracefully.
|
||||
*/
|
||||
export async function getAvailableNomenclature() {
|
||||
await requireSession();
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -37,5 +41,6 @@ export async function getGridData(
|
||||
magasin: string = "TOTAL",
|
||||
filters?: Partial<GridFilters>
|
||||
): Promise<ProductRow[]> {
|
||||
await requireSession();
|
||||
return getProductRows({ codeFournisseur, magasin, filters });
|
||||
}
|
||||
@@ -19,6 +19,13 @@ import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib
|
||||
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
// Persiste l'instantané lu par /api/v1 : sans lui, l'API n'aurait aucune donnée.
|
||||
import { readGridSnapshot, upsertGridRows } from "@/lib/grid-store";
|
||||
import {
|
||||
applyStorePatchInPlace,
|
||||
buildStorePatchEntries,
|
||||
storePatchCandidates,
|
||||
type StorePatch,
|
||||
type StorePatchEntry,
|
||||
} from "@/features/grid/lib/store-patch";
|
||||
|
||||
interface GetProductRowsInput {
|
||||
codeFournisseur: string;
|
||||
@@ -568,28 +575,27 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
}
|
||||
}
|
||||
|
||||
async function reconcileSelectedStoreFromMensuelApi(
|
||||
/** Articles complétés par l'API FF, au plus, lors d'un changement de magasin. */
|
||||
function plafondComplement(): number {
|
||||
return Number(process.env.GRID_STORE_RECONCILE_MAX ?? 300);
|
||||
}
|
||||
|
||||
/** Interroge l'API FF pour les articles du magasin à compléter (cf. store-patch). */
|
||||
async function fetchStorePatchEntries(
|
||||
rows: ProductRow[],
|
||||
magasin: string,
|
||||
dateDebut: string,
|
||||
dateFin: string,
|
||||
sortedPeriods: string[]
|
||||
) {
|
||||
if (magasin === "TOTAL") return;
|
||||
|
||||
const candidates = rows.filter((row) => {
|
||||
if (!row.noid) return false;
|
||||
const storeQty = row.quantiteByStore?.[magasin] ?? 0;
|
||||
return storeQty === 0;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) return;
|
||||
periods: string[],
|
||||
): Promise<StorePatchEntry[]> {
|
||||
const candidates = storePatchCandidates(rows, magasin);
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
// Ce rattrapage fait UNE requête HTTP par article : sur un gros fournisseur,
|
||||
// les candidats se comptent par milliers et le changement de magasin se fige
|
||||
// plusieurs minutes. On le borne, et on dit ce qui a été laissé de côté
|
||||
// plutôt que de tronquer en silence.
|
||||
const PLAFOND = Number(process.env.GRID_STORE_RECONCILE_MAX ?? 300);
|
||||
const PLAFOND = plafondComplement();
|
||||
const retenus = candidates.slice(0, PLAFOND);
|
||||
if (candidates.length > retenus.length) {
|
||||
console.warn(
|
||||
@@ -598,7 +604,6 @@ async function reconcileSelectedStoreFromMensuelApi(
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const mensuelMap = await getMensuelByArticles(
|
||||
retenus.map((row) => ({
|
||||
codein: row.codein,
|
||||
@@ -610,76 +615,20 @@ async function reconcileSelectedStoreFromMensuelApi(
|
||||
dateFin,
|
||||
25
|
||||
);
|
||||
|
||||
let fixedRows = 0;
|
||||
for (const row of retenus) {
|
||||
const entries = (mensuelMap.get(row.codein) ?? []).filter((entry) => entry.site === magasin);
|
||||
if (entries.length === 0) continue;
|
||||
|
||||
const byPeriod = new Map<string, { qty: number; ca: number; marge: number; stock: number; receptions: number }>();
|
||||
for (const entry of entries) {
|
||||
const period = entry.mois.replace("-", "");
|
||||
if (!sortedPeriods.includes(period)) continue;
|
||||
// qte_vendue / ca_ht sont NÉGATIFS côté API (ventes nettes) : on
|
||||
// les nie au lieu de Math.abs pour que les retours restent déduits.
|
||||
const qty = -(Number(entry.ventes?.qte_vendue ?? 0) || 0);
|
||||
const ca = -(Number(entry.ventes?.ca_ht ?? 0) || 0);
|
||||
const marge = Number(entry.ventes?.marge ?? 0) || 0;
|
||||
const stock = Number(entry.stock_fin_mois ?? 0) || 0;
|
||||
const receptions = Number(entry.receptions?.qte_recue ?? 0) || 0;
|
||||
byPeriod.set(period, { qty, ca, marge, stock, receptions });
|
||||
}
|
||||
|
||||
const apiQty = [...byPeriod.values()].reduce((sum, value) => sum + value.qty, 0);
|
||||
if (apiQty === 0) continue;
|
||||
|
||||
row.sales12mByStore ??= {};
|
||||
row.stock12mByStore ??= {};
|
||||
row.receptions12mByStore ??= {};
|
||||
row.caByStore ??= {};
|
||||
row.quantiteByStore ??= {};
|
||||
row.margeByStore ??= {};
|
||||
row.sales12mByStore[magasin] ??= {};
|
||||
row.stock12mByStore[magasin] ??= {};
|
||||
row.receptions12mByStore[magasin] ??= {};
|
||||
|
||||
let storeQty = 0;
|
||||
let storeCa = 0;
|
||||
let storeMarge = 0;
|
||||
let lastStock = 0;
|
||||
|
||||
for (const period of sortedPeriods) {
|
||||
const value = byPeriod.get(period);
|
||||
const currentQty = row.sales12mByStore[magasin][period] ?? 0;
|
||||
const nextQty = value?.qty ?? 0;
|
||||
const deltaQty = nextQty - currentQty;
|
||||
|
||||
row.sales12mByStore[magasin][period] = nextQty;
|
||||
row.receptions12mByStore[magasin][period] = value?.receptions ?? 0;
|
||||
if (value) lastStock = value.stock;
|
||||
row.stock12mByStore[magasin][period] = lastStock;
|
||||
|
||||
row.sales12m[period] = (row.sales12m[period] ?? 0) + deltaQty;
|
||||
storeQty += nextQty;
|
||||
storeCa += value?.ca ?? 0;
|
||||
storeMarge += value?.marge ?? 0;
|
||||
}
|
||||
|
||||
const deltaTotalQty = storeQty - (row.quantiteByStore[magasin] ?? 0);
|
||||
const deltaTotalCa = storeCa - (row.caByStore[magasin] ?? 0);
|
||||
const deltaTotalMarge = storeMarge - (row.margeByStore[magasin] ?? 0);
|
||||
|
||||
row.quantiteByStore[magasin] = storeQty;
|
||||
row.caByStore[magasin] = storeCa;
|
||||
row.margeByStore[magasin] = storeMarge;
|
||||
row.totalQuantite += deltaTotalQty;
|
||||
row.totalCa += deltaTotalCa;
|
||||
row.totalMarge += deltaTotalMarge;
|
||||
row.tauxMarge = row.totalCa > 0 ? (row.totalMarge / row.totalCa) * 100 : 0;
|
||||
if (!row.workingStores.includes(magasin)) row.workingStores.push(magasin);
|
||||
fixedRows++;
|
||||
return buildStorePatchEntries(retenus, magasin, periods, mensuelMap);
|
||||
}
|
||||
|
||||
async function reconcileSelectedStoreFromMensuelApi(
|
||||
rows: ProductRow[],
|
||||
magasin: string,
|
||||
dateDebut: string,
|
||||
dateFin: string,
|
||||
sortedPeriods: string[]
|
||||
) {
|
||||
if (magasin === "TOTAL") return;
|
||||
try {
|
||||
const entries = await fetchStorePatchEntries(rows, magasin, dateDebut, dateFin, sortedPeriods);
|
||||
const fixedRows = applyStorePatchInPlace(rows, magasin, sortedPeriods, entries);
|
||||
if (fixedRows > 0) {
|
||||
console.log(`[getProductRows] ${fixedRows} produits corrigés via API mensuelle pour magasin ${magasin}`);
|
||||
}
|
||||
@@ -688,6 +637,46 @@ async function reconcileSelectedStoreFromMensuelApi(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compléments par magasin, rattachés au tableau de lignes « tous magasins » en
|
||||
* cache : ils vivent et meurent avec lui (expiration, « Actualiser », nouvel
|
||||
* enregistrement), sans autre invalidation à gérer.
|
||||
*/
|
||||
const storePatches = new WeakMap<ProductRow[], Map<string, Promise<StorePatch>>>();
|
||||
|
||||
/**
|
||||
* Complément de l'API FF pour un magasin, calculé sur les lignes « tous
|
||||
* magasins » (jamais modifiées ici). La Grille l'applique à son arrivée, sans
|
||||
* recharger les lignes. Les demandes simultanées partagent le même calcul, et
|
||||
* un échec n'est jamais gardé.
|
||||
*/
|
||||
export async function getStorePatch(codeFournisseur: string, magasin: string): Promise<StorePatch> {
|
||||
const enCache = gridRowsCache.get(`${codeFournisseur}:TOTAL`);
|
||||
const rows = enCache && Date.now() - enCache.createdAt < GRID_ROWS_CACHE_TTL_MS
|
||||
? enCache.rows
|
||||
: await getProductRows({ codeFournisseur, magasin: "TOTAL" });
|
||||
|
||||
let parMagasin = storePatches.get(rows);
|
||||
if (!parMagasin) {
|
||||
parMagasin = new Map();
|
||||
storePatches.set(rows, parMagasin);
|
||||
}
|
||||
let patch = parMagasin.get(magasin);
|
||||
if (!patch) {
|
||||
const periods = last12Periods();
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
const enCours = fetchStorePatchEntries(rows, magasin, dateDebut, dateFin, periods)
|
||||
.then((entries): StorePatch => ({ magasin, periods, entries }));
|
||||
const table = parMagasin;
|
||||
enCours.catch(() => {
|
||||
if (table.get(magasin) === enCours) table.delete(magasin);
|
||||
});
|
||||
parMagasin.set(magasin, enCours);
|
||||
patch = enCours;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait la série « nombre de magasins vendeurs » du détail mensuel complet.
|
||||
*
|
||||
|
||||
@@ -4,7 +4,7 @@ import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { verifierSession } from "@/lib/authz";
|
||||
import { patchGridRowsCache } from "./get-product-rows";
|
||||
import { updateGridRowsGamme } from "@/lib/grid-store";
|
||||
|
||||
@@ -24,6 +24,9 @@ const SaveDraftsSchema = z.object({
|
||||
export async function saveDraftChanges(
|
||||
raw: unknown
|
||||
): Promise<{ success: boolean; saved: number; error?: string }> {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { success: false, saved: 0, error: acces.message };
|
||||
|
||||
const parsed = SaveDraftsSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return { success: false, saved: 0, error: "Validation failed: " + parsed.error.message };
|
||||
@@ -31,9 +34,8 @@ export async function saveDraftChanges(
|
||||
|
||||
const { codeFournisseur, nomFournisseur, magasin, changes } = parsed.data;
|
||||
|
||||
const session = await auth();
|
||||
const rawUserId = (session?.user as { id?: string | number })?.id;
|
||||
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
|
||||
const rawUserId = acces.utilisateur.id;
|
||||
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
|
||||
const finalUserId = userId && !isNaN(userId) ? userId : null;
|
||||
|
||||
try {
|
||||
|
||||
@@ -5,7 +5,8 @@ import { HeatmapGrid } from "@/features/grid/components/heatmap-grid";
|
||||
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
|
||||
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
|
||||
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useStorePatch } from "@/features/grid/hooks/use-store-patch";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
import { AlertCircle, Loader2, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -61,6 +62,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
const setFilter = useGridStore((s) => s.setFilter);
|
||||
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
|
||||
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
|
||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
|
||||
@@ -107,18 +109,21 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
const controller = new AbortController();
|
||||
const params = new URLSearchParams();
|
||||
params.set("fournisseur", codeFournisseur);
|
||||
params.set("magasin", magasin || "TOTAL");
|
||||
// Toujours « tous magasins » : les lignes portent le détail de chaque
|
||||
// magasin, le changement de magasin se fait donc sans rien recharger
|
||||
// (les compléments de l'API FF arrivent à part, cf. useStorePatch).
|
||||
params.set("magasin", "TOTAL");
|
||||
// Les filtres de nomenclature (code1/code2/code3) ne sont pas transmis : le
|
||||
// serveur ne s'en sert pas et la Grille les applique en local. Les faire
|
||||
// voyager relançait un chargement complet à chaque changement.
|
||||
const forceRefresh = refreshRequest !== servedRefreshRef.current;
|
||||
if (forceRefresh) params.set("refresh", "1");
|
||||
|
||||
const cle = `${codeFournisseur}:${magasin || "TOTAL"}`;
|
||||
const cle = rowsKeyFor(codeFournisseur);
|
||||
const { rowsMeta, rows: lignesEnMemoire, setRowsMeta } = useGridStore.getState();
|
||||
|
||||
// Retour sur la Grille (même fournisseur, même magasin, chargée il y a
|
||||
// moins de 10 min) : les lignes sont encore en mémoire, rien à retélécharger.
|
||||
// Retour sur la Grille (même fournisseur, chargée il y a moins de 10 min) :
|
||||
// les lignes sont encore en mémoire, rien à retélécharger.
|
||||
if (!forceRefresh && rowsMeta?.key === cle && lignesEnMemoire.length > 0
|
||||
&& Date.now() - rowsMeta.loadedAt < 10 * 60 * 1000) {
|
||||
setRowsLoaded(lignesEnMemoire.length);
|
||||
@@ -127,11 +132,9 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
return;
|
||||
}
|
||||
|
||||
// Changement de magasin (ou rechargement) sur le même fournisseur : la
|
||||
// Grille reste affichée — les lignes portent déjà le détail par magasin,
|
||||
// la bascule est immédiate — et les chiffres complétés pour ce magasin
|
||||
// remplacent l'affichage d'un bloc, une fois reçus.
|
||||
const memeFournisseur = rowsMeta?.key.startsWith(`${codeFournisseur}:`) === true && lignesEnMemoire.length > 0;
|
||||
// « Actualiser » (ou lignes de plus de 10 min) sur le même fournisseur : la
|
||||
// Grille reste affichée, et les nouvelles lignes la remplacent d'un bloc.
|
||||
const memeFournisseur = rowsMeta?.key === cle && lignesEnMemoire.length > 0;
|
||||
|
||||
const accumulatedRows: ProductRow[] = [];
|
||||
let lastFlush = 0;
|
||||
@@ -210,7 +213,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
}
|
||||
|
||||
flush(loaded, total, true);
|
||||
setRowsMeta({ key: cle, loadedAt: Date.now() });
|
||||
setRowsMeta({ key: cle, loadedAt: Date.now(), patchedStores: [] });
|
||||
if (forceRefresh) servedRefreshRef.current = refreshRequest;
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -226,9 +229,13 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
loadRows();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [codeFournisseur, magasin, refreshRequest, setRows, isMounted]);
|
||||
}, [codeFournisseur, refreshRequest, setRows, isMounted]);
|
||||
|
||||
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
|
||||
// Compléments de l'API FF pour le magasin choisi, appliqués à leur arrivée.
|
||||
useStorePatch(codeFournisseur, isMounted);
|
||||
|
||||
// Magasin initial (ou porté par un lien) : la prop vient de l'URL. Les
|
||||
// changements suivants passent par le store et ne re-rendent pas la page.
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
setActiveMagasin(magasin || "TOTAL");
|
||||
@@ -259,7 +266,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
</h1>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-2 pl-4 shrink-0 border-l border-[var(--border)]">
|
||||
<MetricPill label="Magasin" value={nomMagasin(magasin)} />
|
||||
<MetricPill label="Magasin" value={nomMagasin(activeMagasin)} />
|
||||
<MetricPill label="Produits" value={nbReferences.toLocaleString("fr-FR")} accent />
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,6 +334,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-h-0 min-w-0">
|
||||
<HeatmapGrid
|
||||
codeFournisseur={codeFournisseur}
|
||||
onSelectionChange={setSelectedCodeins}
|
||||
isAdmin={isAdmin}
|
||||
nomFournisseur={nomFournisseur}
|
||||
|
||||
@@ -97,11 +97,15 @@ function GridFilterBarInner({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
|
||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||
|
||||
// Changement de magasin sans aller-retour serveur : les lignes portent déjà
|
||||
// le détail par magasin. L'URL suit (lien partageable, retour par le menu),
|
||||
// via l'historique du navigateur, que Next répercute sur useSearchParams.
|
||||
const handleStoreSelect = (code: string) => {
|
||||
if (code === activeMagasin) return;
|
||||
setActiveMagasin(code);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("magasin", code);
|
||||
router.replace(`/grid?${params.toString()}`, { scroll: false });
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ import { couleurMarge } from "@/lib/marge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface HeatmapGridProps {
|
||||
/** Fournisseur affiché : la sélection est remise à zéro quand il change. */
|
||||
codeFournisseur: string;
|
||||
onSelectionChange?: (codeins: string[]) => void;
|
||||
isAdmin?: boolean;
|
||||
/** Sert à nommer et documenter les extractions issues de la Grille. */
|
||||
@@ -699,10 +701,14 @@ function VentilationParMagasin({ d }: { d: CellDetailData }) {
|
||||
|
||||
// =========================================================================
|
||||
|
||||
function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) {
|
||||
function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) {
|
||||
// L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme.
|
||||
// Champs de filtre lus un par un : la recherche ne doit pas recalculer
|
||||
// `filteredData` (donc reconstruire toutes les lignes) à chaque frappe.
|
||||
const rows = useGridStore((s) => s.rows);
|
||||
const filters = useGridStore((s) => s.filters);
|
||||
const code3 = useGridStore((s) => s.filters.code3);
|
||||
const codeGamme = useGridStore((s) => s.filters.codeGamme);
|
||||
const search = useGridStore((s) => s.filters.search);
|
||||
const displayDensity = useGridStore((s) => s.displayDensity);
|
||||
const draftChanges = useGridStore((s) => s.filters.codeGamme ? s.draftChanges : EMPTY_DRAFT_CHANGES);
|
||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||
@@ -713,12 +719,16 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
|
||||
// Filtre client-side par code3 (famille) et codeGamme
|
||||
const filteredData = useMemo(() => {
|
||||
const { code3, codeGamme } = filters;
|
||||
// Changement de magasin : les accesseurs de tri (quantité, CA, marge, prix)
|
||||
// dépendent du magasin, et TanStack garde la valeur de chaque ligne en cache
|
||||
// sans jamais retrier sur un changement de colonnes. Un nouveau tableau
|
||||
// donne de nouvelles lignes : tri juste, et cases redessinées aussitôt.
|
||||
void activeMagasin;
|
||||
// Garde : un état persisté d'une version antérieure peut encore contenir
|
||||
// une chaîne. La migration du store le corrige, mais un `new Set("320211")`
|
||||
// produirait un ensemble de caractères et viderait la Grille sans rien dire.
|
||||
const codes = Array.isArray(code3) ? code3 : typeof code3 === "string" ? [code3] : null;
|
||||
if (!codes && !codeGamme) return rows;
|
||||
if (!codes && !codeGamme) return rows.slice();
|
||||
// Set plutôt que includes() : la liste peut compter des dizaines de
|
||||
// nomenclatures, et le filtre est réévalué pour chaque ligne.
|
||||
const nomenclatures = codes ? new Set(codes) : null;
|
||||
@@ -731,7 +741,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [rows, filters, draftChanges]);
|
||||
}, [rows, code3, codeGamme, draftChanges, activeMagasin]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
@@ -775,10 +785,12 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
[]
|
||||
);
|
||||
|
||||
// Reset selection when rows change (e.g. supplier change)
|
||||
// Sélection remise à zéro au changement de fournisseur seulement : elle est
|
||||
// indexée par code article, et survit donc à un rechargement des lignes, à un
|
||||
// enregistrement ou au complément des chiffres d'un magasin.
|
||||
useEffect(() => {
|
||||
setRowSelection({});
|
||||
}, [rows]);
|
||||
}, [codeFournisseur]);
|
||||
|
||||
const columns = React.useMemo<ColumnDef<ProductRow>[]>(() => [
|
||||
{
|
||||
@@ -1198,7 +1210,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
state: { sorting, globalFilter: filters.search, rowSelection, columnVisibility, columnSizing },
|
||||
state: { sorting, globalFilter: search, rowSelection, columnVisibility, columnSizing },
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: handleRowSelectionChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
@@ -1249,7 +1261,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
// Delay update to next tick to ensure we're out of any render cycles
|
||||
setTimeout(() => onSelectionChange(selectedCodeins), 0);
|
||||
}
|
||||
}, [table, rowSelection, onSelectionChange, filteredData, filters.search]);
|
||||
}, [table, rowSelection, onSelectionChange, filteredData, search]);
|
||||
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { CODE_TOUS_MAGASINS } from "@/lib/magasins";
|
||||
import type { StorePatch } from "@/features/grid/lib/store-patch";
|
||||
|
||||
/**
|
||||
* Demande le complément de l'API FF du magasin affiché, une fois les lignes
|
||||
* chargées, et l'applique à son arrivée. Une seule demande par magasin et par
|
||||
* chargement : revenir sur un magasin déjà complété ne coûte rien.
|
||||
*/
|
||||
export function useStorePatch(codeFournisseur: string, enabled: boolean) {
|
||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||
const rowsMeta = useGridStore((s) => s.rowsMeta);
|
||||
const applyStorePatch = useGridStore((s) => s.applyStorePatch);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || activeMagasin === CODE_TOUS_MAGASINS || !rowsMeta) return;
|
||||
if (rowsMeta.key !== rowsKeyFor(codeFournisseur) || rowsMeta.patchedStores.includes(activeMagasin)) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const gen = { key: rowsMeta.key, loadedAt: rowsMeta.loadedAt };
|
||||
const params = new URLSearchParams({ fournisseur: codeFournisseur, magasin: activeMagasin });
|
||||
fetch(`/api/grid/rows/store-patch?${params}`, { signal: controller.signal, cache: "no-store" })
|
||||
.then((res) => (res.ok ? (res.json() as Promise<StorePatch>) : null))
|
||||
.then((patch) => {
|
||||
if (patch && !controller.signal.aborted) applyStorePatch(gen, patch);
|
||||
})
|
||||
// Échec ou abandon : les chiffres SQL restent affichés, et la demande
|
||||
// repartira au prochain passage sur ce magasin.
|
||||
.catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [enabled, codeFournisseur, activeMagasin, rowsMeta, applyStorePatch]);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Complément des chiffres d'un magasin par l'API FF (ventes mensuelles).
|
||||
*
|
||||
* Le calcul SQL de la Grille peut manquer les ventes d'un magasin pour certains
|
||||
* articles (quantité à 0 pour ce magasin). L'API FF les connaît : on en tire,
|
||||
* pour ces articles seulement, les séries et totaux du magasin.
|
||||
*
|
||||
* Module sans dépendance d'exécution : utilisé côté serveur (calcul du
|
||||
* complément) comme côté navigateur (application aux lignes affichées).
|
||||
*/
|
||||
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
import type { FfMensuelEntry } from "@/lib/api-ff-client";
|
||||
|
||||
/** Chiffres d'un article pour le magasin complété. */
|
||||
export interface StorePatchEntry {
|
||||
codein: string;
|
||||
/** Ventes par mois (« YYYYMM »). */
|
||||
sales: Record<string, number>;
|
||||
/** Stock de fin de mois, reporté sur les mois sans donnée. */
|
||||
stock: Record<string, number>;
|
||||
receptions: Record<string, number>;
|
||||
quantite: number;
|
||||
ca: number;
|
||||
marge: number;
|
||||
}
|
||||
|
||||
export interface StorePatch {
|
||||
magasin: string;
|
||||
/** Fenêtre des 12 mois du calcul : un complément d'un autre mois est ignoré. */
|
||||
periods: string[];
|
||||
entries: StorePatchEntry[];
|
||||
}
|
||||
|
||||
/** Articles à compléter : connus de FF (noid) et sans aucune vente SQL dans ce magasin. */
|
||||
export function storePatchCandidates(rows: ProductRow[], magasin: string): ProductRow[] {
|
||||
return rows.filter((row) => !!row.noid && (row.quantiteByStore?.[magasin] ?? 0) === 0);
|
||||
}
|
||||
|
||||
/** Chiffres du magasin tirés de l'API, pour les articles où elle montre des ventes. */
|
||||
export function buildStorePatchEntries(
|
||||
retenus: ProductRow[],
|
||||
magasin: string,
|
||||
periods: string[],
|
||||
mensuel: Map<string, FfMensuelEntry[]>,
|
||||
): StorePatchEntry[] {
|
||||
const resultat: StorePatchEntry[] = [];
|
||||
for (const row of retenus) {
|
||||
const entries = (mensuel.get(row.codein) ?? []).filter((entry) => entry.site === magasin);
|
||||
if (entries.length === 0) continue;
|
||||
|
||||
const byPeriod = new Map<string, { qty: number; ca: number; marge: number; stock: number; receptions: number }>();
|
||||
for (const entry of entries) {
|
||||
const period = entry.mois.replace("-", "");
|
||||
if (!periods.includes(period)) continue;
|
||||
// qte_vendue / ca_ht sont NÉGATIFS côté API (ventes nettes) : on
|
||||
// les nie au lieu de Math.abs pour que les retours restent déduits.
|
||||
const qty = -(Number(entry.ventes?.qte_vendue ?? 0) || 0);
|
||||
const ca = -(Number(entry.ventes?.ca_ht ?? 0) || 0);
|
||||
const marge = Number(entry.ventes?.marge ?? 0) || 0;
|
||||
const stock = Number(entry.stock_fin_mois ?? 0) || 0;
|
||||
const receptions = Number(entry.receptions?.qte_recue ?? 0) || 0;
|
||||
byPeriod.set(period, { qty, ca, marge, stock, receptions });
|
||||
}
|
||||
|
||||
const apiQty = [...byPeriod.values()].reduce((sum, value) => sum + value.qty, 0);
|
||||
if (apiQty === 0) continue;
|
||||
|
||||
const e: StorePatchEntry = { codein: row.codein, sales: {}, stock: {}, receptions: {}, quantite: 0, ca: 0, marge: 0 };
|
||||
let lastStock = 0;
|
||||
for (const period of periods) {
|
||||
const value = byPeriod.get(period);
|
||||
e.sales[period] = value?.qty ?? 0;
|
||||
e.receptions[period] = value?.receptions ?? 0;
|
||||
if (value) lastStock = value.stock;
|
||||
e.stock[period] = lastStock;
|
||||
e.quantite += value?.qty ?? 0;
|
||||
e.ca += value?.ca ?? 0;
|
||||
e.marge += value?.marge ?? 0;
|
||||
}
|
||||
resultat.push(e);
|
||||
}
|
||||
return resultat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Application historique, côté serveur (`/api/grid/rows?magasin=…`) : corrige le
|
||||
* magasin ET reporte les écarts sur les totaux « tous magasins » de la ligne.
|
||||
* Renvoie le nombre de lignes corrigées.
|
||||
*/
|
||||
export function applyStorePatchInPlace(
|
||||
rows: ProductRow[],
|
||||
magasin: string,
|
||||
periods: string[],
|
||||
entries: StorePatchEntry[],
|
||||
): number {
|
||||
const parCode = new Map(entries.map((e) => [e.codein, e]));
|
||||
let corrigees = 0;
|
||||
for (const row of rows) {
|
||||
const e = parCode.get(row.codein);
|
||||
if (!e) continue;
|
||||
|
||||
row.sales12mByStore ??= {};
|
||||
row.stock12mByStore ??= {};
|
||||
row.receptions12mByStore ??= {};
|
||||
row.caByStore ??= {};
|
||||
row.quantiteByStore ??= {};
|
||||
row.margeByStore ??= {};
|
||||
row.sales12mByStore[magasin] ??= {};
|
||||
row.stock12mByStore[magasin] ??= {};
|
||||
row.receptions12mByStore[magasin] ??= {};
|
||||
|
||||
for (const period of periods) {
|
||||
const currentQty = row.sales12mByStore[magasin][period] ?? 0;
|
||||
const nextQty = e.sales[period] ?? 0;
|
||||
row.sales12mByStore[magasin][period] = nextQty;
|
||||
row.receptions12mByStore[magasin][period] = e.receptions[period] ?? 0;
|
||||
row.stock12mByStore[magasin][period] = e.stock[period] ?? 0;
|
||||
row.sales12m[period] = (row.sales12m[period] ?? 0) + (nextQty - currentQty);
|
||||
}
|
||||
|
||||
const deltaTotalQty = e.quantite - (row.quantiteByStore[magasin] ?? 0);
|
||||
const deltaTotalCa = e.ca - (row.caByStore[magasin] ?? 0);
|
||||
const deltaTotalMarge = e.marge - (row.margeByStore[magasin] ?? 0);
|
||||
|
||||
row.quantiteByStore[magasin] = e.quantite;
|
||||
row.caByStore[magasin] = e.ca;
|
||||
row.margeByStore[magasin] = e.marge;
|
||||
row.totalQuantite += deltaTotalQty;
|
||||
row.totalCa += deltaTotalCa;
|
||||
row.totalMarge += deltaTotalMarge;
|
||||
row.tauxMarge = row.totalCa > 0 ? (row.totalMarge / row.totalCa) * 100 : 0;
|
||||
if (!row.workingStores.includes(magasin)) row.workingStores.push(magasin);
|
||||
corrigees++;
|
||||
}
|
||||
return corrigees;
|
||||
}
|
||||
|
||||
/**
|
||||
* Application dans le navigateur : nouvelle ligne, chiffres du magasin seuls.
|
||||
* Les totaux « tous magasins » ne bougent pas, pour que la vue « Nos 2
|
||||
* magasins » reste identique quel que soit le magasin consulté avant.
|
||||
*/
|
||||
export function withStorePatch(row: ProductRow, magasin: string, e: StorePatchEntry): ProductRow {
|
||||
return {
|
||||
...row,
|
||||
sales12mByStore: { ...row.sales12mByStore, [magasin]: e.sales },
|
||||
stock12mByStore: { ...row.stock12mByStore, [magasin]: e.stock },
|
||||
receptions12mByStore: { ...row.receptions12mByStore, [magasin]: e.receptions },
|
||||
quantiteByStore: { ...row.quantiteByStore, [magasin]: e.quantite },
|
||||
caByStore: { ...row.caByStore, [magasin]: e.ca },
|
||||
margeByStore: { ...row.margeByStore, [magasin]: e.marge },
|
||||
workingStores: row.workingStores.includes(magasin) ? row.workingStores : [...row.workingStores, magasin],
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,19 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { GammeCode, GridFilters, GridSummary, ProductRow } from "@/types/grid";
|
||||
import { withStorePatch, type StorePatch } from "@/features/grid/lib/store-patch";
|
||||
import { getMonthsFromRows } from "@/features/grid/lib/months";
|
||||
|
||||
/** Ce que décrit `rowsMeta` : les lignes d'un fournisseur, tous magasins. */
|
||||
export interface RowsMeta {
|
||||
key: string;
|
||||
loadedAt: number;
|
||||
/** Magasins dont le complément de l'API FF est déjà appliqué à ces lignes. */
|
||||
patchedStores: string[];
|
||||
}
|
||||
|
||||
/** Clé des lignes chargées pour un fournisseur (toujours « tous magasins »). */
|
||||
export const rowsKeyFor = (codeFournisseur: string) => `${codeFournisseur}:TOTAL`;
|
||||
|
||||
interface GridState {
|
||||
/** Source data from server */
|
||||
@@ -25,12 +38,17 @@ interface GridState {
|
||||
refreshRequest: number;
|
||||
requestRefresh: () => void;
|
||||
/**
|
||||
* Ce que contiennent `rows` (fournisseur:magasin) et quand elles ont été
|
||||
* chargées. Non persisté : sert à ne pas retélécharger la Grille quand on y
|
||||
* revient, et à changer de magasin sans vider l'écran.
|
||||
* Ce que contiennent `rows` et quand elles ont été chargées. Non persisté :
|
||||
* sert à ne pas retélécharger la Grille quand on y revient, et à savoir quels
|
||||
* magasins ont déjà reçu leur complément.
|
||||
*/
|
||||
rowsMeta: { key: string; loadedAt: number } | null;
|
||||
setRowsMeta: (meta: { key: string; loadedAt: number } | null) => void;
|
||||
rowsMeta: RowsMeta | null;
|
||||
setRowsMeta: (meta: RowsMeta | null) => void;
|
||||
/**
|
||||
* Applique le complément d'un magasin, s'il concerne encore les lignes
|
||||
* affichées (`gen` = clé et date de chargement au moment de la demande).
|
||||
*/
|
||||
applyStorePatch: (gen: { key: string; loadedAt: number }, patch: StorePatch) => void;
|
||||
/** Persisted column visibility state */
|
||||
columnVisibility: Record<string, boolean>;
|
||||
/** Persisted column sizing state */
|
||||
@@ -231,6 +249,34 @@ export const useGridStore = create<GridState>()(
|
||||
setActiveGridQuery: (query) => set({ activeGridQuery: sansParamRefresh(query) }),
|
||||
requestRefresh: () => set((state) => ({ refreshRequest: state.refreshRequest + 1 })),
|
||||
setRowsMeta: (meta) => set({ rowsMeta: meta }),
|
||||
applyStorePatch: (gen, patch) => {
|
||||
const { rows, rowsByCodein, rowsMeta } = get();
|
||||
if (!rowsMeta || rowsMeta.key !== gen.key || rowsMeta.loadedAt !== gen.loadedAt) return;
|
||||
const patchedStores = rowsMeta.patchedStores.includes(patch.magasin)
|
||||
? rowsMeta.patchedStores
|
||||
: [...rowsMeta.patchedStores, patch.magasin];
|
||||
// Complément calculé sur une autre fenêtre de 12 mois (changement de
|
||||
// mois entre les deux requêtes) : on le laisse de côté.
|
||||
const mois = getMonthsFromRows(rows);
|
||||
const memeFenetre = !mois || mois.join(",") === patch.periods.join(",");
|
||||
const parCode = new Map(patch.entries.map((e) => [e.codein, e]));
|
||||
if (!memeFenetre || !patch.entries.some((e) => rowsByCodein[e.codein])) {
|
||||
set({ rowsMeta: { ...rowsMeta, patchedStores } });
|
||||
return;
|
||||
}
|
||||
// Nouvelles lignes pour les seuls articles complétés : les autres
|
||||
// gardent leur objet, et donc leur rendu mémorisé.
|
||||
const nouvelles = rows.map((r) => {
|
||||
const e = parCode.get(r.codein);
|
||||
return e ? withStorePatch(r, patch.magasin, e) : r;
|
||||
});
|
||||
set({ rows: nouvelles, rowsByCodein: indexRows(nouvelles), rowsMeta: { ...rowsMeta, patchedStores } });
|
||||
if (summaryTimer) clearTimeout(summaryTimer);
|
||||
summaryTimer = setTimeout(() => {
|
||||
const state = get();
|
||||
set({ summary: computeSummary(state.rows, state.draftChanges, state.activeMagasin) });
|
||||
}, 80);
|
||||
},
|
||||
restoreSnapshot: (changes) => {
|
||||
set({ draftChanges: changes, summary: computeSummary(get().rows, changes, get().activeMagasin) });
|
||||
},
|
||||
|
||||
+129
-123
@@ -1,43 +1,114 @@
|
||||
"use server";
|
||||
|
||||
import { Pool } from "pg";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json");
|
||||
|
||||
export interface DbConfig {
|
||||
url: string;
|
||||
openRouterKey?: string;
|
||||
openRouterModel?: string;
|
||||
aiProvider?: "openrouter" | "google";
|
||||
googleAiKey?: string;
|
||||
googleAiModel?: string;
|
||||
/** Qlik Sense — données réseau */
|
||||
qlikHost?: string;
|
||||
qlikUser?: string;
|
||||
qlikPassword?: string;
|
||||
/**
|
||||
* API REST FF Nancy (ex. https://api.ffnancy.fr). Sert au panneau de statut et
|
||||
* au rattrapage des ventes par magasin dans la Grille. Configurable ici pour
|
||||
* ne pas dépendre d'une variable d'environnement qu'on ne peut pas changer
|
||||
* sans redéployer.
|
||||
* Actions des Paramètres — réservées aux administrateurs.
|
||||
*
|
||||
* Aucun secret ne repart vers le navigateur : les mots de passe (base, Qlik) et
|
||||
* les clés d'IA restent dans `data/.db-config.json`. Un mot de passe laissé vide
|
||||
* dans le formulaire signifie « conserver celui qui est enregistré ».
|
||||
*/
|
||||
ffApiBaseUrl?: string;
|
||||
|
||||
import { Pool } from "pg";
|
||||
import { requireAdmin, verifierAdmin } from "@/lib/authz";
|
||||
import { CONFIG_FILE, readConfig, writeConfig } from "./config-file";
|
||||
import { diagnostiquerApiFf } from "./ff-api-diagnostic";
|
||||
|
||||
export interface ChampsBaseDeDonnees {
|
||||
host: string;
|
||||
port: string;
|
||||
database: string;
|
||||
user: string;
|
||||
/** Vide ou absent : le mot de passe enregistré est conservé. */
|
||||
password?: string;
|
||||
ssl: boolean;
|
||||
}
|
||||
|
||||
/** Lit la config existante (ou {} si absente). */
|
||||
async function readConfig(): Promise<Partial<DbConfig>> {
|
||||
/** Ce que le formulaire peut afficher : jamais de mot de passe ni de clé. */
|
||||
export interface ParametresAffichables {
|
||||
db: {
|
||||
host: string;
|
||||
port: string;
|
||||
database: string;
|
||||
user: string;
|
||||
ssl: boolean;
|
||||
motDePasseEnregistre: boolean;
|
||||
} | null;
|
||||
qlik: { host: string; user: string; motDePasseEnregistre: boolean };
|
||||
ffApiBaseUrl: string;
|
||||
}
|
||||
|
||||
/** Décode un composant d'URL, ou le garde tel quel s'il n'est pas encodé proprement. */
|
||||
function decoder(valeur: string): string {
|
||||
try {
|
||||
const data = await fs.readFile(CONFIG_FILE, "utf-8");
|
||||
return JSON.parse(data);
|
||||
return decodeURIComponent(valeur);
|
||||
} catch {
|
||||
return {};
|
||||
return valeur;
|
||||
}
|
||||
}
|
||||
|
||||
export async function testDatabaseConnection(url: string) {
|
||||
/** Découpe l'URL `postgres://` enregistrée en champs du formulaire. */
|
||||
function lireUrlPostgres(url: string) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return {
|
||||
host: u.hostname,
|
||||
port: u.port || "5432",
|
||||
database: decoder(u.pathname.slice(1)),
|
||||
user: decoder(u.username),
|
||||
password: decoder(u.password),
|
||||
ssl: url.includes("sslmode=require"),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("[Settings] URL de base enregistrée illisible :", (e as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function construireUrlPostgres(champs: ChampsBaseDeDonnees, motDePasse: string): string {
|
||||
const user = encodeURIComponent(champs.user.trim());
|
||||
const identifiants = motDePasse ? `${user}:${encodeURIComponent(motDePasse)}` : user;
|
||||
const port = champs.port.trim() || "5432";
|
||||
return `postgres://${identifiants}@${champs.host.trim()}:${port}/${champs.database.trim()}${champs.ssl ? "?sslmode=require" : ""}`;
|
||||
}
|
||||
|
||||
/** Mot de passe saisi, sinon celui de l'URL enregistrée. */
|
||||
async function motDePasseBase(champs: ChampsBaseDeDonnees): Promise<string> {
|
||||
if (champs.password) return champs.password;
|
||||
const { url } = await readConfig();
|
||||
return (url && lireUrlPostgres(url)?.password) || "";
|
||||
}
|
||||
|
||||
/** Réglages enregistrés, sans aucun secret. */
|
||||
export async function getParametresAffichables(): Promise<ParametresAffichables> {
|
||||
await requireAdmin();
|
||||
const config = await readConfig();
|
||||
const base = config.url ? lireUrlPostgres(config.url) : null;
|
||||
return {
|
||||
db: base
|
||||
? {
|
||||
host: base.host,
|
||||
port: base.port,
|
||||
database: base.database,
|
||||
user: base.user,
|
||||
ssl: base.ssl,
|
||||
motDePasseEnregistre: base.password !== "",
|
||||
}
|
||||
: null,
|
||||
qlik: {
|
||||
host: config.qlikHost ?? "",
|
||||
user: config.qlikUser ?? "",
|
||||
// Même ordre de priorité que getQlikConfig() : réglage enregistré, puis variable d'environnement.
|
||||
motDePasseEnregistre: Boolean(config.qlikPassword || process.env.QLIK_PWD),
|
||||
},
|
||||
ffApiBaseUrl: config.ffApiBaseUrl ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export async function testDatabaseConnection(champs: ChampsBaseDeDonnees) {
|
||||
const acces = await verifierAdmin();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
const url = construireUrlPostgres(champs, await motDePasseBase(champs));
|
||||
console.log("Testing connection to:", url.replace(/:([^@]+)@/, ":****@"));
|
||||
const pool = new Pool({
|
||||
connectionString: url,
|
||||
@@ -58,24 +129,15 @@ export async function testDatabaseConnection(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveDatabaseSettings(
|
||||
url: string,
|
||||
openRouterKey?: string,
|
||||
openRouterModel?: string,
|
||||
aiProvider?: "openrouter" | "google",
|
||||
googleAiKey?: string,
|
||||
googleAiModel?: string,
|
||||
) {
|
||||
export async function saveDatabaseSettings(champs: ChampsBaseDeDonnees) {
|
||||
const acces = await verifierAdmin();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
try {
|
||||
// Merge avec l'existant pour préserver les autres réglages (ex: Qlik)
|
||||
const existing = await readConfig();
|
||||
const config: DbConfig = { ...existing, url, openRouterKey, openRouterModel, aiProvider, googleAiKey, googleAiModel };
|
||||
|
||||
// S'assurer que le dossier data existe
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
|
||||
const url = construireUrlPostgres(champs, await motDePasseBase(champs));
|
||||
// Seule l'URL change : les clés d'IA et les réglages Qlik / API FF sont conservés.
|
||||
console.log(`[Settings] Saving config to ${CONFIG_FILE}`);
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
await writeConfig({ url });
|
||||
console.log("[Settings] Database configuration saved successfully.");
|
||||
|
||||
// Refresh the shared DB instance immediately
|
||||
@@ -90,12 +152,13 @@ export async function saveDatabaseSettings(
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword: string) {
|
||||
/** Mot de passe vide ou absent : celui qui est enregistré est conservé. */
|
||||
export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword?: string) {
|
||||
const acces = await verifierAdmin();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
try {
|
||||
const existing = await readConfig();
|
||||
const config = { ...existing, qlikHost, qlikUser, qlikPassword } as DbConfig;
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
await writeConfig({ qlikHost, qlikUser, ...(qlikPassword ? { qlikPassword } : {}) });
|
||||
console.log("[Settings] Qlik configuration saved.");
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
@@ -105,10 +168,15 @@ export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikP
|
||||
}
|
||||
}
|
||||
|
||||
export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword: string) {
|
||||
/** Mot de passe vide ou absent : on teste avec celui qui est enregistré. */
|
||||
export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword?: string) {
|
||||
const acces = await verifierAdmin();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
try {
|
||||
const { getQlikConfig, qlikNtlmSession } = await import("@/lib/qlik-client");
|
||||
const cfg = { ...getQlikConfig(), user: qlikUser, password: qlikPassword };
|
||||
const enregistre = getQlikConfig();
|
||||
const cfg = { ...enregistre, user: qlikUser, password: qlikPassword || enregistre.password };
|
||||
if (qlikHost) cfg.host = qlikHost;
|
||||
await qlikNtlmSession(cfg);
|
||||
return { success: true };
|
||||
@@ -120,15 +188,15 @@ export async function testQlikConnection(qlikHost: string, qlikUser: string, qli
|
||||
|
||||
/** Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut. */
|
||||
export async function saveFfApiSettings(ffApiBaseUrl: string) {
|
||||
const acces = await verifierAdmin();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
try {
|
||||
const cleaned = ffApiBaseUrl.trim().replace(/\/+$/, "");
|
||||
if (cleaned && !/^https?:\/\//i.test(cleaned)) {
|
||||
return { success: false, error: "L'URL doit commencer par http:// ou https://" };
|
||||
}
|
||||
const existing = await readConfig();
|
||||
const config = { ...existing, ffApiBaseUrl: cleaned || undefined } as DbConfig;
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
await writeConfig({ ffApiBaseUrl: cleaned || undefined });
|
||||
// Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde.
|
||||
const { resetFfApiBaseCache } = await import("@/lib/api-ff-client");
|
||||
resetFfApiBaseCache();
|
||||
@@ -141,71 +209,9 @@ export async function saveFfApiSettings(ffApiBaseUrl: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste l'API FF Nancy et renvoie un diagnostic **exploitable**.
|
||||
*
|
||||
* L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir
|
||||
* si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue
|
||||
* donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP.
|
||||
*/
|
||||
export async function testFfApiConnection(ffApiBaseUrl?: string) {
|
||||
const base = (ffApiBaseUrl?.trim() || (await readConfig()).ffApiBaseUrl || process.env.FF_API_BASE_URL || "https://api.ffnancy.fr").replace(/\/+$/, "");
|
||||
const url = `${base}/api/sync/status`;
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const ms = Date.now() - started;
|
||||
if (!res.ok) {
|
||||
return { success: false, url, error: `Le serveur a répondu HTTP ${res.status} (${res.statusText || "sans message"}) en ${ms} ms.` };
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body) {
|
||||
return { success: false, url, error: `Réponse HTTP 200 mais corps illisible (JSON attendu).` };
|
||||
}
|
||||
// La réponse brute de l'API n'a pas la forme attendue par l'interface :
|
||||
// sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200.
|
||||
const { normalizeSyncStatus } = await import("@/lib/api-ff-client");
|
||||
const status = normalizeSyncStatus(body);
|
||||
if (!status) {
|
||||
return { success: false, url, error: `Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).` };
|
||||
}
|
||||
return { success: true, url, ms, status };
|
||||
} catch (error: unknown) {
|
||||
const ms = Date.now() - started;
|
||||
const raw = error instanceof Error ? error.message : String(error);
|
||||
const name = error instanceof Error ? error.name : "";
|
||||
// fetch masque la cause réelle derrière « fetch failed » : on la déplie.
|
||||
const cause = (error as { cause?: { code?: string; message?: string } })?.cause;
|
||||
let hint = raw;
|
||||
if (name === "TimeoutError" || name === "AbortError") {
|
||||
hint = `Aucune réponse en ${ms} ms — serveur injoignable ou trop lent.`;
|
||||
} else if (cause?.code === "ENOTFOUND") {
|
||||
hint = `Nom d'hôte introuvable (DNS) — vérifiez l'URL.`;
|
||||
} else if (cause?.code === "ECONNREFUSED") {
|
||||
hint = `Connexion refusée — le service n'écoute pas sur cette adresse.`;
|
||||
} else if (cause?.code) {
|
||||
hint = `${cause.code}${cause.message ? ` — ${cause.message}` : ""}`;
|
||||
}
|
||||
console.error(`[Settings] FF API test KO (${url}):`, raw);
|
||||
return { success: false, url, error: hint };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSavedDatabaseConfig(): Promise<DbConfig | null> {
|
||||
try {
|
||||
if (!(await fs.stat(CONFIG_FILE).catch(() => null))) {
|
||||
console.log(`[Settings] Config file not found at ${CONFIG_FILE}`);
|
||||
return null;
|
||||
}
|
||||
const data = await fs.readFile(CONFIG_FILE, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
console.log(`[Settings] Config read from ${CONFIG_FILE}. Key present: ${!!config.openRouterKey}, Model: ${config.openRouterModel || "default"}`);
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error(`[Settings] Error reading config from ${CONFIG_FILE}:`, error);
|
||||
return null;
|
||||
}
|
||||
/** Teste l'API FF Nancy (voir `diagnostiquerApiFf`). */
|
||||
export async function testFfApiConnection(ffApiBaseUrl?: string): Promise<Awaited<ReturnType<typeof diagnostiquerApiFf>>> {
|
||||
const acces = await verifierAdmin();
|
||||
if (!acces.ok) return { success: false, url: "", error: acces.message };
|
||||
return diagnostiquerApiFf(ffApiBaseUrl);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* CollectFlow — Fichier de configuration `data/.db-config.json`.
|
||||
*
|
||||
* Module **serveur**, pas fichier d'actions : ses fonctions ne doivent pas être
|
||||
* appelables depuis le navigateur (le fichier contient des mots de passe et des
|
||||
* clés d'API). Les actions de `./actions.ts` les utilisent après contrôle du rôle.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export const DATA_DIR = path.join(process.cwd(), "data");
|
||||
export const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json");
|
||||
|
||||
export interface DbConfig {
|
||||
url: string;
|
||||
openRouterKey?: string;
|
||||
openRouterModel?: string;
|
||||
aiProvider?: "openrouter" | "google";
|
||||
googleAiKey?: string;
|
||||
googleAiModel?: string;
|
||||
/** Qlik Sense — données réseau */
|
||||
qlikHost?: string;
|
||||
qlikUser?: string;
|
||||
qlikPassword?: string;
|
||||
/**
|
||||
* API REST FF Nancy (ex. https://api.ffnancy.fr). Sert au panneau de statut et
|
||||
* au rattrapage des ventes par magasin dans la Grille. Configurable ici pour
|
||||
* ne pas dépendre d'une variable d'environnement qu'on ne peut pas changer
|
||||
* sans redéployer.
|
||||
*/
|
||||
ffApiBaseUrl?: string;
|
||||
}
|
||||
|
||||
/** Lit la config existante (ou {} si absente). */
|
||||
export async function readConfig(): Promise<Partial<DbConfig>> {
|
||||
try {
|
||||
const data = await fs.readFile(CONFIG_FILE, "utf-8");
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fusionne `patch` dans la config enregistrée : les clés absentes du patch sont
|
||||
* conservées telles quelles (une clé passée à `undefined` est retirée).
|
||||
*/
|
||||
export async function writeConfig(patch: Partial<DbConfig>): Promise<void> {
|
||||
const existing = await readConfig();
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...existing, ...patch }, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* CollectFlow — Diagnostic de l'API FF Nancy.
|
||||
*
|
||||
* Partagé par le bouton « Tester » des Paramètres (administrateurs) et par la
|
||||
* route `/api/ff-status` (tout utilisateur connecté). Module serveur et non
|
||||
* fichier d'actions : chaque appelant fait son propre contrôle d'accès.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { readConfig } from "./config-file";
|
||||
|
||||
/**
|
||||
* Teste l'API FF Nancy et renvoie un diagnostic **exploitable**.
|
||||
*
|
||||
* L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir
|
||||
* si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue
|
||||
* donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP.
|
||||
*/
|
||||
export async function diagnostiquerApiFf(ffApiBaseUrl?: string) {
|
||||
const base = (ffApiBaseUrl?.trim() || (await readConfig()).ffApiBaseUrl || process.env.FF_API_BASE_URL || "https://api.ffnancy.fr").replace(/\/+$/, "");
|
||||
const url = `${base}/api/sync/status`;
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const ms = Date.now() - started;
|
||||
if (!res.ok) {
|
||||
return { success: false, url, error: `Le serveur a répondu HTTP ${res.status} (${res.statusText || "sans message"}) en ${ms} ms.` };
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body) {
|
||||
return { success: false, url, error: `Réponse HTTP 200 mais corps illisible (JSON attendu).` };
|
||||
}
|
||||
// La réponse brute de l'API n'a pas la forme attendue par l'interface :
|
||||
// sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200.
|
||||
const { normalizeSyncStatus } = await import("@/lib/api-ff-client");
|
||||
const status = normalizeSyncStatus(body);
|
||||
if (!status) {
|
||||
return { success: false, url, error: `Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).` };
|
||||
}
|
||||
return { success: true, url, ms, status };
|
||||
} catch (error: unknown) {
|
||||
const ms = Date.now() - started;
|
||||
const raw = error instanceof Error ? error.message : String(error);
|
||||
const name = error instanceof Error ? error.name : "";
|
||||
// fetch masque la cause réelle derrière « fetch failed » : on la déplie.
|
||||
const cause = (error as { cause?: { code?: string; message?: string } })?.cause;
|
||||
let hint = raw;
|
||||
if (name === "TimeoutError" || name === "AbortError") {
|
||||
hint = `Aucune réponse en ${ms} ms — serveur injoignable ou trop lent.`;
|
||||
} else if (cause?.code === "ENOTFOUND") {
|
||||
hint = `Nom d'hôte introuvable (DNS) — vérifiez l'URL.`;
|
||||
} else if (cause?.code === "ECONNREFUSED") {
|
||||
hint = `Connexion refusée — le service n'écoute pas sur cette adresse.`;
|
||||
} else if (cause?.code) {
|
||||
hint = `${cause.code}${cause.message ? ` — ${cause.message}` : ""}`;
|
||||
}
|
||||
console.error(`[Settings] FF API test KO (${url}):`, raw);
|
||||
return { success: false, url, error: hint };
|
||||
}
|
||||
}
|
||||
@@ -8,29 +8,33 @@ export interface DatabaseSettings {
|
||||
port: string;
|
||||
database: string;
|
||||
user: string;
|
||||
/** Saisie en cours uniquement : jamais enregistré dans le navigateur. */
|
||||
password?: string;
|
||||
ssl: boolean;
|
||||
}
|
||||
|
||||
interface DatabaseSettingsState extends DatabaseSettings {
|
||||
/** Un mot de passe est enregistré sur le serveur (lu au chargement de la page, jamais mémorisé). */
|
||||
motDePasseEnregistre: boolean;
|
||||
setHost: (host: string) => void;
|
||||
setPort: (port: string) => void;
|
||||
setDatabase: (database: string) => void;
|
||||
setUser: (user: string) => void;
|
||||
setPassword: (password: string) => void;
|
||||
setSsl: (ssl: boolean) => void;
|
||||
getDatabaseUrl: () => string;
|
||||
setMotDePasseEnregistre: (motDePasseEnregistre: boolean) => void;
|
||||
}
|
||||
|
||||
export const useDbSettingsStore = create<DatabaseSettingsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
(set) => ({
|
||||
host: "localhost",
|
||||
port: "5432",
|
||||
database: "collectflow",
|
||||
user: "postgres",
|
||||
password: "",
|
||||
ssl: false,
|
||||
motDePasseEnregistre: false,
|
||||
|
||||
setHost: (host) => set({ host }),
|
||||
setPort: (port) => set({ port }),
|
||||
@@ -38,16 +42,19 @@ export const useDbSettingsStore = create<DatabaseSettingsState>()(
|
||||
setUser: (user) => set({ user }),
|
||||
setPassword: (password) => set({ password }),
|
||||
setSsl: (ssl) => set({ ssl }),
|
||||
|
||||
getDatabaseUrl: () => {
|
||||
const { user, password, host, port, database, ssl } = get();
|
||||
const auth = password ? `${user}:${password}` : user;
|
||||
const url = `postgres://${auth}@${host}:${port}/${database}${ssl ? "?sslmode=require" : ""}`;
|
||||
return url;
|
||||
},
|
||||
setMotDePasseEnregistre: (motDePasseEnregistre) => set({ motDePasseEnregistre }),
|
||||
}),
|
||||
{
|
||||
name: "collectflow-db-settings",
|
||||
// Le mot de passe reste sur le serveur : seuls les autres champs sont mémorisés.
|
||||
partialize: ({ host, port, database, user, ssl }) => ({ host, port, database, user, ssl }),
|
||||
// Version 1 : les versions précédentes enregistraient le mot de passe en clair.
|
||||
version: 1,
|
||||
migrate: (persisted) => {
|
||||
const reste = { ...(persisted as Partial<DatabaseSettings> | undefined) };
|
||||
delete reste.password;
|
||||
return reste as DatabaseSettingsState;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -3,12 +3,14 @@
|
||||
import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { verifierSession } from "@/lib/authz";
|
||||
|
||||
export async function deleteSnapshot(id: number) {
|
||||
const session = await auth();
|
||||
const rawUserId = (session?.user as any)?.id;
|
||||
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
const rawUserId = acces.utilisateur.id;
|
||||
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
|
||||
|
||||
console.log(`[deleteSnapshot] Request for ID: ${id}, Session UserId: ${userId}`);
|
||||
|
||||
@@ -29,8 +31,9 @@ export async function deleteSnapshot(id: number) {
|
||||
|
||||
console.log(`[deleteSnapshot] Successfully deleted ID: ${id}`);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
// Le détail technique reste dans les journaux du serveur.
|
||||
console.error("[deleteSnapshot] DB Error:", err);
|
||||
return { success: false, error: err.message || "Delete failed" };
|
||||
return { success: false, error: "erreur technique sur le serveur" };
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { requireSession } from "@/lib/authz";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
|
||||
export async function getSnapshots(type?: "snapshot" | "export") {
|
||||
const utilisateur = await requireSession();
|
||||
try {
|
||||
const session = await auth();
|
||||
const rawUserId = (session?.user as any)?.id;
|
||||
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
|
||||
const rawUserId = utilisateur.id;
|
||||
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
|
||||
|
||||
// Si on a un userId, on filtre par celui-ci.
|
||||
// Sinon, on filtre par userId IS NULL (mode anonyme/local)
|
||||
@@ -24,8 +24,8 @@ export async function getSnapshots(type?: "snapshot" | "export") {
|
||||
.from(sessionSnapshots)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(sessionSnapshots.createdAt));
|
||||
} catch (err: any) {
|
||||
const msg = (err?.message || String(err)).split("\n")[0];
|
||||
} catch (err) {
|
||||
const msg = ((err as Error)?.message || String(err)).split("\n")[0];
|
||||
console.error(`[getSnapshots] ERROR (type=${type}):`, msg, err);
|
||||
// Remonter l'erreur : une liste vide faisait afficher « Aucun snapshot »
|
||||
// au lieu de signaler que le chargement a échoué.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { z } from "zod";
|
||||
import { sql, and, eq } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { verifierSession } from "@/lib/authz";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
const SaveSnapshotSchema = z.object({
|
||||
@@ -26,6 +26,9 @@ const SaveSnapshotSchema = z.object({
|
||||
});
|
||||
|
||||
export async function saveSnapshot(raw: unknown) {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
const parsed = SaveSnapshotSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
console.error("Snapshot validation failed:", parsed.error.format());
|
||||
@@ -33,9 +36,8 @@ export async function saveSnapshot(raw: unknown) {
|
||||
}
|
||||
|
||||
const { codeFournisseur, nomFournisseur, magasin, label, changes, summary, type } = parsed.data;
|
||||
const session = await auth();
|
||||
const rawUserId = (session?.user as any)?.id;
|
||||
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
|
||||
const rawUserId = acces.utilisateur.id;
|
||||
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
|
||||
|
||||
console.log(`[saveSnapshot] User: ${userId} (raw: ${rawUserId}), Supplier: ${codeFournisseur}, Type: ${type}`);
|
||||
|
||||
@@ -59,7 +61,7 @@ export async function saveSnapshot(raw: unknown) {
|
||||
revalidatePath("/snapshots");
|
||||
revalidatePath("/exports");
|
||||
return { success: true, snapshotId: created?.id };
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
console.error("Initial snapshot save failed, attempting auto-repair...", err);
|
||||
|
||||
try {
|
||||
@@ -103,14 +105,10 @@ export async function saveSnapshot(raw: unknown) {
|
||||
.returning({ id: sessionSnapshots.id });
|
||||
|
||||
return { success: true, snapshotId: retryCreated?.id };
|
||||
} catch (repairErr: any) {
|
||||
} catch (repairErr) {
|
||||
// Le détail technique (les deux erreurs) reste dans les journaux du serveur.
|
||||
console.error("Snapshot auto-repair/retry failed:", repairErr);
|
||||
const origMsg = (err.message || String(err)).split('\n')[0];
|
||||
const retryMsg = (repairErr.message || String(repairErr)).split('\n')[0];
|
||||
return {
|
||||
success: false,
|
||||
error: `Erreur initiale: ${origMsg} | Erreur après réparation: ${retryMsg}`
|
||||
};
|
||||
return { success: false, error: "erreur technique sur le serveur" };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,7 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
||||
setSnapshots((prev) => prev.filter((x) => x.id !== s.id));
|
||||
toast.succes(estExport ? "L'export a été retiré de votre historique." : "La session a été supprimée de votre historique.");
|
||||
} else {
|
||||
toast.erreur(`La suppression a échoué : ${res.error ?? "erreur inconnue"}.`);
|
||||
toast.erreur(`La suppression a échoué : ${(res.error ?? "erreur inconnue").replace(/\.$/, "")}.`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isStaleServerActionError(err)) void proposerRechargement();
|
||||
|
||||
+4
-6
@@ -24,7 +24,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
try {
|
||||
console.log(`[AUTH] Checking user "${credentials.username}"...`);
|
||||
|
||||
let user: any = null;
|
||||
let user: { id: number | string; username: string; passwordHash: string; role: string } | undefined;
|
||||
|
||||
// Try DB first
|
||||
try {
|
||||
@@ -33,12 +33,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
.where(eq(users.username, credentials.username as string));
|
||||
user = dbUser;
|
||||
console.log(`[AUTH] DB check: User found: ${!!user}`);
|
||||
} catch (dbErr) {
|
||||
} catch {
|
||||
// Base injoignable uniquement : un compte absent de la base ne doit
|
||||
// pas pouvoir se connecter avec le fichier de secours (admin/admin).
|
||||
console.warn("[AUTH] DB unreachable, falling back to JSON.");
|
||||
}
|
||||
|
||||
// Fallback to JSON if DB failed or user not found
|
||||
if (!user) {
|
||||
const fallbackUsers = getFallbackUsers();
|
||||
user = fallbackUsers.find(u => u.username === credentials.username);
|
||||
console.log(`[AUTH] JSON check: User found: ${!!user}`);
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* CollectFlow — Contrôle d'accès commun aux actions serveur, routes et pages.
|
||||
*
|
||||
* Le jeton de session (JWT) seul ne suffit pas : il reste valide jusqu'à son
|
||||
* expiration, même si le compte a été supprimé ou rétrogradé entre-temps. On
|
||||
* relit donc l'utilisateur dans la table `users` (avec un cache court), et le
|
||||
* rôle enregistré en base l'emporte sur celui du jeton.
|
||||
*
|
||||
* Base injoignable : on se contente du jeton (mode dégradé), sans quoi le compte
|
||||
* de secours `data/users.json` ne pourrait plus rien faire — y compris configurer
|
||||
* la base.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
|
||||
export interface UtilisateurCourant {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const MESSAGE_SESSION = "Session expirée : reconnectez-vous.";
|
||||
const MESSAGE_ADMIN = "Action réservée aux administrateurs.";
|
||||
|
||||
/** Refus d'accès. `statut` sert aux routes : 401 sans session valide, 403 sans le rôle requis. */
|
||||
export class AccesRefuse extends Error {
|
||||
constructor(message: string, readonly statut: 401 | 403 = 403) {
|
||||
super(message);
|
||||
this.name = "AccesRefuse";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Relecture du compte en base ──────────────────────────────────────────────
|
||||
|
||||
/** Durée pendant laquelle une relecture de la table `users` est réutilisée. */
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
/** Après un échec de connexion, on ne réessaie pas avant ce délai (chaque essai peut durer 10 s). */
|
||||
const PAUSE_BASE_INJOIGNABLE_MS = 15_000;
|
||||
|
||||
interface FicheUtilisateur {
|
||||
exists: boolean;
|
||||
role: string;
|
||||
username: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
const cache = new Map<string, FicheUtilisateur>();
|
||||
let baseInjoignableDepuis = 0;
|
||||
|
||||
/** Plus grand entier accepté par la colonne `users.id` (serial). */
|
||||
const ID_MAX = 2_147_483_647;
|
||||
|
||||
/**
|
||||
* Fiche de l'utilisateur en base, ou `null` si la base n'a pas pu répondre.
|
||||
* Un identifiant qui ne peut pas exister (compte de secours « 0 », valeur non
|
||||
* numérique) est cherché tel quel : introuvable si la base répond.
|
||||
*/
|
||||
async function lireFiche(id: string): Promise<FicheUtilisateur | null> {
|
||||
const maintenant = Date.now();
|
||||
const enCache = cache.get(id);
|
||||
if (enCache && maintenant - enCache.at < CACHE_TTL_MS) return enCache;
|
||||
if (maintenant - baseInjoignableDepuis < PAUSE_BASE_INJOIGNABLE_MS) return null;
|
||||
|
||||
const n = /^\d+$/.test(id) ? Number(id) : -1;
|
||||
const idNumerique = Number.isSafeInteger(n) && n <= ID_MAX ? n : -1;
|
||||
|
||||
try {
|
||||
const [ligne] = await db
|
||||
.select({ username: users.username, role: users.role })
|
||||
.from(users)
|
||||
.where(eq(users.id, idNumerique))
|
||||
.limit(1);
|
||||
const fiche: FicheUtilisateur = ligne
|
||||
? { exists: true, role: ligne.role, username: ligne.username, at: maintenant }
|
||||
: { exists: false, role: "", username: "", at: maintenant };
|
||||
cache.set(id, fiche);
|
||||
return fiche;
|
||||
} catch (e) {
|
||||
baseInjoignableDepuis = maintenant;
|
||||
console.warn("[authz] Base injoignable, contrôle sur le seul jeton de session :", (e as Error).message?.slice(0, 200));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contrôles ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Utilisateur connecté, relu en base. Lève `AccesRefuse` (401) sinon. */
|
||||
export async function requireSession(): Promise<UtilisateurCourant> {
|
||||
const session = await auth();
|
||||
const jeton = session?.user as { id?: string | number; name?: string | null; role?: string } | undefined;
|
||||
if (!jeton) throw new AccesRefuse(MESSAGE_SESSION, 401);
|
||||
|
||||
const id = jeton.id != null ? String(jeton.id) : "";
|
||||
const fiche = await lireFiche(id);
|
||||
|
||||
if (!fiche) {
|
||||
// Mode dégradé : le jeton fait foi.
|
||||
return { id, username: jeton.name ?? "", role: jeton.role ?? "user" };
|
||||
}
|
||||
if (!fiche.exists) throw new AccesRefuse(MESSAGE_SESSION, 401);
|
||||
return { id, username: fiche.username, role: fiche.role };
|
||||
}
|
||||
|
||||
/** Administrateur connecté. Lève `AccesRefuse` (401 ou 403) sinon. */
|
||||
export async function requireAdmin(): Promise<UtilisateurCourant> {
|
||||
const utilisateur = await requireSession();
|
||||
if (utilisateur.role !== "admin") throw new AccesRefuse(MESSAGE_ADMIN, 403);
|
||||
return utilisateur;
|
||||
}
|
||||
|
||||
// ── Variantes sans exception ─────────────────────────────────────────────────
|
||||
|
||||
export type Autorisation =
|
||||
| { ok: true; utilisateur: UtilisateurCourant }
|
||||
| { ok: false; message: string; statut: 401 | 403 };
|
||||
|
||||
async function verifier(controle: () => Promise<UtilisateurCourant>): Promise<Autorisation> {
|
||||
try {
|
||||
return { ok: true, utilisateur: await controle() };
|
||||
} catch (e) {
|
||||
if (e instanceof AccesRefuse) return { ok: false, message: e.message, statut: e.statut };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pour les actions qui renvoient un résultat `{ success: false, error }` plutôt
|
||||
* que de lever une exception, et pour les pages (le `redirect()` doit être appelé
|
||||
* hors d'un try/catch).
|
||||
*/
|
||||
export function verifierSession(): Promise<Autorisation> {
|
||||
return verifier(requireSession);
|
||||
}
|
||||
|
||||
export function verifierAdmin(): Promise<Autorisation> {
|
||||
return verifier(requireAdmin);
|
||||
}
|
||||
|
||||
// ── Variantes pour les routes ────────────────────────────────────────────────
|
||||
|
||||
function reponse(a: Autorisation): UtilisateurCourant | Response {
|
||||
return a.ok ? a.utilisateur : NextResponse.json({ error: a.message }, { status: a.statut });
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilisateur connecté, ou réponse JSON 401 à renvoyer telle quelle :
|
||||
*
|
||||
* const acces = await sessionOuReponse();
|
||||
* if (acces instanceof Response) return acces;
|
||||
*/
|
||||
export async function sessionOuReponse(): Promise<UtilisateurCourant | Response> {
|
||||
return reponse(await verifierSession());
|
||||
}
|
||||
|
||||
/** Administrateur connecté, ou réponse JSON 401 / 403 à renvoyer telle quelle. */
|
||||
export async function adminOuReponse(): Promise<UtilisateurCourant | Response> {
|
||||
return reponse(await verifierAdmin());
|
||||
}
|
||||
Reference in new issue
Block a user