mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat(grid): implement product streaming with JSON chunks for grid client
This commit is contained in:
1 parent
8809819658
commit
e6f40d3d81
13 files changed
+574
-92
No files matched your search
@@ -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 -->
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)" }} />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 => (
|
{["Code", "Libellé", "Réf.", "Gamme", "Dernière vente", "Jours", "Stock", "CA 12m", "Score"].map(col => (
|
||||||
<th key={col}
|
<div key={col}
|
||||||
className="px-3 py-2 text-left font-semibold whitespace-nowrap"
|
className="px-3 py-2 text-left font-semibold whitespace-nowrap"
|
||||||
style={{ color: "var(--text-muted)" }}
|
style={{ color: "var(--text-muted)" }}
|
||||||
>
|
>
|
||||||
{col}
|
{col}
|
||||||
</th>
|
</div>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
<div style={{ height: rowVirtualizer.getTotalSize(), position: "relative" }}>
|
||||||
<tbody>
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
{filtered.map((row, i) => {
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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`.
|
|
||||||
Reference in new issue
Block a user