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
+580
-98
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 { SupplierSelectionLanding } from "@/features/grid/components/supplier-selection-landing";
|
||||
|
||||
@@ -33,20 +33,16 @@ export default async function GridPage({ searchParams }: GridPageProps) {
|
||||
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);
|
||||
|
||||
return (
|
||||
<GridClient
|
||||
initialRows={rows}
|
||||
codeFournisseur={codeFournisseur}
|
||||
nomFournisseur={selectedFournisseur?.nom || "Fournisseur inconnu"}
|
||||
fournisseurs={fournisseurs}
|
||||
magasins={magasins}
|
||||
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 { computeProductScores } from "@/lib/score-engine";
|
||||
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
|
||||
import { buildLast12MonthsRange, getMensuelByArticles } from "@/lib/api-ff-client";
|
||||
import {
|
||||
pgGetArticlesByFournisseur,
|
||||
pgGetMensuelByFournisseur,
|
||||
@@ -21,9 +21,57 @@ interface GetProductRowsInput {
|
||||
codeFournisseur: string;
|
||||
magasin?: string;
|
||||
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[]> {
|
||||
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;
|
||||
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 ─────────
|
||||
const allRows = Array.from(productMap.values());
|
||||
const rows = allRows.filter(p => p.codeGamme !== "Y" || p.totalQuantite > 0);
|
||||
await reconcileSelectedStoreFromMensuelApi(rows, magasin, dateDebut, dateFin, sortedPeriods);
|
||||
const excludedY = allRows.length - rows.length;
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
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 { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { invalidateGridRowsCache } from "./get-product-rows";
|
||||
|
||||
const SaveDraftsSchema = z.object({
|
||||
codeFournisseur: z.string(),
|
||||
@@ -69,6 +70,8 @@ export async function saveDraftChanges(
|
||||
type: "snapshot",
|
||||
});
|
||||
|
||||
invalidateGridRowsCache(codeFournisseur);
|
||||
|
||||
return { success: true, saved: changes.length };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
|
||||
@@ -18,7 +18,7 @@ interface BulkActionToolbarProps {
|
||||
}
|
||||
|
||||
export function BulkActionToolbar({ selectedCodeins, onClearSelection }: BulkActionToolbarProps) {
|
||||
const { batchSetDraftGamme } = useGridStore();
|
||||
const batchSetDraftGamme = useGridStore((s) => s.batchSetDraftGamme);
|
||||
|
||||
if (selectedCodeins.length === 0) return null;
|
||||
|
||||
|
||||
@@ -29,8 +29,12 @@ function Stat({ label, value, sub }: { label: string; value: string; sub?: strin
|
||||
|
||||
export function FloatingSummaryBar() {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = (session?.user as any)?.role === "admin";
|
||||
const { summary, resetDrafts, rows, filters, draftChanges } = useGridStore();
|
||||
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
|
||||
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 [isPending, startTransition] = useTransition();
|
||||
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"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 { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
|
||||
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 { useSearchParams } from "next/navigation";
|
||||
import { computeProductScores } from "@/lib/score-engine";
|
||||
import type { GridFilters } from "@/types/grid";
|
||||
|
||||
interface GridClientProps {
|
||||
initialRows: ProductRow[];
|
||||
codeFournisseur: string;
|
||||
nomFournisseur: string;
|
||||
fournisseurs: { code: string; nom: string }[];
|
||||
magasins: { code: string; nom: 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 isAdmin = (session?.user as any)?.role === "admin";
|
||||
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
|
||||
const setRows = useGridStore((s) => s.setRows);
|
||||
const rows = useGridStore((s) => s.rows);
|
||||
const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery);
|
||||
@@ -39,10 +45,14 @@ export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fourn
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
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 visibleCodeins = initialRows.map(r => r.codein);
|
||||
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
|
||||
const { save, hasDrafts, count } = useSaveDrafts(magasin, visibleCodeins);
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
@@ -68,9 +78,81 @@ export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fourn
|
||||
setFilter("code3", null);
|
||||
prevFournisseurRef.current = codeFournisseur;
|
||||
}
|
||||
const scoredRows = computeProductScores([...initialRows]);
|
||||
setRows(scoredRows);
|
||||
}, [codeFournisseur, initialRows, setRows, setFilter, isMounted]);
|
||||
}, [codeFournisseur, 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)
|
||||
useEffect(() => {
|
||||
@@ -101,10 +183,23 @@ export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fourn
|
||||
<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-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>
|
||||
</div>
|
||||
<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>
|
||||
{isAdmin && <ExportDropdown nomFournisseur={nomFournisseur} />}
|
||||
{isAdmin && hasDrafts && (
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
"use client";
|
||||
|
||||
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 { GammeCode } from "@/types/grid";
|
||||
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 { StoreCombobox } from "./store-combobox";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -26,10 +16,27 @@ interface 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 searchParams = useSearchParams();
|
||||
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
|
||||
const code3Options = useMemo(() => {
|
||||
@@ -85,7 +92,8 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
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) => {
|
||||
setActiveMagasin(code);
|
||||
@@ -96,7 +104,9 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
|
||||
const handleRefresh = () => {
|
||||
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
|
||||
setTimeout(() => setIsRefreshing(false), 800);
|
||||
};
|
||||
@@ -142,14 +152,14 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Rechercher (code, désignation...)"
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilter("search", e.target.value)}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
className="apple-input w-72 pr-8"
|
||||
style={{ paddingLeft: "32px" }}
|
||||
/>
|
||||
{filters.search && (
|
||||
{searchValue && (
|
||||
<button
|
||||
onClick={() => setFilter("search", "")}
|
||||
onClick={() => setSearchValue("")}
|
||||
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)" }} />
|
||||
|
||||
@@ -134,7 +134,7 @@ const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boo
|
||||
});
|
||||
GammeCell.displayName = "GammeCell";
|
||||
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { Cell, Column, Row } from "@tanstack/react-table";
|
||||
import { VirtualItem } from "@tanstack/react-virtual";
|
||||
|
||||
// 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",
|
||||
}}
|
||||
>
|
||||
{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 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();
|
||||
@@ -269,7 +269,11 @@ function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; act
|
||||
|
||||
export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
// 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
|
||||
const filteredData = useMemo(() => {
|
||||
@@ -288,7 +292,10 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
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 [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
|
||||
|
||||
@@ -669,7 +676,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
</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({
|
||||
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">
|
||||
{table
|
||||
.getAllLeafColumns()
|
||||
.filter((column: any) => column.getCanHide())
|
||||
.map((column: any) => {
|
||||
.filter((column: Column<ProductRow, unknown>) => column.getCanHide())
|
||||
.map((column: Column<ProductRow, unknown>) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"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 type { ProductRow } from "@/types/grid";
|
||||
import { PackageX } from "lucide-react";
|
||||
@@ -49,6 +50,7 @@ const GAMME_COLORS: Record<string, string> = {
|
||||
|
||||
export function NoSalesTab() {
|
||||
const rows = useGridStore(s => s.rows);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const result = getNoSalesIn6Months(rows);
|
||||
@@ -61,6 +63,14 @@ export function NoSalesTab() {
|
||||
});
|
||||
}, [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) {
|
||||
return (
|
||||
<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>
|
||||
|
||||
{/* Tableau */}
|
||||
<div className="flex-1 min-h-0 overflow-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[12px] border-collapse">
|
||||
<thead className="sticky top-0 z-10" style={{ background: "var(--bg-elevated)" }}>
|
||||
<tr style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
{["Code", "Libellé", "Réf.", "Gamme", "Dernière vente", "Jours", "Stock", "CA 12m", "Score"].map(col => (
|
||||
<th key={col}
|
||||
className="px-3 py-2 text-left font-semibold whitespace-nowrap"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((row, i) => {
|
||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
|
||||
<div className="min-w-[900px] text-[12px]">
|
||||
<div className="sticky top-0 z-10 grid grid-cols-[90px_minmax(220px,1fr)_120px_76px_130px_70px_90px_110px_70px]"
|
||||
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 => (
|
||||
<div key={col}
|
||||
className="px-3 py-2 text-left font-semibold whitespace-nowrap"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{col}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ height: rowVirtualizer.getTotalSize(), position: "relative" }}>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = filtered[virtualRow.index];
|
||||
const i = virtualRow.index;
|
||||
const days = daysSince(row.derniereVente);
|
||||
const gamme = row.codeGamme?.trim().toUpperCase() ?? "—";
|
||||
const gammeClass = GAMME_COLORS[gamme] ?? "bg-zinc-500/15 text-zinc-600";
|
||||
return (
|
||||
<tr
|
||||
<div
|
||||
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={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
borderBottom: i < filtered.length - 1 ? "1px solid var(--border)" : undefined,
|
||||
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>
|
||||
<td className="px-3 py-1.5 max-w-[220px] truncate" style={{ color: "var(--text-primary)" }} title={row.libelle1}>{row.libelle1}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-[11px]" style={{ color: "var(--text-muted)" }}>{row.reference || "—"}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<div className="px-3 py-1.5 font-mono" style={{ color: "var(--text-muted)" }}>{row.codein}</div>
|
||||
<div className="px-3 py-1.5 truncate" style={{ color: "var(--text-primary)" }} title={row.libelle1}>{row.libelle1}</div>
|
||||
<div className="px-3 py-1.5 font-mono text-[11px]" style={{ color: "var(--text-muted)" }}>{row.reference || "—"}</div>
|
||||
<div className="px-3 py-1.5">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-bold ${gammeClass}`}>
|
||||
{gamme}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 whitespace-nowrap" style={{ color: row.derniereVente ? "var(--text-secondary)" : "var(--text-muted)" }}>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 whitespace-nowrap" style={{ color: row.derniereVente ? "var(--text-secondary)" : "var(--text-muted)" }}>
|
||||
{formatDate(row.derniereVente)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right font-medium tabular-nums"
|
||||
</div>
|
||||
<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)" }}>
|
||||
{days != null ? days.toLocaleString("fr-FR") : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{row.stockActuel != null ? row.stockActuel.toLocaleString("fr-FR") : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{row.totalCa > 0 ? formatCa(row.totalCa) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums font-semibold"
|
||||
</div>
|
||||
<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)" }}>
|
||||
{row.score > 0 ? row.score : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</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).
|
||||
*/
|
||||
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)
|
||||
const activeDrafts = filterCodeins
|
||||
@@ -49,7 +53,7 @@ export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [activeDrafts, rows, magasin, resetDrafts, clearDrafts, filterCodeins]);
|
||||
}, [activeDrafts, rows, magasin, resetDrafts, clearDrafts, applyDraftsToRows, filterCodeins]);
|
||||
|
||||
return { save, hasDrafts: count > 0, count };
|
||||
}
|
||||
@@ -1,21 +1,18 @@
|
||||
# Master Plan - Résolution Dashboard 502
|
||||
# Plan - Commit et Push des modifications locales
|
||||
|
||||
## 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
|
||||
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
|
||||
- [x] Analyser tous les appels API dans `pg-ff-client.ts` et `api-ff-client.ts`
|
||||
- [x] Centraliser la constante `FF_API_BASE` dans `pg-ff-client.ts`
|
||||
- [x] Remplacer les URL codées en dur par `${FF_API_BASE}`
|
||||
- [x] Vérifier la configuration Docker et les variables d'environnement
|
||||
|
||||
- [x] Tester via `/api/diag` (Implémentation validée)
|
||||
|
||||
- [x] Analyser l'état actuel de Git et identifier les fichiers modifiés/ajoutés (Fait)
|
||||
- [ ] Vérifier la compilation globale avec `rtk tsc` pour s'assurer que tout est propre
|
||||
- [ ] Ajouter les fichiers à l'index Git (`rtk git add`)
|
||||
- [ ] Créer le commit avec un message descriptif et professionnel (`rtk git commit`)
|
||||
- [ ] Envoyer les modifications sur la branche distante (`rtk git push`)
|
||||
- [ ] Valider le statut Git final
|
||||
|
||||
## Progress Log
|
||||
- Analyse initiale terminée : identification d'une URL codée en dur à la ligne 564 de `pg-ff-client.ts`.
|
||||
- Harmonisation effectuée dans `pg-ff-client.ts`.
|
||||
- Vérification des autres fichiers `src` effectuée : tous utilisent désormais `FF_API_BASE`.
|
||||
- **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.
|
||||
Reference in new issue
Block a user