mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-22 06:28:09 +02:00
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
282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
import { useRouter } from "next/navigation";
|
|
import { useState, useTransition } from "react";
|
|
import { Loader2 } from "lucide-react";
|
|
import { DataTable, type DataColumn, type DataFilter } from "@/components/ui/data-table";
|
|
import { Input, Label } from "@/components/ui/form-controls";
|
|
import { Button } from "@/components/ui/button";
|
|
import { MAGASINS, nomMagasin } from "@/lib/magasins";
|
|
import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format";
|
|
import { couleurMarge } from "@/lib/marge";
|
|
import { telechargerExcel } from "@/lib/export-excel";
|
|
import { cn } from "@/lib/utils";
|
|
import type { HitParadePivotRow } from "./page";
|
|
|
|
type Ligne = HitParadePivotRow;
|
|
|
|
/** Champs numériques de la ligne pivotée. */
|
|
type Champ = { [K in keyof Ligne]: Ligne[K] extends number ? K : never }[keyof Ligne];
|
|
|
|
interface Groupe {
|
|
id: string;
|
|
/** Nom affiché au-dessus des colonnes du groupe. */
|
|
titre: string;
|
|
/** Nom complet, pour les infobulles et l'export. */
|
|
nom: string;
|
|
qte: Champ;
|
|
ca: Champ;
|
|
marge: Champ;
|
|
stock: Champ;
|
|
}
|
|
|
|
/** Champs de la ligne pivotée pour chaque magasin (la requête en fournit un jeu par magasin). */
|
|
const CHAMPS_MAGASIN: Record<string, Pick<Groupe, "qte" | "ca" | "marge" | "stock">> = {
|
|
"292": { qte: "qte292", ca: "ca292", marge: "marge292", stock: "stock292" },
|
|
"579": { qte: "qte579", ca: "ca579", marge: "marge579", stock: "stock579" },
|
|
};
|
|
|
|
const GROUPES: Groupe[] = [
|
|
...MAGASINS.filter(m => CHAMPS_MAGASIN[m.code]).map(m => ({
|
|
id: m.code,
|
|
titre: nomMagasin(m.code),
|
|
nom: nomMagasin(m.code),
|
|
...CHAMPS_MAGASIN[m.code],
|
|
})),
|
|
{ id: "total", titre: "Total nos magasins", nom: "nos magasins", qte: "qteTotal", ca: "caTotal", marge: "margeTotal", stock: "stockTotal" },
|
|
];
|
|
|
|
const somme = (rows: readonly Ligne[], champ: Champ) => rows.reduce((acc, r) => acc + r[champ], 0);
|
|
|
|
/** Taux de marge en %, `null` sans chiffre d'affaires. */
|
|
const tauxMarge = (ca: number, marge: number) => (ca > 0 ? (marge / ca) * 100 : null);
|
|
|
|
const famille = (r: Ligne) => (r.nomenclature_code ? `${r.nomenclature_code} — ${r.nomenclature}` : "Sans famille");
|
|
|
|
const vide = <span className="text-[var(--text-muted)]">—</span>;
|
|
|
|
function CelluleMarge({ taux }: { taux: number | null }) {
|
|
if (taux === null) return vide;
|
|
return <span className="font-medium" style={{ color: couleurMarge(taux) }}>{fmtDecimal1(taux)} %</span>;
|
|
}
|
|
|
|
function CelluleStock({ v }: { v: number }) {
|
|
return (
|
|
<span className={cn(v < 0 ? "font-semibold text-[var(--accent-error)]" : v === 0 ? "text-[var(--text-muted)]" : undefined)}>
|
|
{fmtEntier(v)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function colonnesGroupe(g: Groupe): DataColumn<Ligne>[] {
|
|
const total = g.id === "total";
|
|
// En-tête commun, séparation et teinte du total : gérés par DataTable.
|
|
const commun = { group: g.titre, highlight: total, align: "right" as const };
|
|
return [
|
|
{
|
|
...commun,
|
|
id: `${g.id}-qte`,
|
|
header: "Qté",
|
|
hint: `Quantité vendue (${g.nom}) sur la période, retours déduits`,
|
|
sortValue: r => r[g.qte],
|
|
cell: r => (r[g.qte] !== 0 ? fmtEntier(r[g.qte]) : vide),
|
|
footer: rows => fmtEntier(somme(rows, g.qte)),
|
|
className: cn(total && "font-semibold"),
|
|
},
|
|
{
|
|
...commun,
|
|
id: `${g.id}-ca`,
|
|
header: "CA TTC",
|
|
hint: `Chiffre d'affaires TTC (${g.nom}) sur la période, retours déduits`,
|
|
sortValue: r => r[g.ca],
|
|
cell: r => (r[g.ca] !== 0 ? fmtEur2(r[g.ca]) : vide),
|
|
footer: rows => fmtEur2(somme(rows, g.ca)),
|
|
className: cn(total && "font-semibold"),
|
|
},
|
|
{
|
|
...commun,
|
|
id: `${g.id}-marge`,
|
|
header: "% marge",
|
|
hint: `Taux de marge (${g.nom}) : marge divisée par le chiffre d'affaires TTC`,
|
|
sortValue: r => tauxMarge(r[g.ca], r[g.marge]),
|
|
cell: r => <CelluleMarge taux={tauxMarge(r[g.ca], r[g.marge])} />,
|
|
footer: rows => <CelluleMarge taux={tauxMarge(somme(rows, g.ca), somme(rows, g.marge))} />,
|
|
},
|
|
{
|
|
...commun,
|
|
id: `${g.id}-stock`,
|
|
header: "Stock",
|
|
hint: `Stock actuel (${g.nom}). En rouge : stock négatif`,
|
|
sortValue: r => r[g.stock],
|
|
cell: r => <CelluleStock v={r[g.stock]} />,
|
|
footer: rows => fmtEntier(somme(rows, g.stock)),
|
|
},
|
|
];
|
|
}
|
|
|
|
const COLONNES: DataColumn<Ligne>[] = [
|
|
{
|
|
id: "codein",
|
|
header: "Code",
|
|
sortValue: r => r.codein,
|
|
cell: r => <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.codein}</span>,
|
|
footer: () => "Total",
|
|
className: "whitespace-nowrap",
|
|
sticky: true,
|
|
},
|
|
{
|
|
id: "libelle",
|
|
header: "Désignation",
|
|
sortValue: r => r.libelle.trim(),
|
|
cell: r => <span className="font-medium">{r.libelle.trim()}</span>,
|
|
footer: rows => <span className="whitespace-nowrap">{fmtEntier(rows.length)} articles</span>,
|
|
className: "min-w-[140px] sm:min-w-[220px]",
|
|
sticky: true,
|
|
label: true,
|
|
},
|
|
{
|
|
id: "reference",
|
|
header: "Référence",
|
|
hint: "Référence fournisseur de l'article",
|
|
sortValue: r => r.reference,
|
|
cell: r => (r.reference ? <span className="font-mono text-[13px] text-[var(--text-secondary)]">{r.reference}</span> : vide),
|
|
className: "whitespace-nowrap",
|
|
},
|
|
{
|
|
id: "fournisseur",
|
|
header: "Fournisseur",
|
|
sortValue: r => r.fournisseur,
|
|
cell: r => <span className="text-[13px] text-[var(--text-secondary)]">{r.fournisseur}</span>,
|
|
className: "whitespace-nowrap",
|
|
},
|
|
{
|
|
id: "famille",
|
|
header: "Famille",
|
|
hint: "Famille de produits (nomenclature FF)",
|
|
sortValue: famille,
|
|
cell: r =>
|
|
r.nomenclature_code ? (
|
|
<span className="text-[13px] text-[var(--text-secondary)]" title={famille(r)}>
|
|
<span className="font-mono text-[var(--text-muted)]">{r.nomenclature_code}</span> {r.nomenclature}
|
|
</span>
|
|
) : vide,
|
|
className: "min-w-[160px]",
|
|
},
|
|
...GROUPES.flatMap(colonnesGroupe),
|
|
];
|
|
|
|
const FILTRES: DataFilter<Ligne>[] = [
|
|
{ id: "fournisseur", label: "Fournisseur", valueOf: r => r.fournisseur, allLabel: "Tous les fournisseurs" },
|
|
{ id: "famille", label: "Famille", valueOf: famille, allLabel: "Toutes les familles" },
|
|
];
|
|
|
|
const recherche = (r: Ligne) => [r.codein, r.reference, r.libelle];
|
|
|
|
interface Props {
|
|
dateDebut: string;
|
|
dateFin: string;
|
|
pivotted: HitParadePivotRow[];
|
|
}
|
|
|
|
export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
|
const router = useRouter();
|
|
const [navigation, demarrerNavigation] = useTransition();
|
|
|
|
// Dates locales pour les inputs (évite rechargement à chaque frappe)
|
|
const [localDebut, setLocalDebut] = useState(dateDebut);
|
|
const [localFin, setLocalFin] = useState(dateFin);
|
|
|
|
const periodeInvalide = Boolean(localDebut && localFin && localDebut > localFin);
|
|
|
|
function applyDates() {
|
|
if (localDebut && localFin && !periodeInvalide) {
|
|
demarrerNavigation(() => {
|
|
router.push(`/hit-parade?debut=${localDebut}&fin=${localFin}`);
|
|
});
|
|
}
|
|
}
|
|
|
|
async function exportToExcel(rows: readonly Ligne[]) {
|
|
const pct = (ca: number, marge: number) => ca === 0 ? 0 : Math.round((marge / ca) * 1000) / 10;
|
|
const nomExport = (g: Groupe) => (g.id === "total" ? "Total" : g.nom);
|
|
|
|
await telechargerExcel({
|
|
feuille: "Meilleures ventes",
|
|
fichier: `meilleures-ventes_${dateDebut}_${dateFin}`,
|
|
entetes: [
|
|
"Code", "Désignation", "Référence", "Fournisseur", "Nomenclature",
|
|
...GROUPES.flatMap(g => [
|
|
`Qté ${nomExport(g)}`, `CA TTC ${nomExport(g)}`, `% Marge ${nomExport(g)}`, `Stock ${nomExport(g)}`,
|
|
]),
|
|
],
|
|
largeurs: [12, 40, 18, 25, 30, ...GROUPES.flatMap(() => [10, 14, 12, 10])],
|
|
lignes: rows.map(r => [
|
|
r.codein,
|
|
r.libelle.trim(),
|
|
r.reference,
|
|
r.fournisseur,
|
|
r.nomenclature_code ? `${r.nomenclature_code} — ${r.nomenclature}` : "",
|
|
...GROUPES.flatMap(g => [r[g.qte], r[g.ca], pct(r[g.ca], r[g.marge]), r[g.stock]]),
|
|
]),
|
|
totaux: [
|
|
`TOTAL — ${rows.length} articles`, "", "", "", "",
|
|
...GROUPES.flatMap(g => {
|
|
const ca = somme(rows, g.ca);
|
|
return [somme(rows, g.qte), ca, pct(ca, somme(rows, g.marge)), somme(rows, g.stock)];
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4" aria-busy={navigation}>
|
|
{/* Période (relance le calcul côté serveur) */}
|
|
<div className="flex flex-wrap items-end gap-3">
|
|
<div>
|
|
<Label htmlFor="hit-parade-debut">Du</Label>
|
|
<Input
|
|
id="hit-parade-debut"
|
|
type="date"
|
|
value={localDebut}
|
|
onChange={e => setLocalDebut(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="hit-parade-fin">Au</Label>
|
|
<Input
|
|
id="hit-parade-fin"
|
|
type="date"
|
|
value={localFin}
|
|
onChange={e => setLocalFin(e.target.value)}
|
|
/>
|
|
</div>
|
|
<Button onClick={applyDates} disabled={navigation || !localDebut || !localFin || periodeInvalide}>
|
|
{navigation && <Loader2 className="animate-spin" aria-hidden />}
|
|
{navigation ? "Chargement…" : "Appliquer"}
|
|
</Button>
|
|
{periodeInvalide && (
|
|
<p role="alert" className="flex h-9 items-center text-[13px] text-[var(--accent-error)]">
|
|
La date de début doit précéder la date de fin.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className={cn("transition-opacity", navigation && "pointer-events-none opacity-50")}>
|
|
<DataTable<Ligne>
|
|
rows={pivotted}
|
|
columns={COLONNES}
|
|
rowKey={r => r.codein}
|
|
searchIn={recherche}
|
|
searchPlaceholder="Code, référence ou désignation…"
|
|
filters={FILTRES}
|
|
pageSize={100}
|
|
unite="produits"
|
|
initialSort={{ id: "total-ca", dir: "desc" }}
|
|
showFooter
|
|
emptyTitle="Aucune vente sur cette période"
|
|
emptyDescription="Choisissez une autre période, puis cliquez sur « Appliquer »."
|
|
onExport={exportToExcel}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|