mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-18 04:36:21 +02:00
perf: lot 1 — chargements plus rapides et corrections, sans changement visuel
Grille (navigateur) - « Rafraîchir » ne passe plus par l'URL : le paramètre _refresh restait mémorisé et chaque retour par le menu forçait un recalcul serveur complet (migration du store pour nettoyer les valeurs déjà enregistrées). - Lignes reçues regroupées toutes les 300 ms au lieu de reconstruire tout le tableau à chaque paquet de 150 (chargement quadratique). - Sélection indexée par code article (getRowId) : après un filtre, l'action groupée visait d'autres produits. Seules les lignes affichées comptent. - Recherche : un passage par ligne au lieu d'un par colonne ; formateurs de nombres partagés (lib/format.ts) ; colonnes mensuelles mémorisées ; plus de transition-all sur les lignes positionnées par transform. - exceljs, jspdf et xlsx chargés au clic seulement. Grille (serveur) - Requêtes Qlik et snapshot lancées en parallèle de la phase SQL. - Cache mémoire borné (LRU) ; calcul en cours réutilisé même en forcé ; flux interrompu quand le client part. - Après un enregistrement, les gammes sont reportées dans le cache et dans grid_rows au lieu d'invalider (recalcul de ~40 s évité). Données et API - lib/ff-cache.ts : cache mémoire 30 min des lectures FF (stock, hit-parade, CA mensuel, fournisseurs, dernière réception, publicités), vidé en fin de synchro nocturne ; erreurs jamais gardées. - Pool PostgreSQL unique (globalThis), réglage sans workers parallèles posé une fois par connexion au lieu d'une transaction autour de chaque requête. - Délai maximal sur tous les appels à l'API FF. - Index session_snapshots ; commandes : plus de double rechargement ; synchro : saisies regroupées ; paramètres : configuration lue une fois ; journal de capture en ajout seul, rien de formaté sans capture ouverte. Corrections - Historique : l'échec de chargement s'affiche (au lieu de « Aucun snapshot »). - Sessions et validations enregistrent le magasin affiché, plus « TOTAL ». - Publicités : libellé du statut « passées ». Plan complet : docs/plan-optimisation-webui.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
35 files changed
+1010
-240
No files matched your search
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle,
|
||||
CheckCircle, Database, Network, Search,
|
||||
@@ -114,6 +114,10 @@ export function SynchronisationClient() {
|
||||
return () => clearInterval(id);
|
||||
}, [state?.enCours, rafraichirEtat]);
|
||||
|
||||
// Saisie numérique pas encore envoyée (cf. majSettingsDifferee).
|
||||
const patchEnAttente = useRef<Partial<SyncSettings>>({});
|
||||
const minuteurPatch = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const majSettings = async (patch: Partial<SyncSettings>) => {
|
||||
setErreur(null);
|
||||
try {
|
||||
@@ -122,10 +126,26 @@ export function SynchronisationClient() {
|
||||
});
|
||||
const d = await res.json();
|
||||
if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`);
|
||||
setSettings(d.settings);
|
||||
// Une saisie faite pendant l'envoi ne doit pas être écrasée par la réponse.
|
||||
setSettings({ ...d.settings, ...patchEnAttente.current });
|
||||
} catch (e) { setErreur(e instanceof Error ? e.message : String(e)); }
|
||||
};
|
||||
|
||||
// Champs numériques : la valeur s'affiche tout de suite et l'envoi au serveur
|
||||
// est regroupé 600 ms après la dernière frappe. Avant, chaque chiffre tapé
|
||||
// partait en PATCH, et une réponse tardive pouvait écraser la saisie en cours.
|
||||
const majSettingsDifferee = (patch: Partial<SyncSettings>) => {
|
||||
setSettings((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||
patchEnAttente.current = { ...patchEnAttente.current, ...patch };
|
||||
if (minuteurPatch.current) clearTimeout(minuteurPatch.current);
|
||||
minuteurPatch.current = setTimeout(() => {
|
||||
const aEnvoyer = patchEnAttente.current;
|
||||
patchEnAttente.current = {};
|
||||
minuteurPatch.current = null;
|
||||
void majSettings(aEnvoyer);
|
||||
}, 600);
|
||||
};
|
||||
|
||||
const action = async (act: "start" | "stop" | "seed", forcer = false) => {
|
||||
setErreur(null);
|
||||
try {
|
||||
@@ -207,7 +227,7 @@ export function SynchronisationClient() {
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>De</label>
|
||||
<input type="number" min={0} max={23} value={settings.heureDebut}
|
||||
onChange={(e) => majSettings({ heureDebut: Number(e.target.value) })}
|
||||
onChange={(e) => majSettingsDifferee({ heureDebut: Number(e.target.value) })}
|
||||
className="w-16 rounded-lg px-2 py-1.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
||||
</div>
|
||||
@@ -215,7 +235,7 @@ export function SynchronisationClient() {
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>À</label>
|
||||
<input type="number" min={0} max={23} value={settings.heureFin}
|
||||
onChange={(e) => majSettings({ heureFin: Number(e.target.value) })}
|
||||
onChange={(e) => majSettingsDifferee({ heureFin: Number(e.target.value) })}
|
||||
className="w-16 rounded-lg px-2 py-1.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
||||
</div>
|
||||
@@ -225,14 +245,14 @@ export function SynchronisationClient() {
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik / nuit</label>
|
||||
<input type="number" min={0} max={200} value={settings.qlikParNuit}
|
||||
onChange={(e) => majSettings({ qlikParNuit: Number(e.target.value) })}
|
||||
onChange={(e) => majSettingsDifferee({ qlikParNuit: Number(e.target.value) })}
|
||||
className="w-20 rounded-lg px-2 py-1.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik si + vieux que</label>
|
||||
<input type="number" min={0} max={365} value={settings.qlikMinJours}
|
||||
onChange={(e) => majSettings({ qlikMinJours: Number(e.target.value) })}
|
||||
onChange={(e) => majSettingsDifferee({ qlikMinJours: Number(e.target.value) })}
|
||||
className="w-20 rounded-lg px-2 py-1.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { pgGetCaByFournisseur, pgGetCaByNomenclature } from "@/lib/pg-ff-client";
|
||||
import { AnalyticsClient } from "./client";
|
||||
import { cachedFF } from "@/lib/ff-cache";
|
||||
|
||||
// Calcul du mois N-1 (même mois année précédente)
|
||||
function getMoisN1(mois: string): string {
|
||||
@@ -84,7 +85,7 @@ export default async function AnalyticsPage(props: {
|
||||
[];
|
||||
|
||||
if (mode === "fournisseur") {
|
||||
const result = await pgGetCaByFournisseur(mois, moisN1);
|
||||
const result = await cachedFF(`ca-fournisseur:${mois}:${moisN1}`, () => pgGetCaByFournisseur(mois, moisN1));
|
||||
rows = result.map((r) => ({
|
||||
code: r.code,
|
||||
label: r.nom,
|
||||
@@ -93,7 +94,7 @@ export default async function AnalyticsPage(props: {
|
||||
ca_ttc: r.ca_ttc,
|
||||
}));
|
||||
} else {
|
||||
const result = await pgGetCaByNomenclature(mois, moisN1);
|
||||
const result = await cachedFF(`ca-nomenclature:${mois}:${moisN1}`, () => pgGetCaByNomenclature(mois, moisN1));
|
||||
rows = result.map((r) => ({
|
||||
code: r.code,
|
||||
label: `${r.code} — ${r.libelle}`,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CalendarClock,
|
||||
@@ -162,17 +161,17 @@ function SiteSection({
|
||||
rows: CadenceView[];
|
||||
fournisseurs: { code: string; nom: string }[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const sorted = useMemo(() => trier(rows), [rows]);
|
||||
const existingCodes = useMemo(() => new Set(rows.map((r) => r.codefou)), [rows]);
|
||||
const nbACommander = rows.filter((r) => r.actif && r.statut === "a_commander").length;
|
||||
|
||||
// Pas de router.refresh() : les actions appellent déjà revalidatePath, dont
|
||||
// la réponse rafraîchit la page. Les deux ensemble rechargeaient tout deux fois.
|
||||
function run(fn: () => Promise<unknown>) {
|
||||
startTransition(async () => {
|
||||
await fn();
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { pgGetCommandesAuto, type PgCommandeAutoRow } from "@/lib/pg-ff-client";
|
||||
import { listCadences, getFournisseursPourCadence } from "@/features/commandes-auto/actions";
|
||||
import { CommandesAutoTabs } from "./tabs";
|
||||
import { cachedFF } from "@/lib/ff-cache";
|
||||
|
||||
export type { PgCommandeAutoRow };
|
||||
|
||||
@@ -9,7 +10,13 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function CommandesAutoPage() {
|
||||
const [rows, cadences, fournisseurs] = await Promise.all([
|
||||
pgGetCommandesAuto(),
|
||||
// Propositions de l'API FF (un appel, plus un par fournisseur sans franco) :
|
||||
// gardées 2 minutes, pour que chaque modification du cadencier — qui
|
||||
// re-rend la page — n'attende pas de nouveau toute l'API.
|
||||
cachedFF("commandes-auto", () => pgGetCommandesAuto(), {
|
||||
ttlMs: 2 * 60 * 1000,
|
||||
cacheIf: (rows) => rows.length > 0,
|
||||
}),
|
||||
listCadences(),
|
||||
getFournisseursPourCadence(),
|
||||
]);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
import * as XLSX from "xlsx";
|
||||
import type { HitParadePivotRow } from "./page";
|
||||
|
||||
type SortKey = "caTotal" | "ca292" | "ca579" | "qteTotal" | "qte292" | "qte579" | "stockTotal" | "stock292" | "stock579";
|
||||
@@ -136,7 +135,9 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
{ qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, stock292: 0, stock579: 0, stockTotal: 0 }
|
||||
), [sorted]);
|
||||
|
||||
function exportToExcel() {
|
||||
async function exportToExcel() {
|
||||
// Chargée au clic seulement : la bibliothèque pèse plusieurs centaines de Ko.
|
||||
const XLSX = await import("xlsx");
|
||||
const headers = [
|
||||
"Code", "Désignation", "Fournisseur", "Nomenclature",
|
||||
"Qté Nancy", "CA TTC Nancy", "% Marge Nancy", "Stock Nancy",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { pgGetHitParade, HitParadeRow } from "@/lib/pg-ff-client";
|
||||
import { HitParadeClient } from "./client";
|
||||
import { cachedFF } from "@/lib/ff-cache";
|
||||
|
||||
export interface HitParadePivotRow {
|
||||
codein: string;
|
||||
@@ -80,7 +81,7 @@ export default async function HitParadePage(props: {
|
||||
const dateDebut = (searchParams.debut as string) || defaults.debut;
|
||||
const dateFin = (searchParams.fin as string) || defaults.fin;
|
||||
|
||||
const rows = await pgGetHitParade(dateDebut, dateFin);
|
||||
const rows = await cachedFF(`hit-parade:${dateDebut}:${dateFin}`, () => pgGetHitParade(dateDebut, dateFin));
|
||||
const pivotted = pivotHitParade(rows);
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,6 +26,8 @@ function SortIcon({ col, sortKey, sortDir }: { col: SortKey; sortKey: SortKey; s
|
||||
const STATUT_LABELS: Record<string, { label: string; cls: string }> = {
|
||||
en_cours: { label: "En cours", cls: "bg-emerald-100 text-emerald-700" },
|
||||
passee: { label: "Passée", cls: "bg-gray-100 text-gray-600" },
|
||||
// Le filtre envoie `passees` à l'API : la même valeur peut revenir sur les lignes.
|
||||
passees: { label: "Passée", cls: "bg-gray-100 text-gray-600" },
|
||||
a_venir: { label: "À venir", cls: "bg-blue-100 text-blue-700" },
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PublicitesClient } from "./client";
|
||||
import { cachedFF } from "@/lib/ff-cache";
|
||||
|
||||
export interface Publicite {
|
||||
tcr_code: string;
|
||||
@@ -40,17 +41,31 @@ export interface PubliciteHistorique {
|
||||
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
const PAGE_SIZE = 50;
|
||||
/** Délai maximal d'un appel à l'API FF : sans lui, une API muette figeait la page. */
|
||||
const FF_API_TIMEOUT_MS = 15_000;
|
||||
/**
|
||||
* Réponses de l'API gardées 10 minutes (les erreurs ne le sont jamais) : la liste
|
||||
* et l'historique étaient re-téléchargés à chaque clic sur un filtre ou une page.
|
||||
*/
|
||||
const PUB_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
async function fetchPublicites(statut: string, page: number): Promise<{
|
||||
data: Publicite[];
|
||||
total: number;
|
||||
pages: number;
|
||||
error?: string;
|
||||
}> {
|
||||
type PublicitesResult = { data: Publicite[]; total: number; pages: number; error?: string };
|
||||
|
||||
function fetchPublicites(statut: string, page: number): Promise<PublicitesResult> {
|
||||
return cachedFF(`publicites:${statut}:${page}`, () => fetchPublicitesApi(statut, page), {
|
||||
ttlMs: PUB_CACHE_TTL_MS,
|
||||
cacheIf: (r) => !r.error,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchPublicitesApi(statut: string, page: number): Promise<PublicitesResult> {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE) });
|
||||
if (statut && statut !== "toutes") params.set("statut", statut);
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/publicites?${params}`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/publicites?${params}`, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(FF_API_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return { data: [], total: 0, pages: 0, error: `Erreur API: ${res.status} ${res.statusText}` };
|
||||
const json = await res.json();
|
||||
return {
|
||||
@@ -63,9 +78,21 @@ async function fetchPublicites(statut: string, page: number): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHistorique(): Promise<{ data: PubliciteHistorique[]; error?: string }> {
|
||||
type HistoriqueResult = { data: PubliciteHistorique[]; error?: string };
|
||||
|
||||
function fetchHistorique(): Promise<HistoriqueResult> {
|
||||
return cachedFF("publicites-historique", fetchHistoriqueApi, {
|
||||
ttlMs: PUB_CACHE_TTL_MS,
|
||||
cacheIf: (r) => !r.error,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchHistoriqueApi(): Promise<HistoriqueResult> {
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/publicites/historique`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/publicites/historique`, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(FF_API_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return { data: [], error: `Erreur historique: ${res.status}` };
|
||||
const json = await res.json();
|
||||
return { data: (json.publicites ?? []) as PubliciteHistorique[] };
|
||||
|
||||
@@ -13,6 +13,22 @@ import { ApiConnectionInfo } from "@/features/admin/components/api-connection-in
|
||||
import { GridWarmup } from "@/features/admin/components/grid-warmup";
|
||||
import { ServerLogs } from "@/features/settings/components/server-logs";
|
||||
|
||||
/**
|
||||
* Configuration enregistrée, lue UNE fois pour toute la page : trois sections
|
||||
* (connexion, API FF, Qlik) la demandaient chacune au montage, et les actions
|
||||
* serveur s'exécutent les unes après les autres.
|
||||
*/
|
||||
let savedConfigPromise: ReturnType<typeof getSavedDatabaseConfig> | null = null;
|
||||
function loadSavedConfig(force = false): ReturnType<typeof getSavedDatabaseConfig> {
|
||||
if (force || !savedConfigPromise) {
|
||||
savedConfigPromise = getSavedDatabaseConfig().catch((e) => {
|
||||
savedConfigPromise = null;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
return savedConfigPromise;
|
||||
}
|
||||
|
||||
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="apple-card">
|
||||
@@ -51,7 +67,7 @@ function FfApiStatusSection() {
|
||||
|
||||
// Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut).
|
||||
useEffect(() => {
|
||||
getSavedDatabaseConfig()
|
||||
loadSavedConfig()
|
||||
.then((cfg) => {
|
||||
setSavedUrl(cfg?.ffApiBaseUrl ?? null);
|
||||
if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
|
||||
@@ -177,7 +193,7 @@ function QlikSettingsSection() {
|
||||
const [status, setStatus] = useState<{ type: "ok" | "err"; msg: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSavedDatabaseConfig().then((c) => {
|
||||
loadSavedConfig().then((c) => {
|
||||
if (!c) return;
|
||||
setHost(c.qlikHost ?? "");
|
||||
setUser(c.qlikUser ?? "");
|
||||
@@ -265,8 +281,8 @@ export default function SettingsPage() {
|
||||
getDatabaseUrl
|
||||
} = useDbSettingsStore();
|
||||
|
||||
const reloadFromServer = useCallback(async () => {
|
||||
const config = await getSavedDatabaseConfig();
|
||||
const reloadFromServer = useCallback(async (force = false) => {
|
||||
const config = await loadSavedConfig(force);
|
||||
if (config) {
|
||||
if (config.url) {
|
||||
// Parser l'URL pour remettre dans le store
|
||||
@@ -290,7 +306,9 @@ export default function SettingsPage() {
|
||||
// au montage, ce que la règle déconseille en général mais qui est ici le but.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsMounted(true);
|
||||
reloadFromServer();
|
||||
// Lecture fraîche à chaque ouverture de la page ; les sections, montées
|
||||
// juste après (cf. `isMounted`), réutilisent cette même requête.
|
||||
reloadFromServer(true);
|
||||
}, [reloadFromServer]);
|
||||
|
||||
const testDb = async () => {
|
||||
@@ -410,7 +428,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={reloadFromServer}
|
||||
onClick={() => reloadFromServer(true)}
|
||||
className="apple-btn-secondary h-8 px-3 text-[11px] opacity-80 hover:opacity-100"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { pgGetStockNegatif, PgStockNegatifRow, pgGetStockSansVente, PgStockSansVenteRow, pgGetSansVente6Mois, PgSansVente6MoisRow } from "@/lib/pg-ff-client";
|
||||
import { GestionStockClient } from "./client";
|
||||
import { cachedFF } from "@/lib/ff-cache";
|
||||
|
||||
export type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow };
|
||||
|
||||
@@ -15,10 +16,12 @@ export default async function GestionStockPage(props: {
|
||||
const magasin = (searchParams.magasin as string) || "";
|
||||
const tab = (searchParams.tab as string) || "negatif";
|
||||
|
||||
// Données FF recopiées chaque nuit : mises en cache (cf. lib/ff-cache.ts).
|
||||
const site = magasin || undefined;
|
||||
const [rowsNegatif, rowsSansVente, rowsSansVente6Mois] = await Promise.all([
|
||||
pgGetStockNegatif(magasin || undefined),
|
||||
pgGetStockSansVente(magasin || undefined),
|
||||
pgGetSansVente6Mois(magasin || undefined),
|
||||
cachedFF(`stock-negatif:${magasin}`, () => pgGetStockNegatif(site)),
|
||||
cachedFF(`stock-sans-vente:${magasin}`, () => pgGetStockSansVente(site)),
|
||||
cachedFF(`stock-sans-vente-6-mois:${magasin}`, () => pgGetSansVente6Mois(site)),
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,8 +4,10 @@ import type { GridFilters } from "@/types/grid";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function writeNdjson(controller: ReadableStreamDefaultController<Uint8Array>, value: unknown) {
|
||||
controller.enqueue(new TextEncoder().encode(`${JSON.stringify(value)}\n`));
|
||||
controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`));
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -26,14 +28,25 @@ export async function GET(request: NextRequest) {
|
||||
};
|
||||
const forceRefresh = searchParams.get("refresh") === "1";
|
||||
|
||||
// Client parti (changement de fournisseur, page quittée) : on arrête de
|
||||
// sérialiser et d'envoyer. Le calcul lui-même continue — il est partagé et
|
||||
// mis en cache, la prochaine ouverture en profitera.
|
||||
let cancelled = false;
|
||||
request.signal.addEventListener("abort", () => { cancelled = true; });
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
async start(controller) {
|
||||
try {
|
||||
writeNdjson(controller, { type: "start", loaded: 0, total: null });
|
||||
const rows = await getProductRows({ codeFournisseur, magasin, filters, forceRefresh });
|
||||
if (cancelled) return;
|
||||
const total = rows.length;
|
||||
|
||||
for (let start = 0; start < total; start += chunkSize) {
|
||||
if (cancelled) return;
|
||||
const chunk = rows.slice(start, start + chunkSize);
|
||||
writeNdjson(controller, {
|
||||
type: "chunk",
|
||||
@@ -46,12 +59,17 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
writeNdjson(controller, { type: "done", loaded: total, total });
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
writeNdjson(controller, {
|
||||
type: "error",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
controller.close();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* flux déjà fermé côté client */
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user