mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-19 05:06:56 +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
@@ -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 && (
|
||||
|
||||
Reference in new issue
Block a user