Interface : passage de Next.js à une SPA React (Vite + React Router)

Le serveur étant réécrit en Rust, l'interface devient une application
React 19 côté navigateur, compilée par Vite dans dist/ et servie par le
serveur Rust (STATIC_DIR). Composants, styles et logique cliente
inchangés : seule la plomberie Next.js est remplacée.

- Outillage : Vite 8, @vitejs/plugin-react, Tailwind 4 via PostCSS,
  React Router 7 (mode déclaratif), index.html, src/main.tsx, alias @/,
  proxy /api → localhost:5643 en développement, ESLint sans plugin Next.
- Adaptations (src/shims) : next/link, next/navigation (useRouter avec
  refresh, usePathname/useSearchParams suivant aussi les replaceState de
  l'interface), next-auth/react (session lue sur /api/auth/session,
  déconnexion par /api/auth/logout).
- Actions serveur → POST /api/actions/{nom} (src/lib/rpc.ts) : mêmes noms,
  mêmes signatures, mêmes types. La connexion passe par /api/auth/login.
- Pages → table des routes (src/router.tsx) : données lues sur
  /api/pages/{nom} avec Suspense (loading.tsx au premier chargement,
  page conservée pendant une navigation ou un router.refresh()),
  error.tsx en frontière d'erreur, not-found.tsx, titres d'onglet,
  pages d'administration réservées aux administrateurs.
- Suppression du code serveur Next.js (routes /api, accès base, Qlik,
  authentification, middleware…) ; types utiles à l'interface recopiés
  dans src/types/ff.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
This commit is contained in:
Claude committed 2026-10-12 19:30:20 +00:00
1 parent 559e6ed714
commit f104719054
170 files changed
+2528 -24445

No files matched your search

+7 -36
View File
@@ -1,39 +1,10 @@
"use server";
/**
* Suppression d'un enregistrement de l'historique — action exécutée par le
* serveur Rust : `POST /api/actions/deleteSnapshot`.
*/
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { and, eq, isNull } from "drizzle-orm";
import { verifierSession } from "@/lib/authz";
import { callAction } from "@/lib/rpc";
export async function deleteSnapshot(id: number) {
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}`);
try {
// Condition de sécurité : match ID ET match le propriétaire (ou anonyme)
const userCondition = userId && !isNaN(userId)
? eq(sessionSnapshots.userId, userId)
: isNull(sessionSnapshots.userId);
const result = await db.delete(sessionSnapshots)
.where(and(eq(sessionSnapshots.id, id), userCondition))
.returning({ deletedId: sessionSnapshots.id });
if (result.length === 0) {
console.warn(`[deleteSnapshot] No rows deleted for ID ${id}. Snapshot may not exist or belongs to another user.`);
return { success: false, error: "Snapshot non trouvé ou non autorisé" };
}
console.log(`[deleteSnapshot] Successfully deleted ID: ${id}`);
return { success: true };
} catch (err) {
// Le détail technique reste dans les journaux du serveur.
console.error("[deleteSnapshot] DB Error:", err);
return { success: false, error: "erreur technique sur le serveur" };
}
export async function deleteSnapshot(id: number): Promise<{ success: boolean; error?: string }> {
return callAction("deleteSnapshot", [id]);
}
+22 -30
View File
@@ -1,34 +1,26 @@
"use server";
/**
* Historique des sessions de révision et des exports — action exécutée par le
* serveur Rust : `POST /api/actions/getSnapshots`.
*/
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { requireSession } from "@/lib/authz";
import { and, desc, eq, isNull } from "drizzle-orm";
import { callAction } from "@/lib/rpc";
export async function getSnapshots(type?: "snapshot" | "export") {
const utilisateur = await requireSession();
try {
const rawUserId = utilisateur.id;
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
/** Une ligne de `session_snapshots`, telle que le serveur la renvoie. */
export interface SnapshotRow {
id: number;
userId: number | null;
codeFournisseur: string;
nomFournisseur: string | null;
magasin: string;
/** codein → { before, after } */
changes: unknown;
summaryJson: unknown;
label: string | null;
type: string | null;
/** Date ISO de création. */
createdAt: string | null;
}
// Si on a un userId, on filtre par celui-ci.
// Sinon, on filtre par userId IS NULL (mode anonyme/local)
const userCondition = userId && !isNaN(userId)
? eq(sessionSnapshots.userId, userId)
: isNull(sessionSnapshots.userId);
const conditions = [userCondition];
if (type) conditions.push(eq(sessionSnapshots.type, type));
return await db.select()
.from(sessionSnapshots)
.where(and(...conditions))
.orderBy(desc(sessionSnapshots.createdAt));
} 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é.
throw new Error("Chargement de l'historique impossible.");
}
export async function getSnapshots(type?: "snapshot" | "export"): Promise<SnapshotRow[]> {
return callAction("getSnapshots", [type]);
}
+24 -111
View File
@@ -1,114 +1,27 @@
"use server";
/**
* Enregistrement d'une session de révision ou d'un export — action exécutée par
* le serveur Rust : `POST /api/actions/saveSnapshot` (validation des données
* comprise).
*/
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { z } from "zod";
import { sql, and, eq } from "drizzle-orm";
import { verifierSession } from "@/lib/authz";
import { revalidatePath } from "next/cache";
import { callAction } from "@/lib/rpc";
const SaveSnapshotSchema = z.object({
codeFournisseur: z.string(),
nomFournisseur: z.string().optional(),
magasin: z.string(),
label: z.string().optional(),
changes: z.record(z.string(), z.object({
before: z.string().nullable(),
after: z.string(),
})),
type: z.enum(["snapshot", "export"]).optional(),
summary: z.object({
totalRows: z.number(),
totalCa: z.number(),
totalMarge: z.number(),
tauxMargeGlobal: z.number(),
}).optional(),
});
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());
return { success: false, error: "Validation failed: " + parsed.error.issues.map((i: any) => i.message).join(", ") };
}
const { codeFournisseur, nomFournisseur, magasin, label, changes, summary, type } = parsed.data;
const rawUserId = acces.utilisateur.id;
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
console.log(`[saveSnapshot] User: ${userId} (raw: ${rawUserId}), Supplier: ${codeFournisseur}, Type: ${type}`);
const finalUserId = (userId && !isNaN(userId)) ? userId : null;
try {
const [created] = await db
.insert(sessionSnapshots)
.values({
userId: finalUserId,
codeFournisseur,
nomFournisseur: nomFournisseur ?? null,
magasin,
label: label ?? `${type === 'export' ? 'Export' : 'Snapshot'} — ${new Date().toLocaleDateString("fr-FR")}`,
changes,
summaryJson: summary ?? null,
type: type ?? "snapshot",
})
.returning({ id: sessionSnapshots.id });
revalidatePath("/snapshots");
revalidatePath("/exports");
return { success: true, snapshotId: created?.id };
} catch (err) {
console.error("Initial snapshot save failed, attempting auto-repair...", err);
try {
// Tentative systématique de création de table (no-op si déjà là)
await db.execute(sql`
CREATE TABLE IF NOT EXISTS session_snapshots (
id SERIAL PRIMARY KEY,
user_id INTEGER,
code_fournisseur VARCHAR(20) NOT NULL,
nom_fournisseur VARCHAR(255),
magasin VARCHAR(20) NOT NULL,
changes JSONB NOT NULL,
summary_json JSONB,
label TEXT,
type VARCHAR(20) DEFAULT 'snapshot',
created_at TIMESTAMP DEFAULT NOW()
);
`);
// Si la table existait déjà mais sans la colonne 'type' ou 'user_id', on les ajoute
try {
await db.execute(sql`ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'snapshot'`);
await db.execute(sql`ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS user_id INTEGER`);
} catch (e) {
// Ignore
}
// Deuxième tentative d'insertion
const [retryCreated] = await db
.insert(sessionSnapshots)
.values({
userId: finalUserId,
codeFournisseur,
nomFournisseur: nomFournisseur ?? null,
magasin,
label: label ?? `${type === 'export' ? 'Export' : 'Snapshot'} — ${new Date().toLocaleDateString("fr-FR")}`,
changes,
summaryJson: summary ?? null,
type: type ?? "snapshot",
})
.returning({ id: sessionSnapshots.id });
return { success: true, snapshotId: retryCreated?.id };
} catch (repairErr) {
// Le détail technique (les deux erreurs) reste dans les journaux du serveur.
console.error("Snapshot auto-repair/retry failed:", repairErr);
return { success: false, error: "erreur technique sur le serveur" };
}
}
/** Données attendues par le serveur (ex-schéma zod `SaveSnapshotSchema`). */
export interface SaveSnapshotInput {
codeFournisseur: string;
nomFournisseur?: string;
magasin: string;
label?: string;
changes: Record<string, { before: string | null; after: string }>;
type?: "snapshot" | "export";
summary?: {
totalRows: number;
totalCa: number;
totalMarge: number;
tauxMargeGlobal: number;
};
}
export async function saveSnapshot(raw: unknown): Promise<{ success: boolean; snapshotId?: number; error?: string }> {
return callAction("saveSnapshot", [raw]);
}
@@ -1,5 +1,3 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {