feat(grid): implement product streaming with JSON chunks for grid client

This commit is contained in:
Michael committed 2026-05-26 13:31:26 +02:00
1 parent 8809819658
commit e6f40d3d81
13 files changed
+580 -98

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
<!-- rtk-instructions v2 -->
# RTK (Rust Token Killer) - Token-Optimized Commands
## Golden Rule
**Always prefix commands with `rtk`**. If RTK has a dedicated filter, it uses it. If not, it passes through unchanged. This means RTK is always safe to use.
**Important**: Even in command chains with `&&`, use `rtk`:
```bash
# ❌ Wrong
git add . && git commit -m "msg" && git push
# ✅ Correct
rtk git add . && rtk git commit -m "msg" && rtk git push
```
## RTK Commands by Workflow
### Build & Compile (80-90% savings)
```bash
rtk cargo build # Cargo build output
rtk cargo check # Cargo check output
rtk cargo clippy # Clippy warnings grouped by file (80%)
rtk tsc # TypeScript errors grouped by file/code (83%)
rtk lint # ESLint/Biome violations grouped (84%)
rtk prettier --check # Files needing format only (70%)
rtk next build # Next.js build with route metrics (87%)
```
### Test (90-99% savings)
```bash
rtk cargo test # Cargo test failures only (90%)
rtk vitest run # Vitest failures only (99.5%)
rtk playwright test # Playwright failures only (94%)
rtk test <cmd> # Generic test wrapper - failures only
```
### Git (59-80% savings)
```bash
rtk git status # Compact status
rtk git log # Compact log (works with all git flags)
rtk git diff # Compact diff (80%)
rtk git show # Compact show (80%)
rtk git add # Ultra-compact confirmations (59%)
rtk git commit # Ultra-compact confirmations (59%)
rtk git push # Ultra-compact confirmations
rtk git pull # Ultra-compact confirmations
rtk git branch # Compact branch list
rtk git fetch # Compact fetch
rtk git stash # Compact stash
rtk git worktree # Compact worktree
```
Note: Git passthrough works for ALL subcommands, even those not explicitly listed.
### GitHub (26-87% savings)
```bash
rtk gh pr view <num> # Compact PR view (87%)
rtk gh pr checks # Compact PR checks (79%)
rtk gh run list # Compact workflow runs (82%)
rtk gh issue list # Compact issue list (80%)
rtk gh api # Compact API responses (26%)
```
### JavaScript/TypeScript Tooling (70-90% savings)
```bash
rtk pnpm list # Compact dependency tree (70%)
rtk pnpm outdated # Compact outdated packages (80%)
rtk pnpm install # Compact install output (90%)
rtk npm run <script> # Compact npm script output
rtk npx <cmd> # Compact npx command output
rtk prisma # Prisma without ASCII art (88%)
```
### Files & Search (60-75% savings)
```bash
rtk ls <path> # Tree format, compact (65%)
rtk read <file> # Code reading with filtering (60%)
rtk grep <pattern> # Search grouped by file (75%)
rtk find <pattern> # Find grouped by directory (70%)
```
### Analysis & Debug (70-90% savings)
```bash
rtk err <cmd> # Filter errors only from any command
rtk log <file> # Deduplicated logs with counts
rtk json <file> # JSON structure without values
rtk deps # Dependency overview
rtk env # Environment variables compact
rtk summary <cmd> # Smart summary of command output
rtk diff # Ultra-compact diffs
```
### Infrastructure (85% savings)
```bash
rtk docker ps # Compact container list
rtk docker images # Compact image list
rtk docker logs <c> # Deduplicated logs
rtk kubectl get # Compact resource list
rtk kubectl logs # Deduplicated pod logs
```
### Network (65-70% savings)
```bash
rtk curl <url> # Compact HTTP responses (70%)
rtk wget <url> # Compact download output (65%)
```
### Meta Commands
```bash
rtk gain # View token savings statistics
rtk gain --history # View command history with savings
rtk discover # Analyze Codex sessions for missed RTK usage
rtk proxy <cmd> # Run command without filtering (for debugging)
rtk init # Add RTK instructions to AGENTS.md
rtk init --global # Add RTK to ~/.Codex/AGENTS.md
```
## Token Savings Overview
| Category | Commands | Typical Savings |
|----------|----------|-----------------|
| Tests | vitest, playwright, cargo test | 90-99% |
| Build | next, tsc, lint, prettier | 70-87% |
| Git | status, log, diff, add, commit | 59-80% |
| GitHub | gh pr, gh run, gh issue | 26-87% |
| Package Managers | pnpm, npm, npx | 70-90% |
| Files | ls, read, grep, find | 60-75% |
| Infrastructure | docker, kubectl | 85% |
| Network | curl, wget | 65-70% |
Overall average: **60-90% token reduction** on common development operations.
<!-- /rtk-instructions -->
+2 -6
View File
@@ -1,4 +1,4 @@
import { getFournisseurs, getGridData, getMagasins } from "@/features/grid/actions"; import { getFournisseurs, getMagasins } from "@/features/grid/actions";
import { GridClient } from "@/features/grid/components/grid-client"; import { GridClient } from "@/features/grid/components/grid-client";
import { SupplierSelectionLanding } from "@/features/grid/components/supplier-selection-landing"; import { SupplierSelectionLanding } from "@/features/grid/components/supplier-selection-landing";
@@ -33,20 +33,16 @@ export default async function GridPage({ searchParams }: GridPageProps) {
return <SupplierSelectionLanding fournisseurs={fournisseurs} />; return <SupplierSelectionLanding fournisseurs={fournisseurs} />;
} }
// 3. Load real product data for the selected supplier
console.log(`\n>>> [GridPage] Fetching data for: ${codeFournisseur}, shop: ${magasin}`);
const rows = await getGridData(codeFournisseur, magasin, filters);
console.log(`>>> [GridPage] Received ${rows.length} rows from getGridData`);
const selectedFournisseur = fournisseurs.find((f: { code: string; nom: string }) => f.code === codeFournisseur); const selectedFournisseur = fournisseurs.find((f: { code: string; nom: string }) => f.code === codeFournisseur);
return ( return (
<GridClient <GridClient
initialRows={rows}
codeFournisseur={codeFournisseur} codeFournisseur={codeFournisseur}
nomFournisseur={selectedFournisseur?.nom || "Fournisseur inconnu"} nomFournisseur={selectedFournisseur?.nom || "Fournisseur inconnu"}
fournisseurs={fournisseurs} fournisseurs={fournisseurs}
magasins={magasins} magasins={magasins}
magasin={magasin} magasin={magasin}
filters={filters}
/> />
); );
} }
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest } from "next/server";
import { getProductRows } from "@/features/grid/api/get-product-rows";
import type { GridFilters } from "@/types/grid";
export const dynamic = "force-dynamic";
function writeNdjson(controller: ReadableStreamDefaultController<Uint8Array>, value: unknown) {
controller.enqueue(new TextEncoder().encode(`${JSON.stringify(value)}\n`));
}
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const codeFournisseur = searchParams.get("fournisseur");
if (!codeFournisseur) {
return Response.json({ error: "Missing fournisseur" }, { status: 400 });
}
const magasin = searchParams.get("magasin") ?? "TOTAL";
const chunkSize = Math.max(50, Math.min(500, Number(searchParams.get("chunkSize") ?? 150)));
const filters: Partial<GridFilters> = {
code1: searchParams.get("code1"),
code2: searchParams.get("code2"),
code3: searchParams.get("code3"),
};
const forceRefresh = searchParams.get("refresh") === "1";
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
writeNdjson(controller, { type: "start", loaded: 0, total: null });
const rows = await getProductRows({ codeFournisseur, magasin, filters, forceRefresh });
const total = rows.length;
for (let start = 0; start < total; start += chunkSize) {
const chunk = rows.slice(start, start + chunkSize);
writeNdjson(controller, {
type: "chunk",
rows: chunk,
loaded: Math.min(start + chunk.length, total),
total,
});
await new Promise((resolve) => setTimeout(resolve, 0));
}
writeNdjson(controller, { type: "done", loaded: total, total });
} catch (error) {
writeNdjson(controller, {
type: "error",
error: error instanceof Error ? error.message : "Unknown error",
});
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "application/x-ndjson; charset=utf-8",
"Cache-Control": "no-store",
"X-Accel-Buffering": "no",
},
});
}
+156 -2
View File
@@ -1,8 +1,8 @@
"use server"; import "server-only";
import type { ProductRow, GammeCode, GridFilters } from "@/types/grid"; import type { ProductRow, GammeCode, GridFilters } from "@/types/grid";
import { computeProductScores } from "@/lib/score-engine"; import { computeProductScores } from "@/lib/score-engine";
import { buildLast12MonthsRange } from "@/lib/api-ff-client"; import { buildLast12MonthsRange, getMensuelByArticles } from "@/lib/api-ff-client";
import { import {
pgGetArticlesByFournisseur, pgGetArticlesByFournisseur,
pgGetMensuelByFournisseur, pgGetMensuelByFournisseur,
@@ -21,9 +21,57 @@ interface GetProductRowsInput {
codeFournisseur: string; codeFournisseur: string;
magasin?: string; magasin?: string;
filters?: Partial<GridFilters>; filters?: Partial<GridFilters>;
forceRefresh?: boolean;
}
const GRID_ROWS_CACHE_TTL_MS = 10 * 60 * 1000;
const gridRowsCache = new Map<string, { rows: ProductRow[]; createdAt: number }>();
const gridRowsPending = new Map<string, Promise<ProductRow[]>>();
function gridRowsCacheKey(input: GetProductRowsInput): string {
return `${input.codeFournisseur}:${input.magasin ?? "TOTAL"}`;
}
export function invalidateGridRowsCache(codeFournisseur?: string) {
if (codeFournisseur) {
for (const key of gridRowsCache.keys()) {
if (key.startsWith(`${codeFournisseur}:`)) gridRowsCache.delete(key);
}
for (const key of gridRowsPending.keys()) {
if (key.startsWith(`${codeFournisseur}:`)) gridRowsPending.delete(key);
}
return;
}
gridRowsCache.clear();
gridRowsPending.clear();
} }
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> { export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
const cacheKey = gridRowsCacheKey(input);
const cached = gridRowsCache.get(cacheKey);
if (!input.forceRefresh && cached && Date.now() - cached.createdAt < GRID_ROWS_CACHE_TTL_MS) {
return cached.rows;
}
const pending = gridRowsPending.get(cacheKey);
if (!input.forceRefresh && pending) {
return pending;
}
const promise = buildProductRows(input).then((rows) => {
gridRowsCache.set(cacheKey, { rows, createdAt: Date.now() });
gridRowsPending.delete(cacheKey);
return rows;
}).catch((error) => {
gridRowsPending.delete(cacheKey);
throw error;
});
gridRowsPending.set(cacheKey, promise);
return promise;
}
async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
const { codeFournisseur, magasin = "TOTAL" } = input; const { codeFournisseur, magasin = "TOTAL" } = input;
console.log(`\n>>> [getProductRows] supplier: ${codeFournisseur}, magasin: ${magasin}`); console.log(`\n>>> [getProductRows] supplier: ${codeFournisseur}, magasin: ${magasin}`);
@@ -295,6 +343,7 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
// ─── Phase 10 : Filtrer gamme Y sans ventes + compute scores ───────── // ─── Phase 10 : Filtrer gamme Y sans ventes + compute scores ─────────
const allRows = Array.from(productMap.values()); const allRows = Array.from(productMap.values());
const rows = allRows.filter(p => p.codeGamme !== "Y" || p.totalQuantite > 0); const rows = allRows.filter(p => p.codeGamme !== "Y" || p.totalQuantite > 0);
await reconcileSelectedStoreFromMensuelApi(rows, magasin, dateDebut, dateFin, sortedPeriods);
const excludedY = allRows.length - rows.length; const excludedY = allRows.length - rows.length;
console.log(`[getProductRows] ${rows.length} produits (${excludedY} gamme Y sans ventes exclus), ${mensuelByCodein.size} avec ventes`); console.log(`[getProductRows] ${rows.length} produits (${excludedY} gamme Y sans ventes exclus), ${mensuelByCodein.size} avec ventes`);
@@ -305,3 +354,108 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
return []; return [];
} }
} }
async function reconcileSelectedStoreFromMensuelApi(
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;
try {
const mensuelMap = await getMensuelByArticles(
candidates.map((row) => ({
codein: row.codein,
libelle1: row.libelle1,
codefou: row.codeFournisseur,
noid: row.noid,
})),
dateDebut,
dateFin,
25
);
let fixedRows = 0;
for (const row of candidates) {
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;
const qty = Math.abs(Number(entry.ventes?.qte_vendue ?? 0) || 0);
const ca = Math.abs(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++;
}
if (fixedRows > 0) {
console.log(`[getProductRows] ${fixedRows} produits corrigés via API mensuelle pour magasin ${magasin}`);
}
} catch (error) {
console.error("[getProductRows] Mensuel API reconciliation error:", error);
}
}
@@ -5,6 +5,7 @@ import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm"; import { eq, desc } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { invalidateGridRowsCache } from "./get-product-rows";
const SaveDraftsSchema = z.object({ const SaveDraftsSchema = z.object({
codeFournisseur: z.string(), codeFournisseur: z.string(),
@@ -69,6 +70,8 @@ export async function saveDraftChanges(
type: "snapshot", type: "snapshot",
}); });
invalidateGridRowsCache(codeFournisseur);
return { success: true, saved: changes.length }; return { success: true, saved: changes.length };
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error"; const msg = err instanceof Error ? err.message : "Unknown error";
@@ -18,7 +18,7 @@ interface BulkActionToolbarProps {
} }
export function BulkActionToolbar({ selectedCodeins, onClearSelection }: BulkActionToolbarProps) { export function BulkActionToolbar({ selectedCodeins, onClearSelection }: BulkActionToolbarProps) {
const { batchSetDraftGamme } = useGridStore(); const batchSetDraftGamme = useGridStore((s) => s.batchSetDraftGamme);
if (selectedCodeins.length === 0) return null; if (selectedCodeins.length === 0) return null;
@@ -29,8 +29,12 @@ function Stat({ label, value, sub }: { label: string; value: string; sub?: strin
export function FloatingSummaryBar() { export function FloatingSummaryBar() {
const { data: session } = useSession(); const { data: session } = useSession();
const isAdmin = (session?.user as any)?.role === "admin"; const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
const { summary, resetDrafts, rows, filters, draftChanges } = useGridStore(); const summary = useGridStore((s) => s.summary);
const resetDrafts = useGridStore((s) => s.resetDrafts);
const rows = useGridStore((s) => s.rows);
const filters = useGridStore((s) => s.filters);
const draftChanges = useGridStore((s) => s.draftChanges);
const { resetInsights } = useAiCopilotStore(); const { resetInsights } = useAiCopilotStore();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false); const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
+105 -10
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState, useTransition, useMemo } from "react"; import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { HeatmapGrid } from "@/features/grid/components/heatmap-grid"; import { HeatmapGrid } from "@/features/grid/components/heatmap-grid";
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar"; import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar"; import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
@@ -14,20 +14,26 @@ import { CheckCircle, AlertCircle, Loader2 } from "lucide-react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { computeProductScores } from "@/lib/score-engine"; import type { GridFilters } from "@/types/grid";
interface GridClientProps { interface GridClientProps {
initialRows: ProductRow[];
codeFournisseur: string; codeFournisseur: string;
nomFournisseur: string; nomFournisseur: string;
fournisseurs: { code: string; nom: string }[]; fournisseurs: { code: string; nom: string }[];
magasins: { code: string; nom: string }[]; magasins: { code: string; nom: string }[];
magasin: string; magasin: string;
filters: Pick<GridFilters, "code1" | "code2" | "code3">;
} }
export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) { type GridRowsStreamMessage =
| { type: "start"; loaded: number; total: null }
| { type: "chunk"; rows: ProductRow[]; loaded: number; total: number }
| { type: "done"; loaded: number; total: number }
| { type: "error"; error: string };
export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin, filters }: GridClientProps) {
const { data: session } = useSession(); const { data: session } = useSession();
const isAdmin = (session?.user as any)?.role === "admin"; const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
const setRows = useGridStore((s) => s.setRows); const setRows = useGridStore((s) => s.setRows);
const rows = useGridStore((s) => s.rows); const rows = useGridStore((s) => s.rows);
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery); const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
@@ -39,10 +45,14 @@ export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fourn
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle"); const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
const [activeTab, setActiveTab] = useState<"grid" | "no-sales">("grid"); const [activeTab, setActiveTab] = useState<"grid" | "no-sales">("grid");
const [isLoadingRows, setIsLoadingRows] = useState(false);
const [rowsLoaded, setRowsLoaded] = useState(0);
const [totalRows, setTotalRows] = useState<number | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const noSalesCount = useMemo(() => countNoSalesIn6Months(rows), [rows]); const noSalesCount = useMemo(() => countNoSalesIn6Months(rows), [rows]);
const visibleCodeins = initialRows.map(r => r.codein); const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
const { save, hasDrafts, count } = useSaveDrafts(magasin, visibleCodeins); const { save, hasDrafts, count } = useSaveDrafts(magasin, visibleCodeins);
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
@@ -68,9 +78,81 @@ export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fourn
setFilter("code3", null); setFilter("code3", null);
prevFournisseurRef.current = codeFournisseur; prevFournisseurRef.current = codeFournisseur;
} }
const scoredRows = computeProductScores([...initialRows]); }, [codeFournisseur, setFilter, isMounted]);
setRows(scoredRows);
}, [codeFournisseur, initialRows, setRows, setFilter, isMounted]); const refreshToken = searchParams.get("_refresh");
useEffect(() => {
if (!isMounted) return;
const controller = new AbortController();
const params = new URLSearchParams();
params.set("fournisseur", codeFournisseur);
params.set("magasin", magasin || "TOTAL");
if (filters.code1) params.set("code1", filters.code1);
if (filters.code2) params.set("code2", filters.code2);
if (filters.code3) params.set("code3", filters.code3);
if (refreshToken) params.set("refresh", "1");
let accumulatedRows: ProductRow[] = [];
setRows([]);
setRowsLoaded(0);
setTotalRows(null);
setLoadError(null);
setIsLoadingRows(true);
async function loadRows() {
try {
const response = await fetch(`/api/grid/rows?${params.toString()}`, {
signal: controller.signal,
cache: "no-store",
});
if (!response.ok || !response.body) {
throw new Error(`Chargement impossible (${response.status})`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) continue;
const message = JSON.parse(line) as GridRowsStreamMessage;
if (message.type === "chunk") {
accumulatedRows = accumulatedRows.concat(message.rows);
setRows(accumulatedRows);
setRowsLoaded(message.loaded);
setTotalRows(message.total);
} else if (message.type === "done") {
setRowsLoaded(message.loaded);
setTotalRows(message.total);
} else if (message.type === "error") {
throw new Error(message.error);
}
}
}
} catch (error) {
if (!controller.signal.aborted) {
setLoadError(error instanceof Error ? error.message : "Erreur de chargement");
}
} finally {
if (!controller.signal.aborted) {
setIsLoadingRows(false);
}
}
}
loadRows();
return () => controller.abort();
}, [codeFournisseur, magasin, filters.code1, filters.code2, filters.code3, refreshToken, setRows, isMounted]);
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement) // Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
useEffect(() => { useEffect(() => {
@@ -101,10 +183,23 @@ export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fourn
<p className="text-[13px] mt-0.5" style={{ color: "var(--text-secondary)" }}> <p className="text-[13px] mt-0.5" style={{ color: "var(--text-secondary)" }}>
<span className="font-semibold" style={{ color: "var(--text-primary)" }}>{nomFournisseur}</span> <span className="font-semibold" style={{ color: "var(--text-primary)" }}>{nomFournisseur}</span>
{" "}• <span className="font-medium" style={{ color: "var(--text-muted)" }}>{activeStoreNom}</span> {" "}• <span className="font-medium" style={{ color: "var(--text-muted)" }}>{activeStoreNom}</span>
{" "}• {initialRows.length} références {" "}• {totalRows?.toLocaleString("fr-FR") ?? rows.length.toLocaleString("fr-FR")} références
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{(isLoadingRows || loadError) && (
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-semibold"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: loadError ? "var(--accent-error)" : "var(--text-secondary)" }}
>
{isLoadingRows && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{loadError
? loadError
: totalRows
? `${rowsLoaded.toLocaleString("fr-FR")} / ${totalRows.toLocaleString("fr-FR")}`
: "Chargement produits..."}
</div>
)}
<div id="grid-toolbar-actions"></div> <div id="grid-toolbar-actions"></div>
{isAdmin && <ExportDropdown nomFournisseur={nomFournisseur} />} {isAdmin && <ExportDropdown nomFournisseur={nomFournisseur} />}
{isAdmin && hasDrafts && ( {isAdmin && hasDrafts && (
@@ -1,20 +1,10 @@
"use client"; "use client";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { Search, X, ChevronDown, RotateCw } from "lucide-react"; import { Search, X, RotateCw } from "lucide-react";
import { useGridStore } from "@/features/grid/store/use-grid-store"; import { useGridStore } from "@/features/grid/store/use-grid-store";
import { GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const GAMME_FILTERS: { label: string; value: GammeCode | null }[] = [
{ label: "Tous", value: null },
{ label: "A", value: "A" },
{ label: "B", value: "B" },
{ label: "C", value: "C" },
{ label: "Y", value: "Y" },
{ label: "Z", value: "Z" },
];
import { SupplierCombobox } from "./supplier-combobox"; import { SupplierCombobox } from "./supplier-combobox";
import { StoreCombobox } from "./store-combobox"; import { StoreCombobox } from "./store-combobox";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
@@ -26,10 +16,27 @@ interface GridFilterBarProps {
} }
export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) { export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
const { filters, setFilter, rows, draftChanges } = useGridStore(); const filters = useGridStore((s) => s.filters);
const setFilter = useGridStore((s) => s.setFilter);
const rows = useGridStore((s) => s.rows);
const draftChanges = useGridStore((s) => s.draftChanges);
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [isRefreshing, setIsRefreshing] = React.useState(false); const [isRefreshing, setIsRefreshing] = React.useState(false);
const [searchValue, setSearchValue] = React.useState(filters.search);
React.useEffect(() => {
setSearchValue(filters.search);
}, [filters.search]);
React.useEffect(() => {
const handle = window.setTimeout(() => {
if (searchValue !== filters.search) {
setFilter("search", searchValue);
}
}, 250);
return () => window.clearTimeout(handle);
}, [searchValue, filters.search, setFilter]);
// Options nomenclature (famille) construites depuis les rows chargées // Options nomenclature (famille) construites depuis les rows chargées
const code3Options = useMemo(() => { const code3Options = useMemo(() => {
@@ -85,7 +92,8 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
router.push(`/grid?${params.toString()}`); router.push(`/grid?${params.toString()}`);
}; };
const { setActiveMagasin, activeMagasin } = useGridStore(); const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
const activeMagasin = useGridStore((s) => s.activeMagasin);
const handleStoreSelect = (code: string) => { const handleStoreSelect = (code: string) => {
setActiveMagasin(code); setActiveMagasin(code);
@@ -96,7 +104,9 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
const handleRefresh = () => { const handleRefresh = () => {
setIsRefreshing(true); setIsRefreshing(true);
router.refresh(); const params = new URLSearchParams(searchParams.toString());
params.set("_refresh", Date.now().toString());
router.replace(`/grid?${params.toString()}`, { scroll: false });
// Visual feedback delay // Visual feedback delay
setTimeout(() => setIsRefreshing(false), 800); setTimeout(() => setIsRefreshing(false), 800);
}; };
@@ -142,14 +152,14 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
<input <input
type="search" type="search"
placeholder="Rechercher (code, désignation...)" placeholder="Rechercher (code, désignation...)"
value={filters.search} value={searchValue}
onChange={(e) => setFilter("search", e.target.value)} onChange={(e) => setSearchValue(e.target.value)}
className="apple-input w-72 pr-8" className="apple-input w-72 pr-8"
style={{ paddingLeft: "32px" }} style={{ paddingLeft: "32px" }}
/> />
{filters.search && ( {searchValue && (
<button <button
onClick={() => setFilter("search", "")} onClick={() => setSearchValue("")}
className="absolute right-2 top-1/2 -translate-y-1/2" className="absolute right-2 top-1/2 -translate-y-1/2"
> >
<X className="h-3.5 w-3.5 hover:opacity-100 opacity-60 transition-opacity" style={{ color: "var(--text-secondary)" }} /> <X className="h-3.5 w-3.5 hover:opacity-100 opacity-60 transition-opacity" style={{ color: "var(--text-secondary)" }} />
+14 -7
View File
@@ -134,7 +134,7 @@ const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boo
}); });
GammeCell.displayName = "GammeCell"; GammeCell.displayName = "GammeCell";
import { Row } from "@tanstack/react-table"; import { Cell, Column, Row } from "@tanstack/react-table";
import { VirtualItem } from "@tanstack/react-virtual"; import { VirtualItem } from "@tanstack/react-virtual";
// 2. Composant isolé pour la Ligne Virtuelle // 2. Composant isolé pour la Ligne Virtuelle
@@ -170,7 +170,7 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnVisi
borderLeft: isSelected ? "3px solid var(--accent)" : "3px solid transparent", borderLeft: isSelected ? "3px solid var(--accent)" : "3px solid transparent",
}} }}
> >
{row.getVisibleCells().map((cell: any) => { {row.getVisibleCells().map((cell: Cell<ProductRow, unknown>) => {
const isFlexible = cell.column.id === "libelle1" || cell.column.id === "ai" || cell.column.id === "libelle3"; const isFlexible = cell.column.id === "libelle1" || cell.column.id === "ai" || cell.column.id === "libelle3";
const isCenter = cell.column.id === "totalQuantite" || cell.column.id === "totalCa" || cell.column.id === "totalMarge" || cell.column.id.startsWith("month_") || cell.column.id === "gammeInitial" || cell.column.id === "score" || cell.column.id === "gamme"; const isCenter = cell.column.id === "totalQuantite" || cell.column.id === "totalCa" || cell.column.id === "totalMarge" || cell.column.id.startsWith("month_") || cell.column.id === "gammeInitial" || cell.column.id === "score" || cell.column.id === "gamme";
const size = cell.column.getSize(); const size = cell.column.getSize();
@@ -269,7 +269,11 @@ function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; act
export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
// L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme. // L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme.
const { rows, filters, displayDensity, draftChanges, activeMagasin } = useGridStore(); const rows = useGridStore((s) => s.rows);
const filters = useGridStore((s) => s.filters);
const displayDensity = useGridStore((s) => s.displayDensity);
const draftChanges = useGridStore((s) => s.draftChanges);
const activeMagasin = useGridStore((s) => s.activeMagasin);
// Filtre client-side par code3 (famille) et codeGamme // Filtre client-side par code3 (famille) et codeGamme
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
@@ -288,7 +292,10 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const { columnVisibility, setColumnVisibility, columnSizing, setColumnSizing } = useGridStore(); const columnVisibility = useGridStore((s) => s.columnVisibility);
const setColumnVisibility = useGridStore((s) => s.setColumnVisibility);
const columnSizing = useGridStore((s) => s.columnSizing);
const setColumnSizing = useGridStore((s) => s.setColumnSizing);
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null); const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
@@ -669,7 +676,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
</div> </div>
), ),
}, },
], [MONTHS_12, activeMagasin]); // activeMagasin déclenche re-render des cellules mensuelles et totaux ], [MONTHS_12, activeMagasin, isAdmin]); // activeMagasin déclenche re-render des cellules mensuelles et totaux
const table = useReactTable({ const table = useReactTable({
data: filteredData, data: filteredData,
@@ -737,8 +744,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
<DropdownMenuContent align="end" className="w-[220px] max-h-[50vh] overflow-y-auto z-50"> <DropdownMenuContent align="end" className="w-[220px] max-h-[50vh] overflow-y-auto z-50">
{table {table
.getAllLeafColumns() .getAllLeafColumns()
.filter((column: any) => column.getCanHide()) .filter((column: Column<ProductRow, unknown>) => column.getCanHide())
.map((column: any) => { .map((column: Column<ProductRow, unknown>) => {
return ( return (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
key={column.id} key={column.id}
+51 -37
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo, useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useGridStore } from "@/features/grid/store/use-grid-store"; import { useGridStore } from "@/features/grid/store/use-grid-store";
import type { ProductRow } from "@/types/grid"; import type { ProductRow } from "@/types/grid";
import { PackageX } from "lucide-react"; import { PackageX } from "lucide-react";
@@ -49,6 +50,7 @@ const GAMME_COLORS: Record<string, string> = {
export function NoSalesTab() { export function NoSalesTab() {
const rows = useGridStore(s => s.rows); const rows = useGridStore(s => s.rows);
const scrollRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const result = getNoSalesIn6Months(rows); const result = getNoSalesIn6Months(rows);
@@ -61,6 +63,14 @@ export function NoSalesTab() {
}); });
}, [rows]); }, [rows]);
// eslint-disable-next-line react-hooks/incompatible-library
const rowVirtualizer = useVirtualizer({
count: filtered.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 36,
overscan: 12,
});
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
<div className="flex-1 flex items-center justify-center text-sm italic" style={{ color: "var(--text-muted)" }}> <div className="flex-1 flex items-center justify-center text-sm italic" style={{ color: "var(--text-muted)" }}>
@@ -95,64 +105,68 @@ export function NoSalesTab() {
</div> </div>
{/* Tableau */} {/* Tableau */}
<div className="flex-1 min-h-0 overflow-auto rounded-xl" style={{ border: "1px solid var(--border)" }}> <div ref={scrollRef} className="flex-1 min-h-0 overflow-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[12px] border-collapse"> <div className="min-w-[900px] text-[12px]">
<thead className="sticky top-0 z-10" style={{ background: "var(--bg-elevated)" }}> <div className="sticky top-0 z-10 grid grid-cols-[90px_minmax(220px,1fr)_120px_76px_130px_70px_90px_110px_70px]"
<tr style={{ borderBottom: "1px solid var(--border)" }}> style={{ background: "var(--bg-elevated)", borderBottom: "1px solid var(--border)" }}
{["Code", "Libellé", "Réf.", "Gamme", "Dernière vente", "Jours", "Stock", "CA 12m", "Score"].map(col => ( >
<th key={col} {["Code", "Libellé", "Réf.", "Gamme", "Dernière vente", "Jours", "Stock", "CA 12m", "Score"].map(col => (
className="px-3 py-2 text-left font-semibold whitespace-nowrap" <div key={col}
style={{ color: "var(--text-muted)" }} className="px-3 py-2 text-left font-semibold whitespace-nowrap"
> style={{ color: "var(--text-muted)" }}
{col} >
</th> {col}
))} </div>
</tr> ))}
</thead> </div>
<tbody> <div style={{ height: rowVirtualizer.getTotalSize(), position: "relative" }}>
{filtered.map((row, i) => { {rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = filtered[virtualRow.index];
const i = virtualRow.index;
const days = daysSince(row.derniereVente); const days = daysSince(row.derniereVente);
const gamme = row.codeGamme?.trim().toUpperCase() ?? "—"; const gamme = row.codeGamme?.trim().toUpperCase() ?? "—";
const gammeClass = GAMME_COLORS[gamme] ?? "bg-zinc-500/15 text-zinc-600"; const gammeClass = GAMME_COLORS[gamme] ?? "bg-zinc-500/15 text-zinc-600";
return ( return (
<tr <div
key={row.codein} key={row.codein}
className="transition-colors hover:bg-white/5" className="absolute left-0 right-0 grid grid-cols-[90px_minmax(220px,1fr)_120px_76px_130px_70px_90px_110px_70px] items-center transition-colors hover:bg-white/5"
style={{ style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
borderBottom: i < filtered.length - 1 ? "1px solid var(--border)" : undefined, borderBottom: i < filtered.length - 1 ? "1px solid var(--border)" : undefined,
background: i % 2 === 0 ? "transparent" : "var(--bg-subtle, rgba(0,0,0,0.02))", background: i % 2 === 0 ? "transparent" : "var(--bg-subtle, rgba(0,0,0,0.02))",
}} }}
> >
<td className="px-3 py-1.5 font-mono" style={{ color: "var(--text-muted)" }}>{row.codein}</td> <div className="px-3 py-1.5 font-mono" style={{ color: "var(--text-muted)" }}>{row.codein}</div>
<td className="px-3 py-1.5 max-w-[220px] truncate" style={{ color: "var(--text-primary)" }} title={row.libelle1}>{row.libelle1}</td> <div className="px-3 py-1.5 truncate" style={{ color: "var(--text-primary)" }} title={row.libelle1}>{row.libelle1}</div>
<td className="px-3 py-1.5 font-mono text-[11px]" style={{ color: "var(--text-muted)" }}>{row.reference || "—"}</td> <div className="px-3 py-1.5 font-mono text-[11px]" style={{ color: "var(--text-muted)" }}>{row.reference || "—"}</div>
<td className="px-3 py-1.5"> <div className="px-3 py-1.5">
<span className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-bold ${gammeClass}`}> <span className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-bold ${gammeClass}`}>
{gamme} {gamme}
</span> </span>
</td> </div>
<td className="px-3 py-1.5 whitespace-nowrap" style={{ color: row.derniereVente ? "var(--text-secondary)" : "var(--text-muted)" }}> <div className="px-3 py-1.5 whitespace-nowrap" style={{ color: row.derniereVente ? "var(--text-secondary)" : "var(--text-muted)" }}>
{formatDate(row.derniereVente)} {formatDate(row.derniereVente)}
</td> </div>
<td className="px-3 py-1.5 text-right font-medium tabular-nums" <div className="px-3 py-1.5 text-right font-medium tabular-nums"
style={{ color: days && days > 365 ? "var(--color-red, #ef4444)" : "var(--text-secondary)" }}> style={{ color: days && days > 365 ? "var(--color-red, #ef4444)" : "var(--text-secondary)" }}>
{days != null ? days.toLocaleString("fr-FR") : "—"} {days != null ? days.toLocaleString("fr-FR") : "—"}
</td> </div>
<td className="px-3 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}> <div className="px-3 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{row.stockActuel != null ? row.stockActuel.toLocaleString("fr-FR") : "—"} {row.stockActuel != null ? row.stockActuel.toLocaleString("fr-FR") : "—"}
</td> </div>
<td className="px-3 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}> <div className="px-3 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{row.totalCa > 0 ? formatCa(row.totalCa) : "—"} {row.totalCa > 0 ? formatCa(row.totalCa) : "—"}
</td> </div>
<td className="px-3 py-1.5 text-right tabular-nums font-semibold" <div className="px-3 py-1.5 text-right tabular-nums font-semibold"
style={{ color: row.score >= 50 ? "var(--color-amber, #f59e0b)" : "var(--text-muted)" }}> style={{ color: row.score >= 50 ? "var(--color-amber, #f59e0b)" : "var(--text-muted)" }}>
{row.score > 0 ? row.score : "—"} {row.score > 0 ? row.score : "—"}
</td> </div>
</tr> </div>
); );
})} })}
</tbody> </div>
</table> </div>
</div> </div>
</div> </div>
); );
+6 -2
View File
@@ -10,7 +10,11 @@ import { GammeCode } from "@/types/grid";
* Filters changes to only include those in the provided codeins list (e.g. current supplier). * Filters changes to only include those in the provided codeins list (e.g. current supplier).
*/ */
export function useSaveDrafts(magasin: string, filterCodeins?: string[]) { export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
const { draftChanges, rows, resetDrafts, clearDrafts, applyDraftsToRows } = useGridStore(); const draftChanges = useGridStore((s) => s.draftChanges);
const rows = useGridStore((s) => s.rows);
const resetDrafts = useGridStore((s) => s.resetDrafts);
const clearDrafts = useGridStore((s) => s.clearDrafts);
const applyDraftsToRows = useGridStore((s) => s.applyDraftsToRows);
// Only consider changes that are in the filter list (if provided) // Only consider changes that are in the filter list (if provided)
const activeDrafts = filterCodeins const activeDrafts = filterCodeins
@@ -49,7 +53,7 @@ export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
} }
} }
return result; return result;
}, [activeDrafts, rows, magasin, resetDrafts, clearDrafts, filterCodeins]); }, [activeDrafts, rows, magasin, resetDrafts, clearDrafts, applyDraftsToRows, filterCodeins]);
return { save, hasDrafts: count > 0, count }; return { save, hasDrafts: count > 0, count };
} }
+10 -13
View File
@@ -1,21 +1,18 @@
# Master Plan - Résolution Dashboard 502 # Plan - Commit et Push des modifications locales
## Contexte ## Contexte
Le dashboard affiche une erreur 502 Bad Gateway. L'API `api.ffnancy.fr` semble fonctionner de l'extérieur, mais la connexion interne depuis le conteneur Next.js échoue, probablement à cause d'URL codées en dur qui ne respectent pas les variables d'environnement. L'utilisateur a demandé de commiter et pusher ses modifications locales actuelles. L'analyse montre qu'il s'agit d'une implémentation majeure du chargement en streaming pour la grille de produits (11 fichiers modifiés et 2 fichiers non suivis).
## Focus Actuel ## Focus Actuel
Harmonisation des URL API dans le code source pour utiliser `FF_API_BASE_URL`. Validation de la compilation, staging des fichiers, création du commit et push vers le dépôt distant.
## Master Plan ## Master Plan
- [x] Analyser tous les appels API dans `pg-ff-client.ts` et `api-ff-client.ts` - [x] Analyser l'état actuel de Git et identifier les fichiers modifiés/ajoutés (Fait)
- [x] Centraliser la constante `FF_API_BASE` dans `pg-ff-client.ts` - [ ] Vérifier la compilation globale avec `rtk tsc` pour s'assurer que tout est propre
- [x] Remplacer les URL codées en dur par `${FF_API_BASE}` - [ ] Ajouter les fichiers à l'index Git (`rtk git add`)
- [x] Vérifier la configuration Docker et les variables d'environnement - [ ] Créer le commit avec un message descriptif et professionnel (`rtk git commit`)
- [ ] Envoyer les modifications sur la branche distante (`rtk git push`)
- [x] Tester via `/api/diag` (Implémentation validée) - [ ] Valider le statut Git final
## Progress Log ## Progress Log
- Analyse initiale terminée : identification d'une URL codée en dur à la ligne 564 de `pg-ff-client.ts`. - **2026-05-26 13:31** : Analyse initiale des modifications. Identification de l'implémentation de la grille en streaming (`src/features/grid` et `/api/grid`). Initialisation du plan de commit et push.
- Harmonisation effectuée dans `pg-ff-client.ts`.
- Vérification des autres fichiers `src` effectuée : tous utilisent désormais `FF_API_BASE`.