From f104719054acf01b3e2e77dbc5a56130bf3cfeef Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:30:20 +0000 Subject: [PATCH] =?UTF-8?q?Interface=20:=20passage=20de=20Next.js=20=C3=A0?= =?UTF-8?q?=20une=20SPA=20React=20(Vite=20+=20React=20Router)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le serveur étant réécrit en Rust, l'interface devient une application React 19 côté navigateur, compilée par Vite dans dist/ et servie par le serveur Rust (STATIC_DIR). Composants, styles et logique cliente inchangés : seule la plomberie Next.js est remplacée. - Outillage : Vite 8, @vitejs/plugin-react, Tailwind 4 via PostCSS, React Router 7 (mode déclaratif), index.html, src/main.tsx, alias @/, proxy /api → localhost:5643 en développement, ESLint sans plugin Next. - Adaptations (src/shims) : next/link, next/navigation (useRouter avec refresh, usePathname/useSearchParams suivant aussi les replaceState de l'interface), next-auth/react (session lue sur /api/auth/session, déconnexion par /api/auth/logout). - Actions serveur → POST /api/actions/{nom} (src/lib/rpc.ts) : mêmes noms, mêmes signatures, mêmes types. La connexion passe par /api/auth/login. - Pages → table des routes (src/router.tsx) : données lues sur /api/pages/{nom} avec Suspense (loading.tsx au premier chargement, page conservée pendant une navigation ou un router.refresh()), error.tsx en frontière d'erreur, not-found.tsx, titres d'onglet, pages d'administration réservées aux administrateurs. - Suppression du code serveur Next.js (routes /api, accès base, Qlik, authentification, middleware…) ; types utiles à l'interface recopiés dans src/types/ff.ts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- check_drizzle.ts | 19 - drizzle.config.ts | 13 - eslint.config.mjs | 42 +- index.html | 28 + next.config.ts | 15 - package-lock.json | 9850 ++--------------- package.json | 30 +- {src/app => public}/favicon.ico | Bin src/app/(auth)/login/page.tsx | 9 +- .../admin/synchronisation/client.tsx | 2 - .../admin/synchronisation/page.tsx | 19 +- src/app/(dashboard)/analytics/client.tsx | 2 - src/app/(dashboard)/analytics/page.tsx | 56 +- .../commandes-auto/cadencier-client.tsx | 7 +- src/app/(dashboard)/commandes-auto/client.tsx | 2 - src/app/(dashboard)/commandes-auto/page.tsx | 39 +- src/app/(dashboard)/commandes-auto/tabs.tsx | 2 - src/app/(dashboard)/dashboard/page.tsx | 29 +- src/app/(dashboard)/error.tsx | 2 - src/app/(dashboard)/exports/page.tsx | 4 +- src/app/(dashboard)/grid/page.tsx | 31 +- src/app/(dashboard)/historique/page.tsx | 8 +- src/app/(dashboard)/historique/tabs.tsx | 2 - src/app/(dashboard)/hit-parade/client.tsx | 2 - src/app/(dashboard)/hit-parade/page.tsx | 33 +- src/app/(dashboard)/layout.tsx | 14 +- src/app/(dashboard)/produits/fiche.tsx | 2 - src/app/(dashboard)/produits/opportunites.tsx | 4 +- src/app/(dashboard)/produits/page.tsx | 29 +- src/app/(dashboard)/produits/results.tsx | 2 - src/app/(dashboard)/produits/search-bar.tsx | 2 - src/app/(dashboard)/publicites/client.tsx | 2 - src/app/(dashboard)/publicites/page.tsx | 102 +- src/app/(dashboard)/settings/client.tsx | 2 - src/app/(dashboard)/settings/page.tsx | 24 +- src/app/(dashboard)/snapshots/page.tsx | 4 +- src/app/(dashboard)/stock-negatif/client.tsx | 2 - src/app/(dashboard)/stock-negatif/page.tsx | 39 +- src/app/api/admin/grid-warmup/route.ts | 135 - src/app/api/admin/sync/fournisseurs/route.ts | 91 - src/app/api/admin/sync/route.ts | 116 - src/app/api/auth/[...nextauth]/route.ts | 2 - src/app/api/commandes-auto/route.ts | 35 - src/app/api/diag/route.ts | 294 - src/app/api/diag/stock-fournisseur/route.ts | 25 - src/app/api/export/excel/route.ts | 112 - src/app/api/export/modified-gammes/route.ts | 80 - src/app/api/ff-status/route.ts | 28 - src/app/api/grid/rows/route.ts | 88 - src/app/api/grid/rows/store-patch/route.ts | 34 - src/app/api/logs/route.ts | 45 - src/app/api/produits/opportunites/route.ts | 34 - src/app/api/produits/search/route.ts | 65 - src/app/api/qlik/sync/route.ts | 429 - src/app/api/test-db/route.ts | 36 - src/app/api/v1/fournisseurs/route.ts | 62 - src/app/api/v1/gammes/route.ts | 49 - src/app/api/v1/grid/route.ts | 223 - .../api/v1/network/[codeCentrale]/route.ts | 40 - src/app/api/v1/nomenclatures/route.ts | 65 - src/app/api/v1/openapi.json/route.ts | 537 - .../api/v1/products/[codein]/gamme/route.ts | 72 - src/app/api/v1/products/[codein]/route.ts | 73 - src/app/api/v1/products/search/route.ts | 57 - src/app/layout.tsx | 35 - src/app/page.tsx | 4 +- src/app/providers.tsx | 27 +- src/components/shared/admin-only.tsx | 14 + src/components/shared/header.tsx | 2 - .../shared/route-error-boundary.tsx | 42 + src/components/shared/sidebar.tsx | 2 - src/components/shared/theme-provider.tsx | 2 - src/components/ui/data-table.tsx | 2 - src/components/ui/dialog.tsx | 2 - src/components/ui/dropdown-menu.tsx | 2 - src/components/ui/feedback.tsx | 2 - src/components/ui/form-controls.tsx | 2 - src/components/ui/pagination.tsx | 2 - src/components/ui/tabs.tsx | 2 - src/components/ui/tooltip.tsx | 2 - src/db/index.ts | 120 - src/db/schema.ts | 308 - src/features/admin/api/api-key-actions.ts | 75 +- src/features/admin/api/sync-scheduler.ts | 387 - src/features/admin/api/sync-settings.ts | 104 - src/features/admin/api/sync-timer.ts | 41 - src/features/admin/api/user-actions.ts | 78 +- .../admin/components/api-connection-info.tsx | 2 - .../admin/components/api-key-management.tsx | 2 - src/features/admin/components/grid-warmup.tsx | 2 - .../admin/components/user-management.tsx | 2 - src/features/auth/api/login-action.ts | 28 - src/features/auth/components/login-form.tsx | 37 +- src/features/auth/logic/auth-logic.ts | 76 - src/features/auth/logic/seed-admin.ts | 80 - src/features/commandes-auto/actions.ts | 139 +- src/features/grid/actions.ts | 21 +- src/features/grid/api/enregistrer-gammes.ts | 80 - .../grid/api/gammes-a-valider-actions.ts | 36 +- src/features/grid/api/get-product-rows.ts | 779 -- src/features/grid/api/save-draft-changes.ts | 54 +- .../grid/components/assortment-gaps-modal.tsx | 2 - .../grid/components/bulk-action-toolbar.tsx | 2 - .../grid/components/floating-summary-bar.tsx | 2 - src/features/grid/components/gamme-select.tsx | 2 - src/features/grid/components/grid-client.tsx | 2 - .../grid/components/grid-filter-bar.tsx | 2 - src/features/grid/components/heatmap-grid.tsx | 2 - .../grid/components/network-charts.tsx | 2 - .../grid/components/network-monthly-modal.tsx | 2 - .../grid/components/nomenclature-ca-modal.tsx | 2 - .../grid/components/nomenclature-filter.tsx | 2 - .../grid/components/product-monthly-modal.tsx | 2 - src/features/grid/components/stat-tile.tsx | 2 - .../grid/components/store-combobox.tsx | 2 - .../grid/components/supplier-combobox.tsx | 2 - .../components/supplier-selection-landing.tsx | 2 - .../grid/components/sync-qlik-button.tsx | 2 - src/features/grid/hooks/use-save-drafts.ts | 2 - src/features/grid/hooks/use-store-patch.ts | 2 - src/features/grid/lib/store-patch.ts | 2 +- src/features/grid/store/use-grid-store.ts | 2 - .../produits/api/get-produit-fiche.ts | 210 - src/features/produits/api/search-produits.ts | 445 - src/features/produits/types.ts | 4 +- src/features/qlik-sync/use-qlik-sync-job.ts | 2 - src/features/settings/actions.ts | 189 +- .../settings/components/server-logs.tsx | 2 - src/features/settings/config-file.ts | 55 - src/features/settings/ff-api-diagnostic.ts | 64 - .../settings/store/use-db-settings-store.ts | 2 - src/features/snapshots/api/delete-snapshot.ts | 43 +- src/features/snapshots/api/get-snapshots.ts | 52 +- src/features/snapshots/api/save-snapshot.ts | 135 +- .../snapshots/components/snapshot-list.tsx | 2 - src/instrumentation.ts | 20 - src/lib/api-auth.ts | 131 - src/lib/api-enrich.ts | 157 - src/lib/api-ff-client.ts | 517 - src/lib/api-gammes.ts | 127 - src/lib/api-response.ts | 72 - src/lib/api-schemas.ts | 168 - src/lib/auth.config.ts | 24 - src/lib/auth.ts | 73 - src/lib/authz.ts | 164 - src/lib/ff-cache.ts | 114 - src/lib/gammes-a-valider.ts | 104 - src/lib/grid-store.ts | 485 - src/lib/log-capture.ts | 243 - src/lib/page-data.ts | 128 + src/lib/pg-ff-client.ts | 2159 ---- src/lib/qlik-browser.ts | 59 - src/lib/qlik-client.ts | 386 - src/lib/qlik-date-range.ts | 280 - src/lib/qlik-network-cache.ts | 128 - src/lib/qlik-playwright.ts | 2500 ----- src/lib/qlik-search.ts | 662 -- src/lib/qlik-validation.ts | 156 - src/lib/rpc.ts | 78 + src/lib/throttled-json-storage.ts | 2 - src/main.tsx | 8 + src/middleware.ts | 48 - src/router.tsx | 134 + src/shims/next-auth-react.ts | 123 + src/shims/next-link.tsx | 32 + src/shims/next-navigation.ts | 156 + src/types/ff.ts | 306 + src/types/httpntlm.d.ts | 23 - tsconfig.json | 25 +- vite.config.mts | 38 + 170 files changed, 2528 insertions(+), 24445 deletions(-) delete mode 100644 check_drizzle.ts delete mode 100644 drizzle.config.ts create mode 100644 index.html delete mode 100644 next.config.ts rename {src/app => public}/favicon.ico (100%) delete mode 100644 src/app/api/admin/grid-warmup/route.ts delete mode 100644 src/app/api/admin/sync/fournisseurs/route.ts delete mode 100644 src/app/api/admin/sync/route.ts delete mode 100644 src/app/api/auth/[...nextauth]/route.ts delete mode 100644 src/app/api/commandes-auto/route.ts delete mode 100644 src/app/api/diag/route.ts delete mode 100644 src/app/api/diag/stock-fournisseur/route.ts delete mode 100644 src/app/api/export/excel/route.ts delete mode 100644 src/app/api/export/modified-gammes/route.ts delete mode 100644 src/app/api/ff-status/route.ts delete mode 100644 src/app/api/grid/rows/route.ts delete mode 100644 src/app/api/grid/rows/store-patch/route.ts delete mode 100644 src/app/api/logs/route.ts delete mode 100644 src/app/api/produits/opportunites/route.ts delete mode 100644 src/app/api/produits/search/route.ts delete mode 100644 src/app/api/qlik/sync/route.ts delete mode 100644 src/app/api/test-db/route.ts delete mode 100644 src/app/api/v1/fournisseurs/route.ts delete mode 100644 src/app/api/v1/gammes/route.ts delete mode 100644 src/app/api/v1/grid/route.ts delete mode 100644 src/app/api/v1/network/[codeCentrale]/route.ts delete mode 100644 src/app/api/v1/nomenclatures/route.ts delete mode 100644 src/app/api/v1/openapi.json/route.ts delete mode 100644 src/app/api/v1/products/[codein]/gamme/route.ts delete mode 100644 src/app/api/v1/products/[codein]/route.ts delete mode 100644 src/app/api/v1/products/search/route.ts delete mode 100644 src/app/layout.tsx create mode 100644 src/components/shared/admin-only.tsx create mode 100644 src/components/shared/route-error-boundary.tsx delete mode 100644 src/db/index.ts delete mode 100644 src/db/schema.ts delete mode 100644 src/features/admin/api/sync-scheduler.ts delete mode 100644 src/features/admin/api/sync-settings.ts delete mode 100644 src/features/admin/api/sync-timer.ts delete mode 100644 src/features/auth/api/login-action.ts delete mode 100644 src/features/auth/logic/auth-logic.ts delete mode 100644 src/features/auth/logic/seed-admin.ts delete mode 100644 src/features/grid/api/enregistrer-gammes.ts delete mode 100644 src/features/grid/api/get-product-rows.ts delete mode 100644 src/features/produits/api/get-produit-fiche.ts delete mode 100644 src/features/produits/api/search-produits.ts delete mode 100644 src/features/settings/config-file.ts delete mode 100644 src/features/settings/ff-api-diagnostic.ts delete mode 100644 src/instrumentation.ts delete mode 100644 src/lib/api-auth.ts delete mode 100644 src/lib/api-enrich.ts delete mode 100644 src/lib/api-ff-client.ts delete mode 100644 src/lib/api-gammes.ts delete mode 100644 src/lib/api-response.ts delete mode 100644 src/lib/api-schemas.ts delete mode 100644 src/lib/auth.config.ts delete mode 100644 src/lib/auth.ts delete mode 100644 src/lib/authz.ts delete mode 100644 src/lib/ff-cache.ts delete mode 100644 src/lib/gammes-a-valider.ts delete mode 100644 src/lib/grid-store.ts delete mode 100644 src/lib/log-capture.ts create mode 100644 src/lib/page-data.ts delete mode 100644 src/lib/pg-ff-client.ts delete mode 100644 src/lib/qlik-browser.ts delete mode 100644 src/lib/qlik-client.ts delete mode 100644 src/lib/qlik-date-range.ts delete mode 100644 src/lib/qlik-network-cache.ts delete mode 100644 src/lib/qlik-playwright.ts delete mode 100644 src/lib/qlik-search.ts delete mode 100644 src/lib/qlik-validation.ts create mode 100644 src/lib/rpc.ts create mode 100644 src/main.tsx delete mode 100644 src/middleware.ts create mode 100644 src/router.tsx create mode 100644 src/shims/next-auth-react.ts create mode 100644 src/shims/next-link.tsx create mode 100644 src/shims/next-navigation.ts create mode 100644 src/types/ff.ts delete mode 100644 src/types/httpntlm.d.ts create mode 100644 vite.config.mts diff --git a/check_drizzle.ts b/check_drizzle.ts deleted file mode 100644 index 44932d7..0000000 --- a/check_drizzle.ts +++ /dev/null @@ -1,19 +0,0 @@ - -import { db } from "./src/db"; -import { sql } from "drizzle-orm"; - -async function check() { - try { - const res = await db.execute(sql`SELECT COUNT(*) FROM ventes_produits`); - console.log("Total rows in ventes_produits:", res.rows[0].count); - - const resDistinct = await db.execute(sql`SELECT COUNT(DISTINCT codein) FROM ventes_produits`); - console.log("Total distinct codein in ventes_produits:", resDistinct.rows[0].count); - - const resSupplier = await db.execute(sql`SELECT code_fournisseur, COUNT(*) as row_count, COUNT(DISTINCT codein) as prod_count FROM ventes_produits GROUP BY code_fournisseur`); - console.log("Counts per supplier:", resSupplier.rows); - } catch (e) { - console.error("Error:", e); - } -} -check(); diff --git a/drizzle.config.ts b/drizzle.config.ts deleted file mode 100644 index 91d078b..0000000 --- a/drizzle.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from "drizzle-kit"; -import * as dotenv from "dotenv"; - -dotenv.config({ path: ".env.local" }); - -export default defineConfig({ - schema: "./src/db/schema.ts", - out: "./src/db/migrations", - dialect: "postgresql", - dbCredentials: { - url: process.env.DATABASE_URL!, - }, -}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..8108e2e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,18 +1,36 @@ import { defineConfig, globalIgnores } from "eslint/config"; -import nextVitals from "eslint-config-next/core-web-vitals"; -import nextTs from "eslint-config-next/typescript"; +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +/** + * Lint de l'interface (SPA Vite). Seul `src/` est vérifié : `server/` est en + * Rust, `scripts/` et les fichiers .js de la racine sont des utilitaires Node. + */ const eslintConfig = defineConfig([ - ...nextVitals, - ...nextTs, - // Override default ignores of eslint-config-next. - globalIgnores([ - // Default ignores of eslint-config-next: - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", - ]), + globalIgnores(["dist/**", "server/**", "scripts/**", "_bmad/**", "_bmad-output/**", "docs/**", "*.js", "*.mjs"]), + { + files: ["src/**/*.{ts,tsx}", "vite.config.mts"], + extends: [js.configs.recommended, tseslint.configs.recommended, reactHooks.configs.flat.recommended], + plugins: { "react-refresh": reactRefresh }, + languageOptions: { + ecmaVersion: 2022, + globals: globals.browser, + }, + rules: { + // Exports utilitaires historiques des composants (rechargement à chaud + // seulement moins fin pour ces fichiers). + "react-refresh/only-export-components": [ + "warn", + { + allowConstantExport: true, + allowExportNames: ["buttonVariants", "toast", "confirmer", "useUrlTab", "PROFONDEUR_MAX"], + }, + ], + }, + }, ]); export default eslintConfig; diff --git a/index.html b/index.html new file mode 100644 index 0000000..833e6c5 --- /dev/null +++ b/index.html @@ -0,0 +1,28 @@ + + + + + + CollectFlow + + + + + + + + + +
+ + + diff --git a/next.config.ts b/next.config.ts deleted file mode 100644 index c05866c..0000000 --- a/next.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - output: "standalone", - serverExternalPackages: ["pg", "ws", "httpntlm", "playwright-core"], - experimental: { - serverActions: { - bodySizeLimit: "50mb", - }, - }, - typescript: { - ignoreBuildErrors: true, - }, -}; - -export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 3f5018a..7b63db4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,39 +12,34 @@ "@tanstack/react-virtual": "^3.13.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "drizzle-orm": "^0.45.1", "exceljs": "^4.4.0", - "httpntlm": "^1.8.13", "jspdf": "^4.2.0", "jspdf-autotable": "^5.0.7", "lucide-react": "^0.575.0", - "next": "16.1.6", - "next-auth": "^5.0.0-beta.30", "next-themes": "^0.4.6", - "pg": "^8.18.0", - "playwright-core": "^1.61.0", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", + "react-router": "^7.18.4", "tailwind-merge": "^3.5.0", - "ws": "^8.21.0", - "zod": "^4.3.6", "zustand": "^5.0.11" }, "devDependencies": { + "@eslint/js": "^9.39.5", "@tailwindcss/postcss": "^4", "@types/node": "^20", - "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", - "@types/ws": "^8.18.1", - "drizzle-kit": "^0.31.9", + "@vitejs/plugin-react": "^6.1.2", "eslint": "^9", - "eslint-config-next": "16.1.6", - "shadcn": "^3.8.5", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.13.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", - "typescript": "5.9.3" + "typescript": "5.9.3", + "typescript-eslint": "^8.71.2", + "vite": "^8.3.4" } }, "node_modules/@alloc/quick-lru": { @@ -60,65 +55,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@antfu/ni": { - "version": "25.0.0", - "resolved": "https://registry.npmjs.org/@antfu/ni/-/ni-25.0.0.tgz", - "integrity": "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansis": "^4.0.0", - "fzf": "^0.5.2", - "package-manager-detector": "^1.3.0", - "tinyexec": "^1.0.1" - }, - "bin": { - "na": "bin/na.mjs", - "nci": "bin/nci.mjs", - "ni": "bin/ni.mjs", - "nlx": "bin/nlx.mjs", - "nr": "bin/nr.mjs", - "nun": "bin/nun.mjs", - "nup": "bin/nup.mjs" - } - }, - "node_modules/@auth/core": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz", - "integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==", - "license": "ISC", - "dependencies": { - "@panva/hkdf": "^1.2.1", - "jose": "^6.0.6", - "oauth4webapi": "^3.3.0", - "preact": "10.24.3", - "preact-render-to-string": "6.5.11" - }, - "peerDependencies": { - "@simplewebauthn/browser": "^9.0.1", - "@simplewebauthn/server": "^9.0.2", - "nodemailer": "^6.8.0" - }, - "peerDependenciesMeta": { - "@simplewebauthn/browser": { - "optional": true - }, - "@simplewebauthn/server": { - "optional": true - }, - "nodemailer": { - "optional": true - } - } - }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -127,9 +71,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -137,21 +81,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -168,14 +112,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -184,28 +128,15 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -214,76 +145,40 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.6", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -292,65 +187,10 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -358,9 +198,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -368,9 +208,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -378,27 +218,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -407,95 +247,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", - "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", - "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/runtime": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", @@ -506,33 +257,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -540,1149 +291,19 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@dotenvx/dotenvx": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz", - "integrity": "sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "commander": "^11.1.0", - "dotenv": "^17.2.1", - "eciesjs": "^0.4.10", - "execa": "^5.1.1", - "fdir": "^6.2.0", - "ignore": "^5.3.0", - "object-treeify": "1.1.33", - "picomatch": "^4.0.2", - "which": "^4.0.0" - }, - "bin": { - "dotenvx": "src/cli/dotenvx.js" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@dotenvx/dotenvx/node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^16.13.0 || >=18.0.0" - } - }, - "node_modules/@drizzle-team/brocli": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", - "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@ecies/ciphers": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.5.tgz", - "integrity": "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==", - "dev": true, - "license": "MIT", - "engines": { - "bun": ">=1", - "deno": ">=2", - "node": ">=16" - }, - "peerDependencies": { - "@noble/ciphers": "^1.0.0" - } - }, - "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild-kit/core-utils": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", - "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", - "deprecated": "Merged into tsx: https://tsx.is", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.18.20", - "source-map-support": "^0.5.21" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", - "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" - } - }, - "node_modules/@esbuild-kit/esm-loader": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", - "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", - "deprecated": "Merged into tsx: https://tsx.is", - "dev": true, - "license": "MIT", - "dependencies": { - "@esbuild-kit/core-utils": "^3.3.2", - "get-tsconfig": "^4.7.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1790,10 +411,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/js": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", - "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -1906,19 +540,6 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, - "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1971,560 +592,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@inquirer/ansi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", - "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/confirm": { - "version": "5.1.21", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", - "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "10.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", - "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/type": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", - "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2575,379 +642,14 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", "dev": true, "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/@mswjs/interceptors": { - "version": "0.41.3", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", - "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/logger": "^0.3.0", - "@open-draft/until": "^2.0.0", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "strict-event-emitter": "^0.5.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } - }, - "node_modules/@next/env": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", - "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", - "license": "MIT" - }, - "node_modules/@next/eslint-plugin-next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz", - "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-glob": "3.3.1" - } - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", - "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", - "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", - "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", - "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", - "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", - "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", - "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", - "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@noble/ciphers": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", - "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nolyfill/is-core-module": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", - "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.4.0" - } - }, - "node_modules/@open-draft/deferred-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@open-draft/logger": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-node-process": "^1.2.0", - "outvariant": "^1.4.0" - } - }, - "node_modules/@open-draft/until": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@panva/hkdf": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", - "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", - "license": "MIT", "funding": { - "url": "https://github.com/sponsors/panva" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@radix-ui/number": { @@ -4448,42 +2150,268 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sindresorhus/merge-streams": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", - "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz", @@ -4879,98 +2807,6 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@ts-morph/common": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", - "integrity": "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-glob": "^3.3.3", - "minimatch": "^10.0.1", - "path-browserify": "^1.0.1" - } - }, - "node_modules/@ts-morph/common/node_modules/balanced-match": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", - "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@ts-morph/common/node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@ts-morph/common/node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/@ts-morph/common/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@ts-morph/common/node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4985,18 +2821,11 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "20.19.33", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -5008,18 +2837,6 @@ "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", "license": "MIT" }, - "node_modules/@types/pg": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", - "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, "node_modules/@types/raf": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", @@ -5047,13 +2864,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/statuses": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", - "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -5061,38 +2871,21 @@ "license": "MIT", "optional": true }, - "node_modules/@types/validate-npm-package-name": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", - "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", - "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/type-utils": "8.56.0", - "@typescript-eslint/utils": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5102,15 +2895,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.56.0", + "@typescript-eslint/parser": "^8.71.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "dev": true, "license": "MIT", "engines": { @@ -5118,16 +2911,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", - "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3" }, "engines": { @@ -5139,18 +2932,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", - "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.0", - "@typescript-eslint/types": "^8.56.0", + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", "debug": "^4.4.3" }, "engines": { @@ -5161,18 +2954,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", - "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0" + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5183,9 +2976,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", - "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", "dev": true, "license": "MIT", "engines": { @@ -5196,21 +2989,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", - "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5221,13 +3014,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", - "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", "dev": true, "license": "MIT", "engines": { @@ -5239,21 +3032,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", - "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.56.0", - "@typescript-eslint/tsconfig-utils": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5263,39 +3056,52 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.8" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -5306,16 +3112,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", - "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0" + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5326,17 +3132,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", - "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/types": "8.71.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -5360,287 +3166,34 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", "dev": true, "license": "MIT", "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { - "node": ">= 0.6" + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } } }, "node_modules/acorn": { @@ -5666,16 +3219,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -5693,61 +3236,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -5764,16 +3252,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ansis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", - "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - } - }, "node_modules/archiver": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", @@ -5868,248 +3346,12 @@ "node": ">=10" } }, - "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ast-types": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", - "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/ast-types-flow": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", - "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", - "dev": true, - "license": "MIT" - }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/axe-core": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", - "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", - "dev": true, - "license": "MPL-2.0", - "engines": { - "node": ">=4" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -6147,9 +3389,10 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -6197,31 +3440,6 @@ "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", "license": "MIT" }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -6232,23 +3450,10 @@ "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -6266,11 +3471,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -6312,13 +3517,6 @@ "node": "*" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, "node_modules/buffer-indexof-polyfill": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", @@ -6336,82 +3534,6 @@ "node": ">=0.2.0" } }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -6423,9 +3545,10 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001770", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", - "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", + "dev": true, "funding": [ { "type": "opencollective", @@ -6503,129 +3626,6 @@ "url": "https://polar.sh/cva" } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT" - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -6635,13 +3635,6 @@ "node": ">=6" } }, - "node_modules/code-block-writer": { - "version": "13.0.3", - "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", - "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", - "dev": true, - "license": "MIT" - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -6662,16 +3655,6 @@ "dev": true, "license": "MIT" }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "node_modules/compress-commons": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", @@ -6693,30 +3676,6 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -6724,26 +3683,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, "node_modules/core-js": { "version": "3.48.0", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", @@ -6762,51 +3701,6 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -6857,19 +3751,6 @@ "utrie": "^1.0.2" } }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -6877,77 +3758,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/damerau-levenshtein": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", @@ -6972,21 +3782,6 @@ } } }, - "node_modules/dedent": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", - "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6994,120 +3789,11 @@ "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/des.js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", - "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -7119,29 +3805,6 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, - "node_modules/diff": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", - "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/dompurify": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", @@ -7155,175 +3818,6 @@ "@types/trusted-types": "^2.0.7" } }, - "node_modules/dotenv": { - "version": "17.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", - "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/drizzle-kit": { - "version": "0.31.9", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.9.tgz", - "integrity": "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@drizzle-team/brocli": "^0.10.2", - "@esbuild-kit/esm-loader": "^2.5.5", - "esbuild": "^0.25.4", - "esbuild-register": "^3.5.0" - }, - "bin": { - "drizzle-kit": "bin.cjs" - } - }, - "node_modules/drizzle-orm": { - "version": "0.45.1", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", - "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", - "license": "Apache-2.0", - "peerDependencies": { - "@aws-sdk/client-rds-data": ">=3", - "@cloudflare/workers-types": ">=4", - "@electric-sql/pglite": ">=0.2.0", - "@libsql/client": ">=0.10.0", - "@libsql/client-wasm": ">=0.10.0", - "@neondatabase/serverless": ">=0.10.0", - "@op-engineering/op-sqlite": ">=2", - "@opentelemetry/api": "^1.4.1", - "@planetscale/database": ">=1.13", - "@prisma/client": "*", - "@tidbcloud/serverless": "*", - "@types/better-sqlite3": "*", - "@types/pg": "*", - "@types/sql.js": "*", - "@upstash/redis": ">=1.34.7", - "@vercel/postgres": ">=0.8.0", - "@xata.io/client": "*", - "better-sqlite3": ">=7", - "bun-types": "*", - "expo-sqlite": ">=14.0.0", - "gel": ">=2", - "knex": "*", - "kysely": "*", - "mysql2": ">=2", - "pg": ">=8", - "postgres": ">=3", - "sql.js": ">=1", - "sqlite3": ">=5" - }, - "peerDependenciesMeta": { - "@aws-sdk/client-rds-data": { - "optional": true - }, - "@cloudflare/workers-types": { - "optional": true - }, - "@electric-sql/pglite": { - "optional": true - }, - "@libsql/client": { - "optional": true - }, - "@libsql/client-wasm": { - "optional": true - }, - "@neondatabase/serverless": { - "optional": true - }, - "@op-engineering/op-sqlite": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@planetscale/database": { - "optional": true - }, - "@prisma/client": { - "optional": true - }, - "@tidbcloud/serverless": { - "optional": true - }, - "@types/better-sqlite3": { - "optional": true - }, - "@types/pg": { - "optional": true - }, - "@types/sql.js": { - "optional": true - }, - "@upstash/redis": { - "optional": true - }, - "@vercel/postgres": { - "optional": true - }, - "@xata.io/client": { - "optional": true - }, - "better-sqlite3": { - "optional": true - }, - "bun-types": { - "optional": true - }, - "expo-sqlite": { - "optional": true - }, - "gel": { - "optional": true - }, - "knex": { - "optional": true - }, - "kysely": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "pg": { - "optional": true - }, - "postgres": { - "optional": true - }, - "prisma": { - "optional": true - }, - "sql.js": { - "optional": true - }, - "sqlite3": { - "optional": true - } - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", @@ -7369,55 +3863,13 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/eciesjs": { - "version": "0.4.17", - "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.17.tgz", - "integrity": "sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ecies/ciphers": "^0.2.5", - "@noble/ciphers": "^1.3.0", - "@noble/curves": "^1.9.7", - "@noble/hashes": "^1.8.0" - }, - "engines": { - "bun": ">=1", - "deno": ">=2", - "node": ">=16" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "version": "1.5.452", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", "dev": true, "license": "ISC" }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -7441,258 +3893,6 @@ "node": ">=10.13.0" } }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-iterator-helpers": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", - "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.1", - "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.1.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.3.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.5", - "safe-array-concat": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/esbuild-register": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", - "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "peerDependencies": { - "esbuild": ">=0.12 <1" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -7703,13 +3903,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -7783,238 +3976,6 @@ } } }, - "node_modules/eslint-config-next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz", - "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@next/eslint-plugin-next": "16.1.6", - "eslint-import-resolver-node": "^0.3.6", - "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jsx-a11y": "^6.10.0", - "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^7.0.0", - "globals": "16.4.0", - "typescript-eslint": "^8.46.0" - }, - "peerDependencies": { - "eslint": ">=9.0.0", - "typescript": ">=3.3.1" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/eslint-config-next/node_modules/globals": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", - "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-import-resolver-typescript": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", - "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@nolyfill/is-core-module": "1.0.39", - "debug": "^4.4.0", - "get-tsconfig": "^4.10.0", - "is-bun-module": "^2.0.0", - "stable-hash": "^0.0.5", - "tinyglobby": "^0.2.13", - "unrs-resolver": "^1.6.2" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-import-resolver-typescript" - }, - "peerDependencies": { - "eslint": "*", - "eslint-plugin-import": "*", - "eslint-plugin-import-x": "*" - }, - "peerDependenciesMeta": { - "eslint-plugin-import": { - "optional": true - }, - "eslint-plugin-import-x": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", - "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "aria-query": "^5.3.2", - "array-includes": "^3.1.8", - "array.prototype.flatmap": "^1.3.2", - "ast-types-flow": "^0.0.8", - "axe-core": "^4.10.0", - "axobject-query": "^4.1.0", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "hasown": "^2.0.2", - "jsx-ast-utils": "^3.3.5", - "language-tags": "^1.0.9", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "safe-regex-test": "^1.0.3", - "string.prototype.includes": "^2.0.1" - }, - "engines": { - "node": ">=4.0" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-react": { - "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.3", - "array.prototype.tosorted": "^1.1.4", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.2.1", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.9", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.1", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" - } - }, "node_modules/eslint-plugin-react-hooks": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", @@ -8035,28 +3996,14 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.6", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", - "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", + "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", "dev": true, "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "node-exports-info": "^1.6.0", - "object-keys": "^1.1.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "eslint": "^9 || ^10" } }, "node_modules/eslint-scope": { @@ -8089,6 +4036,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -8107,20 +4067,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -8167,39 +4113,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/exceljs": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", @@ -8220,96 +4133,6 @@ "node": ">=8.3.0" } }, - "node_modules/execa": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", - "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "cross-spawn": "^7.0.6", - "figures": "^6.1.0", - "get-stream": "^9.0.0", - "human-signals": "^8.0.1", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^6.0.0", - "pretty-ms": "^9.2.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": "^18.19.0 || >=20.5.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "10.0.1" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/fast-csv": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", @@ -8330,36 +4153,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", - "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -8391,55 +4184,22 @@ "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", "license": "(MIT AND Zlib)" }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, "engines": { - "node": "^12.20 || >= 14.13" + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/fflate": { @@ -8448,22 +4208,6 @@ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", "license": "MIT" }, - "node_modules/figures": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", - "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-unicode-supported": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -8477,41 +4221,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -8550,82 +4259,33 @@ "dev": true, "license": "ISC" }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, - "node_modules/fs-extra": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", - "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/fstream": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", @@ -8642,71 +4302,6 @@ "node": ">=0.6" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/fuzzysort": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", - "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/fzf": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz", - "integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -8717,54 +4312,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -8774,81 +4321,6 @@ "node": ">=6" } }, - "node_modules/get-own-enumerable-keys": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz", - "integrity": "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -8884,9 +4356,9 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", "dev": true, "license": "MIT", "engines": { @@ -8896,65 +4368,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/graphql": { - "version": "16.12.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", - "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -8965,84 +4384,6 @@ "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/headers-polyfill": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", - "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -9060,16 +4401,6 @@ "hermes-estree": "0.25.1" } }, - "node_modules/hono": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz", - "integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", @@ -9084,101 +4415,6 @@ "node": ">=8.0.0" } }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/httpntlm": { - "version": "1.8.13", - "resolved": "https://registry.npmjs.org/httpntlm/-/httpntlm-1.8.13.tgz", - "integrity": "sha512-2F2FDPiWT4rewPzNMg3uPhNkP3NExENlUGADRUDPQvuftuUTGW98nLZtGemCIW3G40VhWZYgkIDcQFAwZ3mf2Q==", - "funding": [ - { - "type": "paypal", - "url": "https://www.paypal.com/donate/?hosted_button_id=2CKNJLZJBW8ZC" - }, - { - "type": "buymeacoffee", - "url": "https://www.buymeacoffee.com/samdecrock" - } - ], - "dependencies": { - "des.js": "^1.0.1", - "httpreq": ">=0.4.22", - "js-md4": "^0.3.2", - "underscore": "~1.12.1" - }, - "engines": { - "node": ">=10.4.0" - } - }, - "node_modules/httpreq": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/httpreq/-/httpreq-1.1.1.tgz", - "integrity": "sha512-uhSZLPPD2VXXOSN8Cni3kIsoFHaU2pT/nySEU/fHr/ePbqHYr0jeiQRmUKLEirC09SFPsdMoA7LU7UXMd/w0Kw==", - "license": "MIT", - "engines": { - "node": ">= 6.15.1" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/human-signals": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -9259,228 +4495,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/iobuffer": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", "license": "MIT" }, - "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bun-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", - "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.7.1" - } - }, - "node_modules/is-bun-module/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -9491,52 +4511,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -9550,351 +4524,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-in-ssh": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", - "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-node-process": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", - "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", - "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regexp": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", - "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -9902,24 +4531,6 @@ "dev": true, "license": "ISC" }, - "node_modules/iterator.prototype": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", - "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "get-proto": "^1.0.0", - "has-symbols": "^1.1.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -9930,21 +4541,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/js-md4": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", - "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", - "license": "MIT" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9985,13 +4581,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -9999,13 +4588,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -10026,19 +4608,6 @@ "node": ">=6" } }, - "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jspdf": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz", @@ -10065,22 +4634,6 @@ "jspdf": "^2 || ^3 || ^4" } }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" - }, - "engines": { - "node": ">=4.0" - } - }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -10139,36 +4692,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/language-subtag-registry": { - "version": "0.3.23", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", - "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/language-tags": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", - "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", - "dev": true, - "license": "MIT", - "dependencies": { - "language-subtag-registry": "^0.3.20" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -10501,13 +5024,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, "node_modules/listenercount": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", @@ -10616,62 +5132,6 @@ "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "license": "MIT" }, - "node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -10701,126 +5161,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -10861,79 +5201,11 @@ "dev": true, "license": "MIT" }, - "node_modules/msw": { - "version": "2.12.10", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.10.tgz", - "integrity": "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.41.2", - "@open-draft/deferred-promise": "^2.2.0", - "@types/statuses": "^2.0.6", - "cookie": "^1.0.2", - "graphql": "^16.12.0", - "headers-polyfill": "^4.0.2", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "path-to-regexp": "^6.3.0", - "picocolors": "^1.1.1", - "rettime": "^0.10.1", - "statuses": "^2.0.2", - "strict-event-emitter": "^0.5.1", - "tough-cookie": "^6.0.0", - "type-fest": "^5.2.0", - "until-async": "^3.0.2", - "yargs": "^17.7.2" - }, - "bin": { - "msw": "cli/index.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mswjs" - }, - "peerDependencies": { - "typescript": ">= 4.8.x" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/msw/node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, "funding": [ { "type": "github", @@ -10948,22 +5220,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-postinstall": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", - "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", - "dev": true, - "license": "MIT", - "bin": { - "napi-postinstall": "lib/cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -10971,96 +5227,6 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", - "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", - "license": "MIT", - "dependencies": { - "@next/env": "16.1.6", - "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.8.3", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "styled-jsx": "5.1.6" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": ">=20.9.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.6", - "@next/swc-darwin-x64": "16.1.6", - "@next/swc-linux-arm64-gnu": "16.1.6", - "@next/swc-linux-arm64-musl": "16.1.6", - "@next/swc-linux-x64-gnu": "16.1.6", - "@next/swc-linux-x64-musl": "16.1.6", - "@next/swc-win32-arm64-msvc": "16.1.6", - "@next/swc-win32-x64-msvc": "16.1.6", - "sharp": "^0.34.4" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.51.1", - "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, - "node_modules/next-auth": { - "version": "5.0.0-beta.30", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz", - "integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==", - "license": "ISC", - "dependencies": { - "@auth/core": "0.41.0" - }, - "peerDependencies": { - "@simplewebauthn/browser": "^9.0.1", - "@simplewebauthn/server": "^9.0.2", - "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", - "nodemailer": "^7.0.7", - "react": "^18.2.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@simplewebauthn/browser": { - "optional": true - }, - "@simplewebauthn/server": { - "optional": true - }, - "nodemailer": { - "optional": true - } - } - }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -11071,99 +5237,15 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-exports-info": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", - "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "array.prototype.flatmap": "^1.3.3", - "es-errors": "^1.3.0", - "object.entries": "^1.1.9", - "semver": "^6.3.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -11174,191 +5256,6 @@ "node": ">=0.10.0" } }, - "node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/oauth4webapi": { - "version": "3.8.5", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", - "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object-treeify": { - "version": "1.1.33", - "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", - "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -11368,43 +5265,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/open": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", - "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "default-browser": "^5.4.0", - "define-lazy-prop": "^3.0.0", - "is-in-ssh": "^1.0.0", - "is-inside-container": "^1.0.0", - "powershell-utils": "^0.1.0", - "wsl-utils": "^0.3.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -11423,68 +5283,6 @@ "node": ">= 0.8.0" } }, - "node_modules/ora": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", - "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "cli-cursor": "^5.0.0", - "cli-spinners": "^2.9.2", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.0.0", - "log-symbols": "^6.0.0", - "stdin-discarder": "^0.2.2", - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/outvariant": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", - "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", - "dev": true, - "license": "MIT" - }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -11517,13 +5315,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-manager-detector": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", - "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", - "dev": true, - "license": "MIT" - }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -11543,55 +5334,6 @@ "node": ">=6" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse-ms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", - "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "dev": true, - "license": "MIT" - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -11621,20 +5363,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, - "license": "MIT" - }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -11642,150 +5370,30 @@ "license": "MIT", "optional": true }, - "node_modules/pg": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", - "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", - "license": "MIT", - "dependencies": { - "pg-connection-string": "^2.11.0", - "pg-pool": "^3.11.0", - "pg-protocol": "^1.11.0", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.3.0" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", - "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", - "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", - "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", - "license": "MIT" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", - "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", - "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/playwright-core": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", - "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "dev": true, "funding": [ { @@ -11803,99 +5411,14 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.19", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/powershell-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", - "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/preact": { - "version": "10.24.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", - "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/preact-render-to-string": { - "version": "6.5.11", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", - "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", - "license": "MIT", - "peerDependencies": { - "preact": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -11906,78 +5429,12 @@ "node": ">= 0.8.0" } }, - "node_modules/pretty-ms": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", - "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse-ms": "^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/prompts/node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -11988,43 +5445,6 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/radix-ui": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", @@ -12112,32 +5532,6 @@ "performance-now": "^2.1.0" } }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -12159,13 +5553,6 @@ "react": "^19.2.3" } }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, - "license": "MIT" - }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -12213,6 +5600,41 @@ } } }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -12279,46 +5701,6 @@ "node": ">=10" } }, - "node_modules/recast": { - "version": "0.23.11", - "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", - "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-types": "^0.16.1", - "esprima": "~4.0.0", - "source-map": "~0.6.1", - "tiny-invariant": "^1.3.3", - "tslib": "^2.0.1" - }, - "engines": { - "node": ">= 4" - } - }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", @@ -12326,68 +5708,6 @@ "license": "MIT", "optional": true }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -12398,51 +5718,6 @@ "node": ">=4" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/rettime": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", - "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", - "dev": true, - "license": "MIT" - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rgbcolor": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", @@ -12466,89 +5741,38 @@ "rimraf": "bin.js" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">= 18" - } - }, - "node_modules/router/node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, "node_modules/safe-buffer": { @@ -12571,48 +5795,6 @@ ], "license": "MIT" }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, "node_modules/saxes": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", @@ -12641,101 +5823,11 @@ "semver": "bin/semver.js" } }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" }, "node_modules/setimmediate": { "version": "1.0.5", @@ -12743,173 +5835,6 @@ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "license": "MIT" }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, - "node_modules/shadcn": { - "version": "3.8.5", - "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-3.8.5.tgz", - "integrity": "sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@antfu/ni": "^25.0.0", - "@babel/core": "^7.28.0", - "@babel/parser": "^7.28.0", - "@babel/plugin-transform-typescript": "^7.28.0", - "@babel/preset-typescript": "^7.27.1", - "@dotenvx/dotenvx": "^1.48.4", - "@modelcontextprotocol/sdk": "^1.26.0", - "@types/validate-npm-package-name": "^4.0.2", - "browserslist": "^4.26.2", - "commander": "^14.0.0", - "cosmiconfig": "^9.0.0", - "dedent": "^1.6.0", - "deepmerge": "^4.3.1", - "diff": "^8.0.2", - "execa": "^9.6.0", - "fast-glob": "^3.3.3", - "fs-extra": "^11.3.1", - "fuzzysort": "^3.1.0", - "https-proxy-agent": "^7.0.6", - "kleur": "^4.1.5", - "msw": "^2.10.4", - "node-fetch": "^3.3.2", - "open": "^11.0.0", - "ora": "^8.2.0", - "postcss": "^8.5.6", - "postcss-selector-parser": "^7.1.0", - "prompts": "^2.4.2", - "recast": "^0.23.11", - "stringify-object": "^5.0.0", - "tailwind-merge": "^3.0.1", - "ts-morph": "^26.0.0", - "tsconfig-paths": "^4.2.0", - "validate-npm-package-name": "^7.0.1", - "zod": "^3.24.1", - "zod-to-json-schema": "^3.24.6" - }, - "bin": { - "shadcn": "dist/index.js" - } - }, - "node_modules/shadcn/node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/shadcn/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/shadcn/node_modules/tsconfig-paths": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", - "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "json5": "^2.2.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/shadcn/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/sharp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -12933,148 +5858,16 @@ "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/stable-hash": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", - "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", - "dev": true, - "license": "MIT" - }, "node_modules/stackblur-canvas": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", @@ -13085,50 +5878,6 @@ "node": ">=0.1.14" } }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/stdin-discarder": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/strict-event-emitter": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", - "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", - "dev": true, - "license": "MIT" - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -13138,201 +5887,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string.prototype.includes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", - "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", - "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.repeat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/stringify-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", - "integrity": "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "get-own-enumerable-keys": "^1.0.0", - "is-obj": "^3.0.0", - "is-regexp": "^3.1.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/yeoman/stringify-object?sponsor=1" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-final-newline": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -13346,29 +5900,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/styled-jsx": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", - "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", - "license": "MIT", - "dependencies": { - "client-only": "0.0.1" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -13382,19 +5913,6 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/svg-pathdata": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", @@ -13405,19 +5923,6 @@ "node": ">=12.0.0" } }, - "node_modules/tagged-tag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -13475,32 +5980,15 @@ "utrie": "^1.0.2" } }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -13509,57 +5997,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tldts": { - "version": "7.0.23", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", - "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.23" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.23", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", - "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", - "dev": true, - "license": "MIT" - }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -13569,42 +6006,6 @@ "node": ">=14.14" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/traverse": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", @@ -13615,9 +6016,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -13627,43 +6028,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-morph": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", - "integrity": "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ts-morph/common": "~0.27.0", - "code-block-writer": "^13.0.3" - } - }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "node_modules/tsconfig-paths/node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -13693,115 +6057,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz", - "integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "dependencies": { - "tagged-tag": "^1.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -13817,16 +6072,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", - "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.56.0", - "@typescript-eslint/parser": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0" + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13837,119 +6092,16 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/underscore": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", - "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==", - "license": "MIT" - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, - "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/unrs-resolver": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", - "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "napi-postinstall": "^0.3.0" - }, - "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" - } - }, - "node_modules/until-async": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", - "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/kettanaito" - } - }, "node_modules/unzipper": { "version": "0.10.14", "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", @@ -14005,9 +6157,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "dev": true, "funding": [ { @@ -14122,34 +6274,343 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/validate-npm-package-name": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", - "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, "engines": { - "node": ">= 0.8" + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, "engines": { - "node": ">= 8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/which": { @@ -14168,95 +6629,6 @@ "node": ">= 8" } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -14267,135 +6639,18 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/wsl-utils": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", - "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0", - "powershell-utils": "^0.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -14403,80 +6658,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -14490,32 +6671,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zip-stream": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", @@ -14552,24 +6707,15 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } - }, "node_modules/zod-validation-error": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", diff --git a/package.json b/package.json index 16b7847..9571b2a 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,11 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", - "build": "next build", - "start": "node scripts/db-init.js && next start", + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", "lint": "eslint", + "typecheck": "tsc --noEmit", "docker:up": "docker-compose up -d", "docker:down": "docker-compose down", "docker:build": "docker-compose up -d --build" @@ -16,38 +17,33 @@ "@tanstack/react-virtual": "^3.13.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "drizzle-orm": "^0.45.1", "exceljs": "^4.4.0", - "httpntlm": "^1.8.13", "jspdf": "^4.2.0", "jspdf-autotable": "^5.0.7", "lucide-react": "^0.575.0", - "next": "16.1.6", - "next-auth": "^5.0.0-beta.30", "next-themes": "^0.4.6", - "pg": "^8.18.0", - "playwright-core": "^1.61.0", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", + "react-router": "^7.18.4", "tailwind-merge": "^3.5.0", - "ws": "^8.21.0", - "zod": "^4.3.6", "zustand": "^5.0.11" }, "devDependencies": { + "@eslint/js": "^9.39.5", "@tailwindcss/postcss": "^4", "@types/node": "^20", - "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", - "@types/ws": "^8.18.1", - "drizzle-kit": "^0.31.9", + "@vitejs/plugin-react": "^6.1.2", "eslint": "^9", - "eslint-config-next": "16.1.6", - "shadcn": "^3.8.5", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.13.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", - "typescript": "5.9.3" + "typescript": "5.9.3", + "typescript-eslint": "^8.71.2", + "vite": "^8.3.4" } } diff --git a/src/app/favicon.ico b/public/favicon.ico similarity index 100% rename from src/app/favicon.ico rename to public/favicon.ico diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index f31bd72..0397c8a 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,9 +1,12 @@ -import type { Metadata } from "next"; +import { Navigate } from "react-router"; +import { useSession } from "next-auth/react"; import { LoginForm } from "@/features/auth/components/login-form"; -export const metadata: Metadata = { title: "Connexion" }; - export default function LoginPage() { + // Déjà connecté : l'accueil (le serveur fait de même pour un accès direct). + const { status } = useSession(); + if (status === "authenticated") return ; + return (
diff --git a/src/app/(dashboard)/admin/synchronisation/client.tsx b/src/app/(dashboard)/admin/synchronisation/client.tsx index 8171871..4f0dd8a 100644 --- a/src/app/(dashboard)/admin/synchronisation/client.tsx +++ b/src/app/(dashboard)/admin/synchronisation/client.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle, diff --git a/src/app/(dashboard)/admin/synchronisation/page.tsx b/src/app/(dashboard)/admin/synchronisation/page.tsx index 86b6644..5ddd4d0 100644 --- a/src/app/(dashboard)/admin/synchronisation/page.tsx +++ b/src/app/(dashboard)/admin/synchronisation/page.tsx @@ -1,17 +1,16 @@ -import type { Metadata } from "next"; -import { redirect } from "next/navigation"; -import { verifierAdmin } from "@/lib/authz"; +import { AdminOnly } from "@/components/shared/admin-only"; import { SynchronisationClient } from "./client"; -export const metadata: Metadata = { title: "Synchronisation" }; - /** * Paramétrage de la synchronisation nocturne des fournisseurs. * - * L'accès admin est déjà imposé par le middleware (préfixe /admin), sur le rôle - * du jeton ; la page le revérifie en base, comme les routes /api/admin/sync. + * L'accès admin est déjà imposé par le serveur (préfixe /admin, rôle relu en + * base) ; la page le revérifie pour une navigation dans l'application. */ -export default async function SynchronisationPage() { - if (!(await verifierAdmin()).ok) redirect("/dashboard"); - return ; +export default function SynchronisationPage() { + return ( + + + + ); } diff --git a/src/app/(dashboard)/analytics/client.tsx b/src/app/(dashboard)/analytics/client.tsx index 81e30fb..244d594 100644 --- a/src/app/(dashboard)/analytics/client.tsx +++ b/src/app/(dashboard)/analytics/client.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useRouter } from "next/navigation"; import { useMemo, useTransition } from "react"; import { Loader2 } from "lucide-react"; diff --git a/src/app/(dashboard)/analytics/page.tsx b/src/app/(dashboard)/analytics/page.tsx index 3f984df..5972ac5 100644 --- a/src/app/(dashboard)/analytics/page.tsx +++ b/src/app/(dashboard)/analytics/page.tsx @@ -1,15 +1,13 @@ -import type { Metadata } from "next"; import Link from "next/link"; import { CalendarRange, RotateCcw } from "lucide-react"; -import { pgGetCaByFournisseur, pgGetCaByNomenclature } from "@/lib/pg-ff-client"; -import { cachedFF } from "@/lib/ff-cache"; +import type { CaByFournisseurRow, CaByNomenclatureRow } from "@/types/ff"; +import { fetchPage } from "@/lib/rpc"; +import { usePageResult, usePageSearchParams } from "@/lib/page-data"; import { PageHeader } from "@/components/ui/page-header"; import { ErrorState } from "@/components/ui/states"; import { Button } from "@/components/ui/button"; import { AnalyticsClient, type AnalyticsRow, type ModeAnalytics, type OptionMois } from "./client"; -export const metadata: Metadata = { title: "Ventes par mois" }; - const NOMS_MOIS = [ "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre", @@ -82,10 +80,10 @@ function pivotData( return result; } -export default async function AnalyticsPage(props: { - searchParams: Promise>; -}) { - const searchParams = await props.searchParams; +type LigneCa = { code: string; label: string; site: string; mois: string; ca_ttc: number }; + +export default function AnalyticsPage() { + const searchParams = usePageSearchParams(); // Toute autre valeur que « fournisseur » affichait déjà la vue par famille. const mode: ModeAnalytics = ((searchParams.mode as string) || "fournisseur") === "fournisseur" ? "fournisseur" : "nomenclature"; const moisParam = (searchParams.mois as string) || ""; @@ -108,36 +106,34 @@ export default async function AnalyticsPage(props: { moisDisponibles.push({ value: mois, label: libelleMois(mois) }); } - let pivotted: AnalyticsRow[] | null = null; - let erreur: string | null = null; - try { - let rows: Array<{ code: string; label: string; site: string; mois: string; ca_ttc: number }> = - []; - + // Lignes de la base FF, mises en cache par le serveur (mêmes clés qu'avant). + const resultat = usePageResult(`analytics?${mode}:${mois}:${moisN1}`, async (): Promise => { if (mode === "fournisseur") { - const result = await cachedFF(`ca-fournisseur:${mois}:${moisN1}`, () => pgGetCaByFournisseur(mois, moisN1)); - rows = result.map((r) => ({ + const result = await fetchPage("analytics", { mode, mois, moisN1 }); + return result.map((r) => ({ code: r.code, label: r.nom, site: r.site, mois: r.mois, ca_ttc: r.ca_ttc, })); - } else { - const result = await cachedFF(`ca-nomenclature:${mois}:${moisN1}`, () => pgGetCaByNomenclature(mois, moisN1)); - rows = result.map((r) => ({ - code: r.code, - label: `${r.code} — ${r.libelle}`, - site: r.site, - mois: r.mois, - ca_ttc: r.ca_ttc, - })); } + const result = await fetchPage("analytics", { mode, mois, moisN1 }); + return result.map((r) => ({ + code: r.code, + label: `${r.code} — ${r.libelle}`, + site: r.site, + mois: r.mois, + ca_ttc: r.ca_ttc, + })); + }); - pivotted = pivotData(rows, mois, moisN1, mode); - } catch (e) { - console.error("[analytics] chargement impossible :", e); - erreur = e instanceof Error ? e.message : String(e); + let pivotted: AnalyticsRow[] | null = null; + let erreur: string | null = null; + if (resultat.ok) { + pivotted = pivotData(resultat.data, mois, moisN1, mode); + } else { + erreur = resultat.error; } return ( diff --git a/src/app/(dashboard)/commandes-auto/cadencier-client.tsx b/src/app/(dashboard)/commandes-auto/cadencier-client.tsx index 01d688a..4d71dd2 100644 --- a/src/app/(dashboard)/commandes-auto/cadencier-client.tsx +++ b/src/app/(dashboard)/commandes-auto/cadencier-client.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useMemo, useState, useTransition, type FormEvent } from "react"; import { AlertTriangle, CalendarClock, Plus, Power, Trash2 } from "lucide-react"; import { @@ -217,8 +215,9 @@ function SectionMagasin({ /** * Exécute une action serveur et en rend compte : message de réussite, ou * message d'erreur (refus de l'action ou exception) — jamais d'échec muet. - * 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. + * Pas de router.refresh() : les actions rafraîchissent déjà la page après une + * réussite (cf. features/commandes-auto/actions.ts). Les deux ensemble + * rechargeaient tout deux fois. */ function executer(action: () => Promise, messages: { succes: string; echec: string }): Promise { return new Promise((resolve) => { diff --git a/src/app/(dashboard)/commandes-auto/client.tsx b/src/app/(dashboard)/commandes-auto/client.tsx index 6b0491a..6a66daf 100644 --- a/src/app/(dashboard)/commandes-auto/client.tsx +++ b/src/app/(dashboard)/commandes-auto/client.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useMemo } from "react"; import { ShoppingCart } from "lucide-react"; import type { PgCommandeAutoRow } from "./page"; diff --git a/src/app/(dashboard)/commandes-auto/page.tsx b/src/app/(dashboard)/commandes-auto/page.tsx index f74f669..2fe9941 100644 --- a/src/app/(dashboard)/commandes-auto/page.tsx +++ b/src/app/(dashboard)/commandes-auto/page.tsx @@ -1,33 +1,30 @@ -import type { Metadata } from "next"; import { ShoppingCart } from "lucide-react"; -import { pgGetCommandesAuto, type PgCommandeAutoRow } from "@/lib/pg-ff-client"; -import { listCadences, getFournisseursPourCadence } from "@/features/commandes-auto/actions"; +import type { PgCommandeAutoRow } from "@/types/ff"; +import type { CadenceView } from "@/features/commandes-auto/actions"; +import { fetchPage } from "@/lib/rpc"; +import { usePageData, usePageSearchParams } from "@/lib/page-data"; import { PageHeader } from "@/components/ui/page-header"; import { CommandesAutoTabs, type Onglet } from "./tabs"; -import { cachedFF } from "@/lib/ff-cache"; export type { PgCommandeAutoRow }; -export const metadata: Metadata = { title: "Commandes fournisseurs" }; +/** Données de la page : propositions de l'API FF, cadencier et référentiel fournisseurs. */ +interface DonneesCommandesAuto { + rows: PgCommandeAutoRow[]; + cadences: CadenceView[]; + fournisseurs: { code: string; nom: string }[]; +} -// Données live (DB + API FF Nancy) : rendu à la requête, jamais prérendu au build. -export const dynamic = "force-dynamic"; - -export default async function CommandesAutoPage(props: { searchParams: Promise<{ onglet?: string | string[] }> }) { - const { onglet } = await props.searchParams; +// Données live (DB + API FF Nancy) : relues à chaque affichage. Les propositions +// sont gardées 2 minutes par le serveur, pour que chaque modification du +// cadencier — qui rafraîchit la page — n'attende pas de nouveau toute l'API. +export default function CommandesAutoPage() { + const { onglet } = usePageSearchParams(); const ongletInitial: Onglet = onglet === "cadencier" ? "cadencier" : "propositions"; - const [rows, cadences, fournisseurs] = await Promise.all([ - // 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(), - ]); + const { rows, cadences, fournisseurs } = usePageData("commandes-auto", () => + fetchPage("commandes-auto"), + ); return (
diff --git a/src/app/(dashboard)/commandes-auto/tabs.tsx b/src/app/(dashboard)/commandes-auto/tabs.tsx index 4ff1652..90e2d37 100644 --- a/src/app/(dashboard)/commandes-auto/tabs.tsx +++ b/src/app/(dashboard)/commandes-auto/tabs.tsx @@ -1,5 +1,3 @@ -"use client"; - import { ShoppingCart, CalendarClock } from "lucide-react"; import { Tabs, useUrlTab } from "@/components/ui/tabs"; import { CommandesAutoClient } from "./client"; diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 75e21a9..4653769 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -1,8 +1,8 @@ -import type { Metadata } from "next"; import Link from "next/link"; -import { unstable_cache } from "next/cache"; import { BarChart3, Euro, House, Receipt, ShoppingBasket, ShoppingCart, Store } from "lucide-react"; -import { pgGetDashboardData, type DashboardSiteStats, type DashboardSiteTop10, type DashboardTopItem } from "@/lib/pg-ff-client"; +import type { DashboardData, DashboardSiteStats, DashboardSiteTop10, DashboardTopItem } from "@/types/ff"; +import { fetchPage } from "@/lib/rpc"; +import { usePageResult } from "@/lib/page-data"; import { PageHeader } from "@/components/ui/page-header"; import { StatCard } from "@/components/ui/stat-card"; import { Card, CardHeader } from "@/components/ui/card"; @@ -13,15 +13,6 @@ import { LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins"; import { fmtDecimal1, fmtEntier, fmtEur2 } from "@/lib/format"; import { couleurMarge } from "@/lib/marge"; -export const metadata: Metadata = { title: "Accueil" }; - -const getCachedDashboardData = unstable_cache( - pgGetDashboardData, - ["dashboard-data"], - // 30 minutes : ce sont les ventes de la veille, elles ne changent pas dans la journée. - { revalidate: 1800 } -); - // --------------------------------------------------------------------------- // Calculs // --------------------------------------------------------------------------- @@ -203,25 +194,23 @@ function TitreSection({ titre, description }: { titre: string; description?: Rea // Page // --------------------------------------------------------------------------- -export default async function DashboardPage() { - let data; - try { - data = await getCachedDashboardData(); - } catch (e) { - console.error("[Dashboard] pgGetDashboardData failed:", e); +export default function DashboardPage() { + // Mis en cache 30 minutes par le serveur : ce sont les ventes de la veille. + const resultat = usePageResult("dashboard", () => fetchPage("dashboard")); + if (!resultat.ok) { return (
); } - const { dateHier, dateN1, sites, top10BySite } = data; + const { dateHier, dateN1, sites, top10BySite } = resultat.data; const totalCa = sites.reduce((s, r) => s + r.ca_hier, 0); const totalCaN1 = sites.reduce((s, r) => s + r.ca_n1, 0); diff --git a/src/app/(dashboard)/error.tsx b/src/app/(dashboard)/error.tsx index 133e216..278f3ae 100644 --- a/src/app/(dashboard)/error.tsx +++ b/src/app/(dashboard)/error.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useEffect } from "react"; import { RotateCcw } from "lucide-react"; import { ErrorState } from "@/components/ui/states"; diff --git a/src/app/(dashboard)/exports/page.tsx b/src/app/(dashboard)/exports/page.tsx index 151dddf..c9c859a 100644 --- a/src/app/(dashboard)/exports/page.tsx +++ b/src/app/(dashboard)/exports/page.tsx @@ -1,6 +1,6 @@ -import { redirect } from "next/navigation"; +import { Navigate } from "react-router"; /** Ancienne adresse, conservée pour les favoris : voir /historique. */ export default function ExportsPage() { - redirect("/historique?vue=exports"); + return ; } diff --git a/src/app/(dashboard)/grid/page.tsx b/src/app/(dashboard)/grid/page.tsx index b8d30a3..53633e9 100644 --- a/src/app/(dashboard)/grid/page.tsx +++ b/src/app/(dashboard)/grid/page.tsx @@ -1,23 +1,11 @@ -import type { Metadata } from "next"; -import { auth } from "@/lib/auth"; +import { useSessionResolue } from "next-auth/react"; import { getFournisseurs, getMagasins } from "@/features/grid/actions"; +import { usePageData, usePageSearchParams } from "@/lib/page-data"; import { GridClient } from "@/features/grid/components/grid-client"; import { SupplierSelectionLanding } from "@/features/grid/components/supplier-selection-landing"; -export const metadata: Metadata = { title: "Révision d'assortiment" }; - -interface GridPageProps { - searchParams: Promise<{ - fournisseur?: string; - magasin?: string; - code1?: string; - code2?: string; - code3?: string; - }>; -} - -export default async function GridPage({ searchParams }: GridPageProps) { - const params = await searchParams; +export default function GridPage() { + const params = usePageSearchParams(); const codeFournisseur = params.fournisseur; const magasin = params.magasin ?? "TOTAL"; const filters = { @@ -27,13 +15,10 @@ export default async function GridPage({ searchParams }: GridPageProps) { code3: params.code3 ? String(params.code3).split(",").filter(Boolean) : null, }; - // 1. Fetch available suppliers & stores (et les droits, lus une fois côté serveur) - const [fournisseurs, magasins, session] = await Promise.all([ - getFournisseurs(), - getMagasins(), - auth(), - ]); - const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin"; + // 1. Fetch available suppliers & stores (et les droits, lus une fois avant le rendu) + const [fournisseurs, magasins] = usePageData("grid", () => Promise.all([getFournisseurs(), getMagasins()])); + const session = useSessionResolue(); + const isAdmin = session?.user.role === "admin"; // 2. If no supplier selected, show compact selection UI if (!codeFournisseur) { diff --git a/src/app/(dashboard)/historique/page.tsx b/src/app/(dashboard)/historique/page.tsx index 2aefed6..0963805 100644 --- a/src/app/(dashboard)/historique/page.tsx +++ b/src/app/(dashboard)/historique/page.tsx @@ -1,10 +1,8 @@ -import type { Metadata } from "next"; import { History } from "lucide-react"; import { PageHeader } from "@/components/ui/page-header"; +import { usePageSearchParams } from "@/lib/page-data"; import { HistoriqueTabs } from "./tabs"; -export const metadata: Metadata = { title: "Historique" }; - /** * Historique des sessions de révision et des exports Excel. * @@ -12,8 +10,8 @@ export const metadata: Metadata = { title: "Historique" }; * filtrée différemment (et dont la seconde s'intitulait « Historique d'arbitrage »). * Chacun ne voit que ses propres enregistrements. */ -export default async function HistoriquePage(props: { searchParams: Promise<{ vue?: string }> }) { - const { vue } = await props.searchParams; +export default function HistoriquePage() { + const { vue } = usePageSearchParams(); return (
>; -}) { - const searchParams = await props.searchParams; +export default function HitParadePage() { + const searchParams = usePageSearchParams(); const defaults = defaultDates(); - const dateDebut = (searchParams.debut as string) || defaults.debut; - const dateFin = (searchParams.fin as string) || defaults.fin; + const dateDebut = searchParams.debut || defaults.debut; + const dateFin = searchParams.fin || defaults.fin; + + // Lignes de la base FF, mises en cache par le serveur. + const resultat = usePageResult(`hit-parade?${dateDebut}:${dateFin}`, () => + fetchPage("hit-parade", { debut: dateDebut, fin: dateFin }), + ); let pivotted: HitParadePivotRow[] | null = null; let erreur: string | null = null; - try { - const rows = await cachedFF(`hit-parade:${dateDebut}:${dateFin}`, () => pgGetHitParade(dateDebut, dateFin)); - pivotted = pivotHitParade(rows); - } catch (e) { - console.error("[hit-parade] chargement impossible :", e); - erreur = e instanceof Error ? e.message : String(e); + if (resultat.ok) { + pivotted = pivotHitParade(resultat.data); + } else { + erreur = resultat.error; } return ( diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 929f166..e4cbc52 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,13 +1,23 @@ +import { useEffect, useRef } from "react"; +import { useLocation } from "react-router"; import { Sidebar } from "@/components/shared/sidebar"; import { Header } from "@/components/shared/header"; -export default async function DashboardLayout({ children }: { children: React.ReactNode }) { +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + const { pathname } = useLocation(); + const contenu = useRef(null); + + // Changement de page : on repart du haut, comme une navigation Next.js. + useEffect(() => { + contenu.current?.scrollTo({ top: 0 }); + }, [pathname]); + return (
-
+
{children}
diff --git a/src/app/(dashboard)/produits/fiche.tsx b/src/app/(dashboard)/produits/fiche.tsx index 65ba6b2..b14f871 100644 --- a/src/app/(dashboard)/produits/fiche.tsx +++ b/src/app/(dashboard)/produits/fiche.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; diff --git a/src/app/(dashboard)/produits/opportunites.tsx b/src/app/(dashboard)/produits/opportunites.tsx index 5594d65..9d11cce 100644 --- a/src/app/(dashboard)/produits/opportunites.tsx +++ b/src/app/(dashboard)/produits/opportunites.tsx @@ -1,9 +1,7 @@ -"use client"; - import { useState } from "react"; import Link from "next/link"; import { Loader2, RefreshCw, TrendingUp } from "lucide-react"; -import type { PgOpportuniteRow } from "@/lib/pg-ff-client"; +import type { PgOpportuniteRow } from "@/types/ff"; import { Button } from "@/components/ui/button"; import { DataTable, type DataColumn } from "@/components/ui/data-table"; import { EmptyState, ErrorState } from "@/components/ui/states"; diff --git a/src/app/(dashboard)/produits/page.tsx b/src/app/(dashboard)/produits/page.tsx index d9b4a75..422c33e 100644 --- a/src/app/(dashboard)/produits/page.tsx +++ b/src/app/(dashboard)/produits/page.tsx @@ -1,6 +1,7 @@ -import type { Metadata } from "next"; import { PackageSearch, SearchX } from "lucide-react"; -import { getProduitFiche, getProduitFicheByCodeCentrale } from "@/features/produits/api/get-produit-fiche"; +import type { ProduitFiche } from "@/features/produits/types"; +import { fetchPage } from "@/lib/rpc"; +import { usePageData, usePageSearchParams } from "@/lib/page-data"; import { PageHeader } from "@/components/ui/page-header"; import { EmptyState } from "@/components/ui/states"; import { Terme } from "@/components/ui/tooltip"; @@ -8,33 +9,29 @@ import { ProduitSearchBar } from "./search-bar"; import { ProduitResults } from "./results"; import { ProduitFicheView } from "./fiche"; -export const metadata: Metadata = { title: "Recherche produit" }; - -export const dynamic = "force-dynamic"; - /** * Recherche produit — **Qlik d'abord**, catalogue FF Nancy ensuite. * * - `?q=…` → liste de résultats (chargée côté client : l'extraction Qlik - * prend plusieurs secondes, on ne bloque pas le rendu serveur) + * prend plusieurs secondes, on ne bloque pas l'affichage) * - `?codein=…` → fiche d'un produit de notre catalogue (URL partageable) * - `?cc=…` → fiche d'un produit du réseau par son code centrale, qu'il * soit référencé chez nous ou non */ -export default async function ProduitsPage(props: { - searchParams: Promise<{ q?: string; codein?: string; cc?: string }>; -}) { - const searchParams = await props.searchParams; +export default function ProduitsPage() { + const searchParams = usePageSearchParams(); const q = (searchParams.q ?? "").trim(); const codein = (searchParams.codein ?? "").trim(); const codeCentrale = (searchParams.cc ?? "").trim(); - const fiche = codein - ? await getProduitFiche(codein) - : codeCentrale - ? await getProduitFicheByCodeCentrale(codeCentrale) - : null; const ficheDemandee = Boolean(codein || codeCentrale); + const fiche = usePageData(`produit-fiche?${codein}:${codeCentrale}`, (): Promise => + codein + ? fetchPage("produit-fiche", { codein }) + : codeCentrale + ? fetchPage("produit-fiche", { cc: codeCentrale }) + : Promise.resolve(null), + ); return (
diff --git a/src/app/(dashboard)/produits/results.tsx b/src/app/(dashboard)/produits/results.tsx index ed8d4fe..d8b9142 100644 --- a/src/app/(dashboard)/produits/results.tsx +++ b/src/app/(dashboard)/produits/results.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useEffect, useMemo, useState, type ReactNode } from "react"; import Link from "next/link"; import { PackageSearch, Loader2, RefreshCw, AlertTriangle, Database, Globe } from "lucide-react"; diff --git a/src/app/(dashboard)/produits/search-bar.tsx b/src/app/(dashboard)/produits/search-bar.tsx index dc55282..f5bb2d6 100644 --- a/src/app/(dashboard)/produits/search-bar.tsx +++ b/src/app/(dashboard)/produits/search-bar.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useState } from "react"; import { useRouter } from "next/navigation"; import { Search, X } from "lucide-react"; diff --git a/src/app/(dashboard)/publicites/client.tsx b/src/app/(dashboard)/publicites/client.tsx index 56337ea..5a05a54 100644 --- a/src/app/(dashboard)/publicites/client.tsx +++ b/src/app/(dashboard)/publicites/client.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useMemo, useTransition } from "react"; import { useRouter } from "next/navigation"; import { History, Info, Megaphone, RotateCcw } from "lucide-react"; diff --git a/src/app/(dashboard)/publicites/page.tsx b/src/app/(dashboard)/publicites/page.tsx index 932b555..d760255 100644 --- a/src/app/(dashboard)/publicites/page.tsx +++ b/src/app/(dashboard)/publicites/page.tsx @@ -1,10 +1,8 @@ -import type { Metadata } from "next"; import { Megaphone } from "lucide-react"; import { PageHeader } from "@/components/ui/page-header"; +import { fetchPage } from "@/lib/rpc"; +import { usePageData, usePageSearchParams } from "@/lib/page-data"; import { PublicitesClient } from "./client"; -import { cachedFF } from "@/lib/ff-cache"; - -export const metadata: Metadata = { title: "Publicités" }; export interface Publicite { tcr_code: string; @@ -44,80 +42,30 @@ export interface PubliciteHistorique { qte_totale: number; } -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. + * Données de la page, lues par le serveur sur l'API FF (réponses gardées + * 10 minutes, jamais les erreurs) : une API en panne est signalée dans + * `erreurPublicites` / `erreurHistorique`, pas par une exception. */ -const PUB_CACHE_TTL_MS = 10 * 60 * 1000; - -type PublicitesResult = { data: Publicite[]; total: number; pages: number; error?: string }; - -function fetchPublicites(statut: string, page: number): Promise { - return cachedFF(`publicites:${statut}:${page}`, () => fetchPublicitesApi(statut, page), { - ttlMs: PUB_CACHE_TTL_MS, - cacheIf: (r) => !r.error, - }); +interface DonneesPublicites { + publicites: Publicite[]; + total: number; + pages: number; + erreurPublicites?: string; + historique: PubliciteHistorique[]; + erreurHistorique?: string; + pageSize: number; } -async function fetchPublicitesApi(statut: string, page: number): Promise { - 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", - 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 { - data: (json.publicites ?? []) as Publicite[], - total: Number(json.total ?? 0), - pages: Number(json.pages ?? 0), - }; - } catch (e: unknown) { - return { data: [], total: 0, pages: 0, error: `Impossible de joindre l'API: ${e instanceof Error ? e.message : String(e)}` }; - } -} - -type HistoriqueResult = { data: PubliciteHistorique[]; error?: string }; - -function fetchHistorique(): Promise { - return cachedFF("publicites-historique", fetchHistoriqueApi, { - ttlMs: PUB_CACHE_TTL_MS, - cacheIf: (r) => !r.error, - }); -} - -async function fetchHistoriqueApi(): Promise { - try { - 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[] }; - } catch (e: unknown) { - return { data: [], error: `Impossible de joindre l'API: ${e instanceof Error ? e.message : String(e)}` }; - } -} - -export default async function PublicitesPage(props: { - searchParams: Promise>; -}) { - const searchParams = await props.searchParams; +export default function PublicitesPage() { + const searchParams = usePageSearchParams(); const vue = String(searchParams.vue ?? "publicites"); const statut = String(searchParams.statut ?? "toutes"); const page = Math.max(1, Number(searchParams.page) || 1); - const [pubResult, histoResult] = await Promise.all([ - fetchPublicites(statut, page), - fetchHistorique(), - ]); + const donnees = usePageData(`publicites?${statut}:${page}`, () => + fetchPage("publicites", { statut, page }), + ); return (
@@ -130,13 +78,13 @@ export default async function PublicitesPage(props: { vueInitiale={vue === "historique" ? "historique" : "publicites"} statut={statut} page={page} - pageSize={PAGE_SIZE} - publicites={pubResult.data} - total={pubResult.total} - pages={pubResult.pages} - erreurPublicites={pubResult.error} - historique={histoResult.data} - erreurHistorique={histoResult.error} + pageSize={donnees.pageSize} + publicites={donnees.publicites} + total={donnees.total} + pages={donnees.pages} + erreurPublicites={donnees.erreurPublicites} + historique={donnees.historique} + erreurHistorique={donnees.erreurHistorique} />
); diff --git a/src/app/(dashboard)/settings/client.tsx b/src/app/(dashboard)/settings/client.tsx index 6a15b0c..0cb70b7 100644 --- a/src/app/(dashboard)/settings/client.tsx +++ b/src/app/(dashboard)/settings/client.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useCallback, useEffect, useState, type ReactNode } from "react"; import { AlertCircle, diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index b6997ea..de20149 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -1,21 +1,19 @@ -import type { Metadata } from "next"; -import { redirect } from "next/navigation"; -import { verifierAdmin } from "@/lib/authz"; +import { AdminOnly } from "@/components/shared/admin-only"; +import { usePageSearchParams } from "@/lib/page-data"; import { ParametresClient } from "./client"; -export const metadata: Metadata = { title: "Paramètres" }; - /** * Paramètres (administrateurs) : connexions aux sources de données, comptes * utilisateurs, accès à l'API et journal serveur, rangés en onglets. * - * Composant serveur minimal : il lit l'onglet demandé (`?onglet=`) pour que le - * lien se partage et survive au rechargement, puis rend la page cliente. + * Page minimale : elle lit l'onglet demandé (`?onglet=`) pour que le lien se + * partage et survive au rechargement, puis rend la page cliente. */ -export default async function SettingsPage(props: { searchParams: Promise<{ onglet?: string }> }) { - // Le middleware filtre déjà sur le rôle du jeton ; ici, rôle relu en base. - if (!(await verifierAdmin()).ok) redirect("/dashboard"); - - const { onglet } = await props.searchParams; - return ; +export default function SettingsPage() { + const { onglet } = usePageSearchParams(); + return ( + + + + ); } diff --git a/src/app/(dashboard)/snapshots/page.tsx b/src/app/(dashboard)/snapshots/page.tsx index 5b117e0..dbf24df 100644 --- a/src/app/(dashboard)/snapshots/page.tsx +++ b/src/app/(dashboard)/snapshots/page.tsx @@ -1,6 +1,6 @@ -import { redirect } from "next/navigation"; +import { Navigate } from "react-router"; /** Ancienne adresse, conservée pour les favoris : voir /historique. */ export default function SnapshotsPage() { - redirect("/historique"); + return ; } diff --git a/src/app/(dashboard)/stock-negatif/client.tsx b/src/app/(dashboard)/stock-negatif/client.tsx index 995d440..14e95c0 100644 --- a/src/app/(dashboard)/stock-negatif/client.tsx +++ b/src/app/(dashboard)/stock-negatif/client.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useRouter } from "next/navigation"; import { useState, useMemo, useEffect, useTransition, type ReactNode } from "react"; import { Loader2 } from "lucide-react"; diff --git a/src/app/(dashboard)/stock-negatif/page.tsx b/src/app/(dashboard)/stock-negatif/page.tsx index 19d70a4..dabe050 100644 --- a/src/app/(dashboard)/stock-negatif/page.tsx +++ b/src/app/(dashboard)/stock-negatif/page.tsx @@ -1,8 +1,8 @@ -import type { Metadata } from "next"; import Link from "next/link"; import { PackageMinus, RotateCcw } from "lucide-react"; -import { pgGetStockNegatif, PgStockNegatifRow, pgGetStockSansVente, PgStockSansVenteRow, pgGetSansVente6Mois, PgSansVente6MoisRow } from "@/lib/pg-ff-client"; -import { cachedFF } from "@/lib/ff-cache"; +import type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow } from "@/types/ff"; +import { fetchPage } from "@/lib/rpc"; +import { usePageResult, usePageSearchParams } from "@/lib/page-data"; import { PageHeader } from "@/components/ui/page-header"; import { ErrorState } from "@/components/ui/states"; import { Button } from "@/components/ui/button"; @@ -10,36 +10,23 @@ import { GestionStockClient } from "./client"; export type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow }; -export const metadata: Metadata = { title: "Stocks à surveiller" }; - interface DonneesStock { rowsNegatif: PgStockNegatifRow[]; rowsSansVente: PgStockSansVenteRow[]; rowsSansVente6Mois: PgSansVente6MoisRow[]; } -export default async function GestionStockPage(props: { - searchParams: Promise>; -}) { - const searchParams = await props.searchParams; - const magasin = (searchParams.magasin as string) || ""; - const tab = (searchParams.tab as string) || "negatif"; +export default function GestionStockPage() { + const searchParams = usePageSearchParams(); + const magasin = searchParams.magasin || ""; + const tab = searchParams.tab || "negatif"; - // Données FF recopiées chaque nuit : mises en cache (cf. lib/ff-cache.ts). - const site = magasin || undefined; - let donnees: DonneesStock | null = null; - let erreur: string | null = null; - try { - const [rowsNegatif, rowsSansVente, rowsSansVente6Mois] = await Promise.all([ - cachedFF(`stock-negatif:${magasin}`, () => pgGetStockNegatif(site)), - cachedFF(`stock-sans-vente:${magasin}`, () => pgGetStockSansVente(site)), - cachedFF(`stock-sans-vente-6-mois:${magasin}`, () => pgGetSansVente6Mois(site)), - ]); - donnees = { rowsNegatif, rowsSansVente, rowsSansVente6Mois }; - } catch (e) { - console.error("[stock-negatif] chargement impossible :", e); - erreur = e instanceof Error ? e.message : String(e); - } + // Données FF recopiées chaque nuit : mises en cache par le serveur. + const resultat = usePageResult(`stock-negatif?${magasin}`, () => + fetchPage("stock-negatif", { magasin }), + ); + const donnees: DonneesStock | null = resultat.ok ? resultat.data : null; + const erreur: string | null = resultat.ok ? null : resultat.error; const params = new URLSearchParams(); if (magasin) params.set("magasin", magasin); diff --git a/src/app/api/admin/grid-warmup/route.ts b/src/app/api/admin/grid-warmup/route.ts deleted file mode 100644 index 6e56bbd..0000000 --- a/src/app/api/admin/grid-warmup/route.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { adminOuReponse } from "@/lib/authz"; -import { pgGetFournisseurs } from "@/lib/pg-ff-client"; -import { getProductRows } from "@/features/grid/api/get-product-rows"; -import { listGridSuppliers } from "@/lib/grid-store"; - -// Le préchauffage parcourt tous les fournisseurs : c'est long par nature. On rend la -// main immédiatement et le client interroge GET pour suivre l'avancement. -export const maxDuration = 300; -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * Préchauffage de l'instantané de grille (`grid_rows`). - * - * L'API `/api/v1` ne calcule jamais : elle lit un instantané écrit quand la Grille est - * ouverte dans l'application. Sans préchauffage, un consommateur externe (ChatGPT) ne - * verrait donc que les fournisseurs déjà consultés à la main. - * - * Ce job calcule chaque fournisseur une fois, séquentiellement — la persistance se fait - * en effet de bord de `getProductRows()`. Séquentiel volontairement : le calcul est - * lourd en SQL, le paralléliser saturerait PostgreSQL. - */ -type WarmupStatus = "idle" | "running" | "success" | "error"; - -interface WarmupJob { - status: WarmupStatus; - total: number; - done: number; - skipped: number; - failed: number; - currentFournisseur?: string; - startedAt: string; - finishedAt?: string; - error?: string; - lastErrors: string[]; -} - -let job: WarmupJob | null = null; - -function publicJob(j: WarmupJob | null) { - if (!j) { - return { status: "idle" as const, total: 0, done: 0, skipped: 0, failed: 0, lastErrors: [] }; - } - return j; -} - -async function runWarmup(j: WarmupJob, staleHours: number): Promise { - try { - const fournisseurs = await pgGetFournisseurs(); - // Les fournisseurs déjà calculés récemment sont sautés : un second passage - // coûte alors quasiment rien, ce qui rend le bouton rejouable sans crainte. - const existing = await listGridSuppliers(); - const freshLimit = Date.now() - staleHours * 3600_000; - const fresh = new Set( - existing - .filter((s) => s.computedAt && new Date(s.computedAt).getTime() > freshLimit) - .map((s) => s.codeFournisseur), - ); - - j.total = fournisseurs.length; - console.log(`[grid-warmup] ${fournisseurs.length} fournisseurs, ${fresh.size} déjà à jour (< ${staleHours}h)`); - - for (const f of fournisseurs) { - if (fresh.has(f.code)) { - j.skipped++; - continue; - } - j.currentFournisseur = `${f.code} — ${f.nom}`; - try { - await getProductRows({ codeFournisseur: f.code, magasin: "TOTAL", forceRefresh: true }); - j.done++; - } catch (e) { - j.failed++; - const msg = `${f.code}: ${e instanceof Error ? e.message.slice(0, 120) : String(e)}`; - // On garde une trace des derniers échecs sans faire exploser la mémoire. - if (j.lastErrors.length < 10) j.lastErrors.push(msg); - console.error("[grid-warmup]", msg); - } - } - - j.currentFournisseur = undefined; - j.status = "success"; - j.finishedAt = new Date().toISOString(); - console.log(`[grid-warmup] terminé — ${j.done} calculés, ${j.skipped} à jour, ${j.failed} en échec`); - } catch (e) { - j.status = "error"; - j.error = e instanceof Error ? e.message : String(e); - j.finishedAt = new Date().toISOString(); - console.error("[grid-warmup] échec global:", j.error); - } -} - -/** GET /api/admin/grid-warmup — avancement du préchauffage. */ -export async function GET() { - const denied = await adminOuReponse(); - if (denied instanceof Response) return denied; - return NextResponse.json({ success: true, ...publicJob(job) }); -} - -/** - * POST /api/admin/grid-warmup?staleHours=24 - * Démarre le préchauffage en arrière-plan et répond immédiatement. - */ -export async function POST(req: NextRequest) { - const denied = await adminOuReponse(); - if (denied instanceof Response) return denied; - - if (job?.status === "running") { - return NextResponse.json({ success: true, ...publicJob(job) }); - } - - const raw = Number(req.nextUrl.searchParams.get("staleHours")); - const staleHours = Number.isFinite(raw) ? Math.min(720, Math.max(0, raw)) : 24; - - job = { - status: "running", - total: 0, - done: 0, - skipped: 0, - failed: 0, - startedAt: new Date().toISOString(), - lastErrors: [], - }; - const current = job; - console.log(`[grid-warmup] démarré (staleHours=${staleHours})`); - - void runWarmup(current, staleHours).catch((e) => { - current.status = "error"; - current.error = e instanceof Error ? e.message : String(e); - current.finishedAt = new Date().toISOString(); - }); - - return NextResponse.json({ success: true, ...publicJob(job) }); -} diff --git a/src/app/api/admin/sync/fournisseurs/route.ts b/src/app/api/admin/sync/fournisseurs/route.ts deleted file mode 100644 index 292557e..0000000 --- a/src/app/api/admin/sync/fournisseurs/route.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { asc, eq, inArray, sql } from "drizzle-orm"; -import { adminOuReponse } from "@/lib/authz"; -import { db } from "@/db"; -import { syncFournisseurs } from "@/db/schema"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * GET — la liste complète des fournisseurs paramétrés, avec l'état de leur - * dernière synchronisation. - * - * Pas de pagination : quelques centaines de lignes tout au plus, et l'admin a - * besoin de la vue d'ensemble pour décider qui cadencer. - */ -export async function GET() { - const denied = await adminOuReponse(); - if (denied instanceof Response) return denied; - - const rows = await db - .select() - .from(syncFournisseurs) - .orderBy(asc(sql`coalesce(${syncFournisseurs.nomFournisseur}, ${syncFournisseurs.codeFournisseur})`)); - - return NextResponse.json({ - success: true, - fournisseurs: rows, - resume: { - total: rows.length, - actifsSql: rows.filter((r) => r.actifSql).length, - actifsQlik: rows.filter((r) => r.actifQlik).length, - desactivesAuto: rows.filter((r) => r.desactiveMotif).length, - enEchecSql: rows.filter((r) => r.dernierSqlStatut === "echec").length, - enEchecQlik: rows.filter((r) => r.dernierQlikStatut === "echec").length, - jamaisSql: rows.filter((r) => !r.dernierSqlAt).length, - }, - }); -} - -const patchSchema = z.object({ - /** Codes visés. Permet d'activer ou désactiver en masse depuis la page. */ - codes: z.array(z.string().min(1)).min(1).max(2000), - actifSql: z.boolean().optional(), - actifQlik: z.boolean().optional(), -}).refine((v) => v.actifSql !== undefined || v.actifQlik !== undefined, { - message: "Préciser au moins actifSql ou actifQlik", -}); - -/** - * PATCH — active ou désactive des fournisseurs, pour l'une ou l'autre source. - * - * Réactiver lève le motif de désactivation automatique : sans cela, un - * fournisseur remis en service par l'admin garderait un « aucun article » - * trompeur, alors même qu'il vient d'être rouvert volontairement. - */ -export async function PATCH(req: NextRequest) { - const denied = await adminOuReponse(); - if (denied instanceof Response) return denied; - - let body: unknown; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 }); - } - const parsed = patchSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { error: "Paramètres invalides", details: parsed.error.issues.map((i) => i.message) }, - { status: 400 }, - ); - } - const { codes, actifSql, actifQlik } = parsed.data; - - const reactivation = actifSql === true || actifQlik === true; - const rows = await db - .update(syncFournisseurs) - .set({ - ...(actifSql !== undefined ? { actifSql } : {}), - ...(actifQlik !== undefined ? { actifQlik } : {}), - ...(reactivation ? { desactiveMotif: null } : {}), - updatedAt: new Date(), - }) - .where(codes.length === 1 ? eq(syncFournisseurs.codeFournisseur, codes[0]) : inArray(syncFournisseurs.codeFournisseur, codes)) - .returning({ code: syncFournisseurs.codeFournisseur }); - - console.log(`[api/admin/sync] ${rows.length} fournisseur(s) mis à jour (sql=${actifSql} qlik=${actifQlik})`); - return NextResponse.json({ success: true, misAJour: rows.length }); -} diff --git a/src/app/api/admin/sync/route.ts b/src/app/api/admin/sync/route.ts deleted file mode 100644 index 9989685..0000000 --- a/src/app/api/admin/sync/route.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { adminOuReponse } from "@/lib/authz"; -import { readSyncSettings, saveSyncSettings } from "@/features/admin/api/sync-settings"; -import { - getSyncSchedulerState, - lancerRound, - demanderArret, - debutFenetre, - seedSyncFournisseurs, -} from "@/features/admin/api/sync-scheduler"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; -// Un lancement manuel peut enchaîner plusieurs fournisseurs. -export const maxDuration = 300; - -/** GET — réglages, état courant, et prochaine ouverture de fenêtre. */ -export async function GET() { - const denied = await adminOuReponse(); - if (denied instanceof Response) return denied; - - const settings = await readSyncSettings(); - const maintenant = new Date(); - const debut = debutFenetre(settings, maintenant); - - return NextResponse.json({ - success: true, - settings, - state: getSyncSchedulerState(), - /** true = on est actuellement dans la fenêtre nocturne. */ - dansLaFenetre: debut !== null, - fenetreDebutAt: debut?.toISOString() ?? null, - maintenant: maintenant.toISOString(), - }); -} - -const settingsSchema = z.object({ - actif: z.boolean().optional(), - heureDebut: z.coerce.number().int().min(0).max(23).optional(), - heureFin: z.coerce.number().int().min(0).max(23).optional(), - qlikParNuit: z.coerce.number().int().min(0).max(200).optional(), - sqlParQlik: z.coerce.number().int().min(1).max(500).optional(), - qlikMinJours: z.coerce.number().int().min(0).max(365).optional(), -}); - -/** PATCH — met à jour les réglages. */ -export async function PATCH(req: NextRequest) { - const denied = await adminOuReponse(); - if (denied instanceof Response) return denied; - - let body: unknown; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 }); - } - const parsed = settingsSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { error: "Paramètres invalides", details: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) }, - { status: 400 }, - ); - } - - const settings = await saveSyncSettings(parsed.data); - return NextResponse.json({ success: true, settings }); -} - -const actionSchema = z.object({ - action: z.enum(["start", "stop", "seed"]), - /** - * Lancement manuel hors de la fenêtre nocturne. Réservé à l'admin qui sait - * ce qu'il fait : en pleine journée, la charge est visible par les magasins. - */ - forcer: z.boolean().optional(), -}); - -/** POST — démarre un round manuellement, l'arrête, ou resynchronise la liste. */ -export async function POST(req: NextRequest) { - const denied = await adminOuReponse(); - if (denied instanceof Response) return denied; - - let body: unknown; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 }); - } - const parsed = actionSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json({ error: "Action inconnue (start, stop ou seed)" }, { status: 400 }); - } - - if (parsed.data.action === "seed") { - const n = await seedSyncFournisseurs(); - return NextResponse.json({ success: true, fournisseurs: n }); - } - - if (parsed.data.action === "stop") { - demanderArret(); - return NextResponse.json({ success: true, state: getSyncSchedulerState() }); - } - - const etat = getSyncSchedulerState(); - if (etat.enCours) { - return NextResponse.json({ error: "Un round est déjà en cours.", state: etat }, { status: 409 }); - } - - // Fire-and-forget : le round dure des minutes, le client suit via GET. - void lancerRound("manuel", parsed.data.forcer === true).catch((e) => { - console.error("[api/admin/sync] round manuel en échec :", e instanceof Error ? e.message : String(e)); - }); - - return NextResponse.json({ success: true, state: getSyncSchedulerState() }); -} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index 73228a0..0000000 --- a/src/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { handlers } from "@/lib/auth"; -export const { GET, POST } = handlers; diff --git a/src/app/api/commandes-auto/route.ts b/src/app/api/commandes-auto/route.ts deleted file mode 100644 index 8a7cf2b..0000000 --- a/src/app/api/commandes-auto/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { sessionOuReponse } from "@/lib/authz"; - -const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr"; - -/** - * GET /api/commandes-auto[?site=292&codefou=N009] - * - * Proxy vers https://api.ffnancy.fr/api/commandes-auto - * Transfère les query params site et codefou si présents. - */ -export async function GET(req: NextRequest) { - const acces = await sessionOuReponse(); - if (acces instanceof Response) return acces; - - try { - const { searchParams } = req.nextUrl; - const params = new URLSearchParams(); - if (searchParams.get("site")) params.set("site", searchParams.get("site")!); - if (searchParams.get("codefou")) params.set("codefou", searchParams.get("codefou")!); - - const qs = params.toString(); - const url = `${FF_API_BASE}/api/commandes-auto${qs ? `?${qs}` : ""}`; - - const res = await fetch(url, { cache: "no-store" }); - if (!res.ok) { - return NextResponse.json({ error: `API upstream HTTP ${res.status}` }, { status: res.status }); - } - const data = await res.json(); - return NextResponse.json(data); - } catch (e) { - console.error("[api/commandes-auto]", e); - return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); - } -} diff --git a/src/app/api/diag/route.ts b/src/app/api/diag/route.ts deleted file mode 100644 index 6f6c796..0000000 --- a/src/app/api/diag/route.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { db } from "@/db"; -import { sql } from "drizzle-orm"; -import { adminOuReponse } from "@/lib/authz"; - -const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr"; - -async function probe(url: string) { - try { - const res = await fetch(url, { cache: "no-store" }); - const text = await res.text(); - let parsed: unknown = null; - try { parsed = JSON.parse(text); } catch { parsed = text.slice(0, 800); } - let summary: unknown = parsed; - if (Array.isArray(parsed)) { - summary = { count: parsed.length, firstKeys: parsed[0] ? Object.keys(parsed[0]) : [], sample: parsed.slice(0, 2) }; - } else if (parsed && typeof parsed === "object") { - const keys = Object.keys(parsed as object); - const firstArrayKey = keys.find(k => Array.isArray((parsed as Record)[k])); - if (firstArrayKey) { - const arr = (parsed as Record)[firstArrayKey]; - summary = { wrapper: firstArrayKey, count: arr.length, firstKeys: arr[0] ? Object.keys(arr[0] as object) : [], sample: arr.slice(0, 2) }; - } else { - // Objet simple : montrer toutes les clés et le contenu complet - summary = { keys, full: parsed }; - } - } - return { status: res.status, url, summary }; - } catch (e) { - return { error: String(e), url }; - } -} - -export async function GET(req: NextRequest) { - const acces = await adminOuReponse(); - if (acces instanceof Response) return acces; - - const { searchParams } = new URL(req.url); - - // Date range: 12 derniers mois - const now = new Date(); - const dateFin = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().slice(0, 10); - const dateDebut = new Date(now.getFullYear(), now.getMonth() - 12, 1).toISOString().slice(0, 10); - - // Récupérer un codefou réel + un codein réel depuis l'API - let codefou = searchParams.get("codefou"); - let firstCodein: string | null = searchParams.get("codein"); - const forcedNoid = searchParams.get("noid"); // ex: ?noid=129632 - - if (!codefou) { - try { - const res = await fetch(`${FF_API_BASE}/api/fournisseurs?limit=1`, { cache: "no-store" }); - const data = await res.json(); - const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []); - if (list.length > 0) codefou = (list[0] as Record).codefou as string ?? null; - } catch { /* ignore */ } - codefou ??= "D005"; - } - - if (!firstCodein) { - try { - const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=1`, { cache: "no-store" }); - const data = await res.json(); - const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []); - if (list.length > 0) firstCodein = (list[0] as Record).codein as string ?? null; - } catch { /* ignore */ } - } - - // Si un noid est forcé, on probe directement mensuel + referentiel pour cet article - const mensuelNoidForced = forcedNoid - ? probe(`${FF_API_BASE}/api/articles/${encodeURIComponent(forcedNoid)}/mensuel?dateDebut=${dateDebut}&dateFin=${dateFin}`) - : Promise.resolve({ skipped: "no ?noid= param" }); - const referentielNoidForced = forcedNoid - ? probe(`${FF_API_BASE}/api/articles/${encodeURIComponent(forcedNoid)}/referentiel`) - : Promise.resolve({ skipped: "no ?noid= param" }); - - const [ - fournisseurs, - articles, - mvt_dateDebut, - mensuel_noid, - referentiel, - mensuel_forced, - referentiel_forced, - ] = await Promise.all([ - probe(`${FF_API_BASE}/api/fournisseurs?limit=2`), - probe(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=2`), - probe(`${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&dateDebut=${dateDebut}&dateFin=${dateFin}&limit=2`), - // Test mensuel avec no_id auto-résolu - firstCodein - ? (async () => { - try { - const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=1`, { cache: "no-store" }); - const data = await res.json(); - const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []); - const noid = list[0] ? (list[0] as Record).no_id as string : null; - if (noid) return probe(`${FF_API_BASE}/api/articles/${encodeURIComponent(noid)}/mensuel?dateDebut=${dateDebut}&dateFin=${dateFin}`); - } catch { /* ignore */ } - return { skipped: "no no_id available" }; - })() - : Promise.resolve({ skipped: "no article" }), - // Referentiel complet (nomenclature + gamme + stock + prix) - firstCodein - ? (async () => { - try { - const res = await fetch(`${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&limit=5`, { cache: "no-store" }); - const data = await res.json(); - const list = Array.isArray(data) ? data : (Object.values(data).find(v => Array.isArray(v)) as unknown[] ?? []); - const art = (list[4] ?? list[0]) as Record | undefined; - const noId = art?.no_id as string ?? null; - if (noId) return probe(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/referentiel`); - } catch { /* ignore */ } - return { skipped: "no no_id" }; - })() - : Promise.resolve({ skipped: "no codefou" }), - mensuelNoidForced, - referentielNoidForced, - ]); - - // Test PostgreSQL + diagnostic ventesProduits - let postgresql: unknown; - let ventesProduitsDiag: unknown; - try { - const r = await db.execute(sql`SELECT COUNT(*) as count FROM session_snapshots`); - - // Diagnostic ventesProduits : cherche par les 2 codes possibles d'AUXENCE - const vpByName = await db.execute(sql` - SELECT code_fournisseur, COUNT(*) as nb_rows, - COUNT(code_gamme) as nb_gamme, - COUNT(code_gamme_init) as nb_gamme_init, - COUNT(code3) as nb_code3 - FROM ventes_produits - WHERE code_fournisseur IN ('AUXENCE', 'A025', ${codefou}) - GROUP BY code_fournisseur - `); - - // Sample de quelques codeins dans ventesProduits pour ce fournisseur - const vpSample = await db.execute(sql` - SELECT codein, code_fournisseur, code_gamme, code_gamme_init, code3, libelle3 - FROM ventes_produits - WHERE code_fournisseur IN ('AUXENCE', 'A025', ${codefou}) - LIMIT 3 - `); - - postgresql = { - ok: true, - snapshots: Number(r.rows[0]?.count ?? 0), - }; - ventesProduitsDiag = { - byFournisseur: vpByName.rows, - sample: vpSample.rows, - }; - } catch (e) { - postgresql = { ok: false, error: String(e) }; - ventesProduitsDiag = { error: String(e) }; - } - - // Diagnostic analytics CA TTC - let mvtartDiag: unknown; - try { - // 1. Totaux mars 2025 avec mntmvtht et mntmvtttc pour comparer avec API (HT=225k, TTC=269k) - const totaux = await db.execute(sql` - SELECT site, - ABS(SUM(mntmvtht))::float AS sum_ht, - ABS(SUM(mntmvtttc))::float AS sum_ttc, - COUNT(*) AS nb_lignes - FROM mvtart - WHERE genremvt = 3 - AND TO_CHAR(datmvt, 'YYYY-MM') = '2025-03' - AND site IN ('292', '579') - GROUP BY site - `); - // 2. Distribution des valeurs de preference dans artfou1 - const prefDist = await db.execute(sql` - SELECT preference, COUNT(*) AS nb - FROM artfou1 - GROUP BY preference - ORDER BY preference - `); - // 3. Ventes mars 2025 non attribuées (aucune ligne artfou1 avec preference=1) - const sansPreference = await db.execute(sql` - SELECT COUNT(DISTINCT m.artnoid) AS nb_articles_sans_pref - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - WHERE m.genremvt = 3 - AND TO_CHAR(m.datmvt, 'YYYY-MM') = '2025-03' - AND m.site IN ('292','579') - AND NOT EXISTS ( - SELECT 1 FROM artfou1 af - WHERE af.art_no_id = a.no_id AND af.preference = 1 - ) - `); - mvtartDiag = { - totaux_202503: totaux.rows, - artfou1_preference_distribution: prefDist.rows, - articles_vendus_sans_pref1: sansPreference.rows, - }; - } catch (e) { - mvtartDiag = { error: String(e) }; - } - - // Diagnostic franco : colonnes DB avec "franco" + sample fouident + raw commandes-auto - let francoDiag: unknown; - try { - const francoColumns = await db.execute(sql` - SELECT table_name, column_name, data_type - FROM information_schema.columns - WHERE column_name ILIKE '%franco%' - OR column_name ILIKE '%min_cmd%' - OR column_name ILIKE '%mincde%' - OR column_name ILIKE '%min_commande%' - ORDER BY table_name, column_name - `); - // Sample des 3 premières lignes de fouident pour voir toutes les colonnes - const fouidentSample = await db.execute(sql`SELECT * FROM fouident LIMIT 3`); - const foucadSample = await db.execute(sql`SELECT * FROM foucad LIMIT 5`); - francoDiag = { - francoColumns: francoColumns.rows, - fouidentColumns: fouidentSample.rows.length > 0 ? Object.keys(fouidentSample.rows[0] as object) : [], - fouidentSample: fouidentSample.rows, - foucadColumns: foucadSample.rows.length > 0 ? Object.keys(foucadSample.rows[0] as object) : [], - foucadSample: foucadSample.rows, - }; - } catch (e) { - francoDiag = { error: String(e) }; - } - - // Raw commandes-auto : récupère toutes les lignes et filtre celles avec franco_ht manquant - let commandesAutoFrancoAudit: unknown; - try { - const caRes = await fetch(`${FF_API_BASE}/api/commandes-auto`, { cache: "no-store" }); - const caData = await caRes.json(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const caRows: any[] = Array.isArray(caData) ? caData : (caData?.propositions ?? caData?.data ?? []); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const withFranco = caRows.filter((r: any) => r.franco_ht != null && Number(r.franco_ht) > 0); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const withoutFranco = caRows.filter((r: any) => r.franco_ht == null || Number(r.franco_ht) === 0); - commandesAutoFrancoAudit = { - total: caRows.length, - avecFranco: withFranco.length, - sansFranco: withoutFranco.length, - lignesSansFranco: withoutFranco.map((r: any) => ({ - site: r.site, codefou: r.codefou, nom_fou: r.nom_fou, - franco_ht: r.franco_ht, franco_atteint: r.franco_atteint, - })), - }; - } catch (e) { - commandesAutoFrancoAudit = { error: String(e) }; - } - - // Probe des fournisseurs N/A pour trouver où est leur franco - const [ - detailM036_292, - detailM030_579, - fournisseurM036, - fournisseurM030, - ] = await Promise.all([ - probe(`${FF_API_BASE}/api/commandes-auto/M036?site=292`), - probe(`${FF_API_BASE}/api/commandes-auto/M030?site=579`), - probe(`${FF_API_BASE}/api/fournisseurs/M036`), - probe(`${FF_API_BASE}/api/fournisseurs/M030`), - ]); - const francoNaProbe = { detailM036_292, detailM030_579, fournisseurM036, fournisseurM030 }; - - // Raw commandes-auto (premier fournisseur) pour voir les champs réels retournés par l'API - const rawCommandesAuto = await probe(`${FF_API_BASE}/api/commandes-auto`); - - return NextResponse.json({ - timestamp: new Date().toISOString(), - dateRange: { dateDebut, dateFin }, - codefouTested: codefou, - firstCodeinTested: firstCodein, - forcedNoid: forcedNoid ?? "(none — pass ?noid=129632 to test a specific article)", - fournisseurs, - articles, - mouvements_dateDebut: mvt_dateDebut, - endpoints_auto: { - "articles/:no_id/mensuel": mensuel_noid, - "articles/:no_id/referentiel": referentiel, - }, - endpoints_forced_noid: { - "articles/:noid/mensuel": mensuel_forced, - "articles/:noid/referentiel": referentiel_forced, - }, - postgresql, - ventesProduitsDiag, - mvtartDiag, - francoDiag, - rawCommandesAuto, - commandesAutoFrancoAudit, - francoNaProbe, - }); -} diff --git a/src/app/api/diag/stock-fournisseur/route.ts b/src/app/api/diag/stock-fournisseur/route.ts deleted file mode 100644 index 44aa7c4..0000000 --- a/src/app/api/diag/stock-fournisseur/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NextResponse } from "next/server"; -import { pgDiagFournisseurDerniereEntree } from "@/lib/pg-ff-client"; -import { adminOuReponse } from "@/lib/authz"; - -export const dynamic = "force-dynamic"; - -/** - * Diagnostic de l'attribution « fournisseur de la dernière entrée » utilisée par - * la page Gestion de Stock. - * - * `colonneDetectee: null` signifie que `mvtart` ne porte aucune des colonnes - * fournisseur connues : la page retombe alors sur `artfou1.preference = 1`. - * `colonnesMvtart` liste ce qui existe réellement, pour compléter la liste - * blanche `MVTART_FOU_CANDIDATES` si le nom diffère. - */ -export async function GET() { - const acces = await adminOuReponse(); - if (acces instanceof Response) return acces; - - try { - return NextResponse.json(await pgDiagFournisseurDerniereEntree()); - } catch (e) { - return NextResponse.json({ error: String(e) }, { status: 500 }); - } -} diff --git a/src/app/api/export/excel/route.ts b/src/app/api/export/excel/route.ts deleted file mode 100644 index 7eace0c..0000000 --- a/src/app/api/export/excel/route.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import ExcelJS from "exceljs"; -import { z } from "zod"; -import { sessionOuReponse } from "@/lib/authz"; - -const ExportSchema = z.object({ - nomFournisseur: z.string(), - rows: z.array(z.object({ - codein: z.string(), - libelle1: z.string(), - code3: z.string(), - libelle3: z.string(), - codeGamme: z.string().nullable(), - totalQuantite: z.number(), - totalCa: z.number(), - tauxMarge: z.number(), - // Before gamme for delta - gammeAvant: z.string().nullable().optional(), - })), -}); - -export async function POST(req: NextRequest) { - const acces = await sessionOuReponse(); - if (acces instanceof Response) return acces; - - const body = await req.json(); - const parsed = ExportSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); - } - - const { nomFournisseur, rows } = parsed.data; - const workbook = new ExcelJS.Workbook(); - workbook.creator = "CollectFlow"; - workbook.created = new Date(); - - const sheet = workbook.addWorksheet("Assortiment", { - views: [{ state: "frozen", ySplit: 1 }], - }); - - // Header style - const headerStyle: Partial = { - font: { bold: true, color: { argb: "FFFFFFFF" } }, - fill: { type: "pattern", pattern: "solid", fgColor: { argb: "FF0F172A" } }, - alignment: { horizontal: "center" }, - border: { bottom: { style: "thin", color: { argb: "FF334155" } } }, - }; - - sheet.columns = [ - { header: "Code", key: "codein", width: 14 }, - { header: "Désignation", key: "libelle1", width: 40 }, - { header: "Nomenclature", key: "libelle3", width: 25 }, - { header: "Gamme Avant", key: "gammeAvant", width: 14 }, - { header: "Gamme Après", key: "codeGamme", width: 14 }, - { header: "Delta", key: "delta", width: 10 }, - { header: "Vol. 12m", key: "totalQuantite", width: 12 }, - { header: "CA 12m (€)", key: "totalCa", width: 14 }, - { header: "Marge (%)", key: "tauxMarge", width: 12 }, - ]; - - // Apply header style - sheet.getRow(1).eachCell((cell) => { Object.assign(cell, headerStyle); }); - sheet.getRow(1).height = 22; - - // Gamme colours - const GAMME_COLORS: Record = { - A: "FF064E3B", B: "FF1E3A5F", C: "FF78350F", Z: "FF4C0519", - }; - - for (const row of rows) { - const delta = row.gammeAvant !== row.codeGamme ? `${row.gammeAvant ?? "—"} → ${row.codeGamme ?? "—"}` : ""; - const excelRow = sheet.addRow({ - codein: row.codein, - libelle1: row.libelle1, - libelle3: row.libelle3, - gammeAvant: row.gammeAvant ?? "—", - codeGamme: row.codeGamme ?? "—", - delta, - totalQuantite: Math.round(row.totalQuantite), - totalCa: parseFloat(row.totalCa.toFixed(2)), - tauxMarge: parseFloat(row.tauxMarge.toFixed(2)), - }); - - // Colour the "Gamme Après" cell - const gammeCell = excelRow.getCell("codeGamme"); - if (row.codeGamme && GAMME_COLORS[row.codeGamme]) { - gammeCell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: GAMME_COLORS[row.codeGamme] } }; - gammeCell.font = { bold: true, color: { argb: "FFFFFFFF" } }; - } - - // Highlight changed rows - if (delta) { - excelRow.getCell("delta").font = { bold: true, color: { argb: "FFFBBF24" } }; - } - - // Format numbers - excelRow.getCell("totalCa").numFmt = '#,##0.00 "€"'; - excelRow.getCell("tauxMarge").numFmt = '0.00"%"'; - } - - // Generate buffer - const buffer = await workbook.xlsx.writeBuffer(); - - const filename = `Assortiment_${nomFournisseur.replace(/\s+/g, "_")}_${new Date().toISOString().slice(0, 10)}.xlsx`; - - return new NextResponse(buffer, { - headers: { - "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "Content-Disposition": `attachment; filename="${filename}"`, - }, - }); -} diff --git a/src/app/api/export/modified-gammes/route.ts b/src/app/api/export/modified-gammes/route.ts deleted file mode 100644 index 600817f..0000000 --- a/src/app/api/export/modified-gammes/route.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import ExcelJS from "exceljs"; -import { z } from "zod"; -import { sessionOuReponse } from "@/lib/authz"; - -const ExportModifiedGammesSchema = z.object({ - nomFournisseur: z.string(), - changes: z.array(z.object({ - codein: z.string(), - gtin: z.string(), - codeFournisseur: z.string().optional(), - gamme: z.string(), - })), -}); - -export async function POST(req: NextRequest) { - const acces = await sessionOuReponse(); - if (acces instanceof Response) return acces; - - const body = await req.json(); - const parsed = ExportModifiedGammesSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); - } - - const { nomFournisseur, changes } = parsed.data; - const workbook = new ExcelJS.Workbook(); - workbook.creator = "CollectFlow"; - workbook.created = new Date(); - - const sheet = workbook.addWorksheet("Gammes Modifiées", { - views: [{ state: "frozen", ySplit: 1 }], - }); - - // Header style - const headerStyle: Partial = { - font: { bold: true, color: { argb: "FFFFFFFF" } }, - fill: { type: "pattern", pattern: "solid", fgColor: { argb: "FF0F172A" } }, - alignment: { horizontal: "center" }, - border: { bottom: { style: "thin", color: { argb: "FF334155" } } }, - }; - - sheet.columns = [ - { header: "CODE FOURNISSEUR", key: "codeFournisseur", width: 22 }, - { header: "CODEIN", key: "codein", width: 18 }, - { header: "GAMME", key: "gamme", width: 12 }, - ]; - - // Apply header style - sheet.getRow(1).eachCell((cell) => { Object.assign(cell, headerStyle); }); - sheet.getRow(1).height = 22; - - for (const row of changes) { - sheet.addRow({ - codeFournisseur: row.codeFournisseur || "—", - codein: row.codein || "—", - gamme: row.gamme || "—", - }); - } - - // Centrer la colonne Gamme - const gammeCol = sheet.getColumn("gamme"); - gammeCol.alignment = { horizontal: "center" }; - - // Format en texte pour eviter la notation scientifique excel - sheet.getColumn("codeFournisseur").numFmt = "@"; - sheet.getColumn("codein").numFmt = "@"; - - // Generate buffer - const buffer = await workbook.xlsx.writeBuffer(); - - const filename = `Modifications_Gammes_${nomFournisseur.replace(/\s+/g, "_")}_${new Date().toISOString().slice(0, 10)}.xlsx`; - - return new NextResponse(buffer, { - headers: { - "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "Content-Disposition": `attachment; filename="${filename}"`, - }, - }); -} diff --git a/src/app/api/ff-status/route.ts b/src/app/api/ff-status/route.ts deleted file mode 100644 index 02dbe50..0000000 --- a/src/app/api/ff-status/route.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NextResponse } from "next/server"; -import { diagnostiquerApiFf } from "@/features/settings/ff-api-diagnostic"; -import { sessionOuReponse } from "@/lib/authz"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * GET /api/ff-status — état de l'API REST FF Nancy. - * - * S'appuie sur le même diagnostic que le bouton « Tester » des Paramètres, afin - * qu'un échec dise *pourquoi* (DNS, connexion refusée, délai dépassé, code HTTP) - * et *quelle adresse* a été appelée. L'ancienne version renvoyait un 503 sans - * détail, impossible à distinguer d'une simple erreur d'URL. - */ -export async function GET() { - const acces = await sessionOuReponse(); - if (acces instanceof Response) return acces; - - const res = await diagnostiquerApiFf(); - if (!res.success) { - return NextResponse.json( - { error: "API FF Nancy non disponible", url: res.url, detail: res.error }, - { status: 503 }, - ); - } - return NextResponse.json(res.status); -} diff --git a/src/app/api/grid/rows/route.ts b/src/app/api/grid/rows/route.ts deleted file mode 100644 index a12e9d6..0000000 --- a/src/app/api/grid/rows/route.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { NextRequest } from "next/server"; -import { getProductRows } from "@/features/grid/api/get-product-rows"; -import { sessionOuReponse } from "@/lib/authz"; -import type { GridFilters } from "@/types/grid"; - -export const dynamic = "force-dynamic"; - -const encoder = new TextEncoder(); - -function writeNdjson(controller: ReadableStreamDefaultController, value: unknown) { - controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`)); -} - -export async function GET(request: NextRequest) { - const acces = await sessionOuReponse(); - if (acces instanceof Response) return acces; - - 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))); - // `code3` n'est volontairement pas lu : la Grille applique ce filtre en local - // (getProductRows ne s'en sert pas) et il accepte désormais plusieurs valeurs. - const filters: Partial = { - code1: searchParams.get("code1"), - code2: searchParams.get("code2"), - }; - 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({ - 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", - 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) { - if (cancelled) return; - writeNdjson(controller, { - type: "error", - error: error instanceof Error ? error.message : "Unknown error", - }); - } finally { - try { - controller.close(); - } catch { - /* flux déjà fermé côté client */ - } - } - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "application/x-ndjson; charset=utf-8", - "Cache-Control": "no-store", - "X-Accel-Buffering": "no", - }, - }); -} diff --git a/src/app/api/grid/rows/store-patch/route.ts b/src/app/api/grid/rows/store-patch/route.ts deleted file mode 100644 index 35b2263..0000000 --- a/src/app/api/grid/rows/store-patch/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextRequest } from "next/server"; -import { getStorePatch } from "@/features/grid/api/get-product-rows"; -import { MAGASINS } from "@/lib/magasins"; -import { sessionOuReponse } from "@/lib/authz"; - -export const dynamic = "force-dynamic"; - -/** - * GET /api/grid/rows/store-patch?fournisseur=…&magasin=… - * - * Complément de l'API FF pour un magasin (cf. `features/grid/lib/store-patch.ts`). - * La Grille charge toujours les lignes « tous magasins » et applique ce - * complément à son arrivée : le changement de magasin n'attend plus rien. - */ -export async function GET(request: NextRequest) { - const acces = await sessionOuReponse(); - if (acces instanceof Response) return acces; - - const codeFournisseur = request.nextUrl.searchParams.get("fournisseur"); - const magasin = request.nextUrl.searchParams.get("magasin") ?? ""; - // Un code inconnu ferait de chaque article un candidat : autant d'appels - // inutiles à l'API FF. « Tous magasins » n'a pas de complément. - if (!codeFournisseur || !MAGASINS.some((m) => m.code === magasin)) { - return Response.json({ error: "Paramètres fournisseur et magasin requis." }, { status: 400 }); - } - - try { - const patch = await getStorePatch(codeFournisseur, magasin); - return Response.json(patch, { headers: { "Cache-Control": "no-store" } }); - } catch (error) { - console.error(`[store-patch] ${codeFournisseur}/${magasin} KO:`, (error as Error).message?.slice(0, 200)); - return Response.json({ error: "Complément indisponible." }, { status: 502 }); - } -} diff --git a/src/app/api/logs/route.ts b/src/app/api/logs/route.ts deleted file mode 100644 index 56adf2d..0000000 --- a/src/app/api/logs/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * CollectFlow — Journaux serveur des jobs (liste + téléchargement). - * - * `GET /api/logs` → liste des journaux disponibles (JSON) - * `GET /api/logs?id=` → le journal complet en pièce jointe (text/plain) - * - * Réservé aux admins : un journal d'extraction contient les diagnostics internes - * du modèle Qlik et les codes articles manipulés. Aucun secret n'y transite — - * l'extracteur ne journalise que le NOM du cookie de session, jamais sa valeur, - * et jamais le mot de passe Qlik. - */ - -import { NextRequest, NextResponse } from "next/server"; -import { adminOuReponse } from "@/lib/authz"; -import { listCaptures, readCapture } from "@/lib/log-capture"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export async function GET(req: NextRequest) { - const refus = await adminOuReponse(); - if (refus instanceof Response) return refus; - - const id = req.nextUrl.searchParams.get("id"); - if (!id) { - return NextResponse.json({ success: true, logs: await listCaptures() }); - } - - const contenu = await readCapture(id); - if (contenu == null) { - return NextResponse.json({ error: "Journal introuvable ou expiré" }, { status: 404 }); - } - - // Nom de fichier assaini : l'id vient d'un job interne, mais il finit dans - // un en-tête HTTP. - const nom = `${id.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`; - return new NextResponse(contenu, { - status: 200, - headers: { - "Content-Type": "text/plain; charset=utf-8", - "Content-Disposition": `attachment; filename="${nom}"`, - "Cache-Control": "no-store", - }, - }); -} diff --git a/src/app/api/produits/opportunites/route.ts b/src/app/api/produits/opportunites/route.ts deleted file mode 100644 index 9d2ecea..0000000 --- a/src/app/api/produits/opportunites/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { sessionOuReponse } from "@/lib/authz"; -import { pgGetOpportunitesFamille } from "@/lib/pg-ff-client"; -import { buildLast12MonthsRange } from "@/lib/api-ff-client"; - -export const dynamic = "force-dynamic"; - -/** - * GET /api/produits/opportunites?nomNoId=123 - * - * Produits de la même nomenclature, classés par écart entre performance réseau - * et performance locale (quantités par magasin). Chargé à la demande depuis la - * fiche produit : la requête agrège `mvtart` sur 12 mois pour toute la famille, - * on ne veut pas la payer au chargement de la page. - */ -export async function GET(req: NextRequest) { - const acces = await sessionOuReponse(); - if (acces instanceof Response) return acces; - - const raw = req.nextUrl.searchParams.get("nomNoId"); - const nomNoId = Number(raw); - if (!raw || !Number.isInteger(nomNoId) || nomNoId <= 0) { - return NextResponse.json({ error: "Param 'nomNoId' invalide" }, { status: 400 }); - } - - try { - const { dateDebut, dateFin } = buildLast12MonthsRange(); - const rows = await pgGetOpportunitesFamille(nomNoId, dateDebut, dateFin); - return NextResponse.json({ success: true, rows }); - } catch (e) { - console.error("[api/produits/opportunites]", (e as Error).message?.slice(0, 250)); - return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); - } -} diff --git a/src/app/api/produits/search/route.ts b/src/app/api/produits/search/route.ts deleted file mode 100644 index d282d95..0000000 --- a/src/app/api/produits/search/route.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { sessionOuReponse } from "@/lib/authz"; -import { demarrerRecherche, etatRecherche } from "@/features/produits/api/search-produits"; - -// Les réponses sont immédiates (le travail Qlik tourne en tâche de fond), mais on -// garde le runtime Node.js : Playwright/Chromium ne tourne pas en edge, et l'état -// des jobs vit dans la mémoire du process. -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * Recherche produit **Qlik d'abord** — API asynchrone. - * - * `POST /api/produits/search?q=[&force=1]` démarre la recherche et rend la - * main tout de suite. `GET /api/produits/search?q=` renvoie l'avancement, - * puis le résultat. - * - * Pourquoi asynchrone : une recherche enchaîne deux allers-retours Qlik (dont une - * extraction mensuelle) et peut dépasser la minute. Une requête HTTP maintenue - * aussi longtemps se fait couper par le reverse proxy, qui répond une page - * d'erreur **HTML** — le client échouait alors sur « Unexpected token '<' … is - * not valid JSON ». Même schéma que `POST /api/qlik/sync`. - * - * Contrôle de session explicite en plus du middleware : il répond ici en JSON, - * et relit le compte en base (cf. lib/authz). - */ - -function lireTerme(req: NextRequest): string | null { - const q = (req.nextUrl.searchParams.get("q") ?? "").trim(); - return q.length >= 3 ? q : null; -} - -export async function POST(req: NextRequest) { - const denied = await sessionOuReponse(); - if (denied instanceof Response) return denied; - - const q = lireTerme(req); - if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 }); - - const force = req.nextUrl.searchParams.get("force") === "1"; - try { - const job = demarrerRecherche(q, { force }); - return NextResponse.json({ success: true, ...job }); - } catch (e) { - const message = (e as Error).message ?? String(e); - console.error("[api/produits/search] POST", message.slice(0, 300)); - return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 }); - } -} - -export async function GET(req: NextRequest) { - const denied = await sessionOuReponse(); - if (denied instanceof Response) return denied; - - const q = lireTerme(req); - if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 }); - - try { - return NextResponse.json({ success: true, ...etatRecherche(q) }); - } catch (e) { - const message = (e as Error).message ?? String(e); - console.error("[api/produits/search] GET", message.slice(0, 300)); - return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 }); - } -} diff --git a/src/app/api/qlik/sync/route.ts b/src/app/api/qlik/sync/route.ts deleted file mode 100644 index b223f63..0000000 --- a/src/app/api/qlik/sync/route.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { adminOuReponse, sessionOuReponse } from "@/lib/authz"; -import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright"; -import { upsertNetworkMetrics } from "@/lib/qlik-network-cache"; -import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client"; -import { - buildGridNetworkQlikDateFilter, - QLIK_MONTHS_BACK_DEFAULT, -} from "@/lib/qlik-date-range"; -import { startCapture, stopCapture } from "@/lib/log-capture"; - -// Tâche d'extraction Qlik potentiellement très longue (hypercube paginé). -// On accepte quand même 5 min côté plateforme Next.js, mais on rend la main au -// client tout de suite via le state job ci-dessous. -export const maxDuration = 300; -// Forcer le runtime Node.js : Playwright/Chromium ne tourne pas en edge et on a -// besoin d'un process persistant pour conserver l'état de job en mémoire. -export const runtime = "nodejs"; -// Toujours exécuter dynamiquement (sinon Next peut essayer de cacher des POST). -export const dynamic = "force-dynamic"; - -/** État d'un job de sync Qlik pour un fournisseur donné. */ -type QlikSyncStatus = "idle" | "running" | "success" | "error"; - -interface QlikSyncJob { - jobId: string; - /** Code fournisseur (mode fournisseur) — chaîne vide en mode produit. */ - fournisseur: string; - /** Code centrale (mode produit) — absent en mode fournisseur. */ - codeCentrale?: string; - status: QlikSyncStatus; - requested: number; - fetched: number; - upserted: number; - periode?: string; - dateDebut?: string; - dateFin?: string; - startedAt: string; - finishedAt?: string; - error?: string; - message?: string; -} - -/** - * Store en mémoire au niveau module. Une Map par fournisseur car on ne lance - * qu'une extraction à la fois par fournisseur. C'est volontairement simple : - * si le process redémarre, les jobs en cours sont perdus → GET renverra idle - * et l'utilisateur pourra relancer. Suffisant pour corriger le bug UX. - */ -const jobs = new Map(); - -/** - * Clé de job. Le mode fournisseur garde sa clé historique (le code fournisseur - * brut) ; le mode produit est préfixé pour ne jamais entrer en collision. - */ -function jobKeyForProduct(codeCentrale: string): string { - return `cc:${codeCentrale.toUpperCase()}`; -} - -/** - * Nombre maximum d'extractions produit simultanées. - * - * Le mode produit est ouvert à tous les utilisateurs connectés (contrairement au - * mode fournisseur, réservé aux admins). Une extraction sur 1 seul code est très - * légère, mais le serveur Qlik est sujet aux saturations mémoire : on plafonne - * la concurrence plutôt que de lui laisser prendre N requêtes en parallèle. - */ -const MAX_CONCURRENT_PRODUCT_JOBS = 3; - -/** Compte les extractions produit actuellement en cours. */ -function runningProductJobs(): number { - let n = 0; - for (const [key, job] of jobs) { - if (key.startsWith("cc:") && job.status === "running") n++; - } - return n; -} - -/** Construit un jobId court et lisible. */ -function newJobId(): string { - return `qlik_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; -} - -/** Sérialise un job pour la réponse HTTP. */ -function publicJob(job: QlikSyncJob) { - return { - jobId: job.jobId, - fournisseur: job.fournisseur, - codeCentrale: job.codeCentrale, - status: job.status, - requested: job.requested, - fetched: job.fetched, - upserted: job.upserted, - periode: job.periode, - dateDebut: job.dateDebut, - dateFin: job.dateFin, - startedAt: job.startedAt, - finishedAt: job.finishedAt, - error: job.error, - message: job.message, - }; -} - -/** État "idle" sérialisé (pas de job connu pour cette cible). */ -function idleJob(fournisseur: string, codeCentrale?: string) { - return { - jobId: null, - fournisseur, - codeCentrale, - status: "idle" as const, - requested: 0, - fetched: 0, - upserted: 0, - periode: undefined, - dateDebut: undefined, - dateFin: undefined, - startedAt: undefined, - finishedAt: undefined, - error: undefined, - message: undefined, - }; -} - -/** - * Regex de validation d'un code centrale "raisonnable". - * - * Format attendu d'après l'API FF : préfixe `10000` + 6 chiffres (ex `10000167303`), - * donc 11 caractères alphanumériques. Pour rester large et ne pas casser - * d'éventuels codes alphanumériques fournisseur (refs internes), on autorise - * - longueur 2..30 - * - jeu `[A-Z0-9_-]` - * - premier caractère alphanumérique (pas `_` ni `-`) - * - * But : éviter d'envoyer à Qlik des chaînes vides, des `-`, des espaces - * parasites, ou des entrées manifestement invalides qui déclenchent des - * codes d'erreur Engine ou du gaspillage réseau. **Volontairement simple** : - * on ne touche pas au mapping ; on filtre seulement ce qui ne ressemble à - * aucun code plausible. - */ -const CENTRAL_CODE_REGEX = /^[A-Z0-9][A-Z0-9_-]{1,29}$/i; - -/** - * Filtre strict des codes centraux avant d'envoyer à Qlik. - * - trim + drop vides - * - drop "-" (placeholder déjà exclu côté agrégation mais on double-check) - * - drop doublons (préserve l'ordre, comparaison insensible à la casse) - * - drop codes ne matchant pas CENTRAL_CODE_REGEX (longueur / charset) - * - * Renvoie `{ accepted, rejected, deduped }`. Les logs n'exposent que les - * compteurs, jamais les valeurs des codes. - */ -function filterCentralCodes(rawCodes: Array): { - accepted: string[]; - rejected: number; - deduped: number; -} { - const seen = new Set(); - const accepted: string[] = []; - let rejected = 0; - let deduped = 0; - for (const raw of rawCodes) { - if (raw == null) { - rejected++; - continue; - } - const code = String(raw).trim(); - const key = code.toUpperCase(); - if (!code || code === "-" || code === "_") { - rejected++; - continue; - } - if (seen.has(key)) { - deduped++; - continue; - } - if (!CENTRAL_CODE_REGEX.test(code)) { - rejected++; - continue; - } - seen.add(key); - accepted.push(code); - } - return { accepted, rejected, deduped }; -} - -/** - * Exécute réellement l'extraction + l'upsert, en mettant à jour le job - * au fur et à mesure (compteur fetched, puis upserted, puis finishedAt). - */ -async function runJob(job: QlikSyncJob): Promise { - // Journal téléchargeable depuis Paramètres. Ouvert AVANT le premier log du - // job pour ne pas perdre l'entête (choix des champs, carte du modèle Qlik) — - // c'est précisément la partie qu'un terminal tronque. - startCapture(job.jobId, `sync fournisseur=${job.fournisseur} démarrée le ${job.startedAt}`); - try { - const articles = await pgGetArticlesByFournisseur(job.fournisseur); - // Filtre strict des codes (trim, dedupe, exclusion vides/`-`/codes trop suspects). - const { accepted: codes, rejected: codesRejected, deduped: codesDeduped } = filterCentralCodes( - articles.map((a) => a.codeCentrale), - ); - job.requested = codes.length; - console.log( - `[api/qlik/sync] job=${job.jobId} fournisseur=${job.fournisseur} — ${articles.length} articles, ${codes.length} codes centraux retenus, ${codesRejected} rejetés (vide/-/anormaux), ${codesDeduped} doublons supprimés`, - ); - if (codes.length === 0) { - job.status = "success"; - job.message = "Aucun code centrale valide pour ce fournisseur"; - job.finishedAt = new Date().toISOString(); - return; - } - - // Contrat métier strict : toujours 12 mois complets glissants. - // Une variable d'environnement ne doit pas pouvoir raccourcir la série. - const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT); - job.periode = dateFilter.label; - job.dateDebut = dateFilter.dateDebut; - job.dateFin = dateFilter.dateFin; - console.log( - `[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — 12 mois complets ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`, - ); - // Le code fournisseur est transmis en plus des codes : s'il désigne dans - // Qlik le même périmètre (couverture vérifiée côté extracteur), une seule - // sélection remplace les dizaines de milliers de `SelectValues` par code. - // Les codes restent le repli et le contrôle de couverture. - const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter, job.fournisseur); - job.fetched = metrics.size; - console.log(`[api/qlik/sync] job=${job.jobId} ← Qlik a renvoyé ${metrics.size} produits réseau`); - const count = await upsertNetworkMetrics([...metrics.values()]); - job.upserted = count; - console.log(`[api/qlik/sync] job=${job.jobId} ${count} lignes upsert dans le cache`); - job.status = "success"; - job.finishedAt = new Date().toISOString(); - } catch (e) { - applyJobError(job, e); - } finally { - await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`); - } -} - -/** - * Inscrit une erreur dans le job, en traduisant la saturation mémoire du - * serveur Qlik en message actionnable (ce n'est PAS un bug CollectFlow : - * l'app Qlik ne se charge même pas). - */ -function applyJobError(job: QlikSyncJob, e: unknown): void { - const msg = e instanceof Error ? e.message : String(e); - console.error(`[api/qlik/sync] job=${job.jobId}`, msg); - job.status = "error"; - const lower = msg.toLowerCase(); - if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes("file corrupted") || lower.includes('"code":6') || lower.includes('"code":3002')) { - job.error = "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard (ou faites libérer / augmenter la RAM du serveur Qlik)."; - } else { - job.error = msg; - } - job.finishedAt = new Date().toISOString(); -} - -/** - * Extraction Qlik pour UN seul code centrale (fiche produit). - * - * Beaucoup plus légère que la sync fournisseur : un seul code au lieu de - * plusieurs centaines, sur la même fenêtre 12 mois glissants. Réutilise - * exactement le même extracteur, donc le même détail mensuel. - */ -async function runProductJob(job: QlikSyncJob): Promise { - startCapture(job.jobId, `sync produit=${job.codeCentrale} démarrée le ${job.startedAt}`); - try { - const code = job.codeCentrale!; - job.requested = 1; - - const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT); - job.periode = dateFilter.label; - job.dateDebut = dateFilter.dateDebut; - job.dateFin = dateFilter.dateFin; - console.log( - `[api/qlik/sync] job=${job.jobId} → extraction Qlik produit (1 code) — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`, - ); - - const metrics = await fetchNetworkMetricsPlaywright([code], undefined, dateFilter); - job.fetched = metrics.size; - if (metrics.size === 0) { - job.status = "success"; - job.message = "Ce code centrale est inconnu de Qlik (aucune vente réseau sur la période)"; - job.finishedAt = new Date().toISOString(); - return; - } - const count = await upsertNetworkMetrics([...metrics.values()]); - job.upserted = count; - job.status = "success"; - job.message = "Données réseau récupérées"; - job.finishedAt = new Date().toISOString(); - console.log(`[api/qlik/sync] job=${job.jobId} produit ${code} — ${count} ligne(s) upsert`); - } catch (e) { - applyJobError(job, e); - } finally { - await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`); - } -} - -/** - * GET /api/qlik/sync?fournisseur=XXX ou ?codeCentrale=YYY - * Retourne l'état courant du job pour cette cible. Si aucun job n'est - * connu, renvoie un état "idle" (avec un message explicite). Permet au - * frontend de reprendre l'état après un reload et de poller en arrière-plan. - */ -export async function GET(req: NextRequest) { - const codeCentrale = req.nextUrl.searchParams.get("codeCentrale"); - const fournisseur = req.nextUrl.searchParams.get("fournisseur"); - - // Mode produit : ouvert à tout utilisateur connecté. - if (codeCentrale) { - const denied = await sessionOuReponse(); - if (denied instanceof Response) return denied; - const job = jobs.get(jobKeyForProduct(codeCentrale)); - if (!job) { - return NextResponse.json({ success: true, ...idleJob("", codeCentrale) }); - } - return NextResponse.json({ success: true, ...publicJob(job) }); - } - - // Mode fournisseur : admin uniquement (inchangé). - const denied = await adminOuReponse(); - if (denied instanceof Response) return denied; - - if (!fournisseur) { - return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 }); - } - const job = jobs.get(fournisseur); - if (!job) { - return NextResponse.json({ success: true, ...idleJob(fournisseur) }); - } - return NextResponse.json({ success: true, ...publicJob(job) }); -} - -/** - * POST /api/qlik/sync?fournisseur=XXX - * Démarre (ou réutilise si déjà running) un job d'extraction en arrière-plan - * et retourne *immédiatement* { success, status, jobId, ... }. Le client doit - * ensuite poller GET pour suivre l'avancement et être notifié de la fin. - */ -export async function POST(req: NextRequest) { - const codeCentraleParam = req.nextUrl.searchParams.get("codeCentrale"); - - // ─── Mode produit : 1 code centrale, ouvert à tout utilisateur connecté ─── - if (codeCentraleParam) { - const denied = await sessionOuReponse(); - if (denied instanceof Response) return denied; - - // Même validation que la sync fournisseur (trim, rejet des vides / "-" / - // codes hors charset) pour ne jamais envoyer de saleté à l'Engine Qlik. - const { accepted } = filterCentralCodes([codeCentraleParam]); - if (accepted.length === 0) { - return NextResponse.json({ error: "Code centrale invalide" }, { status: 400 }); - } - const code = accepted[0]; - const key = jobKeyForProduct(code); - - const existingProduct = jobs.get(key); - if (existingProduct && existingProduct.status === "running") { - return NextResponse.json({ success: true, ...publicJob(existingProduct) }); - } - - if (runningProductJobs() >= MAX_CONCURRENT_PRODUCT_JOBS) { - return NextResponse.json( - { error: "Trop d'extractions Qlik en cours. Réessayez dans quelques instants." }, - { status: 429 }, - ); - } - - const productJob: QlikSyncJob = { - jobId: newJobId(), - fournisseur: "", - codeCentrale: code, - status: "running", - requested: 0, - fetched: 0, - upserted: 0, - startedAt: new Date().toISOString(), - }; - jobs.set(key, productJob); - console.log(`[api/qlik/sync] job=${productJob.jobId} démarré pour codeCentrale=${code}`); - - void runProductJob(productJob).catch((e) => applyJobError(productJob, e)); - - return NextResponse.json({ success: true, ...publicJob(productJob) }); - } - - // ─── Mode fournisseur : admin uniquement (inchangé) ────────────────────── - const denied = await adminOuReponse(); - if (denied instanceof Response) return denied; - - const fournisseur = req.nextUrl.searchParams.get("fournisseur"); - if (!fournisseur) { - return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 }); - } - - const existing = jobs.get(fournisseur); - if (existing && existing.status === "running") { - // Un job tourne déjà pour ce fournisseur : on le réutilise. - return NextResponse.json({ success: true, ...publicJob(existing) }); - } - - const job: QlikSyncJob = { - jobId: newJobId(), - fournisseur, - status: "running", - requested: 0, - fetched: 0, - upserted: 0, - startedAt: new Date().toISOString(), - }; - jobs.set(fournisseur, job); - console.log(`[api/qlik/sync] job=${job.jobId} démarré pour fournisseur=${fournisseur}`); - - // Lancement fire-and-forget. On ne `await` PAS l'extraction ici : on - // renvoie tout de suite au client et il polle GET. Les erreurs sont - // capturées dans runJob et inscrites dans le job. - void runJob(job).catch((e) => { - // Filet de sécurité (runJob ne devrait pas throw, mais on ne veut - // jamais qu'une exception non capturée crashe le process). - const msg = e instanceof Error ? e.message : String(e); - console.error(`[api/qlik/sync] job=${job.jobId} crash non géré`, msg); - job.status = "error"; - job.error = msg; - job.finishedAt = new Date().toISOString(); - }); - - return NextResponse.json({ success: true, ...publicJob(job) }); -} diff --git a/src/app/api/test-db/route.ts b/src/app/api/test-db/route.ts deleted file mode 100644 index 35dbc20..0000000 --- a/src/app/api/test-db/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NextResponse } from 'next/server'; -import { db } from '@/db'; -import { sql } from 'drizzle-orm'; -import { pgGetStockForCodeins } from '@/lib/pg-ff-client'; -import { adminOuReponse } from '@/lib/authz'; - -export async function GET(request: Request) { - const acces = await adminOuReponse(); - if (acces instanceof Response) return acces; - - try { - const { searchParams } = new URL(request.url); - const codein = searchParams.get('codein') || '334152'; - - const map = await pgGetStockForCodeins([codein]); - - const fouResult = await db.execute(sql` - SELECT - a.codein::text AS codein, - COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur - FROM articles a - LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 - LEFT JOIN fouident fi ON fi.code = af.code - WHERE a.codein::text = ${codein} - `); - - return NextResponse.json({ - stock: Object.fromEntries(map), - fou: fouResult.rows, - }); - } catch (e) { - // Pas de pile d'appels dans la réponse : elle reste dans les journaux du serveur. - console.error('[api/test-db]', e); - return NextResponse.json({ error: (e as Error).message }, { status: 500 }); - } -} diff --git a/src/app/api/v1/fournisseurs/route.ts b/src/app/api/v1/fournisseurs/route.ts deleted file mode 100644 index 44aba51..0000000 --- a/src/app/api/v1/fournisseurs/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { NextRequest } from "next/server"; -import { requireApiAuth } from "@/lib/api-auth"; -import { ok, fail } from "@/lib/api-response"; -import { fournisseursQuerySchema } from "@/lib/api-schemas"; -import { listGridSuppliers } from "@/lib/grid-store"; -import { pgGetFournisseurs } from "@/lib/pg-ff-client"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * GET /api/v1/fournisseurs?search=…&withData=1 - * - * Liste des fournisseurs. Par défaut, tout le référentiel (`fouident`), enrichi de la - * fraîcheur de l'instantané de grille quand il existe — ce qui permet à l'appelant de - * savoir d'avance quels fournisseurs `/api/v1/grid` peut servir immédiatement. - * - * `withData=1` restreint aux seuls fournisseurs déjà présents dans l'instantané. - */ -export async function GET(req: NextRequest) { - const authCtx = await requireApiAuth(req); - if (authCtx instanceof Response) return authCtx; - - const parsed = fournisseursQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams)); - if (!parsed.success) { - return fail("bad_request", "Paramètres invalides.", parsed.error.issues); - } - const { search, withData } = parsed.data; - - const snapshot = await listGridSuppliers(); - const byCode = new Map(snapshot.map((s) => [s.codeFournisseur, s])); - - if (withData === "1") { - const term = search?.toLowerCase(); - const data = snapshot - .filter((s) => !term - || s.codeFournisseur.toLowerCase().includes(term) - || (s.nomFournisseur ?? "").toLowerCase().includes(term)) - .map((s) => ({ - code: s.codeFournisseur, - nom: s.nomFournisseur, - hasSnapshot: true, - rowCount: s.rowCount, - computedAt: s.computedAt, - })); - return ok(data, { meta: { total: data.length, scope: "instantané uniquement" } }); - } - - const fournisseurs = await pgGetFournisseurs(search); - const data = fournisseurs.map((f) => { - const snap = byCode.get(f.code); - return { - code: f.code, - nom: f.nom, - hasSnapshot: Boolean(snap), - rowCount: snap?.rowCount ?? 0, - computedAt: snap?.computedAt ?? null, - }; - }); - - return ok(data, { meta: { total: data.length, withSnapshot: byCode.size } }); -} diff --git a/src/app/api/v1/gammes/route.ts b/src/app/api/v1/gammes/route.ts deleted file mode 100644 index 1c3d019..0000000 --- a/src/app/api/v1/gammes/route.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { NextRequest } from "next/server"; -import { requireApiAuth } from "@/lib/api-auth"; -import { ok, fail } from "@/lib/api-response"; -import { gammesBatchBodySchema } from "@/lib/api-schemas"; -import { affecterGammes, lireCorpsJson } from "@/lib/api-gammes"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; -// Idem /api/v1/grid : le calcul à la demande peut prendre plusieurs secondes. -export const maxDuration = 300; - -/** - * POST /api/v1/gammes { "fournisseur": "…", "changes": [{ "codein": "…", "gamme": "A" }, …] } - * - * Propose la gamme de plusieurs articles d'un fournisseur en un appel. Les - * propositions restent **à valider** dans la Grille (cf. lib/api-gammes). - * Tout ou rien : un article inconnu chez le fournisseur fait échouer l'appel (404). - */ -export async function POST(req: NextRequest) { - const authCtx = await requireApiAuth(req); - if (authCtx instanceof Response) return authCtx; - - const corps = await lireCorpsJson(req); - if (corps instanceof Response) return corps; - const parsed = gammesBatchBodySchema.safeParse(corps); - if (!parsed.success) { - return fail("bad_request", "Corps de requête invalide.", parsed.error.issues); - } - const { fournisseur, changes, compute } = parsed.data; - - const res = await affecterGammes({ - codeFournisseur: fournisseur, - changes, - compute, - auteur: authCtx.subject, - }); - if (res instanceof Response) return res; - - return ok(res.resultats, { - meta: { - codeFournisseur: res.codeFournisseur, - nomFournisseur: res.nomFournisseur, - demandes: res.resultats.length, - modifies: res.resultats.filter((r) => r.modifie).length, - aValider: res.resultats.filter((r) => r.statut === "a_valider").length, - enregistreLe: new Date().toISOString(), - }, - }); -} diff --git a/src/app/api/v1/grid/route.ts b/src/app/api/v1/grid/route.ts deleted file mode 100644 index 5409d5b..0000000 --- a/src/app/api/v1/grid/route.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { NextRequest } from "next/server"; -import { requireApiAuth } from "@/lib/api-auth"; -import { ok, fail, buildPagination } from "@/lib/api-response"; -import { gridQuerySchema, pickFields, toSortKey } from "@/lib/api-schemas"; -import { queryGridRows, getGridFreshness } from "@/lib/grid-store"; -import { enrichRows } from "@/lib/api-enrich"; -import { getProductRows } from "@/features/grid/api/get-product-rows"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; -// Le calcul à la demande (premier appel sur un fournisseur inconnu) enchaîne -// plusieurs requêtes SQL sur la base miroir FF : on laisse de la marge. -export const maxDuration = 300; - -/** - * GET /api/v1/grid?fournisseur=…&gamme=&code1..3=&search=&sort=&order=&page=&limit=&fields=&compute= - * - * Lignes de grille d'un fournisseur, filtrées / triées / paginées **en SQL** depuis - * l'instantané persisté (`grid_rows`). - * - * Si le fournisseur n'a pas encore d'instantané, l'endpoint le **calcule à la demande** - * (`compute=1`, défaut) : sans cela, un appelant externe ne pourrait consulter que les - * fournisseurs déjà ouverts dans la Grille par un humain. Le calcul est protégé par le - * cache et le verrou anti-concurrence de `getProductRows()`, et il persiste l'instantané - * au passage — les appels suivants repassent donc par le chemin rapide. - * - * `compute=0` restaure le comportement strict : échec immédiat en `202 not_ready`. - */ -export async function GET(req: NextRequest) { - const authCtx = await requireApiAuth(req); - if (authCtx instanceof Response) return authCtx; - - const parsed = gridQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams)); - if (!parsed.success) { - return fail("bad_request", "Paramètres invalides.", parsed.error.issues); - } - const q = parsed.data; - - // Distingue « fournisseur jamais calculé » de « filtres sans résultat » (200 vide). - let freshness = await getGridFreshness(q.fournisseur); - let computedOnDemand = false; - - if (!freshness) { - if (q.compute === "0") { - return fail( - "not_ready", - `Aucun instantané pour le fournisseur « ${q.fournisseur} ». Relancez sans « compute=0 » pour le calculer à la demande.`, - { fournisseur: q.fournisseur }, - ); - } - - console.log(`[api/v1/grid] instantané absent pour ${q.fournisseur} — calcul à la demande (via ${authCtx.via})`); - try { - // magasin TOTAL : c'est la seule variante persistée par getProductRows, - // et ProductRow embarque déjà les ventilations par magasin. - await getProductRows({ codeFournisseur: q.fournisseur, magasin: "TOTAL" }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - console.error(`[api/v1/grid] calcul à la demande KO pour ${q.fournisseur}:`, msg); - return fail( - "internal_error", - `Le calcul de la grille a échoué pour le fournisseur « ${q.fournisseur} ».`, - { fournisseur: q.fournisseur }, - ); - } - - freshness = await getGridFreshness(q.fournisseur); - if (!freshness) { - // Calcul réussi mais aucune ligne : le code fournisseur n'existe pas, ou - // il n'a aucun article. À distinguer d'une panne. - return fail( - "not_found", - `Aucun article pour le fournisseur « ${q.fournisseur} ». Vérifiez le code auprès de /api/v1/fournisseurs.`, - { fournisseur: q.fournisseur }, - ); - } - computedOnDemand = true; - } - - const filtres = { - codeFournisseur: q.fournisseur, - gamme: q.gamme, - code1: q.code1, - code2: q.code2, - code3: q.code3, - nomenclature: q.nomenclature, - search: q.search, - sort: toSortKey(q.sort), - order: q.order, - }; - - // Sans `limit`, la réponse peut porter tout un fournisseur — et certains en - // comptent plus de 130 000. Tout charger puis tout sérialiser d'un bloc ferait - // exploser la mémoire du processus : on diffuse par lots, à mémoire constante. - if (q.limit == null) { - return streamAllRows(req, filtres, q, freshness, computedOnDemand); - } - - const result = await queryGridRows({ ...filtres, page: q.page, limit: q.limit }); - - // Métriques Qlik et gamme serveur relues au moment de l'appel (voir api-enrich). - const rows = q.enrich === "1" ? await enrichRows(result.rows) : result.rows; - - const pagination = buildPagination(q.page, q.limit, result.total); - - return ok( - rows.map((r) => pickFields(r, q.fields)), - { - pagination, - meta: { - fournisseur: q.fournisseur, - computedAt: result.computedAt, - snapshotComputedAt: freshness.computedAt, - snapshotRowCount: freshness.rowCount, - enrichi: q.enrich === "1", - /** true = l'instantané n'existait pas et vient d'être calculé par cet appel. */ - computedOnDemand, - /** - * true = cette réponse contient **toutes** les lignes du fournisseur. - * Dit explicitement à un appelant — en particulier un agent — s'il - * peut s'arrêter là, au lieu de le laisser déduire d'une pagination - * qu'il risque d'ignorer. - */ - complet: !pagination.hasMore, - ...(pagination.hasMore - ? { - avertissement: - `Réponse partielle : ${rows.length} lignes sur ${result.total}, parce que « limit » a été précisé. ` - + `Retirez « limit » pour obtenir tout le fournisseur en un appel, ou passez à page=${q.page + 1}.`, - } - : {}), - }, - }, - ); -} - -/** Lots internes : compromis entre nombre d'allers-retours SQL et mémoire retenue. */ -const STREAM_BATCH = 1000; - -type Filtres = Parameters[0]; - -/** - * Diffuse **toutes** les lignes du fournisseur en une seule réponse JSON, sans - * jamais la construire entièrement en mémoire. - * - * La forme reste celle des autres réponses (`{ data, pagination, meta }`) : un - * client existant ne voit aucune différence, il reçoit simplement les octets au - * fil de l'eau. `pagination` et `meta` sont écrits en dernier, une fois le total - * connu — l'ordre des clés n'a aucune importance en JSON. - * - * Une erreur survenant après les premiers octets ne peut plus changer le code - * HTTP : on ferme alors le document avec `meta.erreur`, pour que l'appelant - * détecte une réponse tronquée au lieu de la croire complète. - */ -function streamAllRows( - req: NextRequest, - filtres: Filtres, - q: { enrich: string; fields?: string; fournisseur: string }, - freshness: { rowCount: number; computedAt: string | null }, - computedOnDemand: boolean, -): Response { - const encoder = new TextEncoder(); - - const stream = new ReadableStream({ - async start(controller) { - const write = (s: string) => controller.enqueue(encoder.encode(s)); - let envoyees = 0; - let total = 0; - let computedAt: string | null = null; - let erreur: string | null = null; - - write('{"data":['); - try { - for (let page = 1; ; page++) { - const lot = await queryGridRows({ ...filtres, page, limit: STREAM_BATCH }); - if (page === 1) { - total = lot.total; - computedAt = lot.computedAt; - } - if (lot.rows.length === 0) break; - - const enrichies = q.enrich === "1" ? await enrichRows(lot.rows) : lot.rows; - for (const r of enrichies) { - write((envoyees === 0 ? "" : ",") + JSON.stringify(pickFields(r, q.fields))); - envoyees++; - } - if (lot.rows.length < STREAM_BATCH) break; - } - } catch (e) { - erreur = e instanceof Error ? e.message : String(e); - console.error(`[api/v1/grid] diffusion interrompue pour ${q.fournisseur} après ${envoyees} lignes:`, erreur); - } - - const complet = erreur === null && envoyees === total; - write("]," + JSON.stringify({ - pagination: { page: 1, limit: envoyees, total, totalPages: 1, hasMore: !complet }, - meta: { - fournisseur: q.fournisseur, - computedAt, - snapshotComputedAt: freshness.computedAt, - snapshotRowCount: freshness.rowCount, - enrichi: q.enrich === "1", - computedOnDemand, - diffuse: true, - complet, - ...(erreur - ? { erreur: `Diffusion interrompue après ${envoyees} lignes sur ${total} : ${erreur}` } - : {}), - }, - }).slice(1)); - controller.close(); - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "application/json; charset=utf-8", - "Cache-Control": "no-store", - // Empêche un proxy de tamponner la réponse entière avant de la relayer. - "X-Accel-Buffering": "no", - }, - }); -} diff --git a/src/app/api/v1/network/[codeCentrale]/route.ts b/src/app/api/v1/network/[codeCentrale]/route.ts deleted file mode 100644 index ae715b1..0000000 --- a/src/app/api/v1/network/[codeCentrale]/route.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { NextRequest } from "next/server"; -import { requireApiAuth } from "@/lib/api-auth"; -import { ok, fail } from "@/lib/api-response"; -import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * GET /api/v1/network/:codeCentrale - * - * Métriques réseau Qlik d'un produit (CA, quantité, nombre de magasins, CA/magasin, - * marge %) et sa courbe de quantités mensuelles sur 12 mois glissants. - * - * Lecture **exclusive** de la table `qlik_network_metrics` : aucun appel à Qlik n'est - * déclenché. Pour interroger Qlik en direct (y compris sur des produits que nous ne - * référençons pas), c'est la page Produits — bien plus lente, et volontairement - * séparé de cette API. - */ -export async function GET( - req: NextRequest, - ctx: { params: Promise<{ codeCentrale: string }> }, -) { - const authCtx = await requireApiAuth(req); - if (authCtx instanceof Response) return authCtx; - - const { codeCentrale } = await ctx.params; - if (!codeCentrale) return fail("bad_request", "Paramètre 'codeCentrale' requis."); - - const metrics = await getNetworkMetricsByCodeCentrale([codeCentrale]); - const found = metrics.get(codeCentrale); - if (!found) { - return fail( - "not_found", - `Aucune donnée réseau en cache pour le code centrale « ${codeCentrale} ». Lancez une synchronisation Qlik ou une recherche réseau.`, - ); - } - - return ok({ codeCentrale, ...found }, { meta: { fetchedAt: found.fetchedAt, source: "cache" } }); -} diff --git a/src/app/api/v1/nomenclatures/route.ts b/src/app/api/v1/nomenclatures/route.ts deleted file mode 100644 index c760406..0000000 --- a/src/app/api/v1/nomenclatures/route.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { NextRequest } from "next/server"; -import { requireApiAuth } from "@/lib/api-auth"; -import { ok, fail } from "@/lib/api-response"; -import { nomenclaturesQuerySchema } from "@/lib/api-schemas"; -import { listGridNomenclatures, getGridFreshness } from "@/lib/grid-store"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * GET /api/v1/nomenclatures?fournisseur=…&niveau=1|2|3&parent=… - * - * Répartition des articles d'un fournisseur par poste de nomenclature, avec le - * nombre d'articles et le CA de chacun. - * - * C'est la porte d'entrée pour traiter un gros fournisseur : certains dépassent - * 130 000 articles, et tout récupérer d'un coup n'a de sens ni pour une IA ni - * pour une application. On liste d'abord les postes — quelques dizaines de - * lignes — puis on interroge `/grid?fournisseur=…&nomenclature=…` poste par poste. - * - * Les codes sont hiérarchiques par préfixe (6 chiffres, de 31xxxx à 40xxxx) : - * niveau 1 = « 32 », niveau 2 = « 3202 », niveau 3 = « 320211 ». `parent` accepte - * un préfixe pour descendre l'arborescence sans tout charger. - */ -export async function GET(req: NextRequest) { - const authCtx = await requireApiAuth(req); - if (authCtx instanceof Response) return authCtx; - - const parsed = nomenclaturesQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams)); - if (!parsed.success) { - return fail("bad_request", "Paramètres invalides.", parsed.error.issues); - } - const { fournisseur, niveau, parent } = parsed.data; - - // Même distinction que /grid : « pas encore calculé » n'est pas « vide ». - const freshness = await getGridFreshness(fournisseur); - if (!freshness) { - return fail( - "not_ready", - `Aucun instantané pour le fournisseur « ${fournisseur} ». Appelez /api/v1/grid?fournisseur=${fournisseur} une fois pour le calculer.`, - { fournisseur }, - ); - } - - const postes = await listGridNomenclatures(fournisseur, niveau, parent); - const nbArticles = postes.reduce((s, p) => s + p.nbArticles, 0); - - return ok(postes, { - meta: { - fournisseur, - niveau, - parent: parent ?? null, - nbPostes: postes.length, - /** Total couvert par les postes listés — à rapprocher de snapshotRowCount. */ - nbArticles, - snapshotRowCount: freshness.rowCount, - snapshotComputedAt: freshness.computedAt, - /** - * Le paramètre de /grid à utiliser avec les `code` renvoyés ci-dessus. - * `nomenclature` filtre par préfixe et fonctionne aux trois niveaux. - */ - filtreGrid: "nomenclature", - }, - }); -} diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts deleted file mode 100644 index 3b2588d..0000000 --- a/src/app/api/v1/openapi.json/route.ts +++ /dev/null @@ -1,537 +0,0 @@ -import { NextRequest } from "next/server"; -import { GRID_SORT_KEYS } from "@/lib/grid-store"; -import { GAMMES } from "@/lib/gammes"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * GET /api/v1/openapi.json — spécification OpenAPI 3.1 de l'API CollectFlow. - * - * **Volontairement accessible sans authentification.** Le document ne décrit que la - * structure de l'API (chemins, paramètres, schémas) et ne contient aucune donnée - * métier ; or ChatGPT et les autres plateformes d'agents importent le schéma par URL, - * avant même que la clé ne soit configurée. Exiger une clé ici rendrait l'import - * impossible. - * - * `servers` est construit depuis l'hôte appelant (ou COLLECTFLOW_PUBLIC_URL) : les - * Actions ChatGPT exigent une URL **absolue**, une URL relative étant rejetée. - */ -export async function GET(req: NextRequest) { - const configured = process.env.COLLECTFLOW_PUBLIC_URL?.trim().replace(/\/+$/, ""); - const origin = configured || req.nextUrl.origin; - - const paginationParams = [ - { name: "page", in: "query", schema: { type: "integer", minimum: 1, default: 1 }, description: "Numéro de page." }, - { name: "limit", in: "query", schema: { type: "integer", minimum: 1 }, description: "Lignes par page, sans plafond. Sur /grid, OMETTRE ce paramètre renvoie toutes les lignes du fournisseur en un seul appel — c'est le mode recommandé. Sur les autres endpoints, le défaut est 100." }, - ]; - const sortParams = [ - { name: "sort", in: "query", schema: { type: "string", enum: GRID_SORT_KEYS }, description: "Colonne de tri." }, - { name: "order", in: "query", schema: { type: "string", enum: ["asc", "desc"], default: "desc" }, description: "Sens du tri." }, - { - name: "fields", - in: "query", - schema: { type: "string" }, - description: - "Colonnes à retourner, séparées par des virgules (ex. « codein,libelle1,totalCa »). " - + "À utiliser systématiquement : une ligne complète contient les séries mensuelles et les " - + "ventilations par magasin, ce qui est très volumineux. `codein` est toujours inclus.", - }, - { - name: "enrich", - in: "query", - schema: { type: "string", enum: ["0", "1"], default: "1" }, - description: - "1 (défaut) : relit les métriques réseau Qlik (`network`) et la gamme serveur " - + "(`codeGammeServeur`) au moment de l'appel. 0 : sert l'instantané brut.", - }, - ]; - - const codesGamme = GAMMES.map((g) => g.code); - const legendeGammes = GAMMES.map((g) => `${g.code} (${g.nom})`).join(", "); - const erreur = (description: string) => ({ - description, - content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, - }); - - const spec = { - openapi: "3.1.0", - info: { - title: "CollectFlow API", - version: "1.0.0", - description: - "Données de la grille CollectFlow : ventes sur 12 mois par magasin, stock, marges, " - + "gammes et métriques du réseau Qlik (~270 magasins Foir'Fouille), plus la recherche de produits.\n\n" - + "**N'importe quel fournisseur peut être interrogé directement.** Les réponses sont servies " - + "depuis un instantané persisté ; si le fournisseur demandé n'en a pas encore, `/grid` le calcule " - + "à la demande. Ce premier appel prend alors plusieurs secondes, les suivants sont immédiats. " - + "Passez `compute=0` pour refuser ce calcul et obtenir un `202 not_ready` immédiat.\n\n" - + "**Aucun endpoint ne contacte Qlik en direct** : les métriques réseau viennent d'un cache " - + "alimenté par une synchronisation séparée.\n\n" - + "Deux informations sont relues à chaque appel car elles évoluent indépendamment : les métriques " - + "réseau Qlik (`network`, `null` s'il n'y en a pas) et la gamme serveur non modifiée " - + "(`codeGammeServeur`).\n\n" - + "**Gammes** : `PUT /products/{codein}/gamme` (un article) et `POST /gammes` (plusieurs) " - + "**proposent** une gamme. La proposition reste **à valider** : elle apparaît dans la Grille " - + "comme une modification non enregistrée, pour voir quels produits changent de gamme, et " - + "n'est appliquée qu'une fois validée par un utilisateur (« Enregistrer »). D'ici là, " - + "`codeGamme` est inchangée et la proposition figure dans `gammeAValider`. Une fois validée, " - + "`codeGammeServeur` reste la gamme en base FF jusqu'à l'import des gammes modifiées.", - }, - servers: [{ url: `${origin}/api/v1`, description: "API CollectFlow" }], - security: [{ ApiKeyAuth: [] }], - components: { - securitySchemes: { - ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" }, - }, - schemas: { - Error: { - type: "object", - properties: { - error: { - type: "object", - properties: { - code: { - type: "string", - enum: ["unauthorized", "forbidden", "bad_request", "not_found", "not_ready", "internal_error"], - }, - message: { type: "string" }, - }, - }, - }, - }, - Pagination: { - type: "object", - properties: { - page: { type: "integer" }, - limit: { type: "integer" }, - total: { type: "integer" }, - totalPages: { type: "integer" }, - hasMore: { type: "boolean", description: "true = il reste des pages. Sur /grid, meta.complet dit la même chose en positif." }, - }, - }, - Fournisseur: { - type: "object", - properties: { - code: { type: "string", description: "Code fournisseur, à passer au paramètre `fournisseur`." }, - nom: { type: ["string", "null"] }, - hasSnapshot: { type: "boolean", description: "false = données pas encore disponibles pour ce fournisseur." }, - rowCount: { type: "integer" }, - computedAt: { type: ["string", "null"], format: "date-time" }, - }, - }, - NetworkMetrics: { - type: ["object", "null"], - description: "Métriques du réseau Qlik. `null` quand le produit n'a pas de données réseau.", - properties: { - caReseau: { type: "number" }, - qteReseau: { type: "number" }, - nbMagasinsReseau: { type: "integer", description: "Nombre de magasins du réseau vendant le produit (sur ~270)." }, - caParMagasinReseau: { type: "number", description: "CA moyen par magasin : normalise la présence, préférable au CA brut pour comparer." }, - margePctReseau: { type: "number", description: "Taux de marge, ratio brut (0.32 = 32 %)." }, - qteByMonth: { type: ["object", "null"], description: "Quantités vendues par mois, clés « YYYY-MM »." }, - fetchedAt: { type: ["string", "null"], format: "date-time" }, - }, - }, - Product: { - type: "object", - description: - "Ligne de grille : un article chez un fournisseur. La liste de propriétés ci-dessous " - + "n'est pas exhaustive — la réponse contient l'intégralité de la ligne (une soixantaine " - + "de champs). Utilisez `fields` pour n'en garder qu'une partie.", - properties: { - codein: { type: "string", description: "Identifiant interne de l'article." }, - codeFournisseur: { type: "string" }, - nomFournisseur: { type: "string" }, - libelle1: { type: "string", description: "Libellé de l'article." }, - gtin: { type: "string" }, - reference: { type: "string" }, - codeCentrale: { type: "string", description: "Code centrale — clé de jointure avec le réseau Qlik." }, - codeGammeServeur: { - type: ["string", "null"], - description: - "Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour " - + "raisonner. `null` = aucune gamme. Valeurs : " + legendeGammes + ".", - }, - codeGamme: { type: ["string", "null"], description: "Gamme courante, modifications validées dans CollectFlow comprises." }, - gammeAValider: { type: ["string", "null"], description: "Gamme proposée par l'API et pas encore validée dans la Grille. `null` = aucune proposition en attente." }, - totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." }, - totalQuantite: { type: "number" }, - totalMarge: { type: "number" }, - tauxMarge: { type: "number" }, - stockActuel: { type: "number" }, - pcb: { type: "number", description: "Conditionnement (nombre d'unités par colis)." }, - prixAchat: { type: "number" }, - prixVente: { type: "number" }, - derniereVente: { type: "string", format: "date" }, - nbJoursDerniereVente: { type: "integer" }, - sales12m: { type: "object", description: "Quantités vendues par mois, clés « YYYYMM »." }, - stock12m: { type: "object", description: "Stock de fin de mois, clés « YYYYMM »." }, - receptions12m: { type: "object", description: "Quantités reçues par mois, clés « YYYYMM »." }, - sales12mByStore: { type: "object", description: "Ventes par magasin (292, 579) puis par mois." }, - stock12mByStore: { type: "object", description: "Stock de fin de mois par magasin puis par mois." }, - receptions12mByStore: { type: "object", description: "Réceptions par magasin puis par mois." }, - qteReseauByMonth: { type: ["object", "null"], description: "Quantités vendues par le réseau, clés « YYYY-MM » — série derrière la tendance." }, - network: { $ref: "#/components/schemas/NetworkMetrics" }, - trend: { $ref: "#/components/schemas/Trend" }, - }, - }, - ResultatGamme: { - type: "object", - properties: { - codein: { type: "string" }, - gamme: { type: "string", enum: codesGamme, description: "Gamme proposée." }, - gammePrecedente: { type: ["string", "null"], description: "Gamme enregistrée au moment de l'appel." }, - codeGammeServeur: { type: ["string", "null"], description: "Gamme en base FF, non modifiée par l'API." }, - modifie: { type: "boolean", description: "true = proposition déposée, à valider dans la Grille. false = l'article a déjà cette gamme (une proposition en attente pour lui est retirée)." }, - statut: { type: "string", enum: ["a_valider", "inchangee"], description: "`a_valider` : en attente de validation dans la Grille." }, - }, - }, - Trend: { - type: ["object", "null"], - description: - "Tendance réseau sur 12 mois, par régression linéaire — la même que la colonne " - + "« Tendance » de l'application. `null` si aucune série mensuelle n'est disponible.", - properties: { - direction: { type: "string", enum: ["up", "down", "flat"], description: "Seuil à ±8 % de variation modélisée." }, - pct: { type: ["number", "null"], description: "Variation modélisée sur la période (0.23 = +23 %)." }, - label: { type: "string", description: "Libellé prêt à afficher : « Forte hausse », « Stable »…" }, - monthsUsed: { type: "integer", description: "Nombre de mois réellement utilisés (12 au maximum)." }, - }, - }, - }, - }, - paths: { - "/fournisseurs": { - get: { - operationId: "listerFournisseurs", - summary: "Lister les fournisseurs", - description: - "Renvoie les fournisseurs et indique, via `hasSnapshot`, lesquels ont des données " - + "immédiatement disponibles. À appeler en premier quand le code fournisseur est inconnu.", - parameters: [ - { name: "search", in: "query", schema: { type: "string" }, description: "Filtre sur le nom ou le code." }, - { name: "withData", in: "query", schema: { type: "string", enum: ["0", "1"] }, description: "1 = uniquement ceux dont les données sont disponibles." }, - ], - responses: { - "200": { - description: "Liste des fournisseurs", - content: { - "application/json": { - schema: { - type: "object", - properties: { - data: { type: "array", items: { $ref: "#/components/schemas/Fournisseur" } }, - meta: { type: "object" }, - }, - }, - }, - }, - }, - "401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - }, - }, - }, - "/nomenclatures": { - get: { - operationId: "listerNomenclaturesFournisseur", - summary: "Postes de nomenclature d'un fournisseur", - description: - "Répartition des articles d'un fournisseur par poste de nomenclature, avec le nombre " - + "d'articles et le CA de chacun. **À utiliser avant /grid sur un gros fournisseur** : " - + "certains dépassent 130 000 articles. On liste les postes (quelques dizaines de lignes), " - + "puis on interroge /grid poste par poste avec le filtre indiqué par `meta.filtreGrid` " - + "(code1, code2 ou code3).", - parameters: [ - { name: "fournisseur", in: "query", required: true, schema: { type: "string" } }, - { name: "niveau", in: "query", schema: { type: "integer", enum: [1, 2, 3], default: 1 }, description: "1 = univers, 2 = famille, 3 = sous-famille." }, - { name: "parent", in: "query", schema: { type: "string" }, description: "Préfixe du poste parent, ex. « 32 » pour lister ses familles." }, - ], - responses: { - "200": { - description: "Postes de nomenclature", - content: { - "application/json": { - schema: { - type: "object", - properties: { - data: { - type: "array", - items: { - type: "object", - properties: { - code: { type: "string", description: "À passer au filtre code1/code2/code3 de /grid." }, - libelle: { type: ["string", "null"] }, - nbArticles: { type: "integer" }, - totalCa: { type: "number" }, - totalQuantite: { type: "number" }, - }, - }, - }, - meta: { type: "object" }, - }, - }, - }, - }, - }, - "202": { description: "Fournisseur pas encore calculé", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - "400": { description: "Paramètres invalides", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - }, - }, - }, - "/grid": { - get: { - operationId: "listerProduitsFournisseur", - summary: "Lister les produits d'un fournisseur", - description: - "Lignes de grille d'un fournisseur : ventes 12 mois, stock, marges, gammes et métriques " - + "réseau. Fonctionne pour **n'importe quel** fournisseur : si ses données n'ont jamais été " - + "calculées, l'API les calcule à la demande (premier appel de quelques secondes, " - + "`meta.computedOnDemand = true`). Un `404` signifie que le code fournisseur n'existe pas " - + "ou n'a aucun article — le signaler plutôt que de conclure à une panne.", - parameters: [ - { name: "fournisseur", in: "query", required: true, schema: { type: "string" }, description: "Code fournisseur (voir listerFournisseurs)." }, - { name: "gamme", in: "query", schema: { type: "string" }, description: `Filtre sur la gamme (${codesGamme.join(", ")}).` }, - { name: "code1", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 1." }, - { name: "code2", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 2." }, - { name: "code3", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 3 (code exact)." }, - { name: "nomenclature", in: "query", schema: { type: "string" }, description: "Préfixe de nomenclature renvoyé par listerNomenclaturesFournisseur : 32, 3202 ou 320211. À préférer à code1/code2, qui peuvent être vides." }, - { name: "search", in: "query", schema: { type: "string" }, description: "Filtre texte : libellé, codein, GTIN, référence ou code centrale." }, - { - name: "compute", - in: "query", - schema: { type: "string", enum: ["0", "1"], default: "1" }, - description: - "1 (défaut) : calcule les données du fournisseur si elles n'existent pas encore. " - + "0 : refuse le calcul et répond immédiatement 202 not_ready.", - }, - ...sortParams, - ...paginationParams, - ], - responses: { - "200": { - description: "Produits du fournisseur", - content: { - "application/json": { - schema: { - type: "object", - properties: { - data: { type: "array", items: { $ref: "#/components/schemas/Product" } }, - pagination: { $ref: "#/components/schemas/Pagination" }, - meta: { type: "object" }, - }, - }, - }, - }, - }, - "202": { description: "Données pas encore calculées (uniquement avec compute=0)", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - "400": { description: "Paramètres invalides", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - "401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - "404": { description: "Fournisseur inconnu ou sans article", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - }, - }, - }, - "/products/search": { - get: { - operationId: "rechercherProduits", - summary: "Rechercher des produits, tous fournisseurs confondus", - description: - "Recherche sur le libellé, le codein, le GTIN, la référence et le code centrale, sans " - + "avoir à connaître le fournisseur. À utiliser dès qu'un produit est désigné par son nom.", - parameters: [ - { name: "q", in: "query", required: true, schema: { type: "string", maxLength: 120 }, description: "Termes recherchés." }, - { name: "fournisseur", in: "query", schema: { type: "string" }, description: "Restreint à un fournisseur." }, - { name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme." }, - ...sortParams, - ...paginationParams, - ], - responses: { - "200": { - description: "Produits correspondants", - content: { - "application/json": { - schema: { - type: "object", - properties: { - data: { type: "array", items: { $ref: "#/components/schemas/Product" } }, - pagination: { $ref: "#/components/schemas/Pagination" }, - meta: { type: "object" }, - }, - }, - }, - }, - }, - "401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - }, - }, - }, - "/products/{codein}": { - get: { - operationId: "obtenirProduit", - summary: "Fiche complète d'un produit", - description: "Toutes les données d'un article : séries mensuelles, ventilation par magasin, stock, marges, gammes et métriques réseau.", - parameters: [ - { name: "codein", in: "path", required: true, schema: { type: "string" }, description: "Identifiant interne de l'article." }, - { name: "fournisseur", in: "query", schema: { type: "string" }, description: "Lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs." }, - ], - responses: { - "200": { - description: "Fiche produit", - content: { - "application/json": { - schema: { - type: "object", - properties: { data: { $ref: "#/components/schemas/Product" }, meta: { type: "object" } }, - }, - }, - }, - }, - "404": { description: "Produit absent de l'instantané", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - "401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - }, - }, - }, - "/products/{codein}/gamme": { - put: { - operationId: "changerGammeProduit", - summary: "Proposer la gamme d'un produit (à valider)", - description: - "Propose une gamme pour un article. Elle reste à valider dans la Grille, où elle apparaît " - + "comme une modification non enregistrée. Valeurs : " + legendeGammes + ". " - + "`fournisseur` n'est requis que si l'article est référencé chez plusieurs fournisseurs " - + "(réponse 400 listant les fournisseurs) ou s'il n'a encore jamais été calculé. " - + "Pour plusieurs articles d'un même fournisseur, préférer changerGammes.", - parameters: [ - { name: "codein", in: "path", required: true, schema: { type: "string" }, description: "Identifiant interne de l'article." }, - ], - requestBody: { - required: true, - content: { - "application/json": { - schema: { - type: "object", - required: ["gamme"], - properties: { - gamme: { type: "string", enum: codesGamme }, - fournisseur: { type: "string", description: "Code fournisseur de l'article." }, - compute: { type: "boolean", default: true, description: "Calcule le fournisseur à la demande s'il n'a jamais été calculé." }, - }, - }, - }, - }, - }, - responses: { - "200": { - description: "Gamme proposée, à valider", - content: { - "application/json": { - schema: { - type: "object", - properties: { - data: { - allOf: [ - { $ref: "#/components/schemas/ResultatGamme" }, - { type: "object", properties: { codeFournisseur: { type: "string" }, nomFournisseur: { type: ["string", "null"] } } }, - ], - }, - meta: { type: "object" }, - }, - }, - }, - }, - }, - "400": erreur("Gamme invalide, ou article chez plusieurs fournisseurs sans « fournisseur »"), - "401": erreur("Clé d'API absente, invalide ou révoquée"), - "404": erreur("Article introuvable chez ce fournisseur"), - }, - }, - }, - "/gammes": { - post: { - operationId: "changerGammes", - summary: "Proposer la gamme de plusieurs produits (à valider)", - description: - "Propose en un appel les gammes de plusieurs articles d'un même fournisseur, à valider " - + "dans la Grille. Tout ou rien : si un article est inconnu chez le fournisseur, rien n'est proposé " - + "et la réponse 404 liste les articles en cause dans `error.details.inconnus`. " - + "Valeurs : " + legendeGammes + ".", - requestBody: { - required: true, - content: { - "application/json": { - schema: { - type: "object", - required: ["fournisseur", "changes"], - properties: { - fournisseur: { type: "string", description: "Code fournisseur." }, - changes: { - type: "array", - minItems: 1, - maxItems: 10000, - items: { - type: "object", - required: ["codein", "gamme"], - properties: { - codein: { type: "string" }, - gamme: { type: "string", enum: codesGamme }, - }, - }, - }, - compute: { type: "boolean", default: true, description: "Calcule le fournisseur à la demande s'il n'a jamais été calculé." }, - }, - }, - }, - }, - }, - responses: { - "200": { - description: "Gammes proposées, à valider", - content: { - "application/json": { - schema: { - type: "object", - properties: { - data: { type: "array", items: { $ref: "#/components/schemas/ResultatGamme" } }, - meta: { type: "object" }, - }, - }, - }, - }, - }, - "400": erreur("Corps de requête invalide"), - "401": erreur("Clé d'API absente, invalide ou révoquée"), - "404": erreur("Un ou plusieurs articles introuvables chez ce fournisseur"), - }, - }, - }, - "/network/{codeCentrale}": { - get: { - operationId: "obtenirMetriquesReseau", - summary: "Métriques du réseau Qlik pour un produit", - description: "Performances du produit sur l'ensemble du réseau Foir'Fouille (~270 magasins) et courbe des quantités sur 12 mois glissants.", - parameters: [ - { name: "codeCentrale", in: "path", required: true, schema: { type: "string" }, description: "Code centrale du produit (format 10000XXXXXX)." }, - ], - responses: { - "200": { - description: "Métriques réseau", - content: { - "application/json": { - schema: { - type: "object", - properties: { data: { $ref: "#/components/schemas/NetworkMetrics" }, meta: { type: "object" } }, - }, - }, - }, - }, - "404": { description: "Aucune donnée réseau pour ce code centrale", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - "401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - }, - }, - }, - }, - }; - - // CORS ouvert en lecture : le schéma est public et peut être chargé par un outil tiers. - return Response.json(spec, { headers: { "Access-Control-Allow-Origin": "*" } }); -} diff --git a/src/app/api/v1/products/[codein]/gamme/route.ts b/src/app/api/v1/products/[codein]/gamme/route.ts deleted file mode 100644 index 926ffc3..0000000 --- a/src/app/api/v1/products/[codein]/gamme/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { NextRequest } from "next/server"; -import { requireApiAuth } from "@/lib/api-auth"; -import { ok, fail } from "@/lib/api-response"; -import { productGammeBodySchema } from "@/lib/api-schemas"; -import { listGridSuppliersForCodein } from "@/lib/grid-store"; -import { affecterGammes, lireCorpsJson } from "@/lib/api-gammes"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; -// Idem /api/v1/grid : le calcul à la demande peut prendre plusieurs secondes. -export const maxDuration = 300; - -/** - * PUT /api/v1/products/:codein/gamme { "gamme": "A", "fournisseur"?: "…" } - * - * Propose une gamme pour un article. La proposition reste **à valider** : elle - * apparaît dans la Grille comme une modification non enregistrée, et `codeGamme` - * ne change qu'une fois validée (« Enregistrer » dans la Grille). - * - * `fournisseur` n'est requis que si l'article est référencé chez plusieurs - * fournisseurs, ou s'il n'apparaît pas encore dans l'instantané. - */ -export async function PUT( - req: NextRequest, - ctx: { params: Promise<{ codein: string }> }, -) { - const authCtx = await requireApiAuth(req); - if (authCtx instanceof Response) return authCtx; - - const { codein } = await ctx.params; - if (!codein) return fail("bad_request", "Paramètre 'codein' requis."); - - const corps = await lireCorpsJson(req); - if (corps instanceof Response) return corps; - const parsed = productGammeBodySchema.safeParse(corps); - if (!parsed.success) { - return fail("bad_request", "Corps de requête invalide.", parsed.error.issues); - } - const { gamme, compute } = parsed.data; - - let fournisseur = parsed.data.fournisseur; - if (!fournisseur) { - const fournisseurs = await listGridSuppliersForCodein(codein); - if (fournisseurs.length === 0) { - return fail( - "not_found", - `Aucun instantané pour le produit « ${codein} ». Ajoutez « fournisseur » au corps de la requête pour que l'API calcule le lot à la demande.`, - ); - } - if (fournisseurs.length > 1) { - return fail( - "bad_request", - `Le produit « ${codein} » est référencé chez plusieurs fournisseurs : précisez « fournisseur ».`, - { fournisseurs }, - ); - } - fournisseur = fournisseurs[0].codeFournisseur; - } - - const res = await affecterGammes({ - codeFournisseur: fournisseur, - changes: [{ codein, gamme }], - compute, - auteur: authCtx.subject, - }); - if (res instanceof Response) return res; - - return ok( - { ...res.resultats[0], codeFournisseur: res.codeFournisseur, nomFournisseur: res.nomFournisseur }, - { meta: { enregistreLe: new Date().toISOString() } }, - ); -} diff --git a/src/app/api/v1/products/[codein]/route.ts b/src/app/api/v1/products/[codein]/route.ts deleted file mode 100644 index d52cb5f..0000000 --- a/src/app/api/v1/products/[codein]/route.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { NextRequest } from "next/server"; -import { requireApiAuth } from "@/lib/api-auth"; -import { ok, fail } from "@/lib/api-response"; -import { productDetailSchema } from "@/lib/api-schemas"; -import { getGridRowByCodein } from "@/lib/grid-store"; -import { enrichRows } from "@/lib/api-enrich"; -import { getProductRows } from "@/features/grid/api/get-product-rows"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; -// Idem /api/v1/grid : le calcul à la demande peut prendre plusieurs secondes. -export const maxDuration = 300; - -/** - * GET /api/v1/products/:codein?fournisseur=… - * - * Fiche complète d'un produit : le `ProductRow` intégral (séries mensuelles, - * ventilations par magasin, stock, marges, gammes) enrichi des métriques réseau Qlik - * lues **en cache** (`qlik_network_metrics`) — aucun appel à Qlik. - * - * `fournisseur` lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs ; - * sans lui, l'instantané le plus récent est renvoyé. - */ -export async function GET( - req: NextRequest, - ctx: { params: Promise<{ codein: string }> }, -) { - const authCtx = await requireApiAuth(req); - if (authCtx instanceof Response) return authCtx; - - const { codein } = await ctx.params; - if (!codein) return fail("bad_request", "Paramètre 'codein' requis."); - - const parsed = productDetailSchema.safeParse(Object.fromEntries(req.nextUrl.searchParams)); - if (!parsed.success) { - return fail("bad_request", "Paramètres invalides.", parsed.error.issues); - } - - let found = await getGridRowByCodein(codein, parsed.data.fournisseur); - - // Absent de l'instantané : on peut le calculer, mais seulement si l'appelant a - // précisé le fournisseur — le calcul se fait par lot fournisseur, pas par article. - if (!found && parsed.data.compute === "1" && parsed.data.fournisseur) { - console.log(`[api/v1/products] ${codein} absent — calcul du lot ${parsed.data.fournisseur} à la demande`); - try { - await getProductRows({ codeFournisseur: parsed.data.fournisseur, magasin: "TOTAL" }); - found = await getGridRowByCodein(codein, parsed.data.fournisseur); - } catch (e) { - console.error(`[api/v1/products] calcul KO pour ${parsed.data.fournisseur}:`, e instanceof Error ? e.message : String(e)); - } - } - - if (!found) { - return fail( - "not_found", - parsed.data.fournisseur - ? `Produit « ${codein} » introuvable chez le fournisseur « ${parsed.data.fournisseur} ».` - : `Aucun instantané pour le produit « ${codein} ». Ajoutez « fournisseur=… » pour que l'API calcule le lot à la demande.`, - ); - } - - // Métriques réseau + gamme serveur relues maintenant : le payload date du dernier - // calcul de la grille, alors que ces deux données évoluent indépendamment. - const [enriched] = await enrichRows([found.row]); - - return ok(enriched, { - meta: { - computedAt: found.computedAt, - networkFetchedAt: enriched.network?.fetchedAt ?? null, - hasNetwork: enriched.network !== null, - }, - }); -} diff --git a/src/app/api/v1/products/search/route.ts b/src/app/api/v1/products/search/route.ts deleted file mode 100644 index 352a5f8..0000000 --- a/src/app/api/v1/products/search/route.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { NextRequest } from "next/server"; -import { requireApiAuth } from "@/lib/api-auth"; -import { ok, fail, buildPagination } from "@/lib/api-response"; -import { productSearchSchema, pickFields, toSortKey } from "@/lib/api-schemas"; -import { queryGridRows } from "@/lib/grid-store"; -import { enrichRows } from "@/lib/api-enrich"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * GET /api/v1/products/search?q=…&fournisseur=&gamme=&sort=&order=&page=&limit=&fields= - * - * **Recherche transversale** sur libellé, codein, GTIN, référence et code centrale — - * tous fournisseurs confondus. C'est ce qu'aucune route existante ne sait faire : - * `getProductRows()` exige un fournisseur, donc chercher un produit sans le connaître - * était impossible. - * - * Lit uniquement l'instantané `grid_rows` : un fournisseur jamais ouvert dans la - * Grille n'apparaît pas encore dans les résultats. - */ -export async function GET(req: NextRequest) { - const authCtx = await requireApiAuth(req); - if (authCtx instanceof Response) return authCtx; - - const parsed = productSearchSchema.safeParse(Object.fromEntries(req.nextUrl.searchParams)); - if (!parsed.success) { - return fail("bad_request", "Paramètres invalides.", parsed.error.issues); - } - const q = parsed.data; - - const result = await queryGridRows({ - codeFournisseur: q.fournisseur, - gamme: q.gamme, - search: q.q, - sort: toSortKey(q.sort), - order: q.order, - page: q.page, - limit: q.limit, - }); - - // Métriques Qlik et gamme serveur relues au moment de l'appel (voir api-enrich). - const rows = q.enrich === "1" ? await enrichRows(result.rows) : result.rows; - - return ok( - rows.map((r) => pickFields(r, q.fields)), - { - pagination: buildPagination(q.page, q.limit, result.total), - meta: { - query: q.q, - computedAt: result.computedAt, - scope: q.fournisseur ? `fournisseur:${q.fournisseur}` : "tous fournisseurs", - enrichi: q.enrich === "1", - }, - }, - ); -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx deleted file mode 100644 index adec33a..0000000 --- a/src/app/layout.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { Metadata } from "next"; -import "./globals.css"; -import { Providers } from "./providers"; - -export const metadata: Metadata = { - title: { default: "CollectFlow", template: "%s · CollectFlow" }, - description: "Révision d'assortiment et suivi des ventes des magasins", -}; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - {/* Inter chargé côté navigateur (et non au build) pour éviter toute - dépendance réseau à Google Fonts pendant `next build` (Docker). */} - - - {/* eslint-disable-next-line @next/next/no-page-custom-font */} - - - - - {children} - - - - ); -} diff --git a/src/app/page.tsx b/src/app/page.tsx index 9ef1235..89ea8fd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,5 @@ -import { redirect } from "next/navigation"; +import { Navigate } from "react-router"; export default function HomePage() { - redirect("/dashboard"); + return ; } diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 74e9a73..2959cfc 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,21 +1,24 @@ -"use client"; - import { SessionProvider } from "next-auth/react"; import { ThemeProvider } from "@/components/shared/theme-provider"; import { TooltipProvider } from "@/components/ui/tooltip"; import { FeedbackHost } from "@/components/ui/feedback"; +import { RefreshProvider } from "@/lib/page-data"; export function Providers({ children }: { children: React.ReactNode }) { return ( - // La session n'est pas relue à chaque retour sur l'onglet : elle repartait - // sur le réseau et redessinait toute la page (Grille comprise) à chaque fois. - - - - {children} - - - - + // Compteur de rafraîchissement (`router.refresh()`) : relu par les données + // des pages et par la session. + + {/* La session n'est pas relue à chaque retour sur l'onglet : elle repartait + sur le réseau et redessinait toute la page (Grille comprise) à chaque fois. */} + + + + {children} + + + + + ); } diff --git a/src/components/shared/admin-only.tsx b/src/components/shared/admin-only.tsx new file mode 100644 index 0000000..463a0a1 --- /dev/null +++ b/src/components/shared/admin-only.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from "react"; +import { Navigate } from "react-router"; +import { useSessionResolue } from "next-auth/react"; + +/** + * Pages réservées aux administrateurs : le serveur filtre déjà l'accès direct + * (rôle relu en base) ; ici, la même règle pour une navigation dans + * l'application — retour à l'accueil sinon. + */ +export function AdminOnly({ children }: { children: ReactNode }) { + const session = useSessionResolue(); + if (session?.user.role !== "admin") return ; + return <>{children}; +} diff --git a/src/components/shared/header.tsx b/src/components/shared/header.tsx index 3a9bc0d..c591376 100644 --- a/src/components/shared/header.tsx +++ b/src/components/shared/header.tsx @@ -1,5 +1,3 @@ -"use client"; - import { ChevronDown, LogOut, Moon, Rows3, Sun, User as UserIcon } from "lucide-react"; import { usePathname } from "next/navigation"; import { useTheme } from "next-themes"; diff --git a/src/components/shared/route-error-boundary.tsx b/src/components/shared/route-error-boundary.tsx new file mode 100644 index 0000000..4810cb5 --- /dev/null +++ b/src/components/shared/route-error-boundary.tsx @@ -0,0 +1,42 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { refreshPage } from "@/lib/page-data"; + +interface Props { + /** Rendu à la place de la page en erreur ; `reset` relance son chargement. */ + fallback: (error: Error, reset: () => void) => ReactNode; + children: ReactNode; +} + +interface State { + error: Error | null; +} + +/** + * Frontière d'erreur d'une page (équivalent de `error.tsx`) : le menu et + * l'en-tête restent utilisables. Elle est remontée à chaque changement de page + * (clé posée par le routeur), comme celle de Next.js. + */ +export class RouteErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: unknown): State { + return { error: error instanceof Error ? error : new Error(String(error)) }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + void error; + void info; + } + + private reset = () => { + // Nouvelle clé de données (sans transition : la silhouette s'affiche), + // puis nouvel essai de rendu de la page. + refreshPage({ transition: false }); + this.setState({ error: null }); + }; + + render(): ReactNode { + if (this.state.error) return this.props.fallback(this.state.error, this.reset); + return this.props.children; + } +} diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index c355d18..0e068db 100644 --- a/src/components/shared/sidebar.tsx +++ b/src/components/shared/sidebar.tsx @@ -1,5 +1,3 @@ -"use client"; - import { ChevronLeft, ChevronRight, Package } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; diff --git a/src/components/shared/theme-provider.tsx b/src/components/shared/theme-provider.tsx index b7556ec..4d51e2f 100644 --- a/src/components/shared/theme-provider.tsx +++ b/src/components/shared/theme-provider.tsx @@ -1,5 +1,3 @@ -"use client"; - import { ThemeProvider as NextThemesProvider } from "next-themes"; export function ThemeProvider({ children }: { children: React.ReactNode }) { diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index b12a722..9aea8bb 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * Tableau de données commun : recherche, filtres, tri par colonne, pagination, * export, état vide et ligne de totaux — identiques sur toutes les pages. diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index bf9459c..42c9ea8 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -1,5 +1,3 @@ -"use client" - import * as React from "react" import { XIcon } from "lucide-react" import { Dialog as DialogPrimitive } from "radix-ui" diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 1c9880c..ecc78c4 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -1,5 +1,3 @@ -"use client" - import * as React from "react" import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" diff --git a/src/components/ui/feedback.tsx b/src/components/ui/feedback.tsx index 43214f0..b20b49d 100644 --- a/src/components/ui/feedback.tsx +++ b/src/components/ui/feedback.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * Retours à l'utilisateur, communs à toute l'application : * - `toast.succes / toast.erreur / toast.info` : message discret en bas à droite, diff --git a/src/components/ui/form-controls.tsx b/src/components/ui/form-controls.tsx index 8e9b6f8..eddc738 100644 --- a/src/components/ui/form-controls.tsx +++ b/src/components/ui/form-controls.tsx @@ -1,5 +1,3 @@ -"use client"; - import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react"; import { Search, X } from "lucide-react"; import { cn } from "@/lib/utils"; diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx index 9ed0b15..ea471a7 100644 --- a/src/components/ui/pagination.tsx +++ b/src/components/ui/pagination.tsx @@ -1,5 +1,3 @@ -"use client"; - import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; interface PaginationProps { diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index a74d3b3..efdcac2 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useState, type ReactNode } from "react"; import type { LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index a0f0a77..c2e5395 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -1,5 +1,3 @@ -"use client"; - import type { ReactNode } from "react"; import { Tooltip as TooltipPrimitive } from "radix-ui"; import { Info } from "lucide-react"; diff --git a/src/db/index.ts b/src/db/index.ts deleted file mode 100644 index a72a93a..0000000 --- a/src/db/index.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { drizzle, NodePgDatabase } from "drizzle-orm/node-postgres"; -import { Pool } from "pg"; -import * as schema from "./schema"; -import fs from "fs"; -import path from "path"; - -/** - * Pool unique par processus, rangé dans `globalThis` : l'instrumentation (synchro - * nocturne) et les routes peuvent être compilées dans des lots distincts, chacun - * avec sa propre copie de ce module — et donc son propre pool de 20 connexions. - * Même chose à chaque rechargement à chaud en développement. - */ -type DbState = { pool: Pool | null; db: NodePgDatabase | null }; -const globalForDb = globalThis as typeof globalThis & { __collectflowDb?: DbState }; -const state: DbState = (globalForDb.__collectflowDb ??= { pool: null, db: null }); - -export function getPool(): Pool { - getDb(); // ensure pool is initialized - return state.pool!; -} - -function maskUrl(url: string | undefined) { - if (!url) return "undefined"; - return url.replace(/:([^@]+)@/, ":****@"); -} - -/** - * Initializes or returns the current database instance. - */ -export function getDb() { - if (state.db) return state.db; - - let connectionString = ""; - - try { - const CONFIG_PATH = path.join(process.cwd(), "data", ".db-config.json"); - if (fs.existsSync(CONFIG_PATH)) { - console.log("[DB] Found config file at:", CONFIG_PATH); - const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); - if (config.url) { - connectionString = config.url; - console.log("[DB] Loaded saved database URL from config."); - } - } - } catch (err) { - console.error("[DB] Error reading config file:", err); - } - - // Use environment variable as fallback if no config is found - if (!connectionString && process.env.DATABASE_URL) { - connectionString = process.env.DATABASE_URL; - console.log("[DB] Using environment variable as fallback."); - } - - console.log("[DB] Initializing connection with:", maskUrl(connectionString)); - - if (!connectionString) { - console.error("[DB] CRITICAL: No database connection string available!"); - } - - const connectionHostname = connectionString.match(/@([^:/]+)/)?.[1] || "unknown"; - console.log(`[DB] Target Hostname: ${connectionHostname}`); - - // Délai maximal par requête : opt-in (les calculs nocturnes des plus gros - // fournisseurs peuvent être longs), pour qu'une requête emballée ne monopolise - // pas indéfiniment une des 20 connexions. - const statementTimeout = Number(process.env.PG_STATEMENT_TIMEOUT_MS) || undefined; - - const pool = new Pool({ - connectionString, - max: 20, - idleTimeoutMillis: 30000, - connectionTimeoutMillis: 10000, - application_name: "collectflow", - ...(statementTimeout ? { statement_timeout: statementTimeout } : {}), - }); - - pool.on('error', (err) => { - console.error('[DB] Unexpected error on idle client', err); - }); - - // Pas de workers parallèles PostgreSQL : ils consomment /dev/shm, trop petit - // dans le conteneur, et font échouer les grosses agrégations. Réglé une fois - // par connexion (la requête est mise en file avant toute autre sur ce client) - // au lieu d'une transaction BEGIN / SET LOCAL / COMMIT autour de chaque requête. - pool.on('connect', (client) => { - client.query("SET max_parallel_workers_per_gather = 0").catch((err) => { - console.error('[DB] SET max_parallel_workers_per_gather failed:', err?.message); - }); - }); - - state.pool = pool; - state.db = drizzle(pool, { schema }); - return state.db; -} - -/** - * Resets the current database connection. - * Called when settings are updated via the UI. - */ -export function refreshDb() { - console.log("[DB] Refreshing database connection..."); - if (state.pool) { - state.pool.end().catch(err => console.error("[DB] Error closing pool:", err)); - state.pool = null; - } - state.db = null; -} - -/** - * Proxy for the 'db' instance. - * Allows existing imports (import { db } from "@/db") to work - * while the underlying instance can be hot-swapped. - */ -export const db = new Proxy({} as NodePgDatabase, { - get(target, prop, receiver) { - const d = getDb(); - return Reflect.get(d, prop, receiver); - } -}); diff --git a/src/db/schema.ts b/src/db/schema.ts deleted file mode 100644 index a480879..0000000 --- a/src/db/schema.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer, boolean, primaryKey } from "drizzle-orm/pg-core"; - -export const ventesProduits = pgTable("ventes_produits", { - id: serial("id").primaryKey(), - - // Identification produit - codein: varchar("codein", { length: 20 }).notNull(), - codeFournisseur: varchar("code_fournisseur", { length: 20 }), - nomFournisseur: varchar("nom_fournisseur", { length: 255 }), - libelle1: varchar("libelle1", { length: 500 }), - gtin: varchar("gtin", { length: 30 }), - reference: varchar("reference", { length: 100 }), - colisage: numeric("colisage", { precision: 10, scale: 5 }), - - // Gamme & Nomenclature - codeGamme: varchar("code_gamme", { length: 20 }), - codeGammeInit: varchar("code_gamme_init", { length: 20 }), - code3: varchar("code3", { length: 20 }), - libelle3: varchar("libelle3", { length: 500 }), - - // Dimension magasin & période - magasin: varchar("magasin", { length: 20 }).notNull(), - codeMagasin: varchar("code_magasin", { length: 10 }), - annee: smallint("annee"), - mois: smallint("mois"), - periode: varchar("periode", { length: 10 }).notNull(), - - // Métriques - quantite: numeric("quantite", { precision: 12, scale: 2 }), - montantMvt: numeric("montant_mvt", { precision: 14, scale: 4 }), - margeMvt: numeric("marge_mvt", { precision: 14, scale: 4 }), - - // Métadonnées - importedAt: timestamp("imported_at").defaultNow(), - updatedAt: timestamp("updated_at").defaultNow(), -}, (table) => { - return [ - uniqueIndex("uq_ventes_produit_magasin_periode").on(table.codein, table.magasin, table.periode), - index("idx_ventes_codein").on(table.codein), - index("idx_ventes_fournisseur").on(table.codeFournisseur), - index("idx_ventes_gamme").on(table.codeGamme), - index("idx_ventes_code3").on(table.code3), - index("idx_ventes_magasin").on(table.magasin), - index("idx_ventes_periode").on(table.periode), - index("idx_ventes_annee_mois").on(table.annee, table.mois), - index("idx_ventes_updated_at").on(table.updatedAt), - ]; -}); - -/** User management (Epic 6) */ -export const users = pgTable("users", { - id: serial("id").primaryKey(), - username: varchar("username", { length: 50 }).notNull().unique(), - /** Hashed password */ - passwordHash: text("password_hash").notNull(), - /** 'admin' or 'user' */ - role: varchar("role", { length: 20 }).default("user").notNull(), - createdAt: timestamp("created_at").defaultNow(), -}); - -/** Snapshot of a complete arbitrage session (Epic 5) */ -export const sessionSnapshots = pgTable("session_snapshots", { - id: serial("id").primaryKey(), - /** Link to user who created the snapshot */ - userId: integer("user_id").references(() => users.id), - codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(), - nomFournisseur: varchar("nom_fournisseur", { length: 255 }), - magasin: varchar("magasin", { length: 20 }).notNull(), - /** JSON map of codein → { before: GammeCode, after: GammeCode } */ - changes: jsonb("changes").notNull(), - /** Summary stats at time of snapshot */ - summaryJson: jsonb("summary_json"), - /** Label for the snapshot */ - label: text("label"), - /** snapshot vs export */ - type: varchar("type", { length: 20 }).default("snapshot"), - createdAt: timestamp("created_at").defaultNow(), -}, (table) => [ - // Créés par scripts/db-init.js (la migration réellement jouée au démarrage). - index("idx_snapshots_fournisseur_date").on(table.codeFournisseur, table.createdAt.desc()), - index("idx_snapshots_user_type_date").on(table.userId, table.type, table.createdAt.desc()), -]); - -/** - * Cadencier / alertes de commande par fournisseur ET par magasin. - * Un fournisseur peut être cadencé indépendamment sur chaque site (292/579) - * avec un intervalle propre (toutes les X semaines). L'échéance est calculée - * à partir de la dernière commande réelle (FF Nancy) + intervalle. - */ -export const commandeCadences = pgTable("commande_cadences", { - id: serial("id").primaryKey(), - /** Code fournisseur (fouident.code) */ - codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(), - /** Nom fournisseur dénormalisé pour l'affichage */ - nomFournisseur: varchar("nom_fournisseur", { length: 255 }), - /** Site / magasin : "292" | "579" */ - site: varchar("site", { length: 20 }).notNull(), - /** Fréquence de commande, en semaines */ - intervalleSemaines: integer("intervalle_semaines").notNull(), - /** Cadence active ou non */ - actif: boolean("actif").default(true).notNull(), - createdAt: timestamp("created_at").defaultNow(), - updatedAt: timestamp("updated_at").defaultNow(), -}, (table) => { - return [ - uniqueIndex("uq_cadence_fou_site").on(table.codeFournisseur, table.site), - index("idx_cadence_site").on(table.site), - ]; -}); - -/** - * Cache des métriques réseau Qlik Sense (~270 magasins La Foir'Fouille). - * Clé = code centrale (format 10000XXXXXX). Rafraîchi par /api/qlik/sync. - */ -export const qlikNetworkMetrics = pgTable("qlik_network_metrics", { - /** Code centrale article (clé jointure Qlik ↔ FF) */ - codeCentrale: varchar("code_centrale", { length: 20 }).primaryKey(), - /** CA réseau total du produit */ - caReseau: numeric("ca_reseau", { precision: 16, scale: 2 }), - /** Quantité vendue réseau */ - qteReseau: numeric("qte_reseau", { precision: 14, scale: 2 }), - /** Nombre de magasins travaillant le produit (sur ~270) */ - nbMagasinsReseau: integer("nb_magasins_reseau"), - /** CA moyen par magasin (productivité normalisée présence) */ - caParMagasinReseau: numeric("ca_par_magasin_reseau", { precision: 16, scale: 2 }), - /** Taux de marge réseau (ratio brut Qlik) */ - margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }), - /** Période couverte (libre, ex "12m" ou "2025") */ - periode: varchar("periode", { length: 20 }), - /** Quantité vendue réseau par mois : { "YYYY-MM": qté } (12 derniers mois). */ - qteByMonth: jsonb("qte_by_month"), - /** - * Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }. - * Superset de `qteByMonth`, qui reste maintenu tel quel pour la colonne - * « Tendance / Réseau » de la Grille. - */ - metricsByMonth: jsonb("metrics_by_month"), - /** - * Libellé de l'article tel que Qlik le renvoie. Renseigné par la recherche - * produit (qui interroge la dimension libellé) ; la sync fournisseur ne - * l'écrase pas. Permet d'afficher une fiche pour un produit réseau que le - * catalogue Nancy ne référence pas. - */ - libelleReseau: varchar("libelle_reseau", { length: 255 }), - /** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */ - fournisseurReseau: varchar("fournisseur_reseau", { length: 255 }), - /** Dernière synchro depuis Qlik */ - fetchedAt: timestamp("fetched_at").defaultNow(), -}); - -/** - * Instantané persisté des lignes de grille (une ligne par fournisseur × article). - * - * Raison d'être : `getProductRows()` reconstruit la grille en direct et ne la garde - * qu'en mémoire 10 minutes — perdu à chaque redémarrage. L'API `/api/v1` ne doit - * jamais déclencher ce calcul, elle lit donc cette table, remplie en effet de bord - * quand quelqu'un ouvre la Grille. Aucun calcul supplémentaire n'est introduit : on - * arrête simplement de jeter le résultat. - * - * Seul `magasin = TOTAL` est persisté : `payload` embarque déjà les ventilations par - * magasin (sales12mByStore, caByStore…). - */ -export const gridRows = pgTable("grid_rows", { - codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(), - codein: varchar("codein", { length: 20 }).notNull(), - - // Colonnes scalaires : servent aux filtres, au tri et à la recherche en SQL. - nomFournisseur: varchar("nom_fournisseur", { length: 255 }), - libelle1: varchar("libelle1", { length: 500 }), - gtin: varchar("gtin", { length: 30 }), - reference: varchar("reference", { length: 100 }), - codeCentrale: varchar("code_centrale", { length: 20 }), - code1: varchar("code1", { length: 20 }), - code2: varchar("code2", { length: 20 }), - code3: varchar("code3", { length: 20 }), - codeGamme: varchar("code_gamme", { length: 20 }), - codeGammeInit: varchar("code_gamme_init", { length: 20 }), - totalCa: numeric("total_ca", { precision: 16, scale: 2 }), - totalQuantite: numeric("total_quantite", { precision: 14, scale: 2 }), - totalMarge: numeric("total_marge", { precision: 16, scale: 2 }), - tauxMarge: numeric("taux_marge", { precision: 8, scale: 4 }), - stockActuel: numeric("stock_actuel", { precision: 14, scale: 2 }), - caReseau: numeric("ca_reseau", { precision: 16, scale: 2 }), - qteReseau: numeric("qte_reseau", { precision: 14, scale: 2 }), - nbMagasinsReseau: integer("nb_magasins_reseau"), - caParMagasinReseau: numeric("ca_par_magasin_reseau", { precision: 16, scale: 2 }), - margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }), - - /** ProductRow complet (séries mensuelles, ventilations par magasin, dates…). */ - payload: jsonb("payload").notNull(), - /** Date du calcul ayant produit cette ligne — expose la fraîcheur au consommateur. */ - computedAt: timestamp("computed_at").defaultNow().notNull(), -}, (table) => { - return [ - uniqueIndex("uq_grid_rows_fou_codein").on(table.codeFournisseur, table.codein), - index("idx_grid_rows_fournisseur").on(table.codeFournisseur), - index("idx_grid_rows_codein").on(table.codein), - index("idx_grid_rows_gamme").on(table.codeGamme), - index("idx_grid_rows_code_centrale").on(table.codeCentrale), - index("idx_grid_rows_total_ca").on(table.totalCa), - index("idx_grid_rows_libelle").on(table.libelle1), - ]; -}); - -/** - * Clés d'API pour les appelants non-navigateur (scripts, outils externes). - * - * Seul le hachage SHA-256 est stocké : la clé en clair n'est affichée qu'une fois, - * à la création. Une clé révoquée conserve sa ligne (traçabilité) via `revokedAt`. - */ -export const apiKeys = pgTable("api_keys", { - id: serial("id").primaryKey(), - /** Nom lisible choisi à la création (ex "Export Excel comptabilité"). */ - name: varchar("name", { length: 100 }).notNull(), - /** Préfixe en clair (ex "cf_a1b2c3") — sert à identifier la clé dans l'UI. */ - keyPrefix: varchar("key_prefix", { length: 20 }).notNull(), - /** SHA-256 hexadécimal de la clé complète. */ - keyHash: text("key_hash").notNull().unique(), - /** 'admin' ou 'user' — même sémantique que users.role. */ - role: varchar("role", { length: 20 }).default("user").notNull(), - createdBy: varchar("created_by", { length: 50 }), - createdAt: timestamp("created_at").defaultNow(), - lastUsedAt: timestamp("last_used_at"), - revokedAt: timestamp("revoked_at"), -}); - -/** - * Paramétrage et suivi de la synchronisation nocturne, un enregistrement par - * fournisseur. - * - * Deux sources aux coûts très différents, donc deux cadences distinctes : - * l'instantané SQL prend une minute au pire, l'extraction Qlik jusqu'à huit — - * et le serveur Qlik abandonne quand on le sollicite trop. D'où `actifSql` et - * `actifQlik` séparés, et deux dates de dernier passage. - * - * L'ordre de passage se déduit de `dernier*At` (le plus ancien d'abord) : la - * nuit suivante reprend donc naturellement là où la précédente s'est arrêtée, - * et repart d'elle-même pour un nouveau tour quand tout a été traité. Aucun - * curseur ni compteur de tour à maintenir. - */ -export const syncFournisseurs = pgTable("sync_fournisseurs", { - codeFournisseur: varchar("code_fournisseur", { length: 20 }).primaryKey(), - nomFournisseur: varchar("nom_fournisseur", { length: 255 }), - /** Inclus dans la rotation de l'instantané SQL. */ - actifSql: boolean("actif_sql").default(true).notNull(), - /** Inclus dans la rotation Qlik (beaucoup plus coûteuse). */ - actifQlik: boolean("actif_qlik").default(true).notNull(), - /** - * Renseigné quand le planificateur a désactivé le fournisseur de lui-même — - * typiquement « aucun article ». Évite de réessayer chaque nuit un - * fournisseur vide, tout en gardant la raison visible pour le réactiver. - */ - desactiveMotif: text("desactive_motif"), - - dernierSqlAt: timestamp("dernier_sql_at"), - /** 'succes' | 'echec' | 'vide' */ - dernierSqlStatut: varchar("dernier_sql_statut", { length: 20 }), - dernierSqlLignes: integer("dernier_sql_lignes"), - dernierSqlErreur: text("dernier_sql_erreur"), - dernierSqlMs: integer("dernier_sql_ms"), - - dernierQlikAt: timestamp("dernier_qlik_at"), - dernierQlikStatut: varchar("dernier_qlik_statut", { length: 20 }), - dernierQlikCodes: integer("dernier_qlik_codes"), - dernierQlikErreur: text("dernier_qlik_erreur"), - dernierQlikMs: integer("dernier_qlik_ms"), - - updatedAt: timestamp("updated_at").defaultNow(), -}, (table) => { - return [ - // Index de file d'attente : « le plus ancien d'abord », par source. - index("idx_sync_fou_sql").on(table.actifSql, table.dernierSqlAt), - index("idx_sync_fou_qlik").on(table.actifQlik, table.dernierQlikAt), - ]; -}); - -/** AI Context rules per supplier (Epic: AI Context) */ -export const aiSupplierContext = pgTable("ai_supplier_context", { - /** Supplier code serving as the primary key */ - codeFournisseur: varchar("code_fournisseur", { length: 20 }).primaryKey(), - /** The business rules text provided by the user */ - context: text("context").notNull(), - /** When it was last updated */ - updatedAt: timestamp("updated_at").defaultNow(), -}); - -/** - * Gammes proposées par l'API `/api/v1`, en attente de validation. - * - * L'API n'enregistre plus directement : chaque proposition attend ici qu'un - * utilisateur l'examine dans la Grille, où elle apparaît comme une modification - * non enregistrée. « Enregistrer » la valide (snapshot + `grid_rows`), « Annuler - * les modifications » la rejette ; dans les deux cas la ligne est supprimée. - * Une seule proposition par article et fournisseur : la dernière l'emporte. - */ -export const gammesAValider = pgTable("gammes_a_valider", { - codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(), - codein: varchar("codein", { length: 20 }).notNull(), - /** Gamme proposée. */ - gamme: varchar("gamme", { length: 20 }).notNull(), - /** Gamme enregistrée au moment de la proposition. */ - gammePrecedente: varchar("gamme_precedente", { length: 20 }), - /** Nom de la clé d'API à l'origine de la proposition. */ - auteur: varchar("auteur", { length: 100 }), - createdAt: timestamp("created_at").defaultNow().notNull(), -}, (table) => [ - primaryKey({ columns: [table.codeFournisseur, table.codein] }), -]); diff --git a/src/features/admin/api/api-key-actions.ts b/src/features/admin/api/api-key-actions.ts index 4277f26..5e068a4 100644 --- a/src/features/admin/api/api-key-actions.ts +++ b/src/features/admin/api/api-key-actions.ts @@ -1,19 +1,14 @@ -"use server"; - /** * CollectFlow — Gestion des clés de l'API `/api/v1` (administrateurs). * * La clé en clair n'existe qu'une fois : elle est renvoyée par `createApiKey()` et * jamais restituée ensuite (seul son hachage SHA-256 est stocké). Une clé révoquée * conserve sa ligne, pour garder la trace de son usage passé. + * + * Actions exécutées par le serveur Rust : `POST /api/actions/{nom}`. */ -import { db } from "@/db"; -import { apiKeys } from "@/db/schema"; -import { desc, eq } from "drizzle-orm"; -import { requireAdmin } from "@/lib/authz"; -import { generateApiKey } from "@/lib/api-auth"; -import { revalidatePath } from "next/cache"; +import { callAction } from "@/lib/rpc"; export interface ApiKeyRow { id: number; @@ -28,27 +23,7 @@ export interface ApiKeyRow { /** Liste les clés. Ne renvoie jamais de secret — seulement le préfixe lisible. */ export async function getApiKeys(): Promise { - await requireAdmin(); - const rows = await db - .select({ - id: apiKeys.id, - name: apiKeys.name, - keyPrefix: apiKeys.keyPrefix, - role: apiKeys.role, - createdBy: apiKeys.createdBy, - createdAt: apiKeys.createdAt, - lastUsedAt: apiKeys.lastUsedAt, - revokedAt: apiKeys.revokedAt, - }) - .from(apiKeys) - .orderBy(desc(apiKeys.createdAt)); - - return rows.map((r) => ({ - ...r, - createdAt: r.createdAt ? r.createdAt.toISOString() : null, - lastUsedAt: r.lastUsedAt ? r.lastUsedAt.toISOString() : null, - revokedAt: r.revokedAt ? r.revokedAt.toISOString() : null, - })); + return callAction("getApiKeys", []); } /** @@ -59,51 +34,15 @@ export async function createApiKey( name: string, role: "admin" | "user" = "user", ): Promise<{ success: true; key: string; keyPrefix: string } | { success: false; error: string }> { - const moi = await requireAdmin(); - - const trimmed = name.trim(); - if (!trimmed) return { success: false, error: "Le nom de la clé est obligatoire." }; - if (trimmed.length > 100) return { success: false, error: "Nom trop long (100 caractères max)." }; - - try { - const { key, keyHash, keyPrefix } = generateApiKey(); - await db.insert(apiKeys).values({ - name: trimmed, - keyPrefix, - keyHash, - role, - createdBy: moi.username || null, - }); - revalidatePath("/settings"); - return { success: true, key, keyPrefix }; - } catch (err) { - console.error("[api-keys] création KO:", err); - return { success: false, error: "Erreur technique lors de la création de la clé." }; - } + return callAction("createApiKey", [name, role]); } /** Révoque une clé : elle est refusée dès l'appel suivant, mais reste listée. */ export async function revokeApiKey(id: number): Promise<{ success: boolean; error?: string }> { - await requireAdmin(); - try { - await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id)); - revalidatePath("/settings"); - return { success: true }; - } catch (err) { - console.error("[api-keys] révocation KO:", err); - return { success: false, error: "Erreur lors de la révocation." }; - } + return callAction("revokeApiKey", [id]); } /** Supprime définitivement une clé révoquée (nettoyage de la liste). */ export async function deleteApiKey(id: number): Promise<{ success: boolean; error?: string }> { - await requireAdmin(); - try { - await db.delete(apiKeys).where(eq(apiKeys.id, id)); - revalidatePath("/settings"); - return { success: true }; - } catch (err) { - console.error("[api-keys] suppression KO:", err); - return { success: false, error: "Erreur lors de la suppression." }; - } + return callAction("deleteApiKey", [id]); } diff --git a/src/features/admin/api/sync-scheduler.ts b/src/features/admin/api/sync-scheduler.ts deleted file mode 100644 index 7d80a44..0000000 --- a/src/features/admin/api/sync-scheduler.ts +++ /dev/null @@ -1,387 +0,0 @@ -/** - * CollectFlow — Synchronisation nocturne des fournisseurs. - * - * Deux sources aux coûts très différents, donc deux files distinctes : - * • **SQL** — l'instantané de grille (`getProductRows`), ~43 s sur le plus gros - * fournisseur du catalogue. Alimente la Grille et `/api/v1`. - * • **Qlik** — les métriques réseau, jusqu'à 8 min par fournisseur, sur un - * serveur externe qui abandonne quand on le sollicite trop. Ce sont des - * agrégats mensuels : les rafraîchir chaque nuit n'apporterait rien. - * - * ORDRE DE PASSAGE — le point de conception central. La file est simplement - * « le plus ancien d'abord », en ignorant ceux déjà traités depuis l'ouverture - * de la fenêtre courante. Il en découle, sans curseur ni compteur de tour : - * • la nuit 2 reprend là où la nuit 1 s'est arrêtée (les non-traités sont les - * plus anciens) ; - * • quand plus personne n'est en attente, le tour est fini ; la nuit suivante - * tout le monde redevient éligible et le tour recommence ; - * • un fournisseur en échec garde une date ancienne, donc il repasse en tête — - * le rattrapage est automatique. - * - * La fenêtre ne peut pas interrompre un fournisseur en cours : elle signifie - * « ne plus en **démarrer** après l'heure de fin ». - */ - -import "server-only"; - -import { and, asc, eq, isNull, lt, or, sql, type SQL } from "drizzle-orm"; -import { db } from "@/db"; -import { syncFournisseurs } from "@/db/schema"; -import { getProductRows } from "@/features/grid/api/get-product-rows"; -import { pgGetFournisseurs, pgGetArticlesByFournisseur } from "@/lib/pg-ff-client"; -import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright"; -import { upsertNetworkMetrics } from "@/lib/qlik-network-cache"; -import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range"; -import { readSyncSettings, type SyncSettings } from "@/features/admin/api/sync-settings"; -import { invalidateFFCache } from "@/lib/ff-cache"; - -export type { SyncSettings }; - -// ─── État courant, en mémoire ──────────────────────────────────────────────── - -export interface SyncSchedulerState { - enCours: boolean; - /** "auto" = déclenché par la fenêtre nocturne, "manuel" = lancé depuis l'admin. */ - origine: "auto" | "manuel" | null; - debutAt: string | null; - finAt: string | null; - fournisseurCourant: string | null; - etape: "sql" | "qlik" | null; - sqlFaits: number; - sqlEchecs: number; - qlikFaits: number; - qlikEchecs: number; - /** Renseigné quand la file s'est vidée : le tour est bouclé. */ - tourTermine: boolean; - arretDemande: boolean; - derniereErreur: string | null; -} - -const etat: SyncSchedulerState = { - enCours: false, origine: null, debutAt: null, finAt: null, - fournisseurCourant: null, etape: null, - sqlFaits: 0, sqlEchecs: 0, qlikFaits: 0, qlikEchecs: 0, - tourTermine: false, arretDemande: false, derniereErreur: null, -}; - -export function getSyncSchedulerState(): SyncSchedulerState { - return { ...etat }; -} - -export function demanderArret(): void { - if (etat.enCours) etat.arretDemande = true; -} - -// ─── Fenêtre horaire ───────────────────────────────────────────────────────── - -/** - * Début de la fenêtre en cours, ou `null` si l'heure courante est en dehors. - * - * Gère le passage par minuit (ex. 22h → 5h) : dans ce cas, entre 0h et l'heure - * de fin, la fenêtre a commencé **la veille**. - */ -export function debutFenetre(s: SyncSettings, maintenant = new Date()): Date | null { - const h = maintenant.getHours(); - const debutDuJour = new Date(maintenant); - debutDuJour.setHours(s.heureDebut, 0, 0, 0); - - if (s.heureDebut < s.heureFin) { - return h >= s.heureDebut && h < s.heureFin ? debutDuJour : null; - } - // Fenêtre à cheval sur minuit. - if (h >= s.heureDebut) return debutDuJour; - if (h < s.heureFin) { - const veille = new Date(debutDuJour); - veille.setDate(veille.getDate() - 1); - return veille; - } - return null; -} - -// ─── Référentiel des fournisseurs ──────────────────────────────────────────── - -/** - * Aligne la table de paramétrage sur le référentiel FF : ajoute les nouveaux - * fournisseurs (actifs par défaut), rafraîchit les noms. Ne supprime rien — - * l'historique de synchronisation d'un fournisseur disparu reste consultable. - */ -export async function seedSyncFournisseurs(): Promise { - const fournisseurs = await pgGetFournisseurs(); - if (fournisseurs.length === 0) return 0; - - const CHUNK = 500; - for (let i = 0; i < fournisseurs.length; i += CHUNK) { - const lot = fournisseurs.slice(i, i + CHUNK); - await db - .insert(syncFournisseurs) - .values(lot.map((f) => ({ codeFournisseur: f.code, nomFournisseur: f.nom }))) - .onConflictDoUpdate({ - target: syncFournisseurs.codeFournisseur, - // Seul le nom est réaligné : les choix d'activation appartiennent à l'admin. - set: { nomFournisseur: sql`excluded.nom_fournisseur` }, - }); - } - return fournisseurs.length; -} - -// ─── Files d'attente ───────────────────────────────────────────────────────── - -/** Le plus ancien fournisseur SQL pas encore traité depuis l'ouverture de la fenêtre. */ -async function prochainSql(depuis: Date) { - const [row] = await db - .select() - .from(syncFournisseurs) - .where(and( - eq(syncFournisseurs.actifSql, true), - or(isNull(syncFournisseurs.dernierSqlAt), lt(syncFournisseurs.dernierSqlAt, depuis)) as SQL, - )) - .orderBy(asc(sql`${syncFournisseurs.dernierSqlAt} nulls first`)) - .limit(1); - return row ?? null; -} - -/** Délai avant de retenter un fournisseur Qlik en échec : la nuit suivante. */ -const QLIK_REPRISE_ECHEC_MS = 18 * 3600 * 1000; - -/** - * Le plus ancien fournisseur Qlik à rafraîchir. Il l'est si : - * - ses données dépassent `qlikMinJours` (agrégats mensuels : inutile de - * relancer chaque nuit une extraction de plusieurs minutes) ; - * - ou elles datent d'avant le mois en cours : la fenêtre de 12 mois a glissé, - * son dernier mois manque, sa tendance n'est plus à jour ; - * - ou sa dernière extraction a échoué il y a plus de 18 h. Un échec attendait - * jusqu'ici `qlikMinJours` (7 jours) avant un nouvel essai, alors que la - * plupart sont passagers (serveur Qlik chargé, dernier mois pas encore - * chargé). Le plus ancien passant en premier, un fournisseur qui échoue à - * chaque fois repart en fin de file et ne bloque personne. - */ -async function prochainQlik(qlikMinJours: number, maintenant: Date) { - const seuilAge = new Date(maintenant.getTime() - qlikMinJours * 24 * 3600 * 1000); - const debutMois = new Date(maintenant.getFullYear(), maintenant.getMonth(), 1); - const seuil = seuilAge > debutMois ? seuilAge : debutMois; - const seuilEchec = new Date(maintenant.getTime() - QLIK_REPRISE_ECHEC_MS); - const [row] = await db - .select() - .from(syncFournisseurs) - .where(and( - eq(syncFournisseurs.actifQlik, true), - or( - isNull(syncFournisseurs.dernierQlikAt), - lt(syncFournisseurs.dernierQlikAt, seuil), - and(eq(syncFournisseurs.dernierQlikStatut, "echec"), lt(syncFournisseurs.dernierQlikAt, seuilEchec)), - ) as SQL, - )) - .orderBy(asc(sql`${syncFournisseurs.dernierQlikAt} nulls first`)) - .limit(1); - return row ?? null; -} - -// ─── Traitement d'un fournisseur ───────────────────────────────────────────── - -/** - * Instantané SQL. `dernierSqlAt` est renseigné **même en cas d'échec** : sans - * cela, un fournisseur qui échoue resterait éternellement le plus ancien et - * bloquerait la file derrière lui. - */ -async function traiterSql(code: string, nom: string | null): Promise { - const t0 = Date.now(); - etat.fournisseurCourant = `${code}${nom ? ` — ${nom}` : ""}`; - etat.etape = "sql"; - try { - const rows = await getProductRows({ codeFournisseur: code, magasin: "TOTAL", forceRefresh: true }); - const vide = rows.length === 0; - await db.update(syncFournisseurs).set({ - dernierSqlAt: new Date(), - dernierSqlStatut: vide ? "vide" : "succes", - dernierSqlLignes: rows.length, - dernierSqlErreur: null, - dernierSqlMs: Date.now() - t0, - // Un fournisseur sans article n'a rien à donner : on le sort des deux - // files plutôt que de le réessayer chaque nuit. L'admin voit le motif - // et peut le réactiver. - ...(vide ? { actifSql: false, actifQlik: false, desactiveMotif: "Aucun article — désactivé automatiquement" } : {}), - updatedAt: new Date(), - }).where(eq(syncFournisseurs.codeFournisseur, code)); - if (vide) console.log(`[sync-nuit] ${code} sans article — désactivé automatiquement`); - else etat.sqlFaits++; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - etat.sqlEchecs++; - etat.derniereErreur = `${code} (SQL) : ${msg}`; - console.error(`[sync-nuit] SQL ${code} en échec :`, msg); - await db.update(syncFournisseurs).set({ - dernierSqlAt: new Date(), - dernierSqlStatut: "echec", - dernierSqlErreur: msg.slice(0, 500), - dernierSqlMs: Date.now() - t0, - updatedAt: new Date(), - }).where(eq(syncFournisseurs.codeFournisseur, code)).catch(() => { /* le suivi ne doit pas casser la boucle */ }); - } -} - -/** Format d'un code centrale plausible — même filtre que la synchro manuelle. */ -const CODE_CENTRALE = /^[A-Z0-9][A-Z0-9_-]{1,29}$/i; - -/** Extraction Qlik. Même politique que le SQL sur l'horodatage en cas d'échec. */ -async function traiterQlik(code: string, nom: string | null): Promise { - const t0 = Date.now(); - etat.fournisseurCourant = `${code}${nom ? ` — ${nom}` : ""}`; - etat.etape = "qlik"; - try { - const articles = await pgGetArticlesByFournisseur(code); - const codes = [...new Set( - articles - .map((a) => String(a.codeCentrale ?? "").trim()) - .filter((c) => c && c !== "-" && CODE_CENTRALE.test(c)), - )]; - - if (codes.length === 0) { - await db.update(syncFournisseurs).set({ - dernierQlikAt: new Date(), dernierQlikStatut: "vide", dernierQlikCodes: 0, - dernierQlikErreur: null, dernierQlikMs: Date.now() - t0, updatedAt: new Date(), - }).where(eq(syncFournisseurs.codeFournisseur, code)); - return; - } - - const dateFilter = buildGridNetworkQlikDateFilter(new Date(), envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT)); - const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter, code); - const n = await upsertNetworkMetrics([...metrics.values()]); - - await db.update(syncFournisseurs).set({ - dernierQlikAt: new Date(), dernierQlikStatut: "succes", dernierQlikCodes: n, - dernierQlikErreur: null, dernierQlikMs: Date.now() - t0, updatedAt: new Date(), - }).where(eq(syncFournisseurs.codeFournisseur, code)); - etat.qlikFaits++; - console.log(`[sync-nuit] Qlik ${code} — ${n} codes en ${Math.round((Date.now() - t0) / 1000)} s`); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - etat.qlikEchecs++; - etat.derniereErreur = `${code} (Qlik) : ${msg}`; - console.error(`[sync-nuit] Qlik ${code} en échec :`, msg); - await db.update(syncFournisseurs).set({ - dernierQlikAt: new Date(), dernierQlikStatut: "echec", - dernierQlikErreur: msg.slice(0, 500), dernierQlikMs: Date.now() - t0, updatedAt: new Date(), - }).where(eq(syncFournisseurs.codeFournisseur, code)).catch(() => { /* idem */ }); - } -} - -// ─── Boucle de travail ─────────────────────────────────────────────────────── - -/** - * Traite les files jusqu'à épuisement, sortie de fenêtre, ou demande d'arrêt. - * - * Les deux files avancent en parallèle : une extraction Qlik est intercalée - * tous les `sqlParQlik` fournisseurs SQL. Sans cet entrelacement, Qlik ne - * démarrerait jamais tant que la file SQL n'est pas vide. - */ -export async function lancerRound(origine: "auto" | "manuel", forcerHorsFenetre = false): Promise { - if (etat.enCours) return; - - const s = await readSyncSettings(); - const maintenant = new Date(); - const debut = debutFenetre(s, maintenant); - if (!forcerHorsFenetre && !debut) return; - // Un lancement manuel hors fenêtre borne la file sur « depuis ce matin ». - const depuis = debut ?? new Date(new Date(maintenant).setHours(0, 0, 0, 0)); - - Object.assign(etat, { - enCours: true, origine, debutAt: maintenant.toISOString(), finAt: null, - fournisseurCourant: null, etape: null, - sqlFaits: 0, sqlEchecs: 0, qlikFaits: 0, qlikEchecs: 0, - tourTermine: false, arretDemande: false, derniereErreur: null, - }); - console.log(`[sync-nuit] round ${origine} démarré (fenêtre ${s.heureDebut}h-${s.heureFin}h, Qlik ≤ ${s.qlikParNuit})`); - - try { - await seedSyncFournisseurs(); - - // Garde anti-boucle. L'avancement de la file repose sur l'écriture de - // `dernier*At`, et cette écriture est volontairement non bloquante en cas - // d'erreur. Si elle échoue, le fournisseur resterait le plus ancien et - // serait repris sans fin : on retient donc ce qui a déjà été tenté. - const tentes = new Set(); - - let depuisDernierQlik = 0; - for (;;) { - if (etat.arretDemande) break; - if (!forcerHorsFenetre && !debutFenetre(s, new Date())) { - console.log("[sync-nuit] fin de fenêtre — arrêt après le fournisseur en cours"); - break; - } - - const qlikPossible = etat.qlikFaits + etat.qlikEchecs < s.qlikParNuit; - - // Qlik prioritaire quand le quota d'entrelacement est atteint. - if (qlikPossible && depuisDernierQlik >= s.sqlParQlik) { - const cible = await prochainQlik(s.qlikMinJours, new Date()); - if (cible && !tentes.has(`qlik:${cible.codeFournisseur}`)) { - tentes.add(`qlik:${cible.codeFournisseur}`); - await traiterQlik(cible.codeFournisseur, cible.nomFournisseur); - depuisDernierQlik = 0; - continue; - } - } - - const cibleSql = await prochainSql(depuis); - if (cibleSql) { - if (tentes.has(`sql:${cibleSql.codeFournisseur}`)) { - console.error( - `[sync-nuit] ${cibleSql.codeFournisseur} ressort de la file après avoir été traité :` - + " le suivi n'a pas pu être écrit. Arrêt du round pour ne pas boucler.", - ); - break; - } - tentes.add(`sql:${cibleSql.codeFournisseur}`); - await traiterSql(cibleSql.codeFournisseur, cibleSql.nomFournisseur); - depuisDernierQlik++; - continue; - } - - // File SQL vide : on consacre le temps restant à Qlik. - if (qlikPossible) { - const cible = await prochainQlik(s.qlikMinJours, new Date()); - if (cible && !tentes.has(`qlik:${cible.codeFournisseur}`)) { - tentes.add(`qlik:${cible.codeFournisseur}`); - await traiterQlik(cible.codeFournisseur, cible.nomFournisseur); - depuisDernierQlik = 0; - continue; - } - } - - etat.tourTermine = true; - console.log("[sync-nuit] plus rien en attente — tour terminé"); - break; - } - } catch (e) { - etat.derniereErreur = e instanceof Error ? e.message : String(e); - console.error("[sync-nuit] round interrompu :", etat.derniereErreur); - } finally { - // Les pages (stock, hit-parade, CA…) relisent la base fraîche au lieu - // d'attendre la fin de vie de leur cache. - invalidateFFCache(); - etat.enCours = false; - etat.fournisseurCourant = null; - etat.etape = null; - etat.finAt = new Date().toISOString(); - console.log( - `[sync-nuit] round terminé — SQL ${etat.sqlFaits} ok / ${etat.sqlEchecs} en échec,` - + ` Qlik ${etat.qlikFaits} ok / ${etat.qlikEchecs} en échec, tour bouclé=${etat.tourTermine}`, - ); - } -} - -/** - * Battement du minuteur : ne fait quelque chose que dans la fenêtre, et jamais - * deux rounds à la fois. Volontairement silencieux hors fenêtre — il est appelé - * chaque minute. - */ -export async function tickScheduler(): Promise { - try { - const s = await readSyncSettings(); - if (!s.actif || etat.enCours) return; - if (!debutFenetre(s)) return; - await lancerRound("auto"); - } catch (e) { - console.error("[sync-nuit] tick en erreur :", e instanceof Error ? e.message : String(e)); - } -} diff --git a/src/features/admin/api/sync-settings.ts b/src/features/admin/api/sync-settings.ts deleted file mode 100644 index 541dbac..0000000 --- a/src/features/admin/api/sync-settings.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * CollectFlow — Réglages de la synchronisation nocturne. - * - * Rangés dans `data/.db-config.json`, comme les autres réglages de - * l'application (base, Qlik, API FF), pour qu'il n'y ait qu'un seul endroit à - * sauvegarder et à monter dans le conteneur. - * - * Module **serveur**, pas fichier d'actions : il exporte aussi des valeurs - * (les défauts, le type), ce que `"use server"` interdit. - */ - -import "server-only"; - -import fs from "fs/promises"; -import path from "path"; - -const DATA_DIR = path.join(process.cwd(), "data"); -const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json"); - -export interface SyncSettings { - /** - * Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter - * Qlik et la base la nuit suivante sans que personne l'ait décidé. - */ - actif: boolean; - /** Heure d'ouverture de la fenêtre (0-23). */ - heureDebut: number; - /** Heure de fermeture. Aucun fournisseur n'est **démarré** après. */ - heureFin: number; - /** - * Nombre maximum d'extractions Qlik par nuit. Chacune peut prendre 8 minutes - * et le serveur abandonne quand il est trop sollicité : ce plafond est la - * principale protection contre une nuit entièrement passée sur Qlik. - */ - qlikParNuit: number; - /** Une extraction Qlik est intercalée tous les N fournisseurs SQL. */ - sqlParQlik: number; - /** - * Âge minimal des données Qlik avant de les refaire. Ce sont des agrégats - * mensuels : les rafraîchir toutes les nuits coûterait cher pour rien. - */ - qlikMinJours: number; -} - -export const SYNC_SETTINGS_DEFAUT: SyncSettings = { - actif: false, - heureDebut: 0, - heureFin: 5, - qlikParNuit: 6, - sqlParQlik: 20, - qlikMinJours: 7, -}; - -function borne(v: unknown, min: number, max: number, defaut: number): number { - const n = Number(v); - if (!Number.isFinite(n)) return defaut; - return Math.min(max, Math.max(min, Math.trunc(n))); -} - -/** Normalise ce qui vient du fichier : il est éditable à la main. */ -function normaliser(brut: Partial | undefined): SyncSettings { - const d = SYNC_SETTINGS_DEFAUT; - const s: SyncSettings = { - actif: Boolean(brut?.actif), - heureDebut: borne(brut?.heureDebut, 0, 23, d.heureDebut), - heureFin: borne(brut?.heureFin, 0, 23, d.heureFin), - qlikParNuit: borne(brut?.qlikParNuit, 0, 200, d.qlikParNuit), - sqlParQlik: borne(brut?.sqlParQlik, 1, 500, d.sqlParQlik), - qlikMinJours: borne(brut?.qlikMinJours, 0, 365, d.qlikMinJours), - }; - // Une fenêtre de durée nulle ne s'ouvrirait jamais : on revient au défaut - // plutôt que de laisser le planificateur muet sans explication. - if (s.heureDebut === s.heureFin) { - s.heureDebut = d.heureDebut; - s.heureFin = d.heureFin; - } - return s; -} - -export async function readSyncSettings(): Promise { - try { - const raw = await fs.readFile(CONFIG_FILE, "utf-8"); - const cfg = JSON.parse(raw) as { syncNuit?: Partial }; - return normaliser(cfg?.syncNuit); - } catch { - return { ...SYNC_SETTINGS_DEFAUT }; - } -} - -export async function saveSyncSettings(patch: Partial): Promise { - let existant: Record = {}; - try { - existant = JSON.parse(await fs.readFile(CONFIG_FILE, "utf-8")); - } catch { - // Pas encore de fichier : on le crée avec les seuls réglages de synchro. - } - const courant = normaliser((existant.syncNuit as Partial) ?? undefined); - const suivant = normaliser({ ...courant, ...patch }); - - await fs.mkdir(DATA_DIR, { recursive: true }); - await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...existant, syncNuit: suivant }, null, 2)); - console.log(`[sync-nuit] réglages enregistrés : ${JSON.stringify(suivant)}`); - return suivant; -} diff --git a/src/features/admin/api/sync-timer.ts b/src/features/admin/api/sync-timer.ts deleted file mode 100644 index ae1acd6..0000000 --- a/src/features/admin/api/sync-timer.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * CollectFlow — Minuteur de la synchronisation nocturne. - * - * Démarré une fois au boot par `src/instrumentation.ts`. Il bat chaque minute - * et laisse `tickScheduler()` décider s'il y a quelque chose à faire : hors - * fenêtre, ou si un round tourne déjà, le battement ne coûte rien. - * - * Conséquence assumée du choix « minuteur interne » plutôt que cron système : - * rien ne tourne si l'application est arrêtée. En contrepartie, aucune - * configuration côté hôte, et le minuteur se réarme tout seul au redémarrage. - */ - -import "server-only"; - -import { tickScheduler } from "@/features/admin/api/sync-scheduler"; - -const INTERVALLE_MS = 60_000; - -/** - * Garde au niveau du module : en développement, le rechargement à chaud peut - * réévaluer `register()` plusieurs fois, et on se retrouverait avec autant de - * minuteurs empilés. - */ -let minuteur: ReturnType | null = null; - -export function startSyncTimer(): void { - if (minuteur) return; - - console.log(`[sync-nuit] minuteur démarré (battement ${INTERVALLE_MS / 1000} s)`); - minuteur = setInterval(() => { - // `tickScheduler` capture déjà ses propres erreurs ; ce `catch` ne couvre - // qu'un rejet inattendu, pour qu'il ne devienne jamais une exception non - // gérée capable d'abattre le processus. - void tickScheduler().catch((e) => { - console.error("[sync-nuit] battement en erreur :", e instanceof Error ? e.message : String(e)); - }); - }, INTERVALLE_MS); - - // Ne pas maintenir le processus en vie uniquement pour ce minuteur. - minuteur.unref?.(); -} diff --git a/src/features/admin/api/user-actions.ts b/src/features/admin/api/user-actions.ts index f7a4e89..389957b 100644 --- a/src/features/admin/api/user-actions.ts +++ b/src/features/admin/api/user-actions.ts @@ -1,74 +1,46 @@ -"use server"; +/** + * Gestion des comptes (administrateurs) — actions exécutées par le serveur + * Rust : `POST /api/actions/{nom}`. + */ -import { db } from "@/db"; -import { users } from "@/db/schema"; -import { eq } from "drizzle-orm"; -import { hashPassword } from "@/features/auth/logic/auth-logic"; -import { requireAdmin } from "@/lib/authz"; -import { revalidatePath } from "next/cache"; +import { callAction } from "@/lib/rpc"; + +export interface UtilisateurListe { + id: number; + username: string; + role: string; + /** Date ISO de création. */ + createdAt: string | null; +} /** * Récupère tous les utilisateurs (Admin seulement). */ -export async function getUsers() { - await requireAdmin(); - return db.select({ - id: users.id, - username: users.username, - role: users.role, - createdAt: users.createdAt - }).from(users); +export async function getUsers(): Promise { + return callAction("getUsers", []); } /** * Crée un nouvel utilisateur. */ -export async function createUser(username: string, password: string, role: "admin" | "user" = "user") { - await requireAdmin(); - - try { - await db.insert(users).values({ - username, - passwordHash: hashPassword(password), - role, - }); - revalidatePath("/settings"); - return { success: true }; - } catch (err) { - console.error("CreateUser Error:", err); - return { success: false, error: "L'utilisateur existe déjà ou une erreur technique est survenue." }; - } +export async function createUser( + username: string, + password: string, + role: "admin" | "user" = "user", +): Promise<{ success: boolean; error?: string }> { + return callAction("createUser", [username, password, role]); } /** * Supprime un utilisateur. */ -export async function deleteUser(id: number) { - const moi = await requireAdmin(); - if (Number(moi.id) === id) { - return { success: false, error: "Vous ne pouvez pas supprimer votre propre compte." }; - } - - try { - await db.delete(users).where(eq(users.id, id)); - revalidatePath("/settings"); - return { success: true }; - } catch { - return { success: false, error: "Erreur lors de la suppression." }; - } +export async function deleteUser(id: number): Promise<{ success: boolean; error?: string }> { + return callAction("deleteUser", [id]); } /** * Met à jour le mot de passe d'un utilisateur. */ -export async function updatePassword(id: number, newPassword: string) { - await requireAdmin(); - try { - await db.update(users) - .set({ passwordHash: hashPassword(newPassword) }) - .where(eq(users.id, id)); - return { success: true }; - } catch { - return { success: false, error: "Erreur lors de la mise à jour du mot de passe." }; - } +export async function updatePassword(id: number, newPassword: string): Promise<{ success: boolean; error?: string }> { + return callAction("updatePassword", [id, newPassword]); } diff --git a/src/features/admin/components/api-connection-info.tsx b/src/features/admin/components/api-connection-info.tsx index 7aee6bd..643637b 100644 --- a/src/features/admin/components/api-connection-info.tsx +++ b/src/features/admin/components/api-connection-info.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * CollectFlow — Informations de connexion à l'API `/api/v1`. * diff --git a/src/features/admin/components/api-key-management.tsx b/src/features/admin/components/api-key-management.tsx index dcc6335..8dfd917 100644 --- a/src/features/admin/components/api-key-management.tsx +++ b/src/features/admin/components/api-key-management.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * CollectFlow — Gestion des clés de l'API `/api/v1`. * diff --git a/src/features/admin/components/grid-warmup.tsx b/src/features/admin/components/grid-warmup.tsx index 614ac3e..73b1fe4 100644 --- a/src/features/admin/components/grid-warmup.tsx +++ b/src/features/admin/components/grid-warmup.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * CollectFlow — Préchauffage des données exposées par l'API. * diff --git a/src/features/admin/components/user-management.tsx b/src/features/admin/components/user-management.tsx index 13b9d48..75429e0 100644 --- a/src/features/admin/components/user-management.tsx +++ b/src/features/admin/components/user-management.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useCallback, useEffect, useState, type FormEvent } from "react"; import { KeyRound, Loader2, Shield, Trash2, User, UserPlus } from "lucide-react"; import { getUsers, createUser, deleteUser, updatePassword } from "../api/user-actions"; diff --git a/src/features/auth/api/login-action.ts b/src/features/auth/api/login-action.ts deleted file mode 100644 index 9b514f9..0000000 --- a/src/features/auth/api/login-action.ts +++ /dev/null @@ -1,28 +0,0 @@ -"use server"; - -import { signIn } from "@/lib/auth"; -import { AuthError } from "next-auth"; - -/** - * Server action to handle user login. - */ -export async function loginAction(prevState: string | undefined, formData: FormData) { - try { - await signIn("credentials", { - username: formData.get("username"), - password: formData.get("password"), - redirectTo: "/dashboard" - }); - } catch (error) { - if (error instanceof AuthError) { - switch (error.type) { - case "CredentialsSignin": - return "Utilisateur ou mot de passe incorrect."; - default: - return "Une erreur technique est survenue."; - } - } - // Next.js redirect throws a special error that must be rethrown - throw error; - } -} diff --git a/src/features/auth/components/login-form.tsx b/src/features/auth/components/login-form.tsx index bd220d5..8aaab14 100644 --- a/src/features/auth/components/login-form.tsx +++ b/src/features/auth/components/login-form.tsx @@ -1,11 +1,42 @@ -"use client"; - import { useActionState } from "react"; -import { loginAction } from "../api/login-action"; import { AlertCircle, Loader2, Lock, ShieldCheck, User } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/form-controls"; +const MESSAGE_IDENTIFIANTS = "Utilisateur ou mot de passe incorrect."; +const MESSAGE_TECHNIQUE = "Une erreur technique est survenue."; + +/** + * Connexion : `POST /api/auth/login` (le serveur pose le cookie de session), + * puis chargement complet du tableau de bord pour repartir avec la session. + * Renvoie le message d'erreur à afficher, ou ne rend jamais la main en cas de + * réussite (le bouton reste « Connexion… » jusqu'au changement de page). + */ +async function loginAction(_prevState: string | undefined, formData: FormData): Promise { + let res: Response; + try { + res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ + username: String(formData.get("username") ?? ""), + password: String(formData.get("password") ?? ""), + }), + }); + } catch { + return MESSAGE_TECHNIQUE; + } + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: unknown } | null; + if (typeof body?.error === "string" && body.error) return body.error; + return res.status === 401 ? MESSAGE_IDENTIFIANTS : MESSAGE_TECHNIQUE; + } + await res.text().catch(() => ""); + window.location.assign("/dashboard"); + return new Promise(() => {}); +} + export function LoginForm() { const [error, action, isPending] = useActionState(loginAction, undefined); diff --git a/src/features/auth/logic/auth-logic.ts b/src/features/auth/logic/auth-logic.ts deleted file mode 100644 index 100b79c..0000000 --- a/src/features/auth/logic/auth-logic.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { scryptSync, randomBytes, timingSafeEqual } from "crypto"; -import fs from "fs"; -import path from "path"; - -const USERS_JSON_PATH = path.join(process.cwd(), "data", "users.json"); - -export interface UserJson { - id: string; - username: string; - passwordHash: string; - role: string; -} - -/** - * Hashes a password using scrypt. - * Format: salt:hash - */ -export function hashPassword(password: string): string { - const salt = randomBytes(16).toString("hex"); - const hash = scryptSync(password, salt, 64).toString("hex"); - return `${salt}:${hash}`; -} - -/** - * Verifies a password against a stored salt:hash string. - */ -export function verifyPassword(password: string, storedHash: string): boolean { - try { - const [salt, hash] = storedHash.split(":"); - if (!salt || !hash) return false; - - const hashBuffer = scryptSync(password, salt, 64); - const storedHashBuffer = Buffer.from(hash, "hex"); - - return timingSafeEqual(hashBuffer, storedHashBuffer); - } catch (e) { - return false; - } -} - -/** - * Reads users from local JSON fallback. - */ -export function getFallbackUsers(): UserJson[] { - try { - if (!fs.existsSync(USERS_JSON_PATH)) { - // Initial seed if file doesn't exist - const defaultAdmin: UserJson = { - id: "0", - username: "admin", - passwordHash: hashPassword("admin"), - role: "admin" - }; - saveFallbackUsers([defaultAdmin]); - return [defaultAdmin]; - } - const data = fs.readFileSync(USERS_JSON_PATH, "utf-8"); - return JSON.parse(data); - } catch (e) { - console.error("[AUTH] Error reading users.json:", e); - return []; - } -} - -/** - * Saves users to local JSON fallback. - */ -export function saveFallbackUsers(users: UserJson[]) { - try { - const dir = path.dirname(USERS_JSON_PATH); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(USERS_JSON_PATH, JSON.stringify(users, null, 2)); - } catch (e) { - console.error("[AUTH] Error writing users.json:", e); - } -} diff --git a/src/features/auth/logic/seed-admin.ts b/src/features/auth/logic/seed-admin.ts deleted file mode 100644 index 09ae497..0000000 --- a/src/features/auth/logic/seed-admin.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { db } from "@/db"; -import { users } from "@/db/schema"; -import { hashPassword, getFallbackUsers, saveFallbackUsers } from "./auth-logic"; -import { sql } from "drizzle-orm"; - -/** Amorçage en cours ou réussi pour ce processus (remis à null en cas d'échec). */ -let amorcage: Promise | null = null; - -/** - * Prépare les comptes au premier démarrage, une seule fois par processus. - * - * - Base joignable : crée la table `users` si besoin (auto-réparation), puis le - * compte admin/admin **uniquement si la table est vide**. Un admin supprimé ou - * renommé n'est donc pas recréé à chaque connexion. Le fichier de secours - * `data/users.json` n'est pas touché. - * - Base injoignable (première installation, base pas encore configurée) : on - * s'assure que le fichier de secours contient un administrateur, et on - * retentera la base à la prochaine connexion. - * - * Renvoie true si le compte admin/admin vient d'être créé en base. - */ -export function ensureAdminExists(): Promise { - if (!amorcage) { - amorcage = amorcer().catch((err: unknown) => { - // Échec : on retentera à la prochaine connexion. - amorcage = null; - console.warn("[AUTH] Database seeding failed (expected if DB not configured yet):", (err as Error)?.message ?? err); - return false; - }); - } - return amorcage; -} - -async function amorcer(): Promise { - console.log("[AUTH] Testing database connection for seeding..."); - try { - await db.execute(sql`SELECT 1`); - } catch (err) { - assurerAdminDeSecours(); - throw err; - } - - // Auto-repair: Ensure table exists - await db.execute(sql` - CREATE TABLE IF NOT EXISTS "users" ( - id SERIAL PRIMARY KEY, - username VARCHAR(50) NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - role VARCHAR(20) NOT NULL DEFAULT 'user', - created_at TIMESTAMP DEFAULT NOW() - ); - `); - - const [{ nb }] = await db.select({ nb: sql`count(*)::int` }).from(users); - if (nb > 0) return false; - - console.log("[AUTH] Table users vide : création du compte admin/admin."); - // Si un autre processus l'a créé entre-temps, l'unicité du nom fait échouer - // l'insertion : on la laisse sans effet plutôt que de relancer l'amorçage. - const crees = await db.insert(users).values({ - username: "admin", - passwordHash: hashPassword("admin"), - role: "admin", - }).onConflictDoNothing().returning({ id: users.id }); - return crees.length > 0; -} - -/** Base injoignable : le fichier de secours doit contenir au moins un administrateur. */ -function assurerAdminDeSecours() { - const fallbackUsers = getFallbackUsers(); - if (fallbackUsers.length === 0) { - console.log("[AUTH] Initializing local JSON fallback with admin/admin..."); - saveFallbackUsers([{ - id: "0", - username: "admin", - passwordHash: hashPassword("admin"), - role: "admin" - }]); - } -} diff --git a/src/features/commandes-auto/actions.ts b/src/features/commandes-auto/actions.ts index c4623b2..f9916fe 100644 --- a/src/features/commandes-auto/actions.ts +++ b/src/features/commandes-auto/actions.ts @@ -1,11 +1,10 @@ -"use server"; +/** + * Cadencier des commandes fournisseurs — actions exécutées par le serveur + * Rust : `POST /api/actions/{nom}`. + */ -import { revalidatePath } from "next/cache"; -import { and, eq } from "drizzle-orm"; -import { db } from "@/db"; -import { commandeCadences } from "@/db/schema"; -import { getDerniereReceptionCached, getFournisseursCached } from "@/lib/ff-cache"; -import { requireSession, verifierSession } from "@/lib/authz"; +import { callAction } from "@/lib/rpc"; +import { refreshPage } from "@/lib/page-data"; export type CadenceStatut = "a_commander" | "bientot" | "ok" | "inconnu"; @@ -25,21 +24,18 @@ export interface CadenceView { statut: CadenceStatut; } -const MS_PAR_JOUR = 24 * 60 * 60 * 1000; -/** Seuil (en jours) en dessous duquel une cadence passe en "bientôt à commander" */ -const SEUIL_BIENTOT_JOURS = 7; - -function calcStatut(joursRestants: number | null): CadenceStatut { - if (joursRestants === null) return "inconnu"; - if (joursRestants <= 0) return "a_commander"; - if (joursRestants <= SEUIL_BIENTOT_JOURS) return "bientot"; - return "ok"; +/** + * Les actions Next.js de modification appelaient `revalidatePath("/commandes-auto")`, + * dont la réponse rafraîchissait la page : même effet ici après une réussite. + */ +function rafraichirSiOk(resultat: R): R { + if (resultat.ok) refreshPage(); + return resultat; } /** Liste des fournisseurs (référentiel) pour le sélecteur de cadence. */ export async function getFournisseursPourCadence(): Promise<{ code: string; nom: string }[]> { - await requireSession(); - return getFournisseursCached(); + return callAction("getFournisseursPourCadence", []); } /** @@ -47,45 +43,7 @@ export async function getFournisseursPourCadence(): Promise<{ code: string; nom: * de la dernière réception (FF Nancy). */ export async function listCadences(): Promise { - await requireSession(); - let rows: typeof commandeCadences.$inferSelect[] = []; - let receptions = new Map(); - try { - rows = await db.select().from(commandeCadences); - // Réceptions des seuls fournisseurs du cadencier : la lecture sur tout - // l'historique de tous les fournisseurs était la plus lourde de la page. - receptions = await getDerniereReceptionCached(rows.map((r) => r.codeFournisseur)); - } catch (e) { - console.error("[cadence] listCadences error:", (e as Error).message); - return []; - } - - const now = Date.now(); - - return rows.map((r) => { - const receptionIso = receptions.get(`${r.codeFournisseur}|${r.site}`) ?? null; - - let echeanceIso: string | null = null; - let joursRestants: number | null = null; - if (receptionIso) { - const echeance = new Date(receptionIso).getTime() + r.intervalleSemaines * 7 * MS_PAR_JOUR; - echeanceIso = new Date(echeance).toISOString(); - joursRestants = Math.ceil((echeance - now) / MS_PAR_JOUR); - } - - return { - id: r.id, - codefou: r.codeFournisseur, - nomfou: r.nomFournisseur ?? r.codeFournisseur, - site: r.site, - intervalleSemaines: r.intervalleSemaines, - actif: r.actif, - derniereReception: receptionIso, - echeance: echeanceIso, - joursRestants, - statut: r.actif ? calcStatut(joursRestants) : "inconnu", - } satisfies CadenceView; - }); + return callAction("listCadences", []); } export interface UpsertCadenceInput { @@ -98,76 +56,15 @@ export interface UpsertCadenceInput { /** Crée ou met à jour la cadence d'un fournisseur sur un site. */ export async function upsertCadence(input: UpsertCadenceInput): Promise<{ ok: boolean; error?: string }> { - const acces = await verifierSession(); - if (!acces.ok) return { ok: false, error: acces.message }; - - const codefou = input.codefou?.trim(); - const site = input.site?.trim(); - const intervalle = Math.round(Number(input.intervalleSemaines)); - - if (!codefou || !site) return { ok: false, error: "Fournisseur et magasin requis." }; - if (!Number.isFinite(intervalle) || intervalle < 1 || intervalle > 104) { - return { ok: false, error: "L'intervalle doit être compris entre 1 et 104 semaines." }; - } - - try { - await db - .insert(commandeCadences) - .values({ - codeFournisseur: codefou, - nomFournisseur: input.nomfou ?? null, - site, - intervalleSemaines: intervalle, - actif: input.actif ?? true, - }) - .onConflictDoUpdate({ - target: [commandeCadences.codeFournisseur, commandeCadences.site], - set: { - nomFournisseur: input.nomfou ?? null, - intervalleSemaines: intervalle, - actif: input.actif ?? true, - updatedAt: new Date(), - }, - }); - revalidatePath("/commandes-auto"); - return { ok: true }; - } catch (e) { - console.error("[cadence] upsertCadence error:", (e as Error).message); - return { ok: false, error: "Échec de l'enregistrement." }; - } + return rafraichirSiOk(await callAction<{ ok: boolean; error?: string }>("upsertCadence", [input])); } /** Active / désactive une cadence sans la supprimer. */ export async function toggleCadence(codefou: string, site: string, actif: boolean): Promise<{ ok: boolean; error?: string }> { - const acces = await verifierSession(); - if (!acces.ok) return { ok: false, error: acces.message }; - - try { - await db - .update(commandeCadences) - .set({ actif, updatedAt: new Date() }) - .where(and(eq(commandeCadences.codeFournisseur, codefou), eq(commandeCadences.site, site))); - revalidatePath("/commandes-auto"); - return { ok: true }; - } catch (e) { - console.error("[cadence] toggleCadence error:", (e as Error).message); - return { ok: false }; - } + return rafraichirSiOk(await callAction<{ ok: boolean; error?: string }>("toggleCadence", [codefou, site, actif])); } /** Supprime définitivement une cadence. */ export async function removeCadence(codefou: string, site: string): Promise<{ ok: boolean; error?: string }> { - const acces = await verifierSession(); - if (!acces.ok) return { ok: false, error: acces.message }; - - try { - await db - .delete(commandeCadences) - .where(and(eq(commandeCadences.codeFournisseur, codefou), eq(commandeCadences.site, site))); - revalidatePath("/commandes-auto"); - return { ok: true }; - } catch (e) { - console.error("[cadence] removeCadence error:", (e as Error).message); - return { ok: false }; - } + return rafraichirSiOk(await callAction<{ ok: boolean; error?: string }>("removeCadence", [codefou, site])); } diff --git a/src/features/grid/actions.ts b/src/features/grid/actions.ts index 80b1fc4..07e4445 100644 --- a/src/features/grid/actions.ts +++ b/src/features/grid/actions.ts @@ -1,23 +1,20 @@ -"use server"; +/** + * Actions de la Grille (ex-« server actions » Next.js), exécutées par le + * serveur Rust : `POST /api/actions/{nom}`. + */ -import { getSitesFromApi } from "@/lib/api-ff-client"; -import { getFournisseursCached } from "@/lib/ff-cache"; -import { requireSession } from "@/lib/authz"; +import { callAction } from "@/lib/rpc"; /** * Get the list of all suppliers from PostgreSQL (fouadr1). */ -export async function getFournisseurs() { - await requireSession(); - // Relu à chaque navigation dans la Grille (changement de fournisseur, de - // magasin…) : le référentiel ne change qu'avec la recopie nocturne. - return getFournisseursCached(); +export async function getFournisseurs(): Promise<{ code: string; nom: string }[]> { + return callAction("getFournisseurs", []); } /** * Get the list of all stores (sites) from the FF Nancy API. */ -export async function getMagasins() { - await requireSession(); - return getSitesFromApi(); +export async function getMagasins(): Promise<{ code: string; nom: string }[]> { + return callAction("getMagasins", []); } diff --git a/src/features/grid/api/enregistrer-gammes.ts b/src/features/grid/api/enregistrer-gammes.ts deleted file mode 100644 index 8c09861..0000000 --- a/src/features/grid/api/enregistrer-gammes.ts +++ /dev/null @@ -1,80 +0,0 @@ -import "server-only"; - -import { db } from "@/db"; -import { sessionSnapshots } from "@/db/schema"; -import { eq, desc } from "drizzle-orm"; -import { patchGridRowsCache } from "./get-product-rows"; -import { updateGridRowsGamme } from "@/lib/grid-store"; - -export interface ChangementGamme { - codein: string; - /** Gamme avant la modification (état serveur), conservée pour l'historique. */ - codeGammeBefore: string | null; - codeGamme: string; -} - -export interface EnregistrementGammes { - codeFournisseur: string; - nomFournisseur?: string | null; - magasin: string; - changes: ChangementGamme[]; - userId: number | null; - /** Libellé du snapshot, « Draft — date » par défaut. */ - label?: string; -} - -/** - * Enregistre des gammes pour un fournisseur — chemin unique de la Grille et de /api/v1. - * - * Les changements sont fusionnés par-dessus le dernier snapshot du fournisseur, - * puis reportés dans le cache mémoire de la Grille et dans l'instantané `grid_rows`. - * Lève une erreur si l'écriture du snapshot échoue. - */ -export async function enregistrerGammes(input: EnregistrementGammes): Promise { - const { codeFournisseur, nomFournisseur, magasin, changes, userId } = input; - if (changes.length === 0) return 0; - - // Charger le dernier snapshot existant pour merger les changements - const existing = await db - .select() - .from(sessionSnapshots) - .where(eq(sessionSnapshots.codeFournisseur, codeFournisseur)) - .orderBy(desc(sessionSnapshots.createdAt)) - .limit(1); - - const prevChanges: Record = - existing.length > 0 - ? (existing[0].changes as Record) - : {}; - - // Merger les nouveaux changements par-dessus l'existant - const mergedChanges = { ...prevChanges }; - for (const c of changes) { - mergedChanges[c.codein] = { - before: c.codeGammeBefore, - after: c.codeGamme, - }; - } - - // Upsert : insérer un nouveau snapshot "draft" pour ce fournisseur - await db.insert(sessionSnapshots).values({ - userId, - codeFournisseur, - nomFournisseur: nomFournisseur ?? null, - magasin, - label: input.label ?? `Draft — ${new Date().toLocaleDateString("fr-FR")}`, - changes: mergedChanges, - summaryJson: null, - type: "snapshot", - }); - - // Les lignes en cache reçoivent les nouvelles gammes au lieu d'être jetées : - // l'invalidation imposait un recalcul complet (jusqu'à ~40 s) à la - // réouverture suivante. L'instantané lu par /api/v1 suit, sans bloquer. - patchGridRowsCache(codeFournisseur, changes); - void updateGridRowsGamme(codeFournisseur, changes).catch((e) => - console.error("[enregistrerGammes] mise à jour grid_rows KO:", (e as Error).message?.slice(0, 200)), - ); - - return changes.length; -} diff --git a/src/features/grid/api/gammes-a-valider-actions.ts b/src/features/grid/api/gammes-a-valider-actions.ts index 0a7a8f9..227923d 100644 --- a/src/features/grid/api/gammes-a-valider-actions.ts +++ b/src/features/grid/api/gammes-a-valider-actions.ts @@ -1,37 +1,21 @@ -"use server"; +/** + * Gammes proposées par l'API `/api/v1`, en attente de validation dans la + * Grille — actions exécutées par le serveur Rust : `POST /api/actions/{nom}`. + */ -import { z } from "zod"; -import { verifierSession } from "@/lib/authz"; -import { listerGammesAValider, supprimerGammesAValider, type GammeAValider } from "@/lib/gammes-a-valider"; +import { callAction } from "@/lib/rpc"; +import type { GammeAValider } from "@/types/ff"; + +export type { GammeAValider }; /** Gammes proposées par l'API pour un fournisseur, en attente de validation. */ export async function getGammesAValider( codeFournisseur: string, ): Promise<{ success: boolean; propositions: GammeAValider[]; error?: string }> { - const acces = await verifierSession(); - if (!acces.ok) return { success: false, propositions: [], error: acces.message }; - try { - return { success: true, propositions: await listerGammesAValider(String(codeFournisseur)) }; - } catch (err) { - return { success: false, propositions: [], error: err instanceof Error ? err.message : "Unknown error" }; - } + return callAction("getGammesAValider", [codeFournisseur]); } -const RejetSchema = z.object({ - codeFournisseur: z.string().min(1), - codeins: z.array(z.string()), -}); - /** Rejette des propositions de l'API (« Annuler les modifications » dans la Grille). */ export async function rejeterGammesAValider(raw: unknown): Promise<{ success: boolean; error?: string }> { - const acces = await verifierSession(); - if (!acces.ok) return { success: false, error: acces.message }; - const parsed = RejetSchema.safeParse(raw); - if (!parsed.success) return { success: false, error: "Validation failed: " + parsed.error.message }; - try { - await supprimerGammesAValider(parsed.data.codeFournisseur, parsed.data.codeins); - return { success: true }; - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : "Unknown error" }; - } + return callAction("rejeterGammesAValider", [raw]); } diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts deleted file mode 100644 index c9f4a6f..0000000 --- a/src/features/grid/api/get-product-rows.ts +++ /dev/null @@ -1,779 +0,0 @@ -import "server-only"; - -import type { ProductRow, GammeCode, GridFilters } from "@/types/grid"; -import { buildLast12MonthsRange, getMensuelByArticles } from "@/lib/api-ff-client"; -import { - pgGetArticlesByFournisseur, - pgGetMensuelByFournisseur, - pgGetGammesByFournisseur, - pgGetNomenclatureByFournisseur, - pgGetStockByFournisseur, - pgGetCommandesByFournisseur, - pgGetPrixVenteByFournisseur, - type PgStockRow, -} from "@/lib/pg-ff-client"; -import { db } from "@/db"; -import { sessionSnapshots } from "@/db/schema"; -import { eq, desc } from "drizzle-orm"; -import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache"; -import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; -// Persiste l'instantané lu par /api/v1 : sans lui, l'API n'aurait aucune donnée. -import { readGridSnapshot, upsertGridRows } from "@/lib/grid-store"; -import { - applyStorePatchInPlace, - buildStorePatchEntries, - storePatchCandidates, - type StorePatch, - type StorePatchEntry, -} from "@/features/grid/lib/store-patch"; - -interface GetProductRowsInput { - codeFournisseur: string; - magasin?: string; - filters?: Partial; - forceRefresh?: boolean; -} - -const GRID_ROWS_CACHE_TTL_MS = 10 * 60 * 1000; -/** - * Bornes du cache mémoire. La synchro nocturne et le préchauffage passent par - * `getProductRows()` pour CHAQUE fournisseur : sans plafond, le processus finissait - * par garder toutes les grilles en mémoire (plusieurs centaines de Mo), ce qui - * ralentissait toutes les requêtes (ramasse-miettes). Ordre d'insertion de la Map - * = ordre d'usage : on évince les entrées les moins récemment servies. - */ -const GRID_ROWS_CACHE_MAX_ENTRIES = Number(process.env.GRID_ROWS_CACHE_MAX_ENTRIES ?? 30); -const GRID_ROWS_CACHE_MAX_ROWS = Number(process.env.GRID_ROWS_CACHE_MAX_ROWS ?? 150_000); -const gridRowsCache = new Map(); -const gridRowsPending = new Map>(); -/** Nombre d'enregistrements de gammes par fournisseur (cf. patchGridRowsCache). */ -const gammeSaveVersion = new Map(); - -function cacheGridRows(key: string, rows: ProductRow[]): void { - gridRowsCache.delete(key); - gridRowsCache.set(key, { rows, createdAt: Date.now() }); - let totalRows = 0; - for (const entry of gridRowsCache.values()) totalRows += entry.rows.length; - for (const [oldKey, entry] of gridRowsCache) { - if (gridRowsCache.size <= 1) break; - if (gridRowsCache.size <= GRID_ROWS_CACHE_MAX_ENTRIES && totalRows <= GRID_ROWS_CACHE_MAX_ROWS) break; - gridRowsCache.delete(oldKey); - totalRows -= entry.rows.length; - } -} - -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(); -} - -/** - * Reporte des gammes enregistrées sur les lignes déjà en cache, au lieu de jeter - * le cache : l'invalidation forçait un recalcul complet (jusqu'à ~40 s sur un gros - * fournisseur) à la réouverture suivante. Le résultat est celui qu'aurait donné - * ce recalcul : la colonne Gamme reprend la valeur du dernier snapshot (Phase 9), - * la colonne INIT reste l'état serveur. - */ -export function patchGridRowsCache( - codeFournisseur: string, - changes: ReadonlyArray<{ codein: string; codeGamme: string }>, -): void { - if (changes.length === 0) return; - // Un calcul en cours pour ce fournisseur a lu les gammes avant cet - // enregistrement : le compteur lui signale de les relire avant d'être caché. - gammeSaveVersion.set(codeFournisseur, (gammeSaveVersion.get(codeFournisseur) ?? 0) + 1); - const byCodein = new Map(changes.map((c) => [c.codein, c.codeGamme as GammeCode])); - for (const [key, entry] of gridRowsCache) { - if (!key.startsWith(`${codeFournisseur}:`)) continue; - for (const row of entry.rows) { - const gamme = byCodein.get(row.codein); - if (gamme !== undefined) row.codeGamme = gamme; - } - } -} - -/** - * Rafraîchit UNIQUEMENT la colonne INIT (codeGammeInit = gamme serveur courante) - * sur des lignes servies depuis le cache. La colonne Gamme (codeGamme) n'est pas - * touchée : elle conserve la valeur issue du snapshot. Ainsi l'état serveur (INIT) - * est toujours à jour à chaque chargement, même sur un hit du cache 10 min — et le - * marqueur "modifié" (Gamme ≠ INIT) se résorbe dès que le serveur rattrape la modif. - */ -async function refreshGammeInit(rows: ProductRow[], codeFournisseur: string): Promise { - try { - const gammeMap = await pgGetGammesByFournisseur(codeFournisseur); - if (gammeMap.size === 0) return; - for (const row of rows) { - const live = gammeMap.get(row.codein); - if (live !== undefined) row.codeGammeInit = live as GammeCode; - } - } catch (e) { - console.error("[getProductRows] refreshGammeInit error:", (e as Error).message?.slice(0, 200)); - } -} - -/** - * Écrit l'instantané de grille lu par /api/v1 (table `grid_rows`). - * - * Seul `magasin = TOTAL` est persisté : `ProductRow` embarque déjà les ventilations - * par magasin (sales12mByStore, caByStore…), inutile de stocker trois variantes. - * Toute erreur est avalée : la persistance ne doit jamais casser l'affichage. - */ -async function persistGridSnapshot(input: GetProductRowsInput, rows: ProductRow[]): Promise { - if ((input.magasin ?? "TOTAL") !== "TOTAL") return; - try { - const n = await upsertGridRows(input.codeFournisseur, rows); - console.log(`[getProductRows] instantané persisté: ${n} lignes pour ${input.codeFournisseur}`); - } catch (e) { - console.error("[getProductRows] persistance grid_rows KO:", (e as Error).message?.slice(0, 200)); - } -} - -export async function getProductRows(input: GetProductRowsInput): Promise { - const cacheKey = gridRowsCacheKey(input); - const cached = gridRowsCache.get(cacheKey); - if (!input.forceRefresh && cached && Date.now() - cached.createdAt < GRID_ROWS_CACHE_TTL_MS) { - // Remonte l'entrée en tête de l'ordre d'usage (éviction LRU). - gridRowsCache.delete(cacheKey); - gridRowsCache.set(cacheKey, cached); - // La colonne INIT doit refléter l'état serveur à CHAQUE chargement, - // y compris sur un hit de cache (les données lourdes restent, elles, cachées). - await refreshGammeInit(cached.rows, input.codeFournisseur); - return cached.rows; - } - - // Un calcul déjà en cours est réutilisé même en rechargement forcé : il part - // de données fraîches, et en lancer un second en parallèle doublait la charge - // (double clic, onglet rouvert…) sans rien apporter. - const pending = gridRowsPending.get(cacheKey); - if (pending) { - return pending; - } - - // Instantané persisté (calcul de la nuit ou de la dernière ouverture) : servi - // en une lecture au lieu d'un recalcul de 10 à 40 s. Jamais en rechargement - // forcé — c'est précisément ce que l'utilisateur demande d'éviter. - if (!input.forceRefresh) { - const servi = await serveFromSnapshot(input, cacheKey); - if (servi) return servi; - } - - const versionAuDepart = gammeSaveVersion.get(input.codeFournisseur) ?? 0; - const promise = buildProductRows(input).then(async (rows) => { - if ((gammeSaveVersion.get(input.codeFournisseur) ?? 0) !== versionAuDepart) { - // Gammes enregistrées pendant le calcul : on reprend le dernier snapshot. - applySnapshotChanges(rows, await loadLatestSnapshotChanges(input.codeFournisseur)); - } - cacheGridRows(cacheKey, rows); - gridRowsPending.delete(cacheKey); - // Persiste l'instantané pour /api/v1 : le calcul vient d'avoir lieu, on - // arrête simplement de jeter le résultat. Volontairement NON bloquant — - // l'affichage de la Grille ne doit pas attendre l'écriture. - void persistGridSnapshot(input, rows); - return rows; - }).catch((error) => { - gridRowsPending.delete(cacheKey); - throw error; - }); - - gridRowsPending.set(cacheKey, promise); - return promise; -} - -/** Au-delà, l'instantané est servi mais un recalcul est relancé en arrière-plan. */ -const SNAPSHOT_REFRESH_AFTER_MS = 20 * 60 * 60 * 1000; - -/** - * Sert la Grille depuis `grid_rows` quand c'est possible : mêmes lignes que le - * calcul en direct (elles en sont issues), avec la gamme FF du moment (INIT) et - * les dernières gammes enregistrées reportées dessus. Hors « tous magasins », - * le rattrapage du magasin choisi est appliqué comme après un calcul. - * - * `null` = pas d'instantané utilisable (absent, ancien format, autre mois) : - * l'appelant recalcule. - */ -async function serveFromSnapshot(input: GetProductRowsInput, cacheKey: string): Promise { - const { codeFournisseur, magasin = "TOTAL" } = input; - let snapshot: Awaited>; - try { - snapshot = await readGridSnapshot(codeFournisseur); - } catch (e) { - console.error("[getProductRows] lecture grid_rows KO:", (e as Error).message?.slice(0, 200)); - return null; - } - if (!snapshot || snapshot.rows.length === 0) return null; - - const { rows } = snapshot; - // Les colonnes réseau de l'instantané datent de son calcul : on les relit dans - // le cache Qlik, comme le calcul en direct. Sans cela, une synchro Qlik faite - // depuis (au changement de mois notamment) restait invisible jusqu'au - // prochain recalcul, et la tendance réseau disparaissait. - const [changes, network] = await Promise.all([ - loadLatestSnapshotChanges(codeFournisseur), - fetchNetworkMetrics(rows.map((r) => r.codeCentrale ?? "")), - refreshGammeInit(rows, codeFournisseur), - ]); - applySnapshotChanges(rows, changes); - enrichWithNetworkMetrics(rows, network); - - if (magasin !== "TOTAL") { - const { dateDebut, dateFin } = buildLast12MonthsRange(); - await reconcileSelectedStoreFromMensuelApi(rows, magasin, dateDebut, dateFin, last12Periods()); - } - - cacheGridRows(cacheKey, rows); - - // Instantané qui n'a pas été recalculé par la nuit : on le rafraîchit sans - // faire attendre l'utilisateur (la prochaine ouverture en profitera). - // Toujours le calcul « tous magasins » : c'est lui qui réécrit l'instantané. - if (Date.now() - snapshot.computedAt.getTime() > SNAPSHOT_REFRESH_AFTER_MS && !gridRowsPending.has(`${codeFournisseur}:TOTAL`)) { - void getProductRows({ codeFournisseur, magasin: "TOTAL", forceRefresh: true }).catch((e) => - console.error("[getProductRows] rafraîchissement en arrière-plan KO:", (e as Error).message?.slice(0, 200)), - ); - } - return rows; -} - -/** Les 12 derniers mois complets, clés « YYYYMM » triées (même fenêtre que le calcul). */ -function last12Periods(): string[] { - const now = new Date(); - const periods: string[] = []; - for (let i = 12; i >= 1; i--) { - const d = new Date(now.getFullYear(), now.getMonth() - i, 1); - periods.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`); - } - return periods; -} - -async function buildProductRows(input: GetProductRowsInput): Promise { - const { codeFournisseur, magasin = "TOTAL" } = input; - console.log(`\n>>> [getProductRows] supplier: ${codeFournisseur}, magasin: ${magasin}`); - - try { - const { dateDebut, dateFin } = buildLast12MonthsRange(); - - // ─── Phase 1 : 7 requêtes SQL en parallèle ──────────────────────────── - // Remplace des centaines/milliers d'appels HTTP per-article. - const articlesPromise = pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; }); - // Phases 8 et 9 lancées dès maintenant : elles n'attendent que la liste des - // articles (codes centraux) ou le seul code fournisseur. Exécutées après la - // Phase 1, elles ajoutaient deux allers-retours en série à chaque calcul. - const networkPromise = articlesPromise.then((arts) => - fetchNetworkMetrics(arts.map((a) => (a.codeCentrale ? String(a.codeCentrale).trim() : ""))), - ); - const snapshotPromise = loadLatestSnapshotChanges(codeFournisseur); - - const [ - articles, - mensuelRows, - gammeMap, - nomMap, - stockMap, - commandesMap, - prixVenteMap, - ] = await Promise.all([ - articlesPromise, - pgGetMensuelByFournisseur(codeFournisseur, dateDebut, dateFin).catch(e => { console.error("[getProductRows] pgGetMensuelByFournisseur ERROR:", e); return []; }), - pgGetGammesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetGammesByFournisseur ERROR:", e); return new Map(); }), - pgGetNomenclatureByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetNomenclatureByFournisseur ERROR:", e); return new Map(); }), - pgGetStockByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetStockByFournisseur ERROR:", e); return new Map(); }), - pgGetCommandesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetCommandesByFournisseur ERROR:", e); return new Map(); }), - pgGetPrixVenteByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetPrixVenteByFournisseur ERROR:", e); return new Map>(); }), - ]); - - console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes`); - - // ─── Phase 2 : Fenêtre temporelle 12 mois complets ─────────────────── - const sortedPeriods = last12Periods(); - const allowedPeriods = new Set(sortedPeriods); - - // ─── Phase 3 : Seed productMap depuis articles ──────────────────────── - const productMap = new Map(); - for (const art of articles) { - if (!art.codein || productMap.has(art.codein)) continue; - productMap.set(art.codein, { - codein: art.codein, - codeFournisseur: art.codefou ?? codeFournisseur, - nomFournisseur: art.nomfou ?? "", - fournisseurPrincipalCode: art.codefou_principal ?? undefined, - fournisseurPrincipalNom: art.nomfou_principal ?? undefined, - libelle1: art.libelle1 ?? "", - gtin: art.gtin ?? "", - reference: art.reference ?? "", - codeCentrale: art.codeCentrale ? String(art.codeCentrale).trim() : undefined, - code1: "", libelleNiveau1: "", - code2: "", libelleNiveau2: "", - code3: "", libelle3: "", - codeGamme: null, - codeGammeInit: null, - codeGammeDraft: null, - sales12m: {}, - stock12m: {}, - receptions12m: {}, - totalQuantite: 0, - totalCa: 0, - totalMarge: 0, - tauxMarge: 0, - workingStores: [], - noid: art.no_id ? Number(art.no_id) : undefined, - pcb: art.pcb ? Number(art.pcb) : undefined, - prixVente: art.pv_central ? Number(art.pv_central) : undefined, - }); - } - - // ─── Phase 4 : Agréger mensuelRows → byPeriod par codein ───────────── - // SQL a déjà fait l'agrégation par (codein, site, mois). - // On somme les 2 sites (TOTAL) ET on accumule par site pour le switch client-side. - type PeriodData = { qty: number; ca: number; marge: number; stock: number; receptions: number }; - const mensuelByCodein = new Map>(); - const mensuelBySite = new Map>>(); // codein → site → periode → data - const storeMonthsByCodein = new Map>>(); - - for (const row of mensuelRows) { - const periode = row.mois.replace("-", ""); // "2026-02" → "202602" - if (!allowedPeriods.has(periode)) continue; - - // Accumulation TOTAL (toujours tous sites) - if (!mensuelByCodein.has(row.codein)) mensuelByCodein.set(row.codein, new Map()); - const byPeriod = mensuelByCodein.get(row.codein)!; - if (!byPeriod.has(periode)) byPeriod.set(periode, { qty: 0, ca: 0, marge: 0, stock: 0, receptions: 0 }); - const p = byPeriod.get(periode)!; - p.stock += Number(row.stock_fin_mois) || 0; - p.qty += Number(row.qte_vendue) || 0; - p.ca += Number(row.ca_ht) || 0; - p.marge += Number(row.marge) || 0; - p.receptions += Number(row.qte_recue) || 0; - - // Accumulation par site (pour switch client-side) - if (!mensuelBySite.has(row.codein)) mensuelBySite.set(row.codein, new Map()); - const bySite = mensuelBySite.get(row.codein)!; - if (!bySite.has(row.site)) bySite.set(row.site, new Map()); - const siteByPeriod = bySite.get(row.site)!; - if (!siteByPeriod.has(periode)) siteByPeriod.set(periode, { qty: 0, ca: 0, marge: 0, stock: 0, receptions: 0 }); - const sp = siteByPeriod.get(periode)!; - sp.qty += Number(row.qte_vendue) || 0; - sp.ca += Number(row.ca_ht) || 0; - sp.marge += Number(row.marge) || 0; - sp.stock = Number(row.stock_fin_mois) || 0; // SQL donne déjà le dernier mouvement - sp.receptions += Number(row.qte_recue) || 0; - - // Suivi magasins actifs (pour workingStores) : 1 vente suffit - if (Number(row.qte_vendue) > 0) { - if (!storeMonthsByCodein.has(row.codein)) storeMonthsByCodein.set(row.codein, new Map()); - storeMonthsByCodein.get(row.codein)!.set(row.site, new Set()); - } - } - - // ─── Phase 5 : Remplir ProductRow depuis mensuelByCodein ───────────── - for (const [codein, byPeriod] of mensuelByCodein.entries()) { - const product = productMap.get(codein); - if (!product) continue; - - let lastStock = 0; - for (const periode of sortedPeriods) { - const p = byPeriod.get(periode); - if (p) { - product.sales12m[periode] = p.qty; - product.stock12m[periode] = p.stock; - product.receptions12m[periode] = p.receptions; - product.totalQuantite += p.qty; - product.totalCa += p.ca; - product.totalMarge += p.marge; - lastStock = p.stock; - } else { - product.sales12m[periode] = 0; - product.stock12m[periode] = lastStock; // carry-forward - product.receptions12m[periode] = 0; - } - } - - product.tauxMarge = product.totalCa > 0 ? (product.totalMarge / product.totalCa) * 100 : 0; - - // Breakdowns par site pour le switch client-side - const siteMap = mensuelBySite.get(codein); - if (siteMap) { - product.sales12mByStore = {}; - product.stock12mByStore = {}; - product.receptions12mByStore = {}; - product.caByStore = {}; - product.quantiteByStore = {}; - product.margeByStore = {}; - for (const [site, siteByPeriod] of siteMap.entries()) { - product.sales12mByStore[site] = {}; - product.stock12mByStore[site] = {}; - product.receptions12mByStore![site] = {}; - let sQty = 0, sCa = 0, sMarge = 0; - let lastSiteStock = 0; - for (const periode of sortedPeriods) { - const sp = siteByPeriod.get(periode); - product.sales12mByStore[site][periode] = sp?.qty ?? 0; - product.receptions12mByStore![site][periode] = sp?.receptions ?? 0; - if (sp) { - product.stock12mByStore[site][periode] = sp.stock; - lastSiteStock = sp.stock; - } else { - product.stock12mByStore[site][periode] = lastSiteStock; // carry-forward - } - sQty += sp?.qty ?? 0; - sCa += sp?.ca ?? 0; - sMarge += sp?.marge ?? 0; - } - product.quantiteByStore[site] = sQty; - product.caByStore[site] = sCa; - product.margeByStore[site] = sMarge; - } - - // Le stock TOTAL se recalcule ici, à partir des séries par site. - // - // Il était sommé plus haut depuis les lignes mensuelles brutes, ce - // qui perdait tout magasin SANS MOUVEMENT dans le mois : le stock - // est un niveau, pas un flux — un magasin qui n'a rien vendu ni reçu - // détient toujours sa marchandise. Sur un produit à faible rotation, - // « tous magasins » n'affichait donc que le site actif, en pratique - // Frouard. Les séries par site, elles, sont reportées d'un mois sur - // l'autre : leur somme est la bonne. - const sites = Object.keys(product.stock12mByStore); - if (sites.length > 0) { - for (const periode of sortedPeriods) { - product.stock12m[periode] = sites.reduce( - (total, site) => total + (product.stock12mByStore![site][periode] ?? 0), - 0, - ); - } - } - } - - // workingStores : sites avec au moins 1 vente sur les 12 derniers mois - const storeMonths = storeMonthsByCodein.get(codein); - if (storeMonths) { - product.workingStores = [...storeMonths.keys()].sort(); - } - - // Commandes en cours - const cmdQty = commandesMap.get(codein); - if (cmdQty) product.commandesEnCours = cmdQty; - } - - // ─── Phase 6 : Gammes (saison active) ──────────────────────────────── - for (const [codein, gammeCode] of gammeMap.entries()) { - const product = productMap.get(codein); - if (!product) continue; - product.codeGammeInit = gammeCode as GammeCode; - product.codeGamme = gammeCode as GammeCode; - } - - // ─── Phase 6b : Nomenclature (famille / sous-famille) ──────────────── - for (const [codein, nom] of nomMap.entries()) { - const product = productMap.get(codein); - if (!product) continue; - product.code3 = nom.code3 ?? ""; - product.libelle3 = nom.libelle3 ?? ""; - product.code2 = nom.code2 ?? ""; - product.libelleNiveau2 = nom.libelle2 ?? ""; - product.code1 = nom.code1 ?? ""; - product.libelleNiveau1 = nom.libelle1nom ?? ""; - } - - // ─── Phase 7 : Stock temps réel (cube_stock) ───────────────────────── - for (const [codein, stocks] of stockMap.entries()) { - const product = productMap.get(codein); - if (!product) continue; - - const sitesStock = stocks; - - product.stockActuel = sitesStock.reduce((s, r) => s + (Number(r.stockdispo) || 0), 0); - product.stockTotal = sitesStock.reduce((s, r) => s + (Number(r.qte) || 0), 0); - product.stockValeur = sitesStock.reduce((s, r) => s + (Number(r.valstock) || 0), 0); - - const withPrmp = sitesStock.find(r => Number(r.prmp) > 0); - if (withPrmp) { - product.pa = Number(withPrmp.prmp); - product.prixAchat = product.pa; - } - - const dernVente = sitesStock.reduce((best, r) => - r.dernierevente && r.dernierevente > best ? r.dernierevente : best, ""); - if (dernVente) product.derniereVente = dernVente; - - const dernLiv = sitesStock.reduce((best, r) => - r.dernierereception && r.dernierereception > best ? r.dernierereception : best, ""); - if (dernLiv) product.derniereLivraison = dernLiv; - - // Map store-specific delivery dates - product.derniereLivraisonByStore = {}; - for (const r of sitesStock) { - if (r.site && r.dernierereception) { - product.derniereLivraisonByStore[r.site] = r.dernierereception; - } - } - } - - // ─── Phase 7b : Prix de vente (cube_pv, sinon le PV du cube de stock) ─ - // - // `article_infosup.prix_vente_mini`, renseigné en Phase 3, n'est qu'une - // borne de paramétrage : elle est vide en base et laissait la colonne PV - // entièrement creuse. Le vrai prix vit dans `cube_pv`, par article ET par - // site. On garde le détail par magasin — deux magasins peuvent ne pas - // pratiquer le même prix, et l'afficher vaut mieux que le moyenner. - for (const product of productMap.values()) { - const parSite = prixVenteMap.get(product.codein); - if (parSite && Object.keys(parSite).length > 0) { - product.prixVenteByStore = parSite; - } else { - // Filet de secours : le cube de stock porte le même PV, mais - // seulement pour les articles qui ont une ligne de stock. - const depuisStock: Record = {}; - for (const s of stockMap.get(product.codein) ?? []) { - const pv = Number(s.pv); - const site = String(s.site ?? "").trim(); - if (site && Number.isFinite(pv) && pv > 0) depuisStock[site] = pv; - } - if (Object.keys(depuisStock).length > 0) product.prixVenteByStore = depuisStock; - } - - const prix = Object.values(product.prixVenteByStore ?? {}); - // Le plus élevé quand les magasins divergent : une moyenne inventerait - // un prix qu'aucune caisse ne pratique. L'écart est signalé à l'écran. - if (prix.length > 0) product.prixVente = Math.max(...prix); - } - - // ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ── - enrichWithNetworkMetrics(productMap.values(), await networkPromise); - - // ─── Phase 9 : Restaurer gammes depuis dernier snapshot ────────────── - // La colonne Gamme (codeGamme) conserve la valeur du snapshot telle quelle. - // La colonne INIT (codeGammeInit) reste, elle, l'état LIVE du serveur - // rechargé en Phase 6 — jamais écrasée ici. - applySnapshotChanges(productMap.values(), await snapshotPromise); - - // ─── Phase 10 : Filtrer gamme Y sans ventes ────────────────────────── - 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`); - - return rows; - - } catch (error) { - console.error(`[getProductRows] Error for ${codeFournisseur}:`, error); - // Ne PAS renvoyer [] : une liste vide est indiscernable d'un fournisseur - // sans article. La Grille affichait une page blanche sans explication, et - // la synchro nocturne prenait la panne pour un fournisseur vide — qu'elle - // désactivait alors automatiquement. La remonter laisse chaque appelant la - // traiter comme une erreur (bandeau rouge, statut « echec »). - throw error instanceof Error - ? error - : new Error(`Calcul de la grille impossible pour ${codeFournisseur} : ${String(error)}`); - } -} - -/** Articles complétés par l'API FF, au plus, lors d'un changement de magasin. */ -function plafondComplement(): number { - return Number(process.env.GRID_STORE_RECONCILE_MAX ?? 300); -} - -/** Interroge l'API FF pour les articles du magasin à compléter (cf. store-patch). */ -async function fetchStorePatchEntries( - rows: ProductRow[], - magasin: string, - dateDebut: string, - dateFin: string, - periods: string[], -): Promise { - const candidates = storePatchCandidates(rows, magasin); - if (candidates.length === 0) return []; - - // Ce rattrapage fait UNE requête HTTP par article : sur un gros fournisseur, - // les candidats se comptent par milliers et le changement de magasin se fige - // plusieurs minutes. On le borne, et on dit ce qui a été laissé de côté - // plutôt que de tronquer en silence. - const PLAFOND = plafondComplement(); - const retenus = candidates.slice(0, PLAFOND); - if (candidates.length > retenus.length) { - console.warn( - `[getProductRows] rattrapage magasin ${magasin} borné à ${retenus.length} articles` - + ` sur ${candidates.length} : les autres gardent la valeur SQL.`, - ); - } - - const mensuelMap = await getMensuelByArticles( - retenus.map((row) => ({ - codein: row.codein, - libelle1: row.libelle1, - codefou: row.codeFournisseur, - noid: row.noid, - })), - dateDebut, - dateFin, - 25 - ); - return buildStorePatchEntries(retenus, magasin, periods, mensuelMap); -} - -async function reconcileSelectedStoreFromMensuelApi( - rows: ProductRow[], - magasin: string, - dateDebut: string, - dateFin: string, - sortedPeriods: string[] -) { - if (magasin === "TOTAL") return; - try { - const entries = await fetchStorePatchEntries(rows, magasin, dateDebut, dateFin, sortedPeriods); - const fixedRows = applyStorePatchInPlace(rows, magasin, sortedPeriods, entries); - 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); - } -} - -/** - * Compléments par magasin, rattachés au tableau de lignes « tous magasins » en - * cache : ils vivent et meurent avec lui (expiration, « Actualiser », nouvel - * enregistrement), sans autre invalidation à gérer. - */ -const storePatches = new WeakMap>>(); - -/** - * Complément de l'API FF pour un magasin, calculé sur les lignes « tous - * magasins » (jamais modifiées ici). La Grille l'applique à son arrivée, sans - * recharger les lignes. Les demandes simultanées partagent le même calcul, et - * un échec n'est jamais gardé. - */ -export async function getStorePatch(codeFournisseur: string, magasin: string): Promise { - const enCache = gridRowsCache.get(`${codeFournisseur}:TOTAL`); - const rows = enCache && Date.now() - enCache.createdAt < GRID_ROWS_CACHE_TTL_MS - ? enCache.rows - : await getProductRows({ codeFournisseur, magasin: "TOTAL" }); - - let parMagasin = storePatches.get(rows); - if (!parMagasin) { - parMagasin = new Map(); - storePatches.set(rows, parMagasin); - } - let patch = parMagasin.get(magasin); - if (!patch) { - const periods = last12Periods(); - const { dateDebut, dateFin } = buildLast12MonthsRange(); - const enCours = fetchStorePatchEntries(rows, magasin, dateDebut, dateFin, periods) - .then((entries): StorePatch => ({ magasin, periods, entries })); - const table = parMagasin; - enCours.catch(() => { - if (table.get(magasin) === enCours) table.delete(magasin); - }); - parMagasin.set(magasin, enCours); - patch = enCours; - } - return patch; -} - -/** - * Extrait la série « nombre de magasins vendeurs » du détail mensuel complet. - * - * `metricsByMonth` porte les cinq mesures Qlik du mois ; seule `nbMag` sert à la - * deuxième courbe de la carte tendance. Renvoie `null` plutôt qu'un objet vide - * pour que l'UI distingue « pas de donnée » de « zéro magasin ». - */ -function nbMagParMois( - metricsByMonth: Record | null | undefined, -): Record | null { - if (!metricsByMonth) return null; - const parMois: Record = {}; - for (const [mois] of Object.entries(metricsByMonth)) { - // Un mois PRÉSENT dans le détail est un mois extrait : s'il n'a pas de - // `nbMag`, c'est qu'aucun magasin n'a vendu, donc zéro. Le traiter comme - // « manquant » amputait la série et faisait disparaître toute la courbe - // (les anciens caches n'écrivaient `nbMag` que sur les mois avec vente). - const nb = Number(metricsByMonth[mois]?.nbMag); - parMois[mois] = Number.isFinite(nb) ? nb : 0; - } - return Object.keys(parMois).length > 0 ? parMois : null; -} - -type SnapshotChanges = Record; - -/** - * Phase 9 : la colonne Gamme (codeGamme) reprend la valeur du snapshot ; - * codeGammeInit reste figé (état serveur). - */ -function applySnapshotChanges(rows: Iterable, changes: SnapshotChanges | null): void { - if (!changes) return; - for (const row of rows) { - const change = changes[row.codein]; - if (change?.after) row.codeGamme = change.after as GammeCode; - } -} - -/** Dernières gammes enregistrées pour ce fournisseur (Phase 9), ou `null`. */ -async function loadLatestSnapshotChanges(codeFournisseur: string): Promise { - try { - const snaps = await db - .select({ changes: sessionSnapshots.changes }) - .from(sessionSnapshots) - .where(eq(sessionSnapshots.codeFournisseur, codeFournisseur)) - .orderBy(desc(sessionSnapshots.createdAt)) - .limit(1); - return snaps.length > 0 ? (snaps[0].changes as SnapshotChanges) : null; - } catch (snapErr) { - console.error("[getProductRows] Snapshot restore error:", snapErr); - return null; - } -} - -/** - * Lit les metriques reseau Qlik (cache qlik_network_metrics) des codes centraux - * donnes. `null` en cas d'erreur : la grille s'affiche alors sans donnees reseau. - */ -async function fetchNetworkMetrics( - codesCentrale: string[], -): Promise | null> { - try { - const codes = [...new Set(codesCentrale.filter(Boolean))]; - if (codes.length === 0) return null; - return await getNetworkMetricsByCodeCentrale(codes); - } catch (error) { - console.error("[getProductRows] enrichWithNetworkMetrics error:", error); - return null; - } -} - -/** - * Enrichit les produits avec les metriques reseau Qlik, jointes par code centrale. - * Degradation propre si la sync Qlik n'a jamais tourne ou si le code centrale - * n'est pas encore disponible. - */ -function enrichWithNetworkMetrics( - products: Iterable, - metrics: Map | null, -): void { - if (!metrics || metrics.size === 0) return; - for (const product of products) { - const m = product.codeCentrale ? metrics.get(product.codeCentrale) : undefined; - if (!m) continue; - product.caReseau = m.caReseau; - product.qteReseau = m.qteReseau; - product.nbMagasinsReseau = m.nbMagasinsReseau; - product.caParMagasinReseau = m.caParMagasinReseau; - product.margePctReseau = m.margePctReseau; - product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU; - product.qteReseauByMonth = m.qteByMonth ?? null; - product.nbMagReseauByMonth = nbMagParMois(m.metricsByMonth); - product.networkFetchedAt = m.fetchedAt ?? undefined; - } -} diff --git a/src/features/grid/api/save-draft-changes.ts b/src/features/grid/api/save-draft-changes.ts index 1c432cd..f04b3dc 100644 --- a/src/features/grid/api/save-draft-changes.ts +++ b/src/features/grid/api/save-draft-changes.ts @@ -1,51 +1,25 @@ -"use server"; +/** + * Enregistrement des gammes modifiées dans la Grille — action exécutée par le + * serveur Rust : `POST /api/actions/saveDraftChanges` (validation comprise). + */ -import { z } from "zod"; -import { verifierSession } from "@/lib/authz"; -import { enregistrerGammes } from "./enregistrer-gammes"; -import { supprimerGammesAValider } from "@/lib/gammes-a-valider"; +import { callAction } from "@/lib/rpc"; -const SaveDraftsSchema = z.object({ - codeFournisseur: z.string(), - nomFournisseur: z.string().optional(), - magasin: z.string(), - changes: z.array( - z.object({ - codein: z.string(), - codeGammeBefore: z.string().nullable(), - codeGamme: z.string(), - }) - ), +/** Données attendues par le serveur (ex-schéma zod `SaveDraftsSchema`). */ +export interface SaveDraftsInput { + codeFournisseur: string; + nomFournisseur?: string; + magasin: string; + changes: { codein: string; codeGammeBefore: string | null; codeGamme: string }[]; /** * Propositions de l'API examinées dans la Grille : validées (présentes dans * `changes`) ou écartées. Elles quittent la file « à valider ». */ - gammesAValiderTraitees: z.array(z.string()).optional(), -}); + gammesAValiderTraitees?: string[]; +} export async function saveDraftChanges( raw: unknown ): Promise<{ success: boolean; saved: number; error?: string }> { - const acces = await verifierSession(); - if (!acces.ok) return { success: false, saved: 0, error: acces.message }; - - const parsed = SaveDraftsSchema.safeParse(raw); - if (!parsed.success) { - return { success: false, saved: 0, error: "Validation failed: " + parsed.error.message }; - } - - const { codeFournisseur, nomFournisseur, magasin, changes, gammesAValiderTraitees = [] } = parsed.data; - - const rawUserId = acces.utilisateur.id; - const userId = rawUserId ? parseInt(rawUserId, 10) : null; - const finalUserId = userId && !isNaN(userId) ? userId : null; - - try { - const saved = await enregistrerGammes({ codeFournisseur, nomFournisseur, magasin, changes, userId: finalUserId }); - await supprimerGammesAValider(codeFournisseur, gammesAValiderTraitees); - return { success: true, saved }; - } catch (err) { - const msg = err instanceof Error ? err.message : "Unknown error"; - return { success: false, saved: 0, error: msg }; - } + return callAction("saveDraftChanges", [raw]); } diff --git a/src/features/grid/components/assortment-gaps-modal.tsx b/src/features/grid/components/assortment-gaps-modal.tsx index d691a23..6c5ae58 100644 --- a/src/features/grid/components/assortment-gaps-modal.tsx +++ b/src/features/grid/components/assortment-gaps-modal.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * CollectFlow — Trous d'assortiment : ce qu'un magasin ne travaille pas. * diff --git a/src/features/grid/components/bulk-action-toolbar.tsx b/src/features/grid/components/bulk-action-toolbar.tsx index 962845d..8d4d4c3 100644 --- a/src/features/grid/components/bulk-action-toolbar.tsx +++ b/src/features/grid/components/bulk-action-toolbar.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useGridStore } from "@/features/grid/store/use-grid-store"; import { GammeCode } from "@/types/grid"; import { cn } from "@/lib/utils"; diff --git a/src/features/grid/components/floating-summary-bar.tsx b/src/features/grid/components/floating-summary-bar.tsx index 448c3ba..20c69e9 100644 --- a/src/features/grid/components/floating-summary-bar.tsx +++ b/src/features/grid/components/floating-summary-bar.tsx @@ -1,5 +1,3 @@ -"use client"; - import React from "react"; import { useGridStore } from "@/features/grid/store/use-grid-store"; diff --git a/src/features/grid/components/gamme-select.tsx b/src/features/grid/components/gamme-select.tsx index 37741c5..7963b97 100644 --- a/src/features/grid/components/gamme-select.tsx +++ b/src/features/grid/components/gamme-select.tsx @@ -1,5 +1,3 @@ -"use client"; - import { GammeCode } from "@/types/grid"; import { cn } from "@/lib/utils"; import { CLASSES_SANS_GAMME, GAMMES, trouverGamme } from "@/lib/gammes"; diff --git a/src/features/grid/components/grid-client.tsx b/src/features/grid/components/grid-client.tsx index 34bf1ca..c8cccb5 100644 --- a/src/features/grid/components/grid-client.tsx +++ b/src/features/grid/components/grid-client.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useEffect, useRef, useState } from "react"; import { HeatmapGrid } from "@/features/grid/components/heatmap-grid"; import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar"; diff --git a/src/features/grid/components/grid-filter-bar.tsx b/src/features/grid/components/grid-filter-bar.tsx index ea1fea0..12c6c5f 100644 --- a/src/features/grid/components/grid-filter-bar.tsx +++ b/src/features/grid/components/grid-filter-bar.tsx @@ -1,5 +1,3 @@ -"use client"; - import React, { useMemo } from "react"; import { Search, X } from "lucide-react"; import { useGridStore } from "@/features/grid/store/use-grid-store"; diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 1f9c071..2b9d896 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -1,5 +1,3 @@ -"use client"; - import React, { useRef, useState, useCallback, useEffect, useMemo } from "react"; import { createPortal } from "react-dom"; import { diff --git a/src/features/grid/components/network-charts.tsx b/src/features/grid/components/network-charts.tsx index 645999c..920b0b2 100644 --- a/src/features/grid/components/network-charts.tsx +++ b/src/features/grid/components/network-charts.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * CollectFlow — Graphiques réseau (SVG fait main, aucune librairie de charts). * diff --git a/src/features/grid/components/network-monthly-modal.tsx b/src/features/grid/components/network-monthly-modal.tsx index 26d4499..2aa19ee 100644 --- a/src/features/grid/components/network-monthly-modal.tsx +++ b/src/features/grid/components/network-monthly-modal.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * CollectFlow — Carte « tendance réseau », ouverte depuis la colonne Tendance. * diff --git a/src/features/grid/components/nomenclature-ca-modal.tsx b/src/features/grid/components/nomenclature-ca-modal.tsx index 5b7e7c6..b58aef9 100644 --- a/src/features/grid/components/nomenclature-ca-modal.tsx +++ b/src/features/grid/components/nomenclature-ca-modal.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * CollectFlow — CA par nomenclature : nos magasins face au réseau. * diff --git a/src/features/grid/components/nomenclature-filter.tsx b/src/features/grid/components/nomenclature-filter.tsx index 867c5da..74061d6 100644 --- a/src/features/grid/components/nomenclature-filter.tsx +++ b/src/features/grid/components/nomenclature-filter.tsx @@ -1,5 +1,3 @@ -"use client"; - import * as React from "react"; import { cn } from "@/lib/utils"; import { ChevronDown, Filter, Search, X } from "lucide-react"; diff --git a/src/features/grid/components/product-monthly-modal.tsx b/src/features/grid/components/product-monthly-modal.tsx index 3a8f6d8..a0eb99b 100644 --- a/src/features/grid/components/product-monthly-modal.tsx +++ b/src/features/grid/components/product-monthly-modal.tsx @@ -1,5 +1,3 @@ -"use client"; - /** * CollectFlow — Détail mensuel d'un produit, ouvert depuis la case « Tot. 12m ». * diff --git a/src/features/grid/components/stat-tile.tsx b/src/features/grid/components/stat-tile.tsx index 7360a61..bbf6024 100644 --- a/src/features/grid/components/stat-tile.tsx +++ b/src/features/grid/components/stat-tile.tsx @@ -1,5 +1,3 @@ -"use client"; - import React from "react"; /** diff --git a/src/features/grid/components/store-combobox.tsx b/src/features/grid/components/store-combobox.tsx index db5949b..9c05719 100644 --- a/src/features/grid/components/store-combobox.tsx +++ b/src/features/grid/components/store-combobox.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useMemo } from "react"; import { Segmented } from "@/components/ui/tabs"; import { CODE_TOUS_MAGASINS, LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins"; diff --git a/src/features/grid/components/supplier-combobox.tsx b/src/features/grid/components/supplier-combobox.tsx index fc50df9..ebad548 100644 --- a/src/features/grid/components/supplier-combobox.tsx +++ b/src/features/grid/components/supplier-combobox.tsx @@ -1,5 +1,3 @@ -"use client"; - import * as React from "react"; import { Check, ChevronsUpDown, Search } from "lucide-react"; import { cn } from "@/lib/utils"; diff --git a/src/features/grid/components/supplier-selection-landing.tsx b/src/features/grid/components/supplier-selection-landing.tsx index 7254862..d68190e 100644 --- a/src/features/grid/components/supplier-selection-landing.tsx +++ b/src/features/grid/components/supplier-selection-landing.tsx @@ -1,5 +1,3 @@ -"use client"; - import * as React from "react"; import { LayoutGrid } from "lucide-react"; import { useRouter } from "next/navigation"; diff --git a/src/features/grid/components/sync-qlik-button.tsx b/src/features/grid/components/sync-qlik-button.tsx index 3387492..4ba5991 100644 --- a/src/features/grid/components/sync-qlik-button.tsx +++ b/src/features/grid/components/sync-qlik-button.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useGridStore } from "@/features/grid/store/use-grid-store"; import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react"; import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job"; diff --git a/src/features/grid/hooks/use-save-drafts.ts b/src/features/grid/hooks/use-save-drafts.ts index 5cdc49f..2af1e64 100644 --- a/src/features/grid/hooks/use-save-drafts.ts +++ b/src/features/grid/hooks/use-save-drafts.ts @@ -1,5 +1,3 @@ -"use client"; - import { useCallback } from "react"; import { useGridStore } from "@/features/grid/store/use-grid-store"; import { saveDraftChanges } from "@/features/grid/api/save-draft-changes"; diff --git a/src/features/grid/hooks/use-store-patch.ts b/src/features/grid/hooks/use-store-patch.ts index 818c600..b72b812 100644 --- a/src/features/grid/hooks/use-store-patch.ts +++ b/src/features/grid/hooks/use-store-patch.ts @@ -1,5 +1,3 @@ -"use client"; - import { useEffect } from "react"; import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store"; import { CODE_TOUS_MAGASINS } from "@/lib/magasins"; diff --git a/src/features/grid/lib/store-patch.ts b/src/features/grid/lib/store-patch.ts index dd66ade..ceb619e 100644 --- a/src/features/grid/lib/store-patch.ts +++ b/src/features/grid/lib/store-patch.ts @@ -10,7 +10,7 @@ */ import type { ProductRow } from "@/types/grid"; -import type { FfMensuelEntry } from "@/lib/api-ff-client"; +import type { FfMensuelEntry } from "@/types/ff"; /** Chiffres d'un article pour le magasin complété. */ export interface StorePatchEntry { diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts index 2579754..b17e013 100644 --- a/src/features/grid/store/use-grid-store.ts +++ b/src/features/grid/store/use-grid-store.ts @@ -1,5 +1,3 @@ -"use client"; - import { create } from "zustand"; import { persist } from "zustand/middleware"; import { createThrottledJSONStorage } from "@/lib/throttled-json-storage"; diff --git a/src/features/produits/api/get-produit-fiche.ts b/src/features/produits/api/get-produit-fiche.ts deleted file mode 100644 index 90fd8a1..0000000 --- a/src/features/produits/api/get-produit-fiche.ts +++ /dev/null @@ -1,210 +0,0 @@ -import "server-only"; - -import { - pgGetProduitDetail, - pgGetCodeinByCodeCentrale, - pgGetFournisseursByCodein, - pgGetStockByCodein, - pgGetCommandesByCodein, - pgGetGammeHistoryByCodein, - pgGetMensuelByCodein, -} from "@/lib/pg-ff-client"; -import { buildLast12MonthsRange } from "@/lib/api-ff-client"; -import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache"; -import type { ProduitFiche, ProduitMois, ProduitTotaux } from "@/features/produits/types"; - -/** Les 12 clés de mois "YYYYMM" glissantes, mois courant exclu. */ -function buildMonthKeys(): string[] { - const months: string[] = []; - const now = new Date(); - for (let i = 12; i >= 1; i--) { - const d = new Date(now.getFullYear(), now.getMonth() - i, 1); - months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`); - } - return months; -} - -function emptyMois(mois: string): ProduitMois { - return { mois, qte: 0, ca: 0, marge: 0, stockFinMois: 0, qteRecue: 0 }; -} - -function totauxFrom(rows: ProduitMois[]): ProduitTotaux { - const qte = rows.reduce((s, r) => s + r.qte, 0); - const ca = rows.reduce((s, r) => s + r.ca, 0); - const marge = rows.reduce((s, r) => s + r.marge, 0); - return { qte, ca, marge, tauxMarge: ca > 0 ? (marge / ca) * 100 : 0 }; -} - -/** - * Assemble la fiche d'un produit **à partir de son code centrale** : c'est la - * clé du réseau Qlik, seule identité disponible pour un produit que le réseau - * travaille mais que Nancy ne référence pas. - * - * Si le catalogue Nancy connaît ce code centrale, on rattache toute la partie - * locale (identité, fournisseurs, stock, gammes, 12 mois glissants de ventes). - * Sinon la fiche reste purement réseau, ce qui est un cas parfaitement normal - * depuis la recherche Qlik. - * - * Renvoie `null` uniquement si ni Qlik ni le catalogue ne connaissent le code. - */ -export async function getProduitFicheByCodeCentrale(codeCentrale: string): Promise { - const cleaned = codeCentrale.trim(); - if (!cleaned) return null; - - const codein = await pgGetCodeinByCodeCentrale(cleaned); - if (codein) return getProduitFiche(codein); - - // Produit réseau pur : aucune donnée locale à assembler. - const map = await getNetworkMetricsByCodeCentrale([cleaned]); - const reseau = map.get(cleaned) ?? null; - if (!reseau) return null; - - const { dateDebut, dateFin } = buildLast12MonthsRange(); - const months = buildMonthKeys(); - const mensuelTotal: Record = {}; - for (const m of months) mensuelTotal[m] = emptyMois(m); - - return { - detail: null, - codeCentrale: cleaned, - libelleReseau: reseau.libelleReseau ?? "", - fournisseurReseau: reseau.fournisseurReseau ?? "", - fournisseurs: [], - stock: [], - commandesEnCours: 0, - gammes: [], - months, - mensuelTotal, - mensuelParSite: {}, - totaux: totauxFrom(months.map((m) => mensuelTotal[m])), - totauxParSite: {}, - sitesActifs: [], - reseau, - periode: { dateDebut, dateFin }, - }; -} - -/** - * Assemble la fiche complète d'un produit : identité, fournisseurs, stock, - * gammes, 12 mois glissants de ventes (total et par magasin) et métriques - * réseau Qlik. - * - * Renvoie `null` si le codein est inconnu du catalogue FF. - */ -export async function getProduitFiche(codein: string): Promise { - const cleaned = codein.trim(); - if (!cleaned) return null; - - const { dateDebut, dateFin } = buildLast12MonthsRange(); - - // Tout part en même temps : l'identité du produit était lue seule d'abord, - // puis les cinq autres lectures, puis les métriques réseau — trois attentes - // successives. Les métriques réseau n'attendent que l'identité (code centrale). - const detailPromise = pgGetProduitDetail(cleaned); - const reseauPromise = detailPromise.then(async (d) => { - if (!d?.code_centrale) return null; - const map = await getNetworkMetricsByCodeCentrale([d.code_centrale]); - return map.get(d.code_centrale) ?? null; - }); - const [detail, fournisseurs, stock, commandesEnCours, gammes, mensuelRows, reseau] = await Promise.all([ - detailPromise, - pgGetFournisseursByCodein(cleaned), - pgGetStockByCodein(cleaned), - pgGetCommandesByCodein(cleaned), - pgGetGammeHistoryByCodein(cleaned), - pgGetMensuelByCodein(cleaned, dateDebut, dateFin), - reseauPromise, - ]); - if (!detail) return null; - - const months = buildMonthKeys(); - const allowed = new Set(months); - - const mensuelTotal: Record = {}; - const mensuelParSite: Record> = {}; - for (const m of months) mensuelTotal[m] = emptyMois(m); - - // Mois réellement présents dans les mouvements. Indispensable pour distinguer - // « pas de mouvement ce mois-là » (→ report du stock précédent) de « stock - // effectivement nul » — c'est la convention de la Grille. - const moisAvecDonnees = new Set(); - const moisAvecDonneesParSite = new Map>(); - - for (const row of mensuelRows) { - // SQL renvoie "YYYY-MM", la timeline de l'app utilise "YYYYMM". - const mois = row.mois.replace("-", ""); - if (!allowed.has(mois)) continue; - const site = String(row.site).trim(); - - const qte = Number(row.qte_vendue) || 0; - const ca = Number(row.ca_ht) || 0; - const marge = Number(row.marge) || 0; - const stockFinMois = Number(row.stock_fin_mois) || 0; - const qteRecue = Number(row.qte_recue) || 0; - - moisAvecDonnees.add(mois); - const tot = mensuelTotal[mois]; - tot.qte += qte; - tot.ca += ca; - tot.marge += marge; - tot.stockFinMois += stockFinMois; - tot.qteRecue += qteRecue; - - mensuelParSite[site] ??= Object.fromEntries(months.map((m) => [m, emptyMois(m)])); - if (!moisAvecDonneesParSite.has(site)) moisAvecDonneesParSite.set(site, new Set()); - moisAvecDonneesParSite.get(site)!.add(mois); - const cell = mensuelParSite[site][mois]; - cell.qte += qte; - cell.ca += ca; - cell.marge += marge; - // Le SQL donne déjà le stock du dernier mouvement du mois pour ce site. - cell.stockFinMois = stockFinMois; - cell.qteRecue += qteRecue; - } - - // Report du stock de fin de mois sur les mois SANS mouvement (carry-forward), - // même convention que la Grille : un mois sans mouvement conserve le stock du - // mois précédent, alors qu'un mois avec mouvements et un stock nul reste à 0. - const carryForward = (serie: Record, avecDonnees: Set) => { - let last = 0; - for (const m of months) { - if (avecDonnees.has(m)) last = serie[m].stockFinMois; - else serie[m].stockFinMois = last; - } - }; - carryForward(mensuelTotal, moisAvecDonnees); - for (const [site, bySite] of Object.entries(mensuelParSite)) { - carryForward(bySite, moisAvecDonneesParSite.get(site) ?? new Set()); - } - - const totaux = totauxFrom(months.map((m) => mensuelTotal[m])); - const totauxParSite: Record = {}; - for (const [site, bySite] of Object.entries(mensuelParSite)) { - totauxParSite[site] = totauxFrom(months.map((m) => bySite[m])); - } - - const sitesActifs = Object.entries(totauxParSite) - .filter(([, t]) => t.qte > 0) - .map(([site]) => site) - .sort(); - - - return { - detail, - codeCentrale: detail.code_centrale ?? "", - libelleReseau: reseau?.libelleReseau ?? "", - fournisseurReseau: reseau?.fournisseurReseau ?? "", - fournisseurs, - stock, - commandesEnCours, - gammes, - months, - mensuelTotal, - mensuelParSite, - totaux, - totauxParSite, - sitesActifs, - reseau, - periode: { dateDebut, dateFin }, - }; -} diff --git a/src/features/produits/api/search-produits.ts b/src/features/produits/api/search-produits.ts deleted file mode 100644 index 02c70a5..0000000 --- a/src/features/produits/api/search-produits.ts +++ /dev/null @@ -1,445 +0,0 @@ -import "server-only"; - -import { searchQlikArticles, QLIK_SEARCH_MAX_RESULTS } from "@/lib/qlik-search"; -import type { NetworkMetric } from "@/lib/qlik-client"; -import { - getNetworkMetricsByCodeCentrale, - upsertNetworkMetrics, - type NetworkMetricCached, -} from "@/lib/qlik-network-cache"; -import { pgGetProduitsByCodeCentrale, pgSearchProduits, type PgProduitSearchRow } from "@/lib/pg-ff-client"; -import { buildRolling12QlikMonths, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; -import { normalizeMargePct } from "@/features/produits/lib/compare-reseau"; -import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types"; - -/** - * CollectFlow — Recherche produit **Qlik d'abord**, base FF Nancy ensuite. - * - * Déroulé : - * 1. Qlik Sense, **une seule session** : les articles correspondant au terme ET - * leurs mesures réseau sur 12 mois glissants (`searchQlikArticles`). - * 2. Base FF Nancy : lesquels référençons-nous ? (`pgGetProduitsByCodeCentrale`) - * 3. Cache `qlik_network_metrics` : détail mensuel quand une sync l'a déjà - * extrait (le cube de recherche est agrégé, il ne le fournit pas). - * - * Une seconde session Qlik pour les mesures faisait abandonner le moteur - * (`code 15 — Request aborted`) : deux sessions concurrentes sur la même app, - * dont une avec une grosse sélection, et l'Engine coupe les requêtes. - * - * Si Qlik est injoignable, on se rabat sur la recherche catalogue local pour ne - * pas laisser l'utilisateur sans résultat — la réponse le signale explicitement - * (`source: "db"`). - */ - -/** Durée de validité d'un résultat de recherche en cache mémoire. */ -const CACHE_TTL_MS = 10 * 60 * 1000; - -/** Durée de conservation d'un job terminé (le client a le temps de le relire). */ -const JOB_TTL_MS = 5 * 60 * 1000; - -/** - * Série « magasins vendeurs » extraite du détail mensuel du cache. - * - * Un mois PRÉSENT dans le détail est un mois extrait : sans `nbMag`, c'est - * qu'aucun magasin n'a vendu, donc zéro. Le traiter comme manquant amputerait la - * série et priverait la tendance de son dénominateur. - */ -function nbMagParMois( - metricsByMonth: Record | null | undefined, -): Record | null { - if (!metricsByMonth) return null; - const parMois: Record = {}; - for (const mois of Object.keys(metricsByMonth)) { - const nb = Number(metricsByMonth[mois]?.nbMag); - parMois[mois] = Number.isFinite(nb) ? nb : 0; - } - return Object.keys(parMois).length > 0 ? parMois : null; -} - -/** Un cache réseau n'est fiable que s'il porte explicitement les 12 mois attendus. */ -function cacheReseauComplet( - cache: NetworkMetricCached | undefined, - periode: string | null, - now: Date = new Date(), -): cache is NetworkMetricCached { - const parMois = cache?.qteByMonth; - if (!parMois || !periode || cache.periode !== periode) return false; - return buildRolling12QlikMonths(now).every((mois) => - Object.prototype.hasOwnProperty.call(parMois, mois) && - Number.isFinite(Number(parMois[mois])), - ); -} - -/** - * Nombre maximum de recherches Qlik simultanées. - * - * Le serveur Qlik sature vite (« Out of memory », « Request aborted ») : on - * plafonne plutôt que de lui envoyer N extractions en parallèle. - */ -const MAX_RECHERCHES_SIMULTANEES = 1; - -/** - * Cache mémoire des recherches. Une recherche coûte deux allers-retours Qlik - * (plusieurs secondes chacun) : sans ce cache, un retour arrière du navigateur - * ou un rafraîchissement relancerait toute l'extraction. - */ -const cache = new Map(); - -/** - * Jobs de recherche, indexés par terme normalisé. - * - * Une recherche dure de quelques secondes à plusieurs minutes (deux allers-retours - * Qlik dont une extraction mensuelle). Une requête HTTP maintenue aussi longtemps - * se fait couper par le reverse proxy, qui répond une page d'erreur HTML — le - * client recevait alors « Unexpected token '<' … is not valid JSON ». Le travail - * tourne donc en tâche de fond et le client interroge l'état. - */ -const jobs = new Map(); - -export interface RechercheJob { - jobId: string; - terme: string; - status: "running" | "success" | "error"; - /** Étape en cours, affichée pendant l'attente. */ - etape: string; - startedAt: string; - finishedAt?: string; - error?: string; - result?: ProduitRechercheResultat; -} - -/** État renvoyé au client : le job, ou `idle` si aucune recherche connue. */ -export type RechercheEtat = RechercheJob | { status: "idle"; terme: string }; - -function cacheKey(term: string): string { - return term.trim().toLowerCase().replace(/\s+/g, " "); -} - -/** Purge les entrées expirées (le volume reste faible, un balayage suffit). */ -function purge(): void { - const now = Date.now(); - for (const [k, v] of cache) { - if (now - v.at > CACHE_TTL_MS) cache.delete(k); - } - for (const [k, j] of jobs) { - if (j.status !== "running" && j.finishedAt && now - Date.parse(j.finishedAt) > JOB_TTL_MS) jobs.delete(k); - } -} - -function jobsEnCours(): number { - let n = 0; - for (const j of jobs.values()) if (j.status === "running") n++; - return n; -} - -function jobTermine(terme: string, result: ProduitRechercheResultat): RechercheJob { - return { - jobId: `cache_${cacheKey(terme)}`, - terme, - status: "success", - etape: "Terminé", - startedAt: new Date().toISOString(), - finishedAt: new Date().toISOString(), - result, - }; -} - -/** - * Démarre (ou réutilise) une recherche et rend la main **immédiatement**. - * - * - résultat déjà en cache et pas de `force` → job `success` directement ; - * - recherche déjà en cours sur le même terme → on renvoie ce job ; - * - sinon on lance le travail en tâche de fond. - */ -export function demarrerRecherche( - term: string, - options: { force?: boolean } = {}, -): RechercheJob { - const cleaned = term.trim(); - const key = cacheKey(cleaned); - purge(); - - if (!options.force) { - const hit = cache.get(key); - if (hit) { - console.log(`[produits/search] "${cleaned}" — servi depuis le cache mémoire`); - return jobTermine(cleaned, hit.result); - } - } - - const existant = jobs.get(key); - if (existant && existant.status === "running") return existant; - if (existant && !options.force && existant.status === "success" && existant.result) return existant; - - if (jobsEnCours() >= MAX_RECHERCHES_SIMULTANEES) { - return { - jobId: `refus_${Date.now().toString(36)}`, - terme: cleaned, - status: "error", - etape: "Refusé", - startedAt: new Date().toISOString(), - finishedAt: new Date().toISOString(), - error: "Trop de recherches Qlik en cours. Réessayez dans quelques instants.", - }; - } - - const job: RechercheJob = { - jobId: `rech_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, - terme: cleaned, - status: "running", - etape: "Recherche des articles dans Qlik…", - startedAt: new Date().toISOString(), - }; - jobs.set(key, job); - console.log(`[produits/search] job=${job.jobId} démarré pour "${cleaned}"`); - - // Fire-and-forget : le client suivra l'avancement via `etatRecherche`. - void executerRecherche(cleaned, (etape) => { job.etape = etape; }) - .then((result) => { - job.result = result; - job.status = "success"; - job.etape = "Terminé"; - job.finishedAt = new Date().toISOString(); - // On ne met en cache qu'un résultat Qlik exploitable : mémoriser un - // repli catalogue (panne Qlik passagère) le figerait 10 minutes - // alors qu'un simple « Relancer » aurait suffi. - if (result.source === "qlik" && !result.qlikError) { - cache.set(key, { at: Date.now(), result }); - } - console.log(`[produits/search] job=${job.jobId} terminé — ${result.rows.length} produit(s) en ${result.dureeMs} ms`); - }) - .catch((e) => { - const message = e instanceof Error ? e.message : String(e); - job.status = "error"; - job.etape = "Échec"; - job.error = messageQlikLisible(message); - job.finishedAt = new Date().toISOString(); - console.error(`[produits/search] job=${job.jobId} échec :`, message.slice(0, 300)); - }); - - return job; -} - -/** État courant de la recherche pour ce terme (pour le polling client). */ -export function etatRecherche(term: string): RechercheEtat { - const cleaned = term.trim(); - const key = cacheKey(cleaned); - purge(); - const job = jobs.get(key); - if (job) return job; - const hit = cache.get(key); - if (hit) return jobTermine(cleaned, hit.result); - return { status: "idle", terme: cleaned }; -} - -async function executerRecherche( - term: string, - onEtape: (etape: string) => void = () => {}, -): Promise { - const started = Date.now(); - - // La recherche catalogue local tourne en parallèle : elle sert à la fois de - // repli si Qlik échoue, et de complément (produits Nancy sans code centrale, - // donc invisibles côté réseau). - const localPromise = pgSearchProduits(term).catch((e) => { - console.error("[produits/search] recherche locale échouée:", (e as Error).message); - return [] as PgProduitSearchRow[]; - }); - - let qlik; - try { - onEtape("Recherche des articles dans Qlik…"); - qlik = await searchQlikArticles(term, QLIK_SEARCH_MAX_RESULTS); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - console.error("[produits/search] Qlik indisponible, repli catalogue local:", message); - const locaux = await localPromise; - return { - rows: locaux.map(rowDepuisCatalogueSeul), - source: "db", - qlikError: messageQlikLisible(message), - tronque: locaux.length >= 50, - champUtilise: null, - locauxHorsReseau: [], - dureeMs: Date.now() - started, - }; - } - - const codes = qlik.matches.map((m) => m.codeCentrale); - if (codes.length === 0) { - const locaux = await localPromise; - return { - rows: [], - source: "qlik", - // `avertissement` n'est renseigné que si Qlik a répondu sans appliquer - // le filtre : l'utilisateur doit savoir que ce n'est pas « pas de - // produit » mais « recherche non appliquée ». - qlikError: qlik.avertissement, - tronque: false, - champUtilise: qlik.champUtilise, - locauxHorsReseau: locaux, - dureeMs: Date.now() - started, - }; - } - - // Les mesures réseau sortent DÉJÀ du cube de recherche (même session Qlik). - // Les extraire dans une seconde session concurrente faisait abandonner le - // moteur (`code 15 — Request aborted`) : c'est ce qui faisait « planter » la - // recherche alors que la sync de la Grille, elle seule sur le serveur, - // fonctionnait. - // - // Reste à récupérer le détail mensuel — indisponible dans ce cube. On le lit - // dans le cache alimenté par les syncs fournisseur ; s'il manque, la fiche - // produit propose son bouton « Actualiser depuis Qlik ». - onEtape("Rapprochement avec notre catalogue…"); - const [cacheReseau, catalogue, locaux] = await Promise.all([ - getNetworkMetricsByCodeCentrale(codes).catch((e) => { - console.error("[produits/search] lecture du cache réseau échouée:", (e as Error).message); - return new Map(); - }), - // Deux clés candidates : la dimension « Article Code » et le champ - // `article_no_centrale`. Sur l'app FF elles n'ont pas le même format et - // une seule joint avec `articles.artcentrale`. - pgGetProduitsByCodeCentrale([...codes, ...qlik.matches.map((m) => m.codeCentraleAlt).filter(Boolean)]), - localPromise, - ]); - - // Le cube de recherche utilise encore les master measures annuelles pour - // classer rapidement les résultats. Il ne doit jamais écraser une extraction - // 12 mois déjà vérifiée : dans ce cas on republie les valeurs du cache avec - // leur mensuel. Sans cache complet, les valeurs de découverte restent - // temporaires jusqu'au bouton « Actualiser depuis Qlik ». - try { - const aPersister: NetworkMetric[] = qlik.matches.map((m) => { - const cache = cacheReseau.get(m.codeCentrale); - const fiable = cacheReseauComplet(cache, qlik.periode); - return { - codeCentrale: m.codeCentrale, - caReseau: fiable ? cache.caReseau : m.caReseau, - qteReseau: fiable ? cache.qteReseau : m.qteReseau, - nbMagasinsReseau: fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau, - caParMagasinReseau: fiable ? cache.caParMagasinReseau : m.caParMagasinReseau, - margePctReseau: fiable ? cache.margePctReseau : m.margePctReseau, - periode: fiable ? cache.periode ?? undefined : qlik.periode ?? undefined, - qteByMonth: fiable ? cache.qteByMonth ?? undefined : undefined, - metricsByMonth: fiable ? cache.metricsByMonth ?? undefined : undefined, - libelleReseau: m.libelle || undefined, - fournisseurReseau: m.fournisseur || undefined, - }; - }); - const n = await upsertNetworkMetrics(aPersister); - console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`); - } catch (e) { - console.error("[produits/search] mise en cache des mesures échouée:", (e as Error).message); - } - - const rows: ProduitRechercheRow[] = qlik.matches.map((m) => { - const local = catalogue.get(m.codeCentrale) ?? (m.codeCentraleAlt ? catalogue.get(m.codeCentraleAlt) : undefined); - const cache = cacheReseau.get(m.codeCentrale); - const fiable = cacheReseauComplet(cache, qlik.periode); - const qteReseau = fiable ? cache.qteReseau : m.qteReseau; - const caReseau = fiable ? cache.caReseau : m.caReseau; - const nbMag = fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau; - const caParMagasin = fiable ? cache.caParMagasinReseau : m.caParMagasinReseau; - const margePct = fiable ? cache.margePctReseau : m.margePctReseau; - return { - codeCentrale: m.codeCentrale, - libelle: local?.libelle1 || m.libelle || m.codeCentrale, - libelleReseau: m.libelle, - fournisseur: local?.fournisseur || m.fournisseur || "", - codein: local?.codein ?? null, - nomenclature: local?.nomenclature ?? "", - stockLocal: local ? Number(local.stock_total) || 0 : null, - qteReseau, - caReseau, - nbMagasinsReseau: nbMag, - tauxPresence: nbMag / NB_MAGASINS_RESEAU, - qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0, - caParMagasinReseau: caParMagasin, - prixMoyenReseau: qteReseau > 0 ? caReseau / qteReseau : null, - margePctReseau: normalizeMargePct(margePct), - // Le mensuel ne vient que du cache : le cube de recherche est agrégé. - qteByMonth: fiable ? cache.qteByMonth : null, - nbMagByMonth: fiable ? nbMagParMois(cache.metricsByMonth) : null, - periode: qlik.periode, - }; - }); - - // Les plus vendus du réseau d'abord : c'est l'information utile pour - // arbitrer, un produit que le réseau ne vend pas n'intéresse personne. - rows.sort((a, b) => b.qteReseau - a.qteReseau || a.libelle.localeCompare(b.libelle, "fr")); - - // Les produits Nancy déjà couverts par un résultat réseau ne doivent pas - // réapparaître dans la liste secondaire. On compare sur les deux clés - // candidates, la jointure pouvant se faire par l'une ou par l'autre. - const codesTrouves = new Set(codes); - for (const m of qlik.matches) if (m.codeCentraleAlt) codesTrouves.add(m.codeCentraleAlt); - const locauxHorsReseau = locaux.filter((l) => !l.code_centrale || !codesTrouves.has(l.code_centrale)); - - // Diagnostic de la jointure : 0 rapprochement sur 40 codes signale un format - // de clé différent entre Qlik et `articles.artcentrale`. - if (catalogue.size === 0 && codes.length > 0) { - console.warn( - `[produits/search] aucun des ${codes.length} codes Qlik ne joint avec articles.artcentrale — ` + - `échantillon Article Code=${JSON.stringify(codes.slice(0, 3))} ` + - `article_no_centrale=${JSON.stringify(qlik.matches.slice(0, 3).map((m) => m.codeCentraleAlt))}`, - ); - } - - return { - rows, - source: "qlik", - qlikError: qlik.avertissement, - tronque: qlik.tronque, - champUtilise: qlik.champUtilise, - locauxHorsReseau, - dureeMs: Date.now() - started, - }; -} - -/** Ligne de repli : catalogue Nancy seul, sans données réseau fiables. */ -function rowDepuisCatalogueSeul(r: PgProduitSearchRow): ProduitRechercheRow { - const qteReseau = Number(r.qte_reseau) || 0; - const nbMag = Number(r.nb_magasins_reseau) || 0; - return { - codeCentrale: r.code_centrale, - libelle: r.libelle1 || r.codein, - libelleReseau: "", - fournisseur: r.fournisseur, - codein: r.codein, - nomenclature: r.nomenclature, - stockLocal: Number(r.stock_total) || 0, - qteReseau, - caReseau: 0, - nbMagasinsReseau: nbMag, - tauxPresence: nbMag / NB_MAGASINS_RESEAU, - qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0, - caParMagasinReseau: 0, - prixMoyenReseau: null, - margePctReseau: null, - qteByMonth: null, - nbMagByMonth: null, - periode: null, - }; -} - -/** - * Traduit les pannes Qlik récurrentes en message actionnable. Les autres - * erreurs sont renvoyées telles quelles (tronquées) pour rester diagnosticables. - */ -function messageQlikLisible(message: string): string { - const lower = message.toLowerCase(); - if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes('"code":6') || lower.includes('"code":3002')) { - return "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard."; - } - if (/"code"\s*:\s*15\b/.test(message) || lower.includes("request aborted")) { - return "Qlik a interrompu la requête (recherche trop large pour le moteur). Ajoutez un mot plus précis, puis relancez."; - } - if (lower.includes("identifiants qlik manquants") || lower.includes("qlik_user")) { - return "Identifiants Qlik non configurés (Paramètres → Qlik)."; - } - if (lower.includes("timeout") || lower.includes("econnrefused") || lower.includes("enotfound")) { - return "Serveur Qlik injoignable depuis CollectFlow (réseau ou VPN)."; - } - return message.slice(0, 300); -} diff --git a/src/features/produits/types.ts b/src/features/produits/types.ts index 4aeb292..b070138 100644 --- a/src/features/produits/types.ts +++ b/src/features/produits/types.ts @@ -8,8 +8,8 @@ import type { PgProduitSearchRow, PgProduitStockRow, PgGammeHistoryRow, -} from "@/lib/pg-ff-client"; -import type { NetworkMetricCached } from "@/lib/qlik-network-cache"; + NetworkMetricCached, +} from "@/types/ff"; /** * Une ligne de résultat de recherche produit. diff --git a/src/features/qlik-sync/use-qlik-sync-job.ts b/src/features/qlik-sync/use-qlik-sync-job.ts index a2a0fa3..614131a 100644 --- a/src/features/qlik-sync/use-qlik-sync-job.ts +++ b/src/features/qlik-sync/use-qlik-sync-job.ts @@ -1,5 +1,3 @@ -"use client"; - import { useCallback, useEffect, useRef, useState } from "react"; /** État serveur renvoyé par GET/POST /api/qlik/sync. */ diff --git a/src/features/settings/actions.ts b/src/features/settings/actions.ts index dea37f4..fb51b72 100644 --- a/src/features/settings/actions.ts +++ b/src/features/settings/actions.ts @@ -1,17 +1,14 @@ -"use server"; - /** - * Actions des Paramètres — réservées aux administrateurs. + * Actions des Paramètres — réservées aux administrateurs, exécutées par le + * serveur Rust : `POST /api/actions/{nom}`. * * Aucun secret ne repart vers le navigateur : les mots de passe (base, Qlik) et * les clés d'IA restent dans `data/.db-config.json`. Un mot de passe laissé vide * dans le formulaire signifie « conserver celui qui est enregistré ». */ -import { Pool } from "pg"; -import { requireAdmin, verifierAdmin } from "@/lib/authz"; -import { CONFIG_FILE, readConfig, writeConfig } from "./config-file"; -import { diagnostiquerApiFf } from "./ff-api-diagnostic"; +import { callAction } from "@/lib/rpc"; +import type { FfSyncStatus } from "@/types/ff"; export interface ChampsBaseDeDonnees { host: string; @@ -37,181 +34,45 @@ export interface ParametresAffichables { ffApiBaseUrl: string; } -/** Décode un composant d'URL, ou le garde tel quel s'il n'est pas encodé proprement. */ -function decoder(valeur: string): string { - try { - return decodeURIComponent(valeur); - } catch { - return valeur; - } +export interface ResultatAction { + success: boolean; + error?: string; } -/** Découpe l'URL `postgres://` enregistrée en champs du formulaire. */ -function lireUrlPostgres(url: string) { - try { - const u = new URL(url); - return { - host: u.hostname, - port: u.port || "5432", - database: decoder(u.pathname.slice(1)), - user: decoder(u.username), - password: decoder(u.password), - ssl: url.includes("sslmode=require"), - }; - } catch (e) { - console.error("[Settings] URL de base enregistrée illisible :", (e as Error).message); - return null; - } -} - -function construireUrlPostgres(champs: ChampsBaseDeDonnees, motDePasse: string): string { - const user = encodeURIComponent(champs.user.trim()); - const identifiants = motDePasse ? `${user}:${encodeURIComponent(motDePasse)}` : user; - const port = champs.port.trim() || "5432"; - return `postgres://${identifiants}@${champs.host.trim()}:${port}/${champs.database.trim()}${champs.ssl ? "?sslmode=require" : ""}`; -} - -/** Mot de passe saisi, sinon celui de l'URL enregistrée. */ -async function motDePasseBase(champs: ChampsBaseDeDonnees): Promise { - if (champs.password) return champs.password; - const { url } = await readConfig(); - return (url && lireUrlPostgres(url)?.password) || ""; -} +/** Diagnostic de l'API FF Nancy (bouton « Tester »). */ +export type DiagnosticApiFf = + | { success: true; url: string; ms: number; status: FfSyncStatus } + | { success: false; url: string; error: string }; /** Réglages enregistrés, sans aucun secret. */ export async function getParametresAffichables(): Promise { - await requireAdmin(); - const config = await readConfig(); - const base = config.url ? lireUrlPostgres(config.url) : null; - return { - db: base - ? { - host: base.host, - port: base.port, - database: base.database, - user: base.user, - ssl: base.ssl, - motDePasseEnregistre: base.password !== "", - } - : null, - qlik: { - host: config.qlikHost ?? "", - user: config.qlikUser ?? "", - // Même ordre de priorité que getQlikConfig() : réglage enregistré, puis variable d'environnement. - motDePasseEnregistre: Boolean(config.qlikPassword || process.env.QLIK_PWD), - }, - ffApiBaseUrl: config.ffApiBaseUrl ?? "", - }; + return callAction("getParametresAffichables", []); } -export async function testDatabaseConnection(champs: ChampsBaseDeDonnees) { - const acces = await verifierAdmin(); - if (!acces.ok) return { success: false, error: acces.message }; - - const url = construireUrlPostgres(champs, await motDePasseBase(champs)); - console.log("Testing connection to:", url.replace(/:([^@]+)@/, ":****@")); - const pool = new Pool({ - connectionString: url, - connectionTimeoutMillis: 5000, - }); - - try { - const client = await pool.connect(); - await client.query("SELECT 1"); - client.release(); - return { success: true }; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error("Database connection test failed:", errorMessage); - return { success: false, error: errorMessage }; - } finally { - await pool.end(); - } +export async function testDatabaseConnection(champs: ChampsBaseDeDonnees): Promise { + return callAction("testDatabaseConnection", [champs]); } -export async function saveDatabaseSettings(champs: ChampsBaseDeDonnees) { - const acces = await verifierAdmin(); - if (!acces.ok) return { success: false, error: acces.message }; - - try { - const url = construireUrlPostgres(champs, await motDePasseBase(champs)); - // Seule l'URL change : les clés d'IA et les réglages Qlik / API FF sont conservés. - console.log(`[Settings] Saving config to ${CONFIG_FILE}`); - await writeConfig({ url }); - console.log("[Settings] Database configuration saved successfully."); - - // Refresh the shared DB instance immediately - const { refreshDb } = await import("@/db"); - refreshDb(); - - return { success: true }; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error("Failed to save database configuration:", errorMessage); - return { success: false, error: errorMessage }; - } +export async function saveDatabaseSettings(champs: ChampsBaseDeDonnees): Promise { + return callAction("saveDatabaseSettings", [champs]); } /** Mot de passe vide ou absent : celui qui est enregistré est conservé. */ -export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword?: string) { - const acces = await verifierAdmin(); - if (!acces.ok) return { success: false, error: acces.message }; - - try { - await writeConfig({ qlikHost, qlikUser, ...(qlikPassword ? { qlikPassword } : {}) }); - console.log("[Settings] Qlik configuration saved."); - return { success: true }; - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : String(error); - console.error("[Settings] Failed to save Qlik config:", msg); - return { success: false, error: msg }; - } +export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword?: string): Promise { + return callAction("saveQlikSettings", [qlikHost, qlikUser, qlikPassword]); } /** Mot de passe vide ou absent : on teste avec celui qui est enregistré. */ -export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword?: string) { - const acces = await verifierAdmin(); - if (!acces.ok) return { success: false, error: acces.message }; - - try { - const { getQlikConfig, qlikNtlmSession } = await import("@/lib/qlik-client"); - const enregistre = getQlikConfig(); - const cfg = { ...enregistre, user: qlikUser, password: qlikPassword || enregistre.password }; - if (qlikHost) cfg.host = qlikHost; - await qlikNtlmSession(cfg); - return { success: true }; - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : String(error); - return { success: false, error: msg }; - } +export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword?: string): Promise { + return callAction("testQlikConnection", [qlikHost, qlikUser, qlikPassword]); } /** Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut. */ -export async function saveFfApiSettings(ffApiBaseUrl: string) { - const acces = await verifierAdmin(); - if (!acces.ok) return { success: false, error: acces.message }; - - try { - const cleaned = ffApiBaseUrl.trim().replace(/\/+$/, ""); - if (cleaned && !/^https?:\/\//i.test(cleaned)) { - return { success: false, error: "L'URL doit commencer par http:// ou https://" }; - } - await writeConfig({ ffApiBaseUrl: cleaned || undefined }); - // Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde. - const { resetFfApiBaseCache } = await import("@/lib/api-ff-client"); - resetFfApiBaseCache(); - console.log(`[Settings] FF API base URL saved: ${cleaned || "(défaut)"}`); - return { success: true }; - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : String(error); - console.error("[Settings] Failed to save FF API config:", msg); - return { success: false, error: msg }; - } +export async function saveFfApiSettings(ffApiBaseUrl: string): Promise { + return callAction("saveFfApiSettings", [ffApiBaseUrl]); } -/** Teste l'API FF Nancy (voir `diagnostiquerApiFf`). */ -export async function testFfApiConnection(ffApiBaseUrl?: string): Promise>> { - const acces = await verifierAdmin(); - if (!acces.ok) return { success: false, url: "", error: acces.message }; - return diagnostiquerApiFf(ffApiBaseUrl); +/** Teste l'API FF Nancy (diagnostic détaillé : DNS, refus, délai, code HTTP). */ +export async function testFfApiConnection(ffApiBaseUrl?: string): Promise { + return callAction("testFfApiConnection", [ffApiBaseUrl]); } diff --git a/src/features/settings/components/server-logs.tsx b/src/features/settings/components/server-logs.tsx index 21c53a5..e2f2588 100644 --- a/src/features/settings/components/server-logs.tsx +++ b/src/features/settings/components/server-logs.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useCallback, useEffect, useState } from "react"; import { Download, Loader2, RefreshCw, FileText } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; diff --git a/src/features/settings/config-file.ts b/src/features/settings/config-file.ts deleted file mode 100644 index b9745bb..0000000 --- a/src/features/settings/config-file.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * CollectFlow — Fichier de configuration `data/.db-config.json`. - * - * Module **serveur**, pas fichier d'actions : ses fonctions ne doivent pas être - * appelables depuis le navigateur (le fichier contient des mots de passe et des - * clés d'API). Les actions de `./actions.ts` les utilisent après contrôle du rôle. - */ - -import "server-only"; - -import fs from "fs/promises"; -import path from "path"; - -export const DATA_DIR = path.join(process.cwd(), "data"); -export const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json"); - -export interface DbConfig { - url: string; - openRouterKey?: string; - openRouterModel?: string; - aiProvider?: "openrouter" | "google"; - googleAiKey?: string; - googleAiModel?: string; - /** Qlik Sense — données réseau */ - qlikHost?: string; - qlikUser?: string; - qlikPassword?: string; - /** - * API REST FF Nancy (ex. https://api.ffnancy.fr). Sert au panneau de statut et - * au rattrapage des ventes par magasin dans la Grille. Configurable ici pour - * ne pas dépendre d'une variable d'environnement qu'on ne peut pas changer - * sans redéployer. - */ - ffApiBaseUrl?: string; -} - -/** Lit la config existante (ou {} si absente). */ -export async function readConfig(): Promise> { - try { - const data = await fs.readFile(CONFIG_FILE, "utf-8"); - return JSON.parse(data); - } catch { - return {}; - } -} - -/** - * Fusionne `patch` dans la config enregistrée : les clés absentes du patch sont - * conservées telles quelles (une clé passée à `undefined` est retirée). - */ -export async function writeConfig(patch: Partial): Promise { - const existing = await readConfig(); - await fs.mkdir(DATA_DIR, { recursive: true }); - await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...existing, ...patch }, null, 2)); -} diff --git a/src/features/settings/ff-api-diagnostic.ts b/src/features/settings/ff-api-diagnostic.ts deleted file mode 100644 index 50409a9..0000000 --- a/src/features/settings/ff-api-diagnostic.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * CollectFlow — Diagnostic de l'API FF Nancy. - * - * Partagé par le bouton « Tester » des Paramètres (administrateurs) et par la - * route `/api/ff-status` (tout utilisateur connecté). Module serveur et non - * fichier d'actions : chaque appelant fait son propre contrôle d'accès. - */ - -import "server-only"; - -import { readConfig } from "./config-file"; - -/** - * Teste l'API FF Nancy et renvoie un diagnostic **exploitable**. - * - * L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir - * si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue - * donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP. - */ -export async function diagnostiquerApiFf(ffApiBaseUrl?: string) { - const base = (ffApiBaseUrl?.trim() || (await readConfig()).ffApiBaseUrl || process.env.FF_API_BASE_URL || "https://api.ffnancy.fr").replace(/\/+$/, ""); - const url = `${base}/api/sync/status`; - const started = Date.now(); - try { - const res = await fetch(url, { - cache: "no-store", - signal: AbortSignal.timeout(8000), - }); - const ms = Date.now() - started; - if (!res.ok) { - return { success: false, url, error: `Le serveur a répondu HTTP ${res.status} (${res.statusText || "sans message"}) en ${ms} ms.` }; - } - const body = await res.json().catch(() => null); - if (!body) { - return { success: false, url, error: `Réponse HTTP 200 mais corps illisible (JSON attendu).` }; - } - // La réponse brute de l'API n'a pas la forme attendue par l'interface : - // sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200. - const { normalizeSyncStatus } = await import("@/lib/api-ff-client"); - const status = normalizeSyncStatus(body); - if (!status) { - return { success: false, url, error: `Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).` }; - } - return { success: true, url, ms, status }; - } catch (error: unknown) { - const ms = Date.now() - started; - const raw = error instanceof Error ? error.message : String(error); - const name = error instanceof Error ? error.name : ""; - // fetch masque la cause réelle derrière « fetch failed » : on la déplie. - const cause = (error as { cause?: { code?: string; message?: string } })?.cause; - let hint = raw; - if (name === "TimeoutError" || name === "AbortError") { - hint = `Aucune réponse en ${ms} ms — serveur injoignable ou trop lent.`; - } else if (cause?.code === "ENOTFOUND") { - hint = `Nom d'hôte introuvable (DNS) — vérifiez l'URL.`; - } else if (cause?.code === "ECONNREFUSED") { - hint = `Connexion refusée — le service n'écoute pas sur cette adresse.`; - } else if (cause?.code) { - hint = `${cause.code}${cause.message ? ` — ${cause.message}` : ""}`; - } - console.error(`[Settings] FF API test KO (${url}):`, raw); - return { success: false, url, error: hint }; - } -} diff --git a/src/features/settings/store/use-db-settings-store.ts b/src/features/settings/store/use-db-settings-store.ts index 3afdba7..990870c 100644 --- a/src/features/settings/store/use-db-settings-store.ts +++ b/src/features/settings/store/use-db-settings-store.ts @@ -1,5 +1,3 @@ -"use client"; - import { create } from "zustand"; import { persist } from "zustand/middleware"; diff --git a/src/features/snapshots/api/delete-snapshot.ts b/src/features/snapshots/api/delete-snapshot.ts index 09ce9de..1b4fd73 100644 --- a/src/features/snapshots/api/delete-snapshot.ts +++ b/src/features/snapshots/api/delete-snapshot.ts @@ -1,39 +1,10 @@ -"use server"; +/** + * Suppression d'un enregistrement de l'historique — action exécutée par le + * serveur Rust : `POST /api/actions/deleteSnapshot`. + */ -import { db } from "@/db"; -import { sessionSnapshots } from "@/db/schema"; -import { and, eq, isNull } from "drizzle-orm"; -import { verifierSession } from "@/lib/authz"; +import { callAction } from "@/lib/rpc"; -export async function deleteSnapshot(id: number) { - const acces = await verifierSession(); - if (!acces.ok) return { success: false, error: acces.message }; - - const rawUserId = acces.utilisateur.id; - const userId = rawUserId ? parseInt(rawUserId, 10) : null; - - console.log(`[deleteSnapshot] Request for ID: ${id}, Session UserId: ${userId}`); - - try { - // Condition de sécurité : match ID ET match le propriétaire (ou anonyme) - const userCondition = userId && !isNaN(userId) - ? eq(sessionSnapshots.userId, userId) - : isNull(sessionSnapshots.userId); - - const result = await db.delete(sessionSnapshots) - .where(and(eq(sessionSnapshots.id, id), userCondition)) - .returning({ deletedId: sessionSnapshots.id }); - - if (result.length === 0) { - console.warn(`[deleteSnapshot] No rows deleted for ID ${id}. Snapshot may not exist or belongs to another user.`); - return { success: false, error: "Snapshot non trouvé ou non autorisé" }; - } - - console.log(`[deleteSnapshot] Successfully deleted ID: ${id}`); - return { success: true }; - } catch (err) { - // Le détail technique reste dans les journaux du serveur. - console.error("[deleteSnapshot] DB Error:", err); - return { success: false, error: "erreur technique sur le serveur" }; - } +export async function deleteSnapshot(id: number): Promise<{ success: boolean; error?: string }> { + return callAction("deleteSnapshot", [id]); } diff --git a/src/features/snapshots/api/get-snapshots.ts b/src/features/snapshots/api/get-snapshots.ts index e636a2b..8d6c84f 100644 --- a/src/features/snapshots/api/get-snapshots.ts +++ b/src/features/snapshots/api/get-snapshots.ts @@ -1,34 +1,26 @@ -"use server"; +/** + * Historique des sessions de révision et des exports — action exécutée par le + * serveur Rust : `POST /api/actions/getSnapshots`. + */ -import { db } from "@/db"; -import { sessionSnapshots } from "@/db/schema"; -import { requireSession } from "@/lib/authz"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { callAction } from "@/lib/rpc"; -export async function getSnapshots(type?: "snapshot" | "export") { - const utilisateur = await requireSession(); - try { - const rawUserId = utilisateur.id; - const userId = rawUserId ? parseInt(rawUserId, 10) : null; +/** Une ligne de `session_snapshots`, telle que le serveur la renvoie. */ +export interface SnapshotRow { + id: number; + userId: number | null; + codeFournisseur: string; + nomFournisseur: string | null; + magasin: string; + /** codein → { before, after } */ + changes: unknown; + summaryJson: unknown; + label: string | null; + type: string | null; + /** Date ISO de création. */ + createdAt: string | null; +} - // Si on a un userId, on filtre par celui-ci. - // Sinon, on filtre par userId IS NULL (mode anonyme/local) - const userCondition = userId && !isNaN(userId) - ? eq(sessionSnapshots.userId, userId) - : isNull(sessionSnapshots.userId); - - const conditions = [userCondition]; - if (type) conditions.push(eq(sessionSnapshots.type, type)); - - return await db.select() - .from(sessionSnapshots) - .where(and(...conditions)) - .orderBy(desc(sessionSnapshots.createdAt)); - } catch (err) { - const msg = ((err as Error)?.message || String(err)).split("\n")[0]; - console.error(`[getSnapshots] ERROR (type=${type}):`, msg, err); - // Remonter l'erreur : une liste vide faisait afficher « Aucun snapshot » - // au lieu de signaler que le chargement a échoué. - throw new Error("Chargement de l'historique impossible."); - } +export async function getSnapshots(type?: "snapshot" | "export"): Promise { + return callAction("getSnapshots", [type]); } diff --git a/src/features/snapshots/api/save-snapshot.ts b/src/features/snapshots/api/save-snapshot.ts index 81445fc..0b30d7e 100644 --- a/src/features/snapshots/api/save-snapshot.ts +++ b/src/features/snapshots/api/save-snapshot.ts @@ -1,114 +1,27 @@ -"use server"; +/** + * Enregistrement d'une session de révision ou d'un export — action exécutée par + * le serveur Rust : `POST /api/actions/saveSnapshot` (validation des données + * comprise). + */ -import { db } from "@/db"; -import { sessionSnapshots } from "@/db/schema"; -import { z } from "zod"; -import { sql, and, eq } from "drizzle-orm"; -import { verifierSession } from "@/lib/authz"; -import { revalidatePath } from "next/cache"; +import { callAction } from "@/lib/rpc"; -const SaveSnapshotSchema = z.object({ - codeFournisseur: z.string(), - nomFournisseur: z.string().optional(), - magasin: z.string(), - label: z.string().optional(), - changes: z.record(z.string(), z.object({ - before: z.string().nullable(), - after: z.string(), - })), - type: z.enum(["snapshot", "export"]).optional(), - summary: z.object({ - totalRows: z.number(), - totalCa: z.number(), - totalMarge: z.number(), - tauxMargeGlobal: z.number(), - }).optional(), -}); - -export async function saveSnapshot(raw: unknown) { - const acces = await verifierSession(); - if (!acces.ok) return { success: false, error: acces.message }; - - const parsed = SaveSnapshotSchema.safeParse(raw); - if (!parsed.success) { - console.error("Snapshot validation failed:", parsed.error.format()); - return { success: false, error: "Validation failed: " + parsed.error.issues.map((i: any) => i.message).join(", ") }; - } - - const { codeFournisseur, nomFournisseur, magasin, label, changes, summary, type } = parsed.data; - const rawUserId = acces.utilisateur.id; - const userId = rawUserId ? parseInt(rawUserId, 10) : null; - - console.log(`[saveSnapshot] User: ${userId} (raw: ${rawUserId}), Supplier: ${codeFournisseur}, Type: ${type}`); - - const finalUserId = (userId && !isNaN(userId)) ? userId : null; - - try { - const [created] = await db - .insert(sessionSnapshots) - .values({ - userId: finalUserId, - codeFournisseur, - nomFournisseur: nomFournisseur ?? null, - magasin, - label: label ?? `${type === 'export' ? 'Export' : 'Snapshot'} — ${new Date().toLocaleDateString("fr-FR")}`, - changes, - summaryJson: summary ?? null, - type: type ?? "snapshot", - }) - .returning({ id: sessionSnapshots.id }); - - revalidatePath("/snapshots"); - revalidatePath("/exports"); - return { success: true, snapshotId: created?.id }; - } catch (err) { - console.error("Initial snapshot save failed, attempting auto-repair...", err); - - try { - // Tentative systématique de création de table (no-op si déjà là) - await db.execute(sql` - CREATE TABLE IF NOT EXISTS session_snapshots ( - id SERIAL PRIMARY KEY, - user_id INTEGER, - code_fournisseur VARCHAR(20) NOT NULL, - nom_fournisseur VARCHAR(255), - magasin VARCHAR(20) NOT NULL, - changes JSONB NOT NULL, - summary_json JSONB, - label TEXT, - type VARCHAR(20) DEFAULT 'snapshot', - created_at TIMESTAMP DEFAULT NOW() - ); - `); - - // Si la table existait déjà mais sans la colonne 'type' ou 'user_id', on les ajoute - try { - await db.execute(sql`ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'snapshot'`); - await db.execute(sql`ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS user_id INTEGER`); - } catch (e) { - // Ignore - } - - // Deuxième tentative d'insertion - const [retryCreated] = await db - .insert(sessionSnapshots) - .values({ - userId: finalUserId, - codeFournisseur, - nomFournisseur: nomFournisseur ?? null, - magasin, - label: label ?? `${type === 'export' ? 'Export' : 'Snapshot'} — ${new Date().toLocaleDateString("fr-FR")}`, - changes, - summaryJson: summary ?? null, - type: type ?? "snapshot", - }) - .returning({ id: sessionSnapshots.id }); - - return { success: true, snapshotId: retryCreated?.id }; - } catch (repairErr) { - // Le détail technique (les deux erreurs) reste dans les journaux du serveur. - console.error("Snapshot auto-repair/retry failed:", repairErr); - return { success: false, error: "erreur technique sur le serveur" }; - } - } +/** Données attendues par le serveur (ex-schéma zod `SaveSnapshotSchema`). */ +export interface SaveSnapshotInput { + codeFournisseur: string; + nomFournisseur?: string; + magasin: string; + label?: string; + changes: Record; + type?: "snapshot" | "export"; + summary?: { + totalRows: number; + totalCa: number; + totalMarge: number; + tauxMargeGlobal: number; + }; +} + +export async function saveSnapshot(raw: unknown): Promise<{ success: boolean; snapshotId?: number; error?: string }> { + return callAction("saveSnapshot", [raw]); } diff --git a/src/features/snapshots/components/snapshot-list.tsx b/src/features/snapshots/components/snapshot-list.tsx index 20aa972..70a3bc7 100644 --- a/src/features/snapshots/components/snapshot-list.tsx +++ b/src/features/snapshots/components/snapshot-list.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { diff --git a/src/instrumentation.ts b/src/instrumentation.ts deleted file mode 100644 index 4776c24..0000000 --- a/src/instrumentation.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * CollectFlow — Point d'entrée exécuté une fois au démarrage du serveur. - * - * Sert à armer le minuteur de la synchronisation nocturne. L'import est - * dynamique et conditionné au runtime Node : `register()` est aussi évalué - * dans le runtime Edge, où ni `setInterval` persistant ni l'accès à la base - * n'ont de sens. - */ -export async function register() { - if (process.env.NEXT_RUNTIME !== "nodejs") return; - - try { - const { startSyncTimer } = await import("@/features/admin/api/sync-timer"); - startSyncTimer(); - } catch (e) { - // Un démarrage réussi prime sur le planificateur : s'il ne s'arme pas, - // l'application doit rester utilisable et la synchro manuelle disponible. - console.error("[instrumentation] minuteur de synchro non démarré :", e instanceof Error ? e.message : String(e)); - } -} diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts deleted file mode 100644 index b5d3cbb..0000000 --- a/src/lib/api-auth.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * CollectFlow — Authentification de l'API publique `/api/v1`. - * - * Deux voies d'entrée : - * 1. **Clé d'API** (`X-API-Key` ou `Authorization: Bearer …`) — pour les appelants - * non-navigateur : scripts, outils externes, assistant IA. - * 2. **Session NextAuth** — le front de CollectFlow utilise donc les mêmes routes. - * - * En cas d'échec on renvoie un **401 JSON**, jamais une redirection : `src/middleware.ts` - * exempte `/api/v1/*` précisément pour que ce module puisse répondre proprement. - * - * Seul le hachage SHA-256 des clés est stocké (table `api_keys`) ; la clé en clair - * n'existe qu'au moment de sa création. - */ - -import "server-only"; - -import { createHash, randomBytes, timingSafeEqual } from "crypto"; -import type { NextRequest } from "next/server"; -import { db } from "@/db"; -import { apiKeys } from "@/db/schema"; -import { eq } from "drizzle-orm"; -import { auth } from "@/lib/auth"; -import { fail } from "@/lib/api-response"; - -export interface AuthContext { - /** Origine de l'authentification — utile pour les logs et l'audit. */ - via: "api_key" | "session"; - role: string; - /** Nom de la clé, ou identifiant de l'utilisateur connecté. */ - subject: string; -} - -/** Intervalle minimal entre deux mises à jour de `last_used_at` pour une même clé. */ -const TRACE_USAGE_MS = 60_000; -const derniereTrace = new Map(); - -/** Préfixe des clés CollectFlow, pour les reconnaître d'un coup d'œil. */ -const KEY_PREFIX = "cf_"; - -export function hashApiKey(key: string): string { - return createHash("sha256").update(key, "utf8").digest("hex"); -} - -/** - * Génère une clé d'API. Renvoie la clé **en clair** (à afficher une seule fois), - * son hachage à stocker, et un préfixe lisible pour l'identifier dans l'UI. - */ -export function generateApiKey(): { key: string; keyHash: string; keyPrefix: string } { - const secret = randomBytes(24).toString("base64url"); - const key = `${KEY_PREFIX}${secret}`; - return { key, keyHash: hashApiKey(key), keyPrefix: key.slice(0, 11) }; -} - -/** Comparaison à temps constant de deux hachages hexadécimaux. */ -function hashesEqual(a: string, b: string): boolean { - const ba = Buffer.from(a, "hex"); - const bb = Buffer.from(b, "hex"); - if (ba.length !== bb.length || ba.length === 0) return false; - return timingSafeEqual(ba, bb); -} - -/** Extrait la clé d'un en-tête `X-API-Key` ou `Authorization: Bearer …`. */ -function readKeyFromRequest(req: NextRequest): string | null { - const direct = req.headers.get("x-api-key"); - if (direct?.trim()) return direct.trim(); - const authz = req.headers.get("authorization"); - if (authz) { - const m = authz.match(/^Bearer\s+(.+)$/i); - if (m) return m[1].trim(); - } - return null; -} - -/** - * Authentifie une requête `/api/v1`. - * - * Renvoie un `AuthContext` en cas de succès, ou une réponse d'erreur JSON prête à - * être retournée par le handler. À appeler en première ligne de chaque endpoint : - * - * const authCtx = await requireApiAuth(req); - * if (authCtx instanceof NextResponse) return authCtx; - */ -export async function requireApiAuth(req: NextRequest): Promise { - const presented = readKeyFromRequest(req); - - if (presented) { - const presentedHash = hashApiKey(presented); - // La colonne key_hash est unique : la recherche par égalité suffit, et la - // comparaison à temps constant ci-dessous couvre le reste. - const [row] = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, presentedHash)).limit(1); - - if (!row || !hashesEqual(row.keyHash, presentedHash)) { - return fail("unauthorized", "Clé d'API invalide."); - } - if (row.revokedAt) { - return fail("unauthorized", "Clé d'API révoquée."); - } - - // Trace d'usage — jamais bloquante, et au plus une écriture par minute et - // par clé : un script qui pagine ne déclenche plus un UPDATE par requête. - const maintenant = Date.now(); - if (maintenant - (derniereTrace.get(row.id) ?? 0) >= TRACE_USAGE_MS) { - derniereTrace.set(row.id, maintenant); - void db.update(apiKeys).set({ lastUsedAt: new Date(maintenant) }).where(eq(apiKeys.id, row.id)) - .catch((e) => console.error("[api-auth] lastUsedAt KO:", (e as Error).message?.slice(0, 120))); - } - - return { via: "api_key", role: row.role, subject: row.name }; - } - - // Repli sur la session du navigateur (le front utilise les mêmes routes). - const session = await auth(); - if (session?.user) { - const user = session.user as { name?: string | null; role?: string }; - return { via: "session", role: user.role ?? "user", subject: user.name ?? "session" }; - } - - return fail( - "unauthorized", - "Authentification requise : fournissez un en-tête X-API-Key (ou Authorization: Bearer), ou connectez-vous.", - ); -} - -/** Restreint un endpoint aux appelants administrateurs. */ -export function requireAdminRole(ctx: AuthContext): Response | null { - if (ctx.role !== "admin") { - return fail("forbidden", "Rôle administrateur requis pour cette opération."); - } - return null; -} diff --git a/src/lib/api-enrich.ts b/src/lib/api-enrich.ts deleted file mode 100644 index 143ca9d..0000000 --- a/src/lib/api-enrich.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * CollectFlow — Enrichissement des lignes servies par `/api/v1`. - * - * L'instantané `grid_rows` fige la ligne au moment du calcul. Deux informations - * doivent pourtant refléter l'état **courant** au moment de l'appel : - * - * 1. **Métriques réseau Qlik** — la table `qlik_network_metrics` est mise à jour par - * les synchronisations et les recherches réseau, indépendamment du calcul de la - * grille. On les relit donc à la lecture, et on les expose telles quelles quand - * elles existent (`network: null` sinon). - * - * 2. **Gamme serveur** — `codeGamme` peut être surchargée par un snapshot de session - * (modification locale non enregistrée côté serveur). L'API expose la gamme **non - * modifiée**, celle réellement présente en base PostgreSQL, relue à chaque appel. - * - * Ce n'est pas un recalcul : deux lectures indexées bornées au nombre de lignes de la - * page (500 au maximum), de l'ordre de quelques millisecondes. - */ - -import "server-only"; - -import type { ProductRow } from "@/types/grid"; -import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache"; -import { pgGetGammesByCodeins } from "@/lib/pg-ff-client"; -import { gammesAValiderParArticle } from "@/lib/gammes-a-valider"; -// Module de la Grille : l'API doit renvoyer exactement la tendance affichée dans -// l'application, pas une seconde implémentation qui divergerait. -import { computeNetworkTrend, trendLabel } from "@/features/grid/lib/network-trend"; - -/** Tendance réseau 12 mois, telle qu'affichée dans la colonne « Tendance » de la Grille. */ -export interface ProductTrend { - direction: "up" | "down" | "flat"; - /** Variation des 4 derniers mois vs les 4 premiers (0.23 = +23 %). `null` si aucune base. */ - pct: number | null; - /** Libellé prêt à afficher : « Forte hausse », « Nouveau », « Stable »… */ - label: string; - /** - * `true` = tendance calculée sur la quantité **par magasin vendeur**, ce qui - * neutralise l'effet d'un simple élargissement de la distribution. `false` = - * repli sur les quantités brutes, faute de nombre de magasins sur 12 mois. - */ - surQteParMagasin: boolean; - /** Aucune vente sur les 4 premiers mois, des ventes sur les 4 derniers. */ - nouveau: boolean; - /** Nombre de mois de la série (12 quand elle est complète). */ - monthsUsed: number; -} - -export interface EnrichedProductRow extends ProductRow { - /** - * Gamme telle qu'elle existe sur le serveur PostgreSQL — non modifiée. - * `null` si l'article n'a aucune gamme en base. - */ - codeGammeServeur: string | null; - /** - * Gamme proposée par l'API et pas encore validée dans la Grille. - * `null` = aucune proposition en attente. - */ - gammeAValider: string | null; - /** Métriques réseau Qlik en cache, ou `null` si le produit n'en a pas. */ - network: NetworkMetricCached | null; - /** - * Tendance réseau calculée par régression linéaire sur les 12 derniers mois. - * `null` quand il n'y a aucune série mensuelle. Fournie pour que l'appelant — - * en particulier un agent — n'ait pas à réimplémenter le calcul et à diverger - * de ce que montre l'application. - */ - trend: ProductTrend | null; -} - -/** - * Enrichit un lot de lignes. Les deux lectures sont indépendantes et lancées en - * parallèle ; un échec de l'une n'empêche pas l'autre (l'API reste servie, avec - * la valeur de l'instantané en repli). - */ -export async function enrichRows(rows: ProductRow[]): Promise { - if (rows.length === 0) return []; - - const codesCentraux = [...new Set(rows.map((r) => r.codeCentrale).filter((c): c is string => Boolean(c)))]; - const codeins = [...new Set(rows.map((r) => r.codein).filter(Boolean))]; - - const [network, gammes, aValider] = await Promise.all([ - codesCentraux.length - ? getNetworkMetricsByCodeCentrale(codesCentraux).catch((e) => { - console.error("[api-enrich] métriques réseau KO:", (e as Error).message?.slice(0, 160)); - return new Map(); - }) - : Promise.resolve(new Map()), - codeins.length - ? pgGetGammesByCodeins(codeins).catch((e) => { - console.error("[api-enrich] gammes serveur KO:", (e as Error).message?.slice(0, 160)); - return new Map(); - }) - : Promise.resolve(new Map()), - gammesAValiderParArticle(codeins).catch((e) => { - console.error("[api-enrich] gammes à valider KO:", (e as Error).message?.slice(0, 160)); - return new Map(); - }), - ]); - - return rows.map((row) => { - const metrics = row.codeCentrale ? network.get(row.codeCentrale) ?? null : null; - // Gamme absente de la base = pas de gamme, et non « inconnue » : on distingue - // explicitement par `null` plutôt que de retomber sur la valeur du snapshot. - const gammeServeur = gammes.get(row.codein) ?? null; - - const enriched: EnrichedProductRow = { - ...row, - codeGammeServeur: gammeServeur, - gammeAValider: aValider.get(`${row.codeFournisseur}|${row.codein}`) ?? null, - network: metrics, - trend: null, // renseignée plus bas, une fois la série mensuelle connue - }; - - // `codeGammeInit` porte déjà la sémantique « état serveur » dans la Grille : - // on la garde alignée pour que les deux champs ne divergent jamais. - if (gammes.size > 0) { - enriched.codeGammeInit = gammeServeur as ProductRow["codeGammeInit"]; - } - - // Les colonnes réseau de la ligne datent du calcul : on les réaligne sur le - // cache quand il a quelque chose à dire. - if (metrics) { - enriched.caReseau = metrics.caReseau; - enriched.qteReseau = metrics.qteReseau; - enriched.nbMagasinsReseau = metrics.nbMagasinsReseau; - enriched.caParMagasinReseau = metrics.caParMagasinReseau; - enriched.margePctReseau = metrics.margePctReseau; - enriched.qteReseauByMonth = metrics.qteByMonth; - enriched.networkFetchedAt = metrics.fetchedAt ?? undefined; - } - - // Tendance : mêmes entrées que la colonne « Tendance » de la Grille, à - // savoir la série des quantités **et** celle du nombre de magasins vendeurs - // — sans la seconde, le calcul se rabat sur les quantités brutes et - // confond « vend mieux » avec « est distribué plus largement ». - const serie = enriched.qteReseauByMonth; - const nbMagByMonth = metrics?.metricsByMonth - ? Object.fromEntries( - Object.entries(metrics.metricsByMonth).map(([mois, m]) => [mois, Number(m?.nbMag) || 0]), - ) - : null; - const t = computeNetworkTrend(serie, nbMagByMonth); - if (t.hasData) { - enriched.trend = { - direction: t.direction, - pct: t.pct, - label: trendLabel(t.pct, t.nouveau), - surQteParMagasin: t.surQteParMagasin, - nouveau: t.nouveau, - monthsUsed: t.values.length, - }; - } - - return enriched; - }); -} diff --git a/src/lib/api-ff-client.ts b/src/lib/api-ff-client.ts deleted file mode 100644 index 3a3e0aa..0000000 --- a/src/lib/api-ff-client.ts +++ /dev/null @@ -1,517 +0,0 @@ -/** - * CollectFlow — API FF Nancy Client - * - * Client HTTP centralisé pour l'API REST https://api.ffnancy.fr - * Sync nuit SQL Server → données fraîches J-1 - * - * Stratégie mouvements : appeler SANS filtre genremvt (tous types) - * - genremvt = 3 → alimenter sales12m (ventes) - * - Tous types → lire qtestock pour reconstruire stock12m - */ - -import fs from "fs/promises"; -import path from "path"; - -const CONFIG_FILE = path.join(process.cwd(), "data", ".db-config.json"); -const FF_API_BASE_DEFAULT = "https://api.ffnancy.fr"; -const FF_API_BASE_TTL_MS = 30_000; - -/** - * Options de tout appel à l'API FF : jamais de cache HTTP, et un délai maximal. - * Sans délai, une API muette figeait indéfiniment la page qui l'attendait. - */ -const FF_FETCH_TIMEOUT_MS = Number(process.env.FF_API_TIMEOUT_MS) || 30_000; -export function ffFetchInit(): RequestInit { - return { cache: "no-store", signal: AbortSignal.timeout(FF_FETCH_TIMEOUT_MS) }; -} - -let ffApiBaseCache: { value: string; at: number } | null = null; - -/** - * URL de l'API FF Nancy, résolue **au moment de l'appel**. - * - * Ordre de priorité : réglage enregistré dans Paramètres → variable - * d'environnement `FF_API_BASE_URL` → valeur par défaut. Auparavant l'URL était - * figée au chargement du module depuis l'environnement seul : impossible de la - * corriger depuis l'application quand le serveur changeait d'adresse. - * - * Le résultat est mémorisé 30 s pour ne pas relire le fichier à chaque requête. - */ -async function getFfApiBase(): Promise { - if (ffApiBaseCache && Date.now() - ffApiBaseCache.at < FF_API_BASE_TTL_MS) { - return ffApiBaseCache.value; - } - let configured: string | undefined; - try { - const raw = await fs.readFile(CONFIG_FILE, "utf-8"); - const parsed = JSON.parse(raw) as { ffApiBaseUrl?: string }; - configured = parsed?.ffApiBaseUrl?.trim() || undefined; - } catch { - // Fichier absent ou illisible : on retombe sur l'environnement. - } - const value = (configured || process.env.FF_API_BASE_URL || FF_API_BASE_DEFAULT).replace(/\/+$/, ""); - ffApiBaseCache = { value, at: Date.now() }; - return value; -} - -/** Vide le cache — appelé après un enregistrement dans Paramètres. */ -export function resetFfApiBaseCache(): void { - ffApiBaseCache = null; -} - -// --------------------------------------------------------------------------- -// Types API (shapes des réponses brutes) -// --------------------------------------------------------------------------- - -export interface FfFournisseur { - codefou: string; - nomfou: string; -} - -export interface FfArticle { - codein: string; - libelle1: string; - codefou: string; - nomfou?: string; - pcb?: number; // conditionnement - pv_central?: number; // prix de vente central - noid?: number; // SQL Server internal ID (pour endpoint /mensuel) - reference?: string; // référence fournisseur (ref_fou_principale) - gtin?: string; // code-barres EAN/GTIN - actif?: boolean; // article actif - suspendu?: boolean; // article suspendu -} - -export interface FfMouvement { - codein: string; - genremvt: number; // 3 = vente client - datemvt: string; // ISO date (YYYY-MM-DD) - qte: number; // quantité du mouvement - montant: number; // montant HT - marge?: number; // marge (si disponible) - qtestock: number; // stock APRÈS ce mouvement — clé pour stock12m - site: string; // code magasin / site - codefou_reel?: string; // code fournisseur réel (dernière entrée avant cette vente, ou code de l'entrée) - nom_fournisseur_reel?: string; // nom résolu du fournisseur réel -} - -export interface FfStock { - codein: string; - site?: string; - stockdispo: number; - qte: number; - valstock: number; - prmp?: number; // PRMP (prix de revient moyen pondéré) - pv?: number; // prix de vente - dernieresventes?: string; - dernierereception?: string; - nbjoursdernsventes?: number; -} - -export interface FfCommande { - codein: string; - qtecde: number; -} - -export interface FfSyncTable { - nom: string; - derniereSync: string; - nbLignes?: number; - /** "ok" ou état d'erreur renvoyé par la synchronisation. */ - statut?: string; - erreur?: string | null; -} - -export interface FfSyncStatus { - lastSync: string; - tables: FfSyncTable[]; -} - -/** - * Normalise la réponse de `/api/sync/status`. - * - * L'API renvoie `{ sync: [{ table_name, last_sync, rows_synced, status, error_msg }] }`, - * alors que l'application attendait `{ lastSync, tables: [{ nom, derniereSync, nbLignes }] }`. - * Les champs lus n'existaient donc pas : le panneau des Paramètres affichait une - * coquille vide sans la moindre erreur, puisque la requête HTTP réussissait. - * - * Les deux formes sont acceptées ici, pour rester compatible si l'API évolue. - */ -export function normalizeSyncStatus(raw: unknown): FfSyncStatus | null { - if (!raw || typeof raw !== "object") return null; - const d = raw as Record; - - // Forme déjà attendue par l'application. - if (Array.isArray(d.tables)) { - return { - lastSync: String(d.lastSync ?? ""), - tables: d.tables as FfSyncTable[], - }; - } - - // Forme réelle de l'API FF Nancy. - const sync = Array.isArray(d.sync) ? (d.sync as Array>) : null; - if (!sync) return null; - - const tables: FfSyncTable[] = sync.map((t) => ({ - nom: String(t.table_name ?? t.nom ?? "—"), - derniereSync: String(t.last_sync ?? t.derniereSync ?? ""), - nbLignes: Number(t.rows_synced ?? t.nbLignes ?? 0) || 0, - statut: t.status != null ? String(t.status) : undefined, - erreur: t.error_msg != null ? String(t.error_msg) : null, - })); - - // Pas de date globale dans la réponse : on prend la plus récente des tables. - const lastSync = tables - .map((t) => t.derniereSync) - .filter(Boolean) - .sort() - .at(-1) ?? ""; - - return { lastSync, tables }; -} - -/** Stock agrégé sur tous les sites pour un codein */ -export interface AggregatedStock { - stockActuel: number; // sum stockdispo - stockTotal: number; // sum qte - stockValeur: number; // sum valstock - pa: number; // prmp (premier site non-zéro) - prixVente: number; // pv premier site - derniereVente: string; // max date tous sites - derniereLivraison: string; // max dernierereception tous sites - nbJoursDerniereVente: number; // min tous sites -} - -// --------------------------------------------------------------------------- -// Pagination générique -// --------------------------------------------------------------------------- - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function extractList(data: unknown): any[] { - if (Array.isArray(data)) return data; - const d = data as Record; - // Essaie les wrappers courants - for (const key of ["items", "data", "result", "results", "records", "list", "mouvements", "articles", "fournisseurs", "ventes", "stocks", "commandes"]) { - if (Array.isArray(d[key])) return d[key] as unknown[]; - } - // Cherche la première propriété qui est un tableau - for (const val of Object.values(d)) { - if (Array.isArray(val)) return val as unknown[]; - } - return []; -} - -async function fetchAllPages( - buildUrl: (page: number) => string, - _extractItems: (data: unknown) => T[], - pageSize = 500 -): Promise { - // Page 1 — récupère les items et tente d'obtenir le total pour paralléliser - const url1 = buildUrl(1); - console.log(`[api-ff] GET ${url1}`); - const res1 = await fetch(url1, ffFetchInit()); - if (!res1.ok) { - console.error(`[api-ff] HTTP ${res1.status} — ${url1}`); - return []; - } - const data1 = await res1.json(); - const items1 = extractList(data1) as T[]; - console.log(`[api-ff] page 1: ${items1.length} items, keys: ${items1[0] ? Object.keys(items1[0] as object).join(",") : "empty"}`); - - // Si la première page est incomplète, pas besoin de continuer - if (items1.length < pageSize) return items1; - - // Cherche un total dans la réponse pour paralléliser les pages suivantes - const d1 = data1 as Record; - const total = Number(d1.count ?? d1.total ?? d1.nb_total ?? d1.totalCount ?? 0); - - if (total > pageSize) { - const totalPages = Math.ceil(total / pageSize); - const remaining = Array.from({ length: totalPages - 1 }, (_, i) => i + 2); - // Fetch en chunks de 10 pages en parallèle - const all = [...items1]; - for (let i = 0; i < remaining.length; i += 10) { - const chunk = remaining.slice(i, i + 10); - const results = await Promise.all(chunk.map(async (p) => { - const url = buildUrl(p); - console.log(`[api-ff] GET ${url}`); - const res = await fetch(url, ffFetchInit()); - if (!res.ok) return [] as T[]; - const data = await res.json(); - return extractList(data) as T[]; - })); - for (const r of results) all.push(...r); - } - return all; - } - - // Fallback parallèle sans total : fetch 10 pages à la fois, arrêt dès qu'une page est incomplète - const all = [...items1]; - let startPage = 2; - while (true) { - const chunk = Array.from({ length: 10 }, (_, i) => startPage + i); - const results = await Promise.all(chunk.map(async (p) => { - const url = buildUrl(p); - console.log(`[api-ff] GET ${url}`); - const res = await fetch(url, ffFetchInit()); - if (!res.ok) return { p, items: [] as T[] }; - const data = await res.json(); - return { p, items: extractList(data) as T[] }; - })); - results.sort((a, b) => a.p - b.p); - let done = false; - for (const { items } of results) { - all.push(...items); - if (items.length < pageSize) { done = true; break; } - } - if (done) break; - startPage += 10; - } - return all; -} - -// --------------------------------------------------------------------------- -// Helpers date -// --------------------------------------------------------------------------- - -/** Retourne la fenêtre des 12 mois complets précédant le mois en cours. - * En mars 2026 → { dateDebut: "2025-03-01", dateFin: "2026-02-28" } - */ -export function buildLast12MonthsRange(): { dateDebut: string; dateFin: string } { - const now = new Date(); - // Fin : dernier jour du mois précédent le mois courant - const endMonth = new Date(now.getFullYear(), now.getMonth(), 0); - // Début : 12 mois avant le mois courant - const startMonth = new Date(now.getFullYear(), now.getMonth() - 12, 1); - - return { - dateDebut: startMonth.toISOString().slice(0, 10), - dateFin: endMonth.toISOString().slice(0, 10), - }; -} - -// --------------------------------------------------------------------------- -// Articles -// --------------------------------------------------------------------------- - -export async function getArticlesByFournisseur( - codefou: string -): Promise { - const FF_API_BASE = await getFfApiBase(); - const raw = await fetchAllPages( - (page) => `${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&page=${page}&limit=500`, - extractList, - 500 - ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return raw.map((r: any): FfArticle => ({ - codein: r.codein ?? r.Codein ?? r.code_article ?? r.codeArticle ?? String(r.id ?? ""), - libelle1: r.libelle1 ?? r.Libelle1 ?? r.libelle ?? r.designation ?? r.nom ?? "", - codefou: r.codefou_principal ?? r.codefou ?? r.Codefou ?? r.code_fournisseur ?? r.codeFournisseur ?? codefou, - nomfou: r.nom_fou_principal ?? r.nomfou ?? r.Nomfou ?? r.nom_fournisseur ?? r.nomFournisseur ?? "", - pcb: r.pcb_principal ?? r.pcb ?? r.Pcb ?? r.conditionnement ?? r.colisage, - pv_central: r.pv_central ?? r.PvCentral ?? r.prix_vente_mini ?? r.pv ?? r.prixVente, - noid: r.no_id ?? r.noid ?? r.NoId, - reference: r.ref_fou_principale ?? r.reference ?? r.ref_fournisseur ?? undefined, - gtin: r.gtin ?? r.Gtin ?? r.ean ?? r.barcode ?? undefined, - actif: r.actif != null ? Boolean(Number(r.actif)) : undefined, - suspendu: r.suspendu != null ? Boolean(Number(r.suspendu)) : undefined, - })).filter(a => a.codein); -} - -// --------------------------------------------------------------------------- -// Mouvements (ventes + stock12m) -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Mensuel — stock fin de mois + ventes + réceptions par article et par site -// GET /api/articles/:no_id/mensuel?dateDebut=&dateFin= -// --------------------------------------------------------------------------- - -export interface FfMensuelVentes { - nb_passages: string; - qte_vendue: string; // négatif (ex: "-2.000") - ca_ht: string; // négatif (ex: "-26.6600") - ca_ttc: string; - marge: string; // positif (ex: "12.7600") - taux_marge: string; -} - -export interface FfMensuelReceptions { - nb_receptions: string; - qte_recue: string; -} - -export interface FfMensuelEntry { - mois: string; // "YYYY-MM" - site: string; // code site ("292", "579") - stock_fin_mois: string; // stock fin de mois - prmp_fin_mois: string; // PRMP fin de mois - ventes: FfMensuelVentes | null; - receptions: FfMensuelReceptions | null; -} - -/** - * Récupère les données mensuelles (stock + ventes + réceptions) pour un lot d'articles. - * Utilise no_id comme identifiant (obligatoire pour l'API). - * Retourne Map - */ -export async function getMensuelByArticles( - articles: FfArticle[], - dateDebut: string, - dateFin: string, - batchSize = 50 -): Promise> { - const result = new Map(); - const FF_API_BASE = await getFfApiBase(); - - for (let i = 0; i < articles.length; i += batchSize) { - const batch = articles.slice(i, i + batchSize); - await Promise.all( - batch.map(async (art) => { - const noId = art.noid; - if (!noId) return; - try { - const url = `${FF_API_BASE}/api/articles/${encodeURIComponent(String(noId))}/mensuel?dateDebut=${dateDebut}&dateFin=${dateFin}`; - const res = await fetch(url, ffFetchInit()); - if (!res.ok) return; - const data = await res.json(); - const entries: FfMensuelEntry[] = Array.isArray(data.data) ? data.data : []; - if (entries.length > 0) { - result.set(art.codein, entries); - } - } catch (err) { - console.error(`[api-ff] getMensuel error for ${art.codein}:`, err); - } - }) - ); - } - - return result; -} - -// --------------------------------------------------------------------------- -// Referentiel — article complet : nomenclature, gammes, stock, prix, fournisseurs -// GET /api/articles/:no_id/referentiel -// --------------------------------------------------------------------------- - -export interface FfReferentiel { - article: { - no_id: string; - codein: string; - nom_code: string | null; // code nomenclature (ex: "360504") - nom_libelle: string | null; // libellé nomenclature - nom_niveau: number | null; - nom_chemin_pere: string | null; - }; - gammes: { - gamme_code: string; - gamme_libelle: string; - saison_code: string; - saison_libelle: string; - }[]; - stock: { - site: string; - stockdispo: string; - qte: string; - prmp: string; - valstock: string; - pv: string; - dernierevente: string; - dernierereception: string; - stockmort: boolean; - }[]; - prix: { - achat: string | null; - vente_par_site: { site: string; pv: string }[]; - } | null; - fournisseurs: { - codefou: string; - ref_fou: string; - pcb: string; - prixachat: string; - }[]; - performance: { - derniere_vente: string | null; - derniere_entree: string | null; - qte_totale_vendue: string | null; - ca_ttc_total: string | null; - marge_totale: string | null; - } | null; -} - -/** - * Récupère le référentiel complet (nomenclature, gamme, stock, prix) pour un lot d'articles. - * Utilise no_id comme identifiant. - * Retourne Map - */ -export async function getReferentielByArticles( - articles: FfArticle[], - batchSize = 50 -): Promise> { - const result = new Map(); - const FF_API_BASE = await getFfApiBase(); - - for (let i = 0; i < articles.length; i += batchSize) { - const batch = articles.slice(i, i + batchSize); - await Promise.all( - batch.map(async (art) => { - const noId = art.noid; - if (!noId) return; - try { - const url = `${FF_API_BASE}/api/articles/${encodeURIComponent(String(noId))}/referentiel`; - const res = await fetch(url, ffFetchInit()); - if (!res.ok) return; - const data: FfReferentiel = await res.json(); - if (data?.article) result.set(art.codein, data); - } catch (err) { - console.error(`[api-ff] getReferentiel error for ${art.codein}:`, err); - } - }) - ); - } - - return result; -} - -// --------------------------------------------------------------------------- -// Commandes en cours -// --------------------------------------------------------------------------- - -export async function getCommandesByFournisseur( - codefou: string -): Promise> { - const result = new Map(); - const FF_API_BASE = await getFfApiBase(); - try { - const res = await fetch( - `${FF_API_BASE}/api/commandes/articles?codefou=${encodeURIComponent(codefou)}`, - ffFetchInit() - ); - if (!res.ok) return result; - const data = await res.json(); - const list: FfCommande[] = Array.isArray(data) ? data : ((data as any).items ?? (data as any).data ?? []); - for (const c of list) { - result.set(c.codein, (result.get(c.codein) ?? 0) + (c.qtecde ?? 0)); - } - } catch (err) { - console.error("[api-ff] getCommandesByFournisseur error:", err); - } - return result; -} - -// --------------------------------------------------------------------------- -// Statut de synchronisation -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Sites (magasins) -// --------------------------------------------------------------------------- - -export async function getSitesFromApi(): Promise<{ code: string; nom: string }[]> { - return [ - { code: "292", nom: "Frouard (Nancy)" }, - { code: "579", nom: "Houdemont" }, - ]; -} diff --git a/src/lib/api-gammes.ts b/src/lib/api-gammes.ts deleted file mode 100644 index f6fb1ca..0000000 --- a/src/lib/api-gammes.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * CollectFlow — Affectation de gammes via l'API `/api/v1`. - * - * L'API n'enregistre pas la gamme : elle la **propose**. La proposition est - * rangée dans `gammes_a_valider` et apparaît dans la Grille comme une - * modification non enregistrée — on y voit donc quels produits changent de gamme - * avant de valider (« Enregistrer », même chemin que toute modification de la - * Grille) ou de rejeter (« Annuler les modifications »). Tant qu'elle n'est pas - * validée, `codeGamme` reste inchangée et la proposition est exposée dans - * `gammeAValider`. - * - * Tout ou rien : si un seul article est inconnu chez le fournisseur, rien n'est - * proposé et la réponse liste les articles en cause. - */ - -import "server-only"; - -import type { NextRequest } from "next/server"; -import { fail } from "@/lib/api-response"; -import { getGridFreshness, getGridRowsGammes } from "@/lib/grid-store"; -import { getProductRows } from "@/features/grid/api/get-product-rows"; -import { proposerGammes, type PropositionGamme } from "@/lib/gammes-a-valider"; - -export interface ResultatGamme { - codein: string; - /** Gamme proposée. */ - gamme: string; - /** Gamme enregistrée au moment de l'appel (modifications validées comprises). */ - gammePrecedente: string | null; - /** Gamme présente en base FF, non modifiée par l'API. */ - codeGammeServeur: string | null; - /** - * `true` = la gamme change : proposition déposée, à valider dans la Grille. - * `false` = l'article a déjà cette gamme : rien à valider (une proposition - * antérieure en attente pour lui est retirée). - */ - modifie: boolean; - /** `a_valider` quand `modifie`, sinon `inchangee`. */ - statut: "a_valider" | "inchangee"; -} - -export interface AffectationGammes { - codeFournisseur: string; - nomFournisseur: string | null; - resultats: ResultatGamme[]; -} - -/** Lit un corps JSON, ou renvoie une réponse 400 prête à être retournée. */ -export async function lireCorpsJson(req: NextRequest): Promise { - try { - return await req.json(); - } catch { - return fail("bad_request", "Corps de requête JSON attendu (Content-Type: application/json)."); - } -} - -/** - * Propose des gammes pour des articles d'un fournisseur (à valider dans la Grille). - * - * Si le fournisseur n'a encore jamais été calculé, son instantané est calculé à la - * demande (`compute`), comme sur `/grid` : sans lui, impossible de vérifier que les - * articles lui appartiennent. - */ -export async function affecterGammes(input: { - codeFournisseur: string; - changes: ReadonlyArray<{ codein: string; gamme: string }>; - compute: boolean; - /** Nom de la clé d'API ou de l'utilisateur — affiché dans la Grille avec la proposition. */ - auteur: string; -}): Promise { - const { codeFournisseur, compute, auteur } = input; - - // Un même article cité deux fois : la dernière valeur l'emporte. - const demandes = new Map(input.changes.map((c) => [c.codein, c.gamme])); - const codeins = [...demandes.keys()]; - - if (compute && !(await getGridFreshness(codeFournisseur))) { - console.log(`[api/v1/gammes] instantané absent pour ${codeFournisseur} — calcul à la demande`); - try { - await getProductRows({ codeFournisseur, magasin: "TOTAL" }); - } catch (e) { - console.error(`[api/v1/gammes] calcul KO pour ${codeFournisseur}:`, e instanceof Error ? e.message : String(e)); - return fail("internal_error", `Le calcul de la grille a échoué pour le fournisseur « ${codeFournisseur} ».`); - } - } - - const actuelles = await getGridRowsGammes(codeFournisseur, codeins); - const inconnus = codeins.filter((c) => !actuelles.has(c)); - if (inconnus.length > 0) { - return fail( - "not_found", - `${inconnus.length} article(s) introuvable(s) chez le fournisseur « ${codeFournisseur} » : aucune gamme n'a été enregistrée.`, - { fournisseur: codeFournisseur, inconnus }, - ); - } - - const resultats: ResultatGamme[] = []; - const propositions: PropositionGamme[] = []; - const annules: string[] = []; - let nomFournisseur: string | null = null; - - for (const [codein, gamme] of demandes) { - const actuelle = actuelles.get(codein)!; - nomFournisseur ??= actuelle.nomFournisseur; - const modifie = actuelle.codeGamme !== gamme; - resultats.push({ - codein, - gamme, - gammePrecedente: actuelle.codeGamme, - codeGammeServeur: actuelle.codeGammeInit, - modifie, - statut: modifie ? "a_valider" : "inchangee", - }); - if (modifie) propositions.push({ codein, gamme, gammePrecedente: actuelle.codeGamme }); - else annules.push(codein); - } - - try { - await proposerGammes({ codeFournisseur, propositions, annules, auteur }); - } catch (e) { - console.error(`[api/v1/gammes] proposition KO pour ${codeFournisseur}:`, e instanceof Error ? e.message : String(e)); - return fail("internal_error", "L'enregistrement des gammes à valider a échoué."); - } - - console.log(`[api/v1/gammes] ${codeFournisseur} — ${propositions.length} gamme(s) à valider proposée(s) par ${auteur}`); - return { codeFournisseur, nomFournisseur, resultats }; -} diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts deleted file mode 100644 index f4a821d..0000000 --- a/src/lib/api-response.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * CollectFlow — Enveloppe de réponse de l'API publique `/api/v1`. - * - * Une forme unique pour tout : `{ data, pagination?, meta }` en succès, - * `{ error: { code, message } }` en échec, avec de vrais statuts HTTP. - * Les consommateurs (scripts, IA, outils externes) peuvent ainsi traiter - * toutes les réponses de la même manière. - */ - -import { NextResponse } from "next/server"; - -export type ApiErrorCode = - | "unauthorized" - | "forbidden" - | "bad_request" - | "not_found" - | "not_ready" - | "internal_error"; - -const STATUS_BY_CODE: Record = { - unauthorized: 401, - forbidden: 403, - bad_request: 400, - not_found: 404, - not_ready: 202, - internal_error: 500, -}; - -export interface Pagination { - page: number; - limit: number; - total: number; - totalPages: number; - /** `true` s'il reste des pages à lire — évite d'avoir à comparer page et totalPages. */ - hasMore: boolean; -} - -export function buildPagination(page: number, limit: number, total: number): Pagination { - const totalPages = Math.max(1, Math.ceil(total / limit)); - // `hasMore` explicite : un agent qui doit comparer page et totalPages oublie - // souvent de le faire et conclut à tort qu'il a tout récupéré. - return { page, limit, total, totalPages, hasMore: page < totalPages }; -} - -/** Réponse de succès. `meta` porte la fraîcheur de la donnée (`computedAt`). */ -export function ok( - data: T, - opts: { pagination?: Pagination; meta?: Record } = {}, -): NextResponse { - return NextResponse.json({ - data, - ...(opts.pagination ? { pagination: opts.pagination } : {}), - meta: { ...(opts.meta ?? {}) }, - }); -} - -/** - * Réponse d'erreur. `details` sert notamment aux erreurs de validation zod. - * - * Note : `not_ready` renvoie un 202 — ce n'est pas un échec mais une donnée pas - * encore calculée (voir /api/v1/grid sur un fournisseur jamais ouvert). - */ -export function fail( - code: ApiErrorCode, - message: string, - details?: unknown, -): NextResponse { - return NextResponse.json( - { error: { code, message, ...(details !== undefined ? { details } : {}) } }, - { status: STATUS_BY_CODE[code] }, - ); -} diff --git a/src/lib/api-schemas.ts b/src/lib/api-schemas.ts deleted file mode 100644 index 7e0af74..0000000 --- a/src/lib/api-schemas.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * CollectFlow — Validation des paramètres de l'API `/api/v1` (zod). - * - * Toute entrée utilisateur passe par ici : bornes de pagination, listes blanches - * de tri, projection de champs. Les erreurs remontent en `400 bad_request` avec - * le détail zod, pour que l'appelant sache quoi corriger. - */ - -import { z } from "zod"; -import { GRID_SORT_KEYS, type GridSortKey } from "@/lib/grid-store"; -import { GAMMES, type CodeGamme } from "@/lib/gammes"; - -const SORT_KEYS = GRID_SORT_KEYS as readonly string[]; - -export const paginationShape = { - page: z.coerce.number().int().min(1).default(1), - /** - * Aucun plafond : l'appelant décide de la taille qu'il veut recevoir. - * Le défaut reste modeste (100) pour que les endpoints de recherche, non - * bornés par un fournisseur, ne renvoient pas tout le catalogue par accident. - * `/grid` redéfinit ce paramètre : sans `limit`, il renvoie tout. - */ - limit: z.coerce.number().int().min(1).default(100), -}; - -const sortShape = { - sort: z - .string() - .refine((v) => SORT_KEYS.includes(v), { - message: `Tri inconnu. Valeurs acceptées : ${SORT_KEYS.join(", ")}`, - }) - .optional(), - order: z.enum(["asc", "desc"]).default("desc"), - /** Liste de champs de ProductRow à conserver, séparés par des virgules. */ - fields: z.string().optional(), - /** - * `1` (défaut) : relit les métriques réseau Qlik et la gamme serveur au moment - * de l'appel. `0` : sert l'instantané brut, un peu plus rapide. - */ - enrich: z.enum(["0", "1"]).default("1"), -}; - -/** `/api/v1/grid` — le fournisseur est obligatoire (recherche transversale : /products/search). */ -export const gridQuerySchema = z.object({ - fournisseur: z.string().min(1, "Paramètre 'fournisseur' requis"), - gamme: z.string().min(1).optional(), - code1: z.string().min(1).optional(), - code2: z.string().min(1).optional(), - code3: z.string().min(1).optional(), - /** - * Préfixe de nomenclature (« 32 », « 3202 », « 320211 »), tel que renvoyé par - * /api/v1/nomenclatures. À préférer à code1/code2, qui peuvent être vides - * quand la hiérarchie n'a pas pu être remontée depuis la base FF. - */ - nomenclature: z.string().min(1).max(20).regex(/^[0-9]+$/, "La nomenclature doit être un préfixe numérique (ex. 32, 3202, 320211)").optional(), - search: z.string().min(1).optional(), - /** - * `1` (défaut) : si le fournisseur n'a pas encore d'instantané, l'API le calcule - * à la demande au lieu de répondre `not_ready`. C'est ce qui permet à une app ou - * à un agent externe d'interroger n'importe quel fournisseur sans qu'un humain - * ait ouvert la Grille avant. Le premier appel est alors plus lent (plusieurs - * secondes) ; les suivants sont servis depuis l'instantané. - * `0` : comportement strict, échoue vite en `not_ready`. - */ - compute: z.enum(["0", "1"]).default("1"), - ...sortShape, - ...paginationShape, - /** - * **Aucun plafond, et aucune valeur par défaut** : sans `limit`, `/grid` - * renvoie *toutes* les lignes du fournisseur. - * - * C'est l'objectif de cet endpoint — qu'une app ou un agent récupère un - * fournisseur entier en un appel. Paginer par défaut condamnait l'appelant à - * boucler, ce que les agents font mal : ils s'arrêtent à la première page et - * raisonnent sur un catalogue tronqué. - * - * Réponse volumineuse : une ligne complète porte les séries mensuelles et les - * ventilations par magasin. Combiner avec `fields` est vivement conseillé. - */ - limit: z.coerce.number().int().min(1).optional(), -}); - -/** `/api/v1/products/search` — `fournisseur` facultatif : c'est la recherche transversale. */ -export const productSearchSchema = z.object({ - q: z.string().min(1, "Paramètre 'q' requis").max(120), - fournisseur: z.string().min(1).optional(), - gamme: z.string().min(1).optional(), - ...sortShape, - ...paginationShape, -}); - -export const fournisseursQuerySchema = z.object({ - search: z.string().min(1).optional(), - /** true = uniquement les fournisseurs déjà présents dans l'instantané de grille. */ - withData: z.enum(["0", "1"]).optional(), -}); - -/** `/api/v1/nomenclatures` — inventaire des postes d'un fournisseur. */ -export const nomenclaturesQuerySchema = z.object({ - fournisseur: z.string().min(1, "Paramètre 'fournisseur' requis"), - /** - * 1 = univers, 2 = famille, 3 = sous-famille (selon la nomenclature FF). - * Le `transform` ne fait que redonner le type littéral : les bornes ont déjà - * été validées juste avant, la valeur ne peut être que 1, 2 ou 3. - */ - niveau: z.coerce.number().int().min(1).max(3).default(1).transform((n) => n as 1 | 2 | 3), - /** Restreint à un poste parent : un code1 pour niveau=2, un code2 pour niveau=3. */ - parent: z.string().min(1).optional(), -}); - -export const productDetailSchema = z.object({ - fournisseur: z.string().min(1).optional(), - /** - * `1` (défaut) : calcule l'instantané à la demande si le produit n'y figure pas - * encore. Nécessite `fournisseur`, le calcul se faisant par lot fournisseur. - */ - compute: z.enum(["0", "1"]).default("1"), -}); - -const CODES_GAMME = GAMMES.map((g) => g.code) as [CodeGamme, ...CodeGamme[]]; -const gammeShape = z.enum(CODES_GAMME, { - message: `Gamme inconnue. Valeurs acceptées : ${CODES_GAMME.join(", ")}`, -}); - -/** `PUT /api/v1/products/:codein/gamme` — affecte ou change la gamme d'un article. */ -export const productGammeBodySchema = z.object({ - gamme: gammeShape, - /** Obligatoire seulement si l'article est référencé chez plusieurs fournisseurs. */ - fournisseur: z.string().min(1).optional(), - /** `true` (défaut) : calcule l'instantané du fournisseur s'il n'existe pas encore. */ - compute: z.boolean().default(true), -}); - -/** `POST /api/v1/gammes` — affecte des gammes à plusieurs articles d'un fournisseur. */ -export const gammesBatchBodySchema = z.object({ - fournisseur: z.string().min(1, "Champ 'fournisseur' requis"), - changes: z - .array(z.object({ codein: z.string().min(1), gamme: gammeShape })) - .min(1, "Au moins un changement attendu") - .max(10_000, "10 000 changements au maximum par appel"), - compute: z.boolean().default(true), -}); - -/** - * Projection de champs : `?fields=codein,libelle1,totalCa`. - * - * Sert à alléger la réponse — un `ProductRow` complet porte les séries mensuelles - * et les ventilations par magasin, ce qui est volumineux sur plusieurs centaines de - * lignes. `codein` est toujours conservé pour que la ligne reste identifiable. - */ -export function pickFields(row: T, fields?: string): Partial { - if (!fields) return row; - const wanted = new Set( - fields.split(",").map((f) => f.trim()).filter(Boolean), - ); - if (wanted.size === 0) return row; - wanted.add("codein"); - const out: Record = {}; - for (const key of wanted) { - if (key in row) out[key] = (row as unknown as Record)[key]; - } - return out as Partial; -} - -/** Normalise le tri validé vers la clé attendue par grid-store. */ -export function toSortKey(sort?: string): GridSortKey | undefined { - return sort as GridSortKey | undefined; -} diff --git a/src/lib/auth.config.ts b/src/lib/auth.config.ts deleted file mode 100644 index 35703da..0000000 --- a/src/lib/auth.config.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { NextAuthConfig } from "next-auth"; - -export const authConfig = { - pages: { - signIn: "/login", - }, - callbacks: { - async jwt({ token, user }) { - if (user) { - token.role = (user as any).role; - token.id = user.id; - } - return token; - }, - async session({ session, token }) { - if (session.user) { - (session.user as any).role = token.role as string; - (session.user as any).id = token.id as string; - } - return session; - }, - }, - providers: [], // Les providers seront ajoutés dans auth.ts -} satisfies NextAuthConfig; diff --git a/src/lib/auth.ts b/src/lib/auth.ts deleted file mode 100644 index 9f0927f..0000000 --- a/src/lib/auth.ts +++ /dev/null @@ -1,73 +0,0 @@ -import NextAuth from "next-auth"; -import Credentials from "next-auth/providers/credentials"; -import { db } from "@/db"; -import { users } from "@/db/schema"; -import { eq } from "drizzle-orm"; -import { verifyPassword, getFallbackUsers } from "@/features/auth/logic/auth-logic"; -import { ensureAdminExists } from "@/features/auth/logic/seed-admin"; -import { authConfig } from "./auth.config"; - -export const { handlers, auth, signIn, signOut } = NextAuth({ - ...authConfig, - providers: [ - Credentials({ - name: "Credentials", - credentials: { - username: { label: "Utilisateur", type: "text" }, - password: { label: "Mot de passe", type: "password" } - }, - async authorize(credentials) { - await ensureAdminExists(); - - if (!credentials?.username || !credentials?.password) return null; - - try { - console.log(`[AUTH] Checking user "${credentials.username}"...`); - - let user: { id: number | string; username: string; passwordHash: string; role: string } | undefined; - - // Try DB first - try { - const [dbUser] = await db.select() - .from(users) - .where(eq(users.username, credentials.username as string)); - user = dbUser; - console.log(`[AUTH] DB check: User found: ${!!user}`); - } catch { - // Base injoignable uniquement : un compte absent de la base ne doit - // pas pouvoir se connecter avec le fichier de secours (admin/admin). - console.warn("[AUTH] DB unreachable, falling back to JSON."); - const fallbackUsers = getFallbackUsers(); - user = fallbackUsers.find(u => u.username === credentials.username); - console.log(`[AUTH] JSON check: User found: ${!!user}`); - } - - if (!user) { - console.warn(`[AUTH] User "${credentials.username}" not found anywhere.`); - return null; - } - - const isValid = verifyPassword(credentials.password as string, user.passwordHash); - console.log(`[AUTH] Password valid for "${credentials.username}": ${isValid}`); - - if (!isValid) { - console.warn(`[AUTH] Invalid password for user "${credentials.username}".`); - return null; - } - - return { - id: user.id.toString(), - // `name` est repris dans la session : sans lui, l'en-tête ne - // pouvait pas afficher qui est connecté. - name: user.username, - username: user.username, - role: user.role, - }; - } catch (err: any) { - console.error("[AUTH] CRITICAL ERROR during authorize callback:", err.message || err); - return null; - } - } - }) - ], -}); diff --git a/src/lib/authz.ts b/src/lib/authz.ts deleted file mode 100644 index fc90420..0000000 --- a/src/lib/authz.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * CollectFlow — Contrôle d'accès commun aux actions serveur, routes et pages. - * - * Le jeton de session (JWT) seul ne suffit pas : il reste valide jusqu'à son - * expiration, même si le compte a été supprimé ou rétrogradé entre-temps. On - * relit donc l'utilisateur dans la table `users` (avec un cache court), et le - * rôle enregistré en base l'emporte sur celui du jeton. - * - * Base injoignable : on se contente du jeton (mode dégradé), sans quoi le compte - * de secours `data/users.json` ne pourrait plus rien faire — y compris configurer - * la base. - */ - -import "server-only"; - -import { NextResponse } from "next/server"; -import { eq } from "drizzle-orm"; -import { auth } from "@/lib/auth"; -import { db } from "@/db"; -import { users } from "@/db/schema"; - -export interface UtilisateurCourant { - id: string; - username: string; - role: string; -} - -const MESSAGE_SESSION = "Session expirée : reconnectez-vous."; -const MESSAGE_ADMIN = "Action réservée aux administrateurs."; - -/** Refus d'accès. `statut` sert aux routes : 401 sans session valide, 403 sans le rôle requis. */ -export class AccesRefuse extends Error { - constructor(message: string, readonly statut: 401 | 403 = 403) { - super(message); - this.name = "AccesRefuse"; - } -} - -// ── Relecture du compte en base ────────────────────────────────────────────── - -/** Durée pendant laquelle une relecture de la table `users` est réutilisée. */ -const CACHE_TTL_MS = 60_000; -/** Après un échec de connexion, on ne réessaie pas avant ce délai (chaque essai peut durer 10 s). */ -const PAUSE_BASE_INJOIGNABLE_MS = 15_000; - -interface FicheUtilisateur { - exists: boolean; - role: string; - username: string; - at: number; -} - -const cache = new Map(); -let baseInjoignableDepuis = 0; - -/** Plus grand entier accepté par la colonne `users.id` (serial). */ -const ID_MAX = 2_147_483_647; - -/** - * Fiche de l'utilisateur en base, ou `null` si la base n'a pas pu répondre. - * Un identifiant qui ne peut pas exister (compte de secours « 0 », valeur non - * numérique) est cherché tel quel : introuvable si la base répond. - */ -async function lireFiche(id: string): Promise { - const maintenant = Date.now(); - const enCache = cache.get(id); - if (enCache && maintenant - enCache.at < CACHE_TTL_MS) return enCache; - if (maintenant - baseInjoignableDepuis < PAUSE_BASE_INJOIGNABLE_MS) return null; - - const n = /^\d+$/.test(id) ? Number(id) : -1; - const idNumerique = Number.isSafeInteger(n) && n <= ID_MAX ? n : -1; - - try { - const [ligne] = await db - .select({ username: users.username, role: users.role }) - .from(users) - .where(eq(users.id, idNumerique)) - .limit(1); - const fiche: FicheUtilisateur = ligne - ? { exists: true, role: ligne.role, username: ligne.username, at: maintenant } - : { exists: false, role: "", username: "", at: maintenant }; - cache.set(id, fiche); - return fiche; - } catch (e) { - baseInjoignableDepuis = maintenant; - console.warn("[authz] Base injoignable, contrôle sur le seul jeton de session :", (e as Error).message?.slice(0, 200)); - return null; - } -} - -// ── Contrôles ──────────────────────────────────────────────────────────────── - -/** Utilisateur connecté, relu en base. Lève `AccesRefuse` (401) sinon. */ -export async function requireSession(): Promise { - const session = await auth(); - const jeton = session?.user as { id?: string | number; name?: string | null; role?: string } | undefined; - if (!jeton) throw new AccesRefuse(MESSAGE_SESSION, 401); - - const id = jeton.id != null ? String(jeton.id) : ""; - const fiche = await lireFiche(id); - - if (!fiche) { - // Mode dégradé : le jeton fait foi. - return { id, username: jeton.name ?? "", role: jeton.role ?? "user" }; - } - if (!fiche.exists) throw new AccesRefuse(MESSAGE_SESSION, 401); - return { id, username: fiche.username, role: fiche.role }; -} - -/** Administrateur connecté. Lève `AccesRefuse` (401 ou 403) sinon. */ -export async function requireAdmin(): Promise { - const utilisateur = await requireSession(); - if (utilisateur.role !== "admin") throw new AccesRefuse(MESSAGE_ADMIN, 403); - return utilisateur; -} - -// ── Variantes sans exception ───────────────────────────────────────────────── - -export type Autorisation = - | { ok: true; utilisateur: UtilisateurCourant } - | { ok: false; message: string; statut: 401 | 403 }; - -async function verifier(controle: () => Promise): Promise { - try { - return { ok: true, utilisateur: await controle() }; - } catch (e) { - if (e instanceof AccesRefuse) return { ok: false, message: e.message, statut: e.statut }; - throw e; - } -} - -/** - * Pour les actions qui renvoient un résultat `{ success: false, error }` plutôt - * que de lever une exception, et pour les pages (le `redirect()` doit être appelé - * hors d'un try/catch). - */ -export function verifierSession(): Promise { - return verifier(requireSession); -} - -export function verifierAdmin(): Promise { - return verifier(requireAdmin); -} - -// ── Variantes pour les routes ──────────────────────────────────────────────── - -function reponse(a: Autorisation): UtilisateurCourant | Response { - return a.ok ? a.utilisateur : NextResponse.json({ error: a.message }, { status: a.statut }); -} - -/** - * Utilisateur connecté, ou réponse JSON 401 à renvoyer telle quelle : - * - * const acces = await sessionOuReponse(); - * if (acces instanceof Response) return acces; - */ -export async function sessionOuReponse(): Promise { - return reponse(await verifierSession()); -} - -/** Administrateur connecté, ou réponse JSON 401 / 403 à renvoyer telle quelle. */ -export async function adminOuReponse(): Promise { - return reponse(await verifierAdmin()); -} diff --git a/src/lib/ff-cache.ts b/src/lib/ff-cache.ts deleted file mode 100644 index be2c94c..0000000 --- a/src/lib/ff-cache.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * CollectFlow — Cache mémoire des lectures « FF » (base miroir et API FF Nancy). - * - * Les tables FF (`mvtart`, `cube_stock`, `articles`…) ne sont recopiées qu'une - * fois par nuit depuis SQL Server : recalculer à chaque visite les mêmes - * agrégations lourdes (stock, hit-parade, CA mensuel…) coûtait plusieurs - * secondes par page pour un résultat identique. - * - * Pourquoi pas `unstable_cache` : il sérialise en JSON (une `Map` devient `{}`, - * une `Date` une chaîne) et ne peut pas être invalidé hors d'une requête — or - * c'est la synchro nocturne, lancée par un minuteur, qui sait quand les données - * changent. Ici : - * - une durée de vie bornée (FF_CACHE_TTL_S, 30 min par défaut) ; - * - les appels simultanés pour la même clé partagent une seule requête ; - * - une erreur n'est jamais mise en cache ; - * - `invalidateFFCache()` vide tout (appelé en fin de synchro nocturne). - */ - -import "server-only"; - -import { pgGetDerniereReceptionParFournisseur, pgGetFournisseurs } from "@/lib/pg-ff-client"; - -const TTL_MS = (Number(process.env.FF_CACHE_TTL_S) || 30 * 60) * 1000; -/** Plafond d'entrées : les clés incluent des paramètres (dates, mois, magasin). */ -const MAX_ENTRIES = 100; - -interface Entry { - value?: unknown; - expiresAt: number; - pending?: Promise; -} - -// Rangé dans globalThis : un seul cache par processus, même si ce module est -// présent dans plusieurs lots compilés (routes, instrumentation). -const globalForFFCache = globalThis as typeof globalThis & { - __collectflowFFCache?: { store: Map; generation: number }; -}; -const cache = (globalForFFCache.__collectflowFFCache ??= { store: new Map(), generation: 0 }); -const store = cache.store; - -interface CachedOptions { - /** Durée de vie propre à cette clé, en millisecondes. */ - ttlMs?: number; - /** - * Ne garder le résultat que s'il passe ce test. Sert aux lectures qui - * avalent leurs erreurs et renvoient une liste vide : sans ce garde-fou, - * une panne passagère resterait affichée pendant toute la durée de vie. - */ - cacheIf?: (value: T) => boolean; -} - -export async function cachedFF(key: string, load: () => Promise, options: CachedOptions = {}): Promise { - const now = Date.now(); - const entry = store.get(key); - if (entry && "value" in entry && entry.expiresAt > now) return entry.value as T; - if (entry?.pending) return entry.pending as Promise; - - // Une invalidation pendant le chargement rend son résultat suspect : il est - // alors renvoyé à l'appelant, mais pas conservé. - const generation = cache.generation; - const pending = load().then( - (value) => { - if (generation !== cache.generation) return value; - if (options.cacheIf && !options.cacheIf(value)) { - store.delete(key); - } else { - store.delete(key); // réinsertion en fin : ordre d'éviction = ancienneté - store.set(key, { value, expiresAt: Date.now() + (options.ttlMs ?? TTL_MS) }); - while (store.size > MAX_ENTRIES) { - const oldest = store.keys().next().value; - if (oldest === undefined) break; - store.delete(oldest); - } - } - return value; - }, - (error) => { - if (generation === cache.generation) store.delete(key); - throw error; - }, - ); - // Une éventuelle valeur expirée est remplacée : seule la requête en cours compte. - store.set(key, { expiresAt: 0, pending }); - return pending; -} - -/** Vide tout le cache FF (fin de synchro nocturne, données fraîches). */ -export function invalidateFFCache(): void { - cache.generation++; - store.clear(); -} - -// --------------------------------------------------------------------------- -// Lectures partagées par plusieurs pages -// --------------------------------------------------------------------------- - -/** Référentiel fournisseurs (Grille, cadencier). Vide = erreur avalée, non gardé. */ -export function getFournisseursCached(): Promise<{ code: string; nom: string }[]> { - return cachedFF("fournisseurs", () => pgGetFournisseurs(), { cacheIf: (rows) => rows.length > 0 }); -} - -/** - * Dernière réception par fournisseur et magasin, pour les fournisseurs du - * cadencier seulement : de loin la lecture la plus lourde de la page Commandes. - * Clé = liste triée, donc recalculée quand une cadence est ajoutée ou retirée. - * Vide = erreur avalée (ou aucune réception), non gardé. - */ -export function getDerniereReceptionCached(codes: string[]): Promise> { - const liste = [...new Set(codes.map((c) => c.trim()))].sort(); - if (liste.length === 0) return Promise.resolve(new Map()); - return cachedFF(`derniere-reception:${liste.join(",")}`, () => pgGetDerniereReceptionParFournisseur(liste), { - cacheIf: (map) => map.size > 0, - }); -} diff --git a/src/lib/gammes-a-valider.ts b/src/lib/gammes-a-valider.ts deleted file mode 100644 index 4ba91d1..0000000 --- a/src/lib/gammes-a-valider.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * CollectFlow — Gammes proposées par l'API, en attente de validation. - * - * L'API `/api/v1` ne change plus une gamme directement : elle dépose une - * proposition, que la Grille affiche comme une modification non enregistrée. - * L'utilisateur voit ainsi quels produits changent de gamme, puis valide - * (« Enregistrer ») ou rejette (« Annuler les modifications »). - */ - -import "server-only"; - -import { and, eq, inArray, sql } from "drizzle-orm"; -import { db } from "@/db"; -import { gammesAValider } from "@/db/schema"; - -export interface PropositionGamme { - codein: string; - /** Gamme proposée. */ - gamme: string; - /** Gamme enregistrée au moment de la proposition. */ - gammePrecedente: string | null; -} - -export interface GammeAValider extends PropositionGamme { - auteur: string | null; - /** Date ISO de la proposition. */ - proposeeLe: string; -} - -/** - * Dépose des propositions (la dernière l'emporte pour un même article) et retire - * celles des articles `annules` — redemander la gamme déjà enregistrée annule la - * proposition en attente. - */ -export async function proposerGammes(input: { - codeFournisseur: string; - propositions: PropositionGamme[]; - annules: string[]; - auteur: string; -}): Promise { - const { codeFournisseur, propositions, annules, auteur } = input; - await db.transaction(async (tx) => { - if (annules.length > 0) { - await tx - .delete(gammesAValider) - .where(and(eq(gammesAValider.codeFournisseur, codeFournisseur), inArray(gammesAValider.codein, annules))); - } - if (propositions.length > 0) { - const maintenant = new Date(); - await tx - .insert(gammesAValider) - .values(propositions.map((p) => ({ - codeFournisseur, - codein: p.codein, - gamme: p.gamme, - gammePrecedente: p.gammePrecedente, - auteur: auteur.slice(0, 100), - createdAt: maintenant, - }))) - .onConflictDoUpdate({ - target: [gammesAValider.codeFournisseur, gammesAValider.codein], - set: { - gamme: sql`excluded.gamme`, - gammePrecedente: sql`excluded.gamme_precedente`, - auteur: sql`excluded.auteur`, - createdAt: sql`excluded.created_at`, - }, - }); - } - }); -} - -/** Propositions en attente pour un fournisseur. */ -export async function listerGammesAValider(codeFournisseur: string): Promise { - const rows = await db - .select() - .from(gammesAValider) - .where(eq(gammesAValider.codeFournisseur, codeFournisseur)); - return rows.map((r) => ({ - codein: r.codein, - gamme: r.gamme, - gammePrecedente: r.gammePrecedente, - auteur: r.auteur, - proposeeLe: r.createdAt.toISOString(), - })); -} - -/** Propositions en attente pour une liste d'articles, indexées par `fournisseur|codein`. */ -export async function gammesAValiderParArticle(codeins: string[]): Promise> { - if (codeins.length === 0) return new Map(); - const rows = await db - .select({ codeFournisseur: gammesAValider.codeFournisseur, codein: gammesAValider.codein, gamme: gammesAValider.gamme }) - .from(gammesAValider) - .where(inArray(gammesAValider.codein, codeins)); - return new Map(rows.map((r) => [`${r.codeFournisseur}|${r.codein}`, r.gamme])); -} - -/** Retire des propositions, une fois validées ou rejetées dans la Grille. */ -export async function supprimerGammesAValider(codeFournisseur: string, codeins: string[]): Promise { - if (codeins.length === 0) return; - await db - .delete(gammesAValider) - .where(and(eq(gammesAValider.codeFournisseur, codeFournisseur), inArray(gammesAValider.codein, codeins))); -} diff --git a/src/lib/grid-store.ts b/src/lib/grid-store.ts deleted file mode 100644 index db29e82..0000000 --- a/src/lib/grid-store.ts +++ /dev/null @@ -1,485 +0,0 @@ -/** - * CollectFlow — Instantané persisté de la grille (table `grid_rows`). - * - * `getProductRows()` reconstruit la grille en direct (6 requêtes SQL sur la base - * miroir FF + jointure Qlik) et ne la garde qu'en mémoire 10 minutes. L'API `/api/v1` - * ne doit **jamais** déclencher ce calcul : elle lit cette table, remplie en effet de - * bord quand quelqu'un ouvre la Grille. - * - * On n'introduit donc aucun calcul supplémentaire — on arrête de jeter un résultat - * qui existe déjà, et il survit désormais aux redémarrages. - */ - -import "server-only"; - -import { db } from "@/db"; -import { gridRows } from "@/db/schema"; -import { and, asc, desc, eq, ilike, inArray, or, sql, lt, type SQL } from "drizzle-orm"; -import type { ProductRow } from "@/types/grid"; - -/** Colonnes autorisées au tri — liste blanche, jamais l'entrée utilisateur brute. */ -const SORTABLE = { - libelle1: gridRows.libelle1, - codein: gridRows.codein, - codeFournisseur: gridRows.codeFournisseur, - codeGamme: gridRows.codeGamme, - totalCa: gridRows.totalCa, - totalQuantite: gridRows.totalQuantite, - totalMarge: gridRows.totalMarge, - tauxMarge: gridRows.tauxMarge, - stockActuel: gridRows.stockActuel, - caReseau: gridRows.caReseau, - qteReseau: gridRows.qteReseau, - nbMagasinsReseau: gridRows.nbMagasinsReseau, - caParMagasinReseau: gridRows.caParMagasinReseau, - computedAt: gridRows.computedAt, -} as const; - -export type GridSortKey = keyof typeof SORTABLE; -export const GRID_SORT_KEYS = Object.keys(SORTABLE) as GridSortKey[]; - -export interface GridQuery { - codeFournisseur?: string; - gamme?: string; - code1?: string; - code2?: string; - code3?: string; - /** Préfixe de nomenclature (« 32 », « 3202 », « 320211 ») — filtre sur code3. */ - nomenclature?: string; - /** Recherche libre : libellé, codein, GTIN, référence, code centrale. */ - search?: string; - sort?: GridSortKey; - order?: "asc" | "desc"; - page?: number; - limit?: number; -} - -export interface GridQueryResult { - rows: ProductRow[]; - total: number; - /** Calcul le plus ancien parmi les lignes renvoyées — fraîcheur de la donnée. */ - computedAt: string | null; -} - -/** - * Version du format des lignes persistées. À incrémenter dès qu'un champ de - * `ProductRow` est ajouté ou change de sens : les instantanés plus anciens ne - * seront plus servis à la Grille (recalcul), mais restent lisibles par /api/v1. - */ -export const GRID_PAYLOAD_VERSION = 1; - -/** - * Persiste les lignes calculées pour un fournisseur. - * - * Stratégie : toutes les lignes du lot portent le même `computedAt`, puis on supprime - * celles du fournisseur restées sur un `computedAt` antérieur. Cela purge les articles - * disparus du catalogue sans avoir à passer des centaines de codein en paramètres. - */ -export async function upsertGridRows(codeFournisseur: string, rows: ProductRow[]): Promise { - if (!codeFournisseur || rows.length === 0) return 0; - const computedAt = new Date(); - - const values = rows - .filter((r) => r.codein) - .map((r) => ({ - codeFournisseur, - codein: r.codein, - nomFournisseur: r.nomFournisseur ?? null, - libelle1: r.libelle1 ?? null, - gtin: r.gtin ?? null, - reference: r.reference ?? null, - codeCentrale: r.codeCentrale ?? null, - code1: r.code1 ?? null, - code2: r.code2 ?? null, - code3: r.code3 ?? null, - codeGamme: r.codeGamme ?? null, - codeGammeInit: r.codeGammeInit ?? null, - totalCa: r.totalCa != null ? String(r.totalCa) : null, - totalQuantite: r.totalQuantite != null ? String(r.totalQuantite) : null, - totalMarge: r.totalMarge != null ? String(r.totalMarge) : null, - tauxMarge: r.tauxMarge != null ? String(r.tauxMarge) : null, - stockActuel: r.stockActuel != null ? String(r.stockActuel) : null, - caReseau: r.caReseau != null ? String(r.caReseau) : null, - qteReseau: r.qteReseau != null ? String(r.qteReseau) : null, - nbMagasinsReseau: r.nbMagasinsReseau ?? null, - caParMagasinReseau: r.caParMagasinReseau != null ? String(r.caParMagasinReseau) : null, - margePctReseau: r.margePctReseau != null ? String(r.margePctReseau) : null, - payload: { ...r, payloadVersion: GRID_PAYLOAD_VERSION }, - computedAt, - })); - if (values.length === 0) return 0; - - const CHUNK = 200; // payload jsonb volumineux : lots plus petits que pour des scalaires - let written = 0; - for (let i = 0; i < values.length; i += CHUNK) { - const batch = values.slice(i, i + CHUNK); - await db - .insert(gridRows) - .values(batch) - .onConflictDoUpdate({ - target: [gridRows.codeFournisseur, gridRows.codein], - set: { - nomFournisseur: sql`excluded.nom_fournisseur`, - libelle1: sql`excluded.libelle1`, - gtin: sql`excluded.gtin`, - reference: sql`excluded.reference`, - codeCentrale: sql`excluded.code_centrale`, - code1: sql`excluded.code1`, - code2: sql`excluded.code2`, - code3: sql`excluded.code3`, - codeGamme: sql`excluded.code_gamme`, - codeGammeInit: sql`excluded.code_gamme_init`, - totalCa: sql`excluded.total_ca`, - totalQuantite: sql`excluded.total_quantite`, - totalMarge: sql`excluded.total_marge`, - tauxMarge: sql`excluded.taux_marge`, - stockActuel: sql`excluded.stock_actuel`, - caReseau: sql`excluded.ca_reseau`, - qteReseau: sql`excluded.qte_reseau`, - nbMagasinsReseau: sql`excluded.nb_magasins_reseau`, - caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`, - margePctReseau: sql`excluded.marge_pct_reseau`, - payload: sql`excluded.payload`, - computedAt: sql`excluded.computed_at`, - }, - }); - written += batch.length; - } - - // Purge des articles qui ne font plus partie du catalogue du fournisseur. - await db.delete(gridRows).where( - and(eq(gridRows.codeFournisseur, codeFournisseur), lt(gridRows.computedAt, computedAt)), - ); - - return written; -} - -/** - * Instantané complet d'un fournisseur, tel que persisté par le dernier calcul. - * - * Renvoie `null` (→ recalcul) si rien n'est persisté, si une ligne est d'une - * version de format antérieure, ou si le calcul date d'un autre mois que le mois - * en cours : la fenêtre des 12 mois glissants aurait changé entre-temps. - * Les lignes sont rendues dans l'ordre du calcul en direct (identifiant article). - */ -export async function readGridSnapshot( - codeFournisseur: string, -): Promise<{ rows: ProductRow[]; computedAt: Date } | null> { - const found = await db - .select({ payload: gridRows.payload, computedAt: gridRows.computedAt }) - .from(gridRows) - .where(eq(gridRows.codeFournisseur, codeFournisseur)); - if (found.length === 0) return null; - - let oldest: Date | null = null; - const rows: ProductRow[] = []; - for (const r of found) { - const row = r.payload as ProductRow; - if (row.payloadVersion !== GRID_PAYLOAD_VERSION) return null; - if (!oldest || r.computedAt < oldest) oldest = r.computedAt; - rows.push(row); - } - const now = new Date(); - if (!oldest || oldest.getFullYear() !== now.getFullYear() || oldest.getMonth() !== now.getMonth()) { - return null; - } - rows.sort((a, b) => (a.noid ?? 0) - (b.noid ?? 0)); - return { rows, computedAt: oldest }; -} - -/** - * Reporte des gammes enregistrées dans l'instantané persisté (colonne et payload), - * pour que /api/v1 les voie sans attendre un recalcul complet de la grille. - */ -export async function updateGridRowsGamme( - codeFournisseur: string, - changes: ReadonlyArray<{ codein: string; codeGamme: string }>, -): Promise { - const parGamme = new Map(); - for (const c of changes) { - const codeins = parGamme.get(c.codeGamme) ?? []; - codeins.push(c.codein); - parGamme.set(c.codeGamme, codeins); - } - const CHUNK = 5000; // bien en deçà des 65 535 paramètres liés de PostgreSQL - for (const [gamme, codeins] of parGamme) { - for (let i = 0; i < codeins.length; i += CHUNK) { - await db - .update(gridRows) - .set({ - codeGamme: gamme, - payload: sql`jsonb_set(${gridRows.payload}, '{codeGamme}', to_jsonb(${gamme}::text))`, - }) - .where(and( - eq(gridRows.codeFournisseur, codeFournisseur), - inArray(gridRows.codein, codeins.slice(i, i + CHUNK)), - )); - } - } -} - -/** Construit la clause WHERE commune à la grille et à la recherche transversale. */ -function buildWhere(q: GridQuery): SQL | undefined { - const clauses: SQL[] = []; - if (q.codeFournisseur) clauses.push(eq(gridRows.codeFournisseur, q.codeFournisseur)); - if (q.gamme) clauses.push(eq(gridRows.codeGamme, q.gamme)); - if (q.code1) clauses.push(eq(gridRows.code1, q.code1)); - if (q.code2) clauses.push(eq(gridRows.code2, q.code2)); - if (q.code3) clauses.push(eq(gridRows.code3, q.code3)); - // Filtre par **préfixe** de nomenclature : « 32 », « 3202 » ou « 320211 ». - // C'est celui à utiliser avec /api/v1/nomenclatures, car il ne dépend pas de - // code1/code2 — parfois vides quand la hiérarchie n'a pas pu être remontée. - if (q.nomenclature) clauses.push(sql`${gridRows.code3} like ${q.nomenclature + "%"}`); - const search = q.search?.trim(); - if (search) { - const pattern = `%${search}%`; - const alt = or( - ilike(gridRows.libelle1, pattern), - ilike(gridRows.codein, pattern), - ilike(gridRows.gtin, pattern), - ilike(gridRows.reference, pattern), - ilike(gridRows.codeCentrale, pattern), - ); - if (alt) clauses.push(alt); - } - return clauses.length ? and(...clauses) : undefined; -} - -/** - * Lit les lignes de grille persistées : filtre, tri et pagination faits en SQL. - * Ne déclenche aucun recalcul et ne contacte jamais Qlik. - */ -export async function queryGridRows(q: GridQuery): Promise { - const page = Math.max(1, q.page ?? 1); - // `limit` absent = aucune limite : on renvoie toutes les lignes du filtre. - // Il y avait ici un Math.min(500, …) qui re-plafonnait en silence, quelle que - // soit la valeur demandée — la réponse était tronquée sans que rien ne le dise. - const limit = q.limit != null ? Math.max(1, q.limit) : null; - const where = buildWhere(q); - - const sortCol = SORTABLE[q.sort ?? "totalCa"] ?? gridRows.totalCa; - // NULLS LAST dans les deux sens : un produit sans CA ne doit jamais occuper la tête. - const orderBy = q.order === "asc" ? asc(sortCol) : desc(sortCol); - - const selection = db - .select({ payload: gridRows.payload, computedAt: gridRows.computedAt }) - .from(gridRows) - .where(where) - .orderBy(orderBy, asc(gridRows.codein)); - - // Comptage et page en parallèle : deux connexions du pool, un seul temps d'attente. - const [[countRow], found] = await Promise.all([ - db.select({ total: sql`count(*)::int` }).from(gridRows).where(where), - limit != null ? selection.limit(limit).offset((page - 1) * limit) : selection, - ]); - - let oldest: Date | null = null; - for (const r of found) { - if (r.computedAt && (!oldest || r.computedAt < oldest)) oldest = r.computedAt; - } - - return { - rows: found.map((r) => versLigneApi(r.payload)), - total: countRow?.total ?? 0, - computedAt: oldest ? oldest.toISOString() : null, - }; -} - -/** Ligne exposée par l'API : la version de format est un détail interne. */ -function versLigneApi(payload: unknown): ProductRow { - const ligne = { ...(payload as ProductRow) }; - delete ligne.payloadVersion; - return ligne; -} - -/** Fiche complète d'un produit. `codeFournisseur` lève l'ambiguïté d'un article multi-fournisseurs. */ -export async function getGridRowByCodein( - codein: string, - codeFournisseur?: string, -): Promise<{ row: ProductRow; computedAt: string | null } | null> { - const clauses: SQL[] = [eq(gridRows.codein, codein)]; - if (codeFournisseur) clauses.push(eq(gridRows.codeFournisseur, codeFournisseur)); - - const [found] = await db - .select({ payload: gridRows.payload, computedAt: gridRows.computedAt }) - .from(gridRows) - .where(and(...clauses)) - .orderBy(desc(gridRows.computedAt)) - .limit(1); - - if (!found) return null; - return { - row: versLigneApi(found.payload), - computedAt: found.computedAt ? found.computedAt.toISOString() : null, - }; -} - -/** Fournisseurs chez lesquels un article figure dans l'instantané. */ -export async function listGridSuppliersForCodein( - codein: string, -): Promise> { - return db - .select({ codeFournisseur: gridRows.codeFournisseur, nomFournisseur: gridRows.nomFournisseur }) - .from(gridRows) - .where(eq(gridRows.codein, codein)) - .orderBy(asc(gridRows.codeFournisseur)); -} - -export interface GammeInstantane { - /** Gamme courante, modifications enregistrées comprises. */ - codeGamme: string | null; - /** Gamme serveur au moment du calcul de la grille. */ - codeGammeInit: string | null; - nomFournisseur: string | null; -} - -/** - * Gammes d'articles d'un fournisseur dans l'instantané. Un codein absent de la - * carte n'appartient pas à ce fournisseur (ou n'a pas encore été calculé). - */ -export async function getGridRowsGammes( - codeFournisseur: string, - codeins: readonly string[], -): Promise> { - const out = new Map(); - const CHUNK = 5000; // bien en deçà des 65 535 paramètres liés de PostgreSQL - for (let i = 0; i < codeins.length; i += CHUNK) { - const found = await db - .select({ - codein: gridRows.codein, - codeGamme: gridRows.codeGamme, - codeGammeInit: gridRows.codeGammeInit, - nomFournisseur: gridRows.nomFournisseur, - }) - .from(gridRows) - .where(and( - eq(gridRows.codeFournisseur, codeFournisseur), - inArray(gridRows.codein, codeins.slice(i, i + CHUNK)), - )); - for (const r of found) { - out.set(r.codein, { codeGamme: r.codeGamme, codeGammeInit: r.codeGammeInit, nomFournisseur: r.nomFournisseur }); - } - } - return out; -} - -/** - * Indique si un fournisseur a déjà été calculé au moins une fois. - * Sert à répondre `202 not_ready` plutôt que de déclencher un calcul long. - */ -export async function getGridFreshness( - codeFournisseur: string, -): Promise<{ rowCount: number; computedAt: string | null } | null> { - const [found] = await db - .select({ - rowCount: sql`count(*)::int`, - computedAt: sql`max(${gridRows.computedAt})`, - }) - .from(gridRows) - .where(eq(gridRows.codeFournisseur, codeFournisseur)); - - if (!found || found.rowCount === 0) return null; - return { - rowCount: found.rowCount, - computedAt: found.computedAt ? new Date(found.computedAt).toISOString() : null, - }; -} - -/** Fournisseurs présents dans l'instantané, avec leur fraîcheur. */ -export async function listGridSuppliers(): Promise< - Array<{ codeFournisseur: string; nomFournisseur: string | null; rowCount: number; computedAt: string | null }> -> { - const found = await db - .select({ - codeFournisseur: gridRows.codeFournisseur, - nomFournisseur: sql`max(${gridRows.nomFournisseur})`, - rowCount: sql`count(*)::int`, - computedAt: sql`max(${gridRows.computedAt})`, - }) - .from(gridRows) - .groupBy(gridRows.codeFournisseur) - .orderBy(asc(gridRows.codeFournisseur)); - - return found.map((r) => ({ - codeFournisseur: r.codeFournisseur, - nomFournisseur: r.nomFournisseur, - rowCount: r.rowCount, - computedAt: r.computedAt ? new Date(r.computedAt).toISOString() : null, - })); -} - -// --------------------------------------------------------------------------- -// Inventaire des nomenclatures d'un fournisseur -// --------------------------------------------------------------------------- - -/** Un poste de nomenclature, avec son poids — de quoi décider par où découper. */ -export interface GridNomenclature { - code: string; - /** Libellé du poste, lu dans le payload (il n'a pas de colonne dédiée). */ - libelle: string | null; - nbArticles: number; - totalCa: number; - totalQuantite: number; -} - -/** Longueur du code pour chaque niveau : « 32 » → « 3202 » → « 320211 ». */ -export const NOMENCLATURE_LONGUEURS: Record<1 | 2 | 3, number> = { 1: 2, 2: 4, 3: 6 }; - -/** - * Répartition des articles d'un fournisseur par niveau de nomenclature. - * - * Sans cela, un appelant qui veut découper un gros fournisseur par nomenclature - * est coincé : le filtre existe sur `/grid`, mais rien ne lui dit **quelles** - * valeurs existent — il devrait tout télécharger pour les découvrir, ce qui - * annule le bénéfice du découpage. - * - * Les niveaux sont dérivés par **préfixe de `code3`** (codes hiérarchiques à 6 - * chiffres, de 31xxxx à 40xxxx), et non des colonnes `code1`/`code2`. Celles-ci - * ne sont renseignées que si `pgGetNomenclatureByFournisseur` a su remonter la - * hiérarchie, ce qui dépend d'une détection heuristique de la colonne parent de - * la table `nomenclature` ; quand elle échoue, seul `code3` est rempli. Le - * préfixe, lui, est toujours disponible. - * - * Tout est agrégé en SQL : la réponse fait quelques dizaines de lignes, même pour - * un fournisseur de 130 000 articles. - */ -export async function listGridNomenclatures( - codeFournisseur: string, - niveau: 1 | 2 | 3, - parent?: string, -): Promise { - const longueur = NOMENCLATURE_LONGUEURS[niveau]; - const codeExpr = sql`left(${gridRows.code3}, ${longueur})`; - // Les libellés n'ont pas de colonne dédiée : on les lit dans le payload. - // Aux niveaux 1 et 2 ils peuvent être absents (même cause que ci-dessus) ; - // le code et les compteurs, eux, restent justes. - const libelleKey = niveau === 1 ? "libelleNiveau1" : niveau === 2 ? "libelleNiveau2" : "libelle3"; - - const clauses: SQL[] = [ - eq(gridRows.codeFournisseur, codeFournisseur), - sql`${gridRows.code3} is not null and length(${gridRows.code3}) >= ${longueur}`, - ]; - // `parent` est lui aussi un préfixe : « 32 » pour descendre au niveau 2. - if (parent) clauses.push(sql`${gridRows.code3} like ${parent + "%"}`); - - const rows = await db - .select({ - code: codeExpr, - libelle: sql`max(${gridRows.payload} ->> ${libelleKey})`, - nbArticles: sql`count(*)::int`, - totalCa: sql`coalesce(sum(${gridRows.totalCa}), 0)::float`, - totalQuantite: sql`coalesce(sum(${gridRows.totalQuantite}), 0)::float`, - }) - .from(gridRows) - .where(and(...clauses)) - .groupBy(codeExpr) - .orderBy(desc(sql`coalesce(sum(${gridRows.totalCa}), 0)`)); - - return rows.map((r) => ({ - code: String(r.code ?? ""), - libelle: r.libelle ?? null, - nbArticles: Number(r.nbArticles) || 0, - totalCa: Number(r.totalCa) || 0, - totalQuantite: Number(r.totalQuantite) || 0, - })); -} diff --git a/src/lib/log-capture.ts b/src/lib/log-capture.ts deleted file mode 100644 index e3ddf71..0000000 --- a/src/lib/log-capture.ts +++ /dev/null @@ -1,243 +0,0 @@ -/** - * CollectFlow — Capture des logs serveur d'un job, pour téléchargement. - * - * POURQUOI. Les extractions Qlik produisent plusieurs milliers de lignes - * (`[qlik-pw][page] …`, timings, diagnostics du modèle). Or ces lignes ne - * vivent que dans la sortie standard du conteneur : un terminal les tronque, - * et un copier-coller ne rapporte que la fin — c'est-à-dire jamais l'entête, - * là où se trouvent la carte du modèle Qlik et le choix des champs. Sans elle, - * un diagnostic se fait à l'aveugle. - * - * COMMENT. `console.log/warn/error` est instrumenté UNE fois au chargement du - * module. Tant qu'au moins une capture est ouverte, chaque ligne est recopiée - * dans les captures ouvertes, puis transmise telle quelle à la console - * d'origine (rien n'est retenu, rien ne change dans les logs du conteneur). - * - * ⚠️ Deux jobs simultanés partagent la sortie standard du process : leurs - * lignes ne portent pas d'identifiant permettant de les démêler. Chaque - * capture ouverte reçoit donc TOUTES les lignes de la période — mieux vaut un - * peu de bruit qu'une ligne manquante dans un journal de diagnostic. - * - * Les captures sont écrites sur disque (répertoire temporaire) au fil de l'eau : - * un job qui fait tomber le process laisse quand même son journal. - */ - -import { appendFile, mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -/** Répertoire des journaux. Volontairement hors du projet : ce sont des temporaires. */ -const LOG_DIR = join(tmpdir(), "collectflow-logs"); - -/** Au-delà, on arrête d'accumuler : un job en boucle ne doit pas manger la RAM. */ -const MAX_LIGNES = 200_000; -/** Une ligne pathologique (dump d'hypercube) est tronquée plutôt que gardée entière. */ -const MAX_LONGUEUR_LIGNE = 8_000; -/** Nombre de journaux conservés sur disque. */ -const MAX_FICHIERS = 20; -/** Écriture disque au plus toutes les N ms (seules les nouvelles lignes sont ajoutées). */ -const FLUSH_MS = 2_000; - -interface Capture { - id: string; - lignes: string[]; - tronque: boolean; - debut: number; - flushEnCours: boolean; - flushDemande: boolean; - dernierFlush: number; - /** Nombre de lignes déjà écrites sur disque. */ - ecrites: number; -} - -const captures = new Map(); - -/** Nom de fichier sûr : l'id vient d'un job interne, on le verrouille quand même. */ -function fichierPour(id: string): string { - return join(LOG_DIR, `${id.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`); -} - -function horodatage(): string { - return new Date().toISOString(); -} - -/** Rend une ligne à partir des arguments variadiques de `console.*`. */ -function formater(args: unknown[]): string { - return args - .map((a) => { - if (typeof a === "string") return a; - if (a instanceof Error) return `${a.message}\n${a.stack ?? ""}`; - try { - return JSON.stringify(a); - } catch { - return String(a); - } - }) - .join(" "); -} - -async function flush(capture: Capture): Promise { - if (capture.flushEnCours) { - capture.flushDemande = true; - return; - } - capture.flushEnCours = true; - try { - await mkdir(LOG_DIR, { recursive: true }); - // Ajout des seules nouvelles lignes : réécrire tout le journal (jusqu'à - // 200 000 lignes) toutes les 2 s bloquait le serveur pendant les synchros. - const total = capture.lignes.length; - const nouvelles = capture.lignes.slice(capture.ecrites, total); - if (capture.ecrites === 0) { - await writeFile(fichierPour(capture.id), nouvelles.join("\n") + "\n", "utf8"); - } else if (nouvelles.length > 0) { - await appendFile(fichierPour(capture.id), nouvelles.join("\n") + "\n", "utf8"); - } - capture.ecrites = total; - capture.dernierFlush = Date.now(); - } catch { - // Un journal de diagnostic ne doit jamais faire échouer le job qu'il observe. - } finally { - capture.flushEnCours = false; - if (capture.flushDemande) { - capture.flushDemande = false; - void flush(capture); - } - } -} - -function ajouter(ligne: string): void { - if (captures.size === 0) return; - const texte = ligne.length > MAX_LONGUEUR_LIGNE - ? ligne.slice(0, MAX_LONGUEUR_LIGNE) + " …[ligne tronquée]" - : ligne; - const prefixe = `[${horodatage()}] `; - for (const capture of captures.values()) { - if (capture.lignes.length >= MAX_LIGNES) { - if (!capture.tronque) { - capture.tronque = true; - capture.lignes.push(`${prefixe}[log-capture] limite de ${MAX_LIGNES} lignes atteinte — suite non enregistrée`); - } - continue; - } - capture.lignes.push(prefixe + texte); - if (Date.now() - capture.dernierFlush >= FLUSH_MS) void flush(capture); - } -} - -/** - * Instrumente `console` une seule fois, même en développement où le module peut - * être réévalué à chaud (le drapeau est porté par `globalThis`). - */ -function instrumenterConsole(): void { - const g = globalThis as typeof globalThis & { __cfLogCaptureInstalle?: boolean }; - if (g.__cfLogCaptureInstalle) return; - g.__cfLogCaptureInstalle = true; - - const methodes = ["log", "warn", "error", "info"] as const; - for (const methode of methodes) { - const origine = console[methode].bind(console); - console[methode] = (...args: unknown[]) => { - try { - // Aucune capture ouverte (le cas courant) : on ne formate rien. - // Sérialiser chaque argument de chaque log coûtait à toutes les requêtes. - if (captures.size > 0) ajouter(formater(args)); - } catch { - // idem : jamais au détriment du log réel - } - origine(...args); - }; - } -} - -instrumenterConsole(); - -/** Supprime les journaux les plus anciens au-delà de `MAX_FICHIERS`. */ -async function purger(): Promise { - try { - const noms = (await readdir(LOG_DIR)).filter((n) => n.endsWith(".log")); - if (noms.length <= MAX_FICHIERS) return; - const avecDate = await Promise.all( - noms.map(async (nom) => { - const s = await stat(join(LOG_DIR, nom)).catch(() => null); - return { nom, mtime: s?.mtimeMs ?? 0 }; - }), - ); - avecDate.sort((a, b) => b.mtime - a.mtime); - for (const { nom } of avecDate.slice(MAX_FICHIERS)) { - await unlink(join(LOG_DIR, nom)).catch(() => undefined); - } - } catch { - // purge best-effort - } -} - -/** - * Ouvre une capture. Toutes les lignes écrites sur la console à partir de - * maintenant (par n'importe quel code du process, cf. avertissement en tête) - * y sont recopiées jusqu'à `stopCapture`. - */ -export function startCapture(id: string, entete?: string): void { - const capture: Capture = { - id, - lignes: [], - tronque: false, - debut: Date.now(), - flushEnCours: false, - flushDemande: false, - dernierFlush: 0, - ecrites: 0, - }; - captures.set(id, capture); - capture.lignes.push(`[${horodatage()}] [log-capture] début de capture ${id}`); - if (entete) capture.lignes.push(`[${horodatage()}] [log-capture] ${entete}`); - void flush(capture); - void purger(); -} - -/** Ferme la capture et écrit son état final sur disque. */ -export async function stopCapture(id: string, pied?: string): Promise { - const capture = captures.get(id); - if (!capture) return; - const duree = Math.round((Date.now() - capture.debut) / 1000); - capture.lignes.push(`[${horodatage()}] [log-capture] fin de capture ${id} — ${capture.lignes.length} lignes, ${duree} s`); - if (pied) capture.lignes.push(`[${horodatage()}] [log-capture] ${pied}`); - captures.delete(id); - await flush(capture); -} - -/** - * Contenu d'un journal : la capture en cours si elle est ouverte, sinon le - * fichier laissé sur disque. `null` si aucun des deux n'existe. - */ -export async function readCapture(id: string): Promise { - const capture = captures.get(id); - if (capture) return capture.lignes.join("\n") + "\n"; - try { - return await readFile(fichierPour(id), "utf8"); - } catch { - return null; - } -} - -/** Journaux disponibles, du plus récent au plus ancien. */ -export async function listCaptures(): Promise> { - const resultats: Array<{ id: string; octets: number; modifieLe: string; enCours: boolean }> = []; - try { - for (const nom of await readdir(LOG_DIR)) { - if (!nom.endsWith(".log")) continue; - const s = await stat(join(LOG_DIR, nom)).catch(() => null); - if (!s) continue; - const id = nom.slice(0, -4); - resultats.push({ - id, - octets: s.size, - modifieLe: new Date(s.mtimeMs).toISOString(), - enCours: captures.has(id), - }); - } - } catch { - // répertoire pas encore créé - } - return resultats.sort((a, b) => b.modifieLe.localeCompare(a.modifieLe)); -} diff --git a/src/lib/page-data.ts b/src/lib/page-data.ts new file mode 100644 index 0000000..d47665e --- /dev/null +++ b/src/lib/page-data.ts @@ -0,0 +1,128 @@ +/** + * CollectFlow — Données des pages côté navigateur. + * + * Les pages Next.js étaient des composants serveur : leurs données arrivaient + * avec le rendu, `loading.tsx` s'affichait pendant le premier chargement et une + * navigation (changement de paramètres, `router.refresh()`) gardait la page à + * l'écran jusqu'à l'arrivée des nouvelles données. + * + * Même fonctionnement ici, avec Suspense : `usePageData` suspend la page sur la + * promesse de ses données. Au premier affichage, la frontière Suspense de la + * route montre la silhouette (`loading.tsx`) ; lors d'une navigation ou d'un + * rafraîchissement — toujours faits en transition — l'ancienne page reste + * affichée jusqu'à la réponse. + * + * Les données sont relues à chaque navigation (clé d'historique de React + * Router) et à chaque `router.refresh()` (compteur de rafraîchissement), mais + * pas quand l'interface réécrit seulement l'URL (`history.replaceState`, ex. + * changement d'onglet) : comme avec Next.js. + */ + +import { createElement, createContext, startTransition, use, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useLocation } from "react-router"; + +// ─── Rafraîchissement (router.refresh) ────────────────────────────────────── + +const RefreshContext = createContext(0); + +let incrementer: (() => void) | null = null; + +/** Fournit le compteur de rafraîchissement dont dépendent les données des pages. */ +export function RefreshProvider({ children }: { children: ReactNode }) { + const [version, setVersion] = useState(0); + useEffect(() => { + incrementer = () => setVersion((v) => v + 1); + return () => { + incrementer = null; + }; + }, []); + return createElement(RefreshContext, { value: version }, children); +} + +/** Numéro de rafraîchissement courant. */ +export function useRefreshVersion(): number { + return useContext(RefreshContext); +} + +/** + * Relit les données de la page affichée (équivalent de `router.refresh()`). + * Par défaut en transition : la page reste visible pendant le chargement. + */ +export function refreshPage({ transition = true }: { transition?: boolean } = {}): void { + if (!incrementer) return; + if (transition) startTransition(() => incrementer?.()); + else incrementer(); +} + +// ─── Cache des promesses ──────────────────────────────────────────────────── + +const MAX_ENTREES = 40; +const cache = new Map>(); + +function memo(cle: string, charger: () => Promise): Promise { + let p = cache.get(cle) as Promise | undefined; + if (!p) { + p = charger(); + // Évite un rejet « non géré » dans la console : l'erreur est relevée par `use`. + p.catch(() => {}); + cache.set(cle, p); + while (cache.size > MAX_ENTREES) { + const plusAncienne = cache.keys().next().value; + if (plusAncienne === undefined) break; + cache.delete(plusAncienne); + } + } + return p; +} + +/** + * Clé de rendu de la page courante : change à chaque navigation (nouvelle + * entrée d'historique) et à chaque rafraîchissement, pas sur un simple + * `history.replaceState` de l'interface. + */ +export function usePageKey(): string { + const { key } = useLocation(); + const version = useRefreshVersion(); + return `${key}#${version}`; +} + +/** + * Données d'une page : suspend jusqu'à leur arrivée. Une erreur est relevée et + * remonte à la frontière d'erreur (`error.tsx`), comme l'exception d'une page + * serveur. + */ +export function usePageData(nom: string, charger: () => Promise): T { + const cle = `${nom}@${usePageKey()}`; + return use(memo(cle, charger)); +} + +export type Resultat = { ok: true; data: T } | { ok: false; error: string }; + +/** + * Comme `usePageData`, mais l'erreur est rendue sous forme de résultat : pour + * les pages qui affichaient un `ErrorState` avec le message de l'exception. + */ +export function usePageResult(nom: string, charger: () => Promise): Resultat { + return usePageData>(`${nom}:resultat`, () => + charger().then( + (data) => ({ ok: true as const, data }), + (e: unknown) => ({ ok: false as const, error: e instanceof Error ? e.message : String(e) }), + ), + ); +} + +/** + * Paramètres d'URL de la page, comme le `searchParams` d'une page serveur + * (première valeur de chaque clé). Ceux du routeur : une réécriture directe de + * l'URL par l'interface ne les change pas, comme avec Next.js. + */ +export function usePageSearchParams(): Record { + const { search } = useLocation(); + return useMemo(() => { + const o: Record = {}; + new URLSearchParams(search).forEach((v, k) => { + if (!(k in o)) o[k] = v; + }); + return o; + }, [search]); +} diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts deleted file mode 100644 index 5cbe396..0000000 --- a/src/lib/pg-ff-client.ts +++ /dev/null @@ -1,2159 +0,0 @@ -/** - * CollectFlow — Client PostgreSQL FF Nancy - * - * Remplace les appels HTTP per-article vers api.ffnancy.fr par des requêtes - * SQL bulk directes sur la base PostgreSQL FF Nancy. - * - * Chaque fonction fait 1 seule requête filtrée par codefou — O(1) au lieu de O(N). - */ - -import { db } from "@/db"; -import { sql, type SQL } from "drizzle-orm"; -import { ffFetchInit } from "@/lib/api-ff-client"; - -const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr"; - - -// Cache module-level pour éviter les requêtes information_schema répétées -let _nomenclatureParentCol: string | null | undefined = undefined; // undefined = pas encore chargé - -// --------------------------------------------------------------------------- -// Utilitaire : exécuter une requête sans workers parallèles PostgreSQL. -// Les workers parallèles consomment /dev/shm (mémoire partagée Docker) et font -// échouer les requêtes si le conteneur a un shm_size insuffisant. -// Le réglage est désormais posé une fois par connexion du pool (src/db/index.ts) : -// la transaction BEGIN / SET LOCAL / COMMIT qui entourait chaque requête coûtait -// trois allers-retours de plus à chacune. -// --------------------------------------------------------------------------- -/** - * `fn` sur chaque élément, au plus `simultanes` à la fois, résultats dans l'ordre - * (même forme que Promise.allSettled). Évite de lancer des centaines d'appels à - * l'API FF d'un coup, qui la saturent et finissent en délais dépassés. - */ -async function parLots(items: T[], simultanes: number, fn: (item: T) => Promise): Promise[]> { - const resultats: PromiseSettledResult[] = new Array(items.length); - let suivant = 0; - const ouvrier = async () => { - while (suivant < items.length) { - const i = suivant++; - try { - resultats[i] = { status: "fulfilled", value: await fn(items[i]) }; - } catch (reason) { - resultats[i] = { status: "rejected", reason }; - } - } - }; - await Promise.all(Array.from({ length: Math.min(simultanes, items.length) }, ouvrier)); - return resultats; -} - -async function pgNoParallel(query: SQL): Promise<{ rows: unknown[] }> { - const r = await db.execute(query); - return { rows: r.rows }; -} - -// --------------------------------------------------------------------------- -// Types résultats SQL -// --------------------------------------------------------------------------- - -export interface PgArticle { - no_id: number; - codein: string; - /** Code centrale = articles.artcentrale (format 10000XXXXXX) — clé jointure Qlik "Article Code". Vide pour les articles non référencés centralement. */ - codeCentrale?: string; - codefou: string; - nomfou?: string; - libelle1?: string; - pcb?: number; - reference?: string; - ean13?: string; - gtin?: string; // art_gtin.gtin (préférentiel) - pv_central?: number; // article_infosup.prix_vente_mini - pa?: number; // cube_pa.pa - codefou_principal?: string; // artfou1.code où preference=1 - nomfou_principal?: string; // nom du fournisseur principal -} - -export interface PgMensuelRow { - codein: string; - site: string; - mois: string; // "YYYY-MM" - qte_vendue: number; - ca_ht: number; // mntmvtttc (à vérifier si HT ou TTC) - marge: number; - stock_fin_mois: number; // MAX(qtestock) sur le mois - qte_recue: number; -} - -export interface PgStockRow { - codein: string; - site: string; - stockdispo: number; - qte: number; - valstock: number; - prmp: number; - /** Prix de vente du site — cube_stock.pv, filet de secours si cube_pv est vide. */ - pv?: number; - dernierevente?: string; - dernierereception?: string; -} - -// --------------------------------------------------------------------------- -// 0. Liste des fournisseurs -// --------------------------------------------------------------------------- - -/** - * Retourne la liste des fournisseurs depuis fouident (vrais noms) + artfou1. - */ -export async function pgGetFournisseurs(search?: string): Promise<{ code: string; nom: string }[]> { - try { - const result = await db.execute(sql` - SELECT DISTINCT fi.code, fi.nom - FROM fouident fi - INNER JOIN artfou1 af ON af.code = fi.code - WHERE fi.nom IS NOT NULL - AND fi.nom != '' - ${search ? sql`AND (fi.nom ILIKE ${'%' + search + '%'} OR fi.code ILIKE ${'%' + search + '%'})` : sql``} - ORDER BY fi.nom - `); - return (result.rows as unknown as { code: string; nom: string }[]).filter(r => r.code && r.nom); - } catch (e) { - console.error("[pg-ff] pgGetFournisseurs error:", (e as Error).message?.slice(0, 200)); - return []; - } -} - -// --------------------------------------------------------------------------- -// 1. Articles du fournisseur -// --------------------------------------------------------------------------- - -/** - * Retourne tous les articles d'un fournisseur avec leurs métadonnées. - * 1 seule requête SQL — remplace getArticlesByFournisseur() + per-article fetches. - */ -export async function pgGetArticlesByFournisseur(codefou: string): Promise { - // DISTINCT ON (a.no_id) car artfou1 peut avoir plusieurs lignes par article/fournisseur. - // Pas de filtre "actif" : on retourne tous les articles pour ne pas exclure les articles - // d'une session précédente (snapshot) qui n'ont pas de gamme DB ni de ventes récentes. - // Le filtrage gamme-Y sans ventes est fait en aval dans getProductRows (Phase 10). - // afpref : fournisseur préférentiel (preference=1) de l'article — peut différer du fournisseur consulté. - const result = await pgNoParallel(sql` - SELECT DISTINCT ON (a.no_id) - a.no_id, - a.codein, - a.artcentrale AS "codeCentrale", - af.code AS codefou, - fi.nom AS nomfou, - a.libelle1, - af.pcb, - af.reference, - af.ean13, - COALESCE(ag.gtin, af.ean13) AS gtin, - ai.prix_vente_mini AS pv_central, - pa.pa, - last_fournisseur.codefou_dernier AS codefou_principal, - last_fournisseur.nomfou_dernier AS nomfou_principal - FROM artfou1 af - JOIN articles a ON a.no_id = af.art_no_id - LEFT JOIN fouident fi ON fi.code = af.code - LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id - LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id - LEFT JOIN art_gtin ag ON ag.idarticle = a.no_id AND ag.preferentiel = 1 - LEFT JOIN LATERAL ( - SELECT af2.code AS codefou_dernier, fi2.nom AS nomfou_dernier - FROM artfou1 af2 - LEFT JOIN fouident fi2 ON fi2.code = af2.code - WHERE af2.art_no_id = a.no_id - ORDER BY af2.no_id DESC - LIMIT 1 - ) last_fournisseur ON true - WHERE af.code = ${codefou} - AND a.codein IS NOT NULL - AND (af.suspendu IS NULL OR af.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on')) - AND (a.suspendu IS NULL OR a.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on')) - ORDER BY a.no_id, af.no_id - `); - - console.log(`[pg-ff] Articles: ${result.rows.length} pour ${codefou}`); - return (result.rows as unknown as PgArticle[]).filter(r => r.codein); -} - -// --------------------------------------------------------------------------- -// 2. Données mensuelles (ventes + stock + réceptions) -// --------------------------------------------------------------------------- - -/** - * Retourne les données mensuelles agrégées par (codein, site, mois). - * 1 seule requête SQL — remplace getMensuelByArticles() (N appels HTTP). - * - * genremvt = 3 → ventes clients - * genremvt 1/2 → réceptions (à confirmer selon nomenclature FF Nancy) - * qtestock → stock après mouvement (MAX du mois ≈ stock fin de mois) - */ -export async function pgGetMensuelByFournisseur( - codefou: string, - dateDebut: string, - dateFin: string -): Promise { - // IMPORTANT : utiliser DISTINCT sur artfou1 pour éviter les doublons de mouvements. - // Un article peut avoir plusieurs lignes artfou1 pour le même fournisseur (pcb/ref différents), - // ce qui multiplierait chaque mouvement et gonflerait les totaux. - // - // stock_fin_mois = qtestock du DERNIER mouvement du mois (tri par datmvt DESC). - // MAX(qtestock) donnait le stock de début de mois (avant les premières ventes), ce qui était faux. - const result = await pgNoParallel(sql` - WITH base AS ( - SELECT - a.codein, - m.site, - TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, - m.qtemvt, - m.mntmvtttc, - m.margemvt, - m.genremvt, - m.qtestock, - ROW_NUMBER() OVER ( - PARTITION BY a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') - ORDER BY m.datmvt DESC - ) AS rn_last - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = ${codefou}) af - ON af.art_no_id = a.no_id - WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date - AND m.site IN ('292', '579') - ) - SELECT - codein, - site, - mois, - SUM(CASE WHEN genremvt = 3 THEN -qtemvt ELSE 0 END)::float AS qte_vendue, - SUM(CASE WHEN genremvt = 3 THEN -mntmvtttc ELSE 0 END)::float AS ca_ht, - SUM(CASE WHEN genremvt = 3 THEN margemvt ELSE 0 END)::float AS marge, - MAX(CASE WHEN rn_last = 1 THEN qtestock ELSE NULL END)::float AS stock_fin_mois, - SUM(CASE WHEN genremvt IN (1, 2) THEN qtemvt ELSE 0 END)::float AS qte_recue - FROM base - GROUP BY codein, site, mois - ORDER BY codein, site, mois - `); - - return result.rows as unknown as PgMensuelRow[]; -} - -// --------------------------------------------------------------------------- -// 3. Gammes (saison active) -// --------------------------------------------------------------------------- - -/** - * Retourne la gamme courante (saison active) pour chaque article du fournisseur. - * 1 requête SQL — remplace getReferentielByArticles() pour la partie gammes. - */ -export async function pgGetGammesByFournisseur(codefou: string): Promise> { - // DISTINCT ON (codein) → 1 gamme par article, saison la plus récente en premier - // saisons n'a PAS de colonne "actif" → pas de filtre actif - const result = await pgNoParallel(sql` - SELECT DISTINCT ON (a.codein) - a.codein, - g.code AS gamme_code - FROM art_gamme_saison ags - JOIN articles a ON a.no_id = ags.artnoid - JOIN artfou1 af ON af.art_no_id = a.no_id AND af.code = ${codefou} - JOIN gammes g ON g.no_id = ags.idgamme - JOIN saisons s ON s.no_id = ags.idsaison - ORDER BY a.codein, s.no_id DESC - `); - - const map = new Map(); - for (const row of result.rows as unknown as { codein: string; gamme_code: string }[]) { - if (row.codein && row.gamme_code) map.set(row.codein, String(row.gamme_code).trim()); - } - return map; -} - -/** - * Gammes **serveur** pour une liste de codein, tous fournisseurs confondus. - * - * Variante de `pgGetGammesByFournisseur` sans la jointure `artfou1` : la recherche - * de l'API `/api/v1` est transversale, on ne connaît donc pas le fournisseur. - * C'est la gamme telle qu'elle existe en base — non modifiée, indépendante des - * snapshots de session qui peuvent surcharger `codeGamme` dans la Grille. - */ -export async function pgGetGammesByCodeins(codeins: string[]): Promise> { - const unique = [...new Set(codeins.map(c => String(c ?? "").trim()).filter(Boolean))]; - if (unique.length === 0) return new Map(); - - // Découpage obligatoire : chaque codein devient un paramètre lié, or PostgreSQL - // en accepte 65535 au maximum. Un gros fournisseur dépasse 130 000 articles — - // la requête échouait alors purement et simplement. - const CHUNK = 2000; - const map = new Map(); - - for (let i = 0; i < unique.length; i += CHUNK) { - const batch = unique.slice(i, i + CHUNK); - // DISTINCT ON (codein) → 1 gamme par article, saison la plus récente en premier. - const result = await pgNoParallel(sql` - SELECT DISTINCT ON (a.codein) - TRIM(a.codein::text) AS codein, - g.code AS gamme_code - FROM art_gamme_saison ags - JOIN articles a ON a.no_id = ags.artnoid - JOIN gammes g ON g.no_id = ags.idgamme - JOIN saisons s ON s.no_id = ags.idsaison - WHERE TRIM(a.codein::text) IN (${sql.join(batch.map(c => sql`${c}`), sql`, `)}) - ORDER BY a.codein, s.no_id DESC - `); - for (const row of result.rows as unknown as { codein: string; gamme_code: string }[]) { - if (row.codein && row.gamme_code) map.set(row.codein, String(row.gamme_code).trim()); - } - } - console.log(`[pg-ff] pgGetGammesByCodeins: ${map.size}/${unique.length} articles avec gamme`); - return map; -} - -// --------------------------------------------------------------------------- -// 4. Nomenclature (famille / sous-famille) -// --------------------------------------------------------------------------- - -export interface PgNomRow { - codein: string; - code3: string; // code feuille (ex: "360504") - libelle3: string; // libellé feuille - code2?: string; // famille (4 chiffres) - libelle2?: string; - code1?: string; // secteur (2 chiffres) - libelle1nom?: string; -} - -/** - * Retourne la nomenclature (famille/sous-famille) pour chaque article du fournisseur. - * FK confirmée : articles.nom_no_id → nomenclature.no_id - * Colonnes confirmées : nomenclature.code, nomenclature.libelle - * Colonne parent : découverte dynamiquement via information_schema - */ -/** - * Découvre (une seule fois par process) le nom de la colonne "parent" de la - * table `nomenclature`, qui porte la FK vers le `no_id` du niveau supérieur. - * - * On ne peut pas la coder en dur : le nom varie selon les installations - * ("chemin_pere" existe mais c'est un chemin *texte* matérialisé, pas la FK), - * d'où le filtre explicite sur `data_type` entier. - */ -async function getNomenclatureParentCol(): Promise { - if (_nomenclatureParentCol !== undefined) return _nomenclatureParentCol; - - const metaResult = await db.execute(sql` - SELECT column_name, data_type FROM information_schema.columns - WHERE table_name = 'nomenclature' ORDER BY ordinal_position - `); - const nomCols = (metaResult.rows as { column_name: string; data_type: string }[]); - console.log("[pg-ff] Nomenclature colonnes:", nomCols.map(r => `${r.column_name}(${r.data_type})`).join(", ")); - // Cherche une colonne "parent" qui soit un entier (FK vers no_id du parent) - // "chemin_pere" = chemin vers le père en français - // On vérifie le data_type : integer/bigint/smallint uniquement (pas text/varchar qui serait un chemin matérialisé) - const parentRow = nomCols.find(r => - (/parent|pere|father/i.test(r.column_name) || (r.column_name !== "no_id" && /no_id$/i.test(r.column_name))) - && /int|serial/i.test(r.data_type) - ); - _nomenclatureParentCol = parentRow?.column_name ?? null; - console.log("[pg-ff] Nomenclature parentCol:", _nomenclatureParentCol); - return _nomenclatureParentCol; -} - -export async function pgGetNomenclatureByFournisseur(codefou: string): Promise> { - const parentCol = await getNomenclatureParentCol(); - - let result; - if (parentCol) { - result = await pgNoParallel(sql` - SELECT - a.codein, - n3.code AS code3, - n3.libelle AS libelle3, - n2.code AS code2, - n2.libelle AS libelle2, - n1.code AS code1, - n1.libelle AS libelle1nom - FROM artfou1 af - JOIN articles a ON a.no_id = af.art_no_id AND af.code = ${codefou} - JOIN nomenclature n3 ON n3.no_id = a.nom_no_id - LEFT JOIN nomenclature n2 ON n2.no_id = n3.${sql.raw(parentCol)} - LEFT JOIN nomenclature n1 ON n1.no_id = n2.${sql.raw(parentCol)} - WHERE a.codein IS NOT NULL - `); - } else { - // Sans hiérarchie : juste le niveau feuille - result = await pgNoParallel(sql` - SELECT - a.codein, - n3.code AS code3, - n3.libelle AS libelle3 - FROM artfou1 af - JOIN articles a ON a.no_id = af.art_no_id AND af.code = ${codefou} - JOIN nomenclature n3 ON n3.no_id = a.nom_no_id - WHERE a.codein IS NOT NULL - `); - } - - console.log(`[pg-ff] Nomenclature: ${result.rows.length} articles, parentCol="${parentCol ?? "none"}"`); - return buildNomMap(result.rows as unknown as PgNomRow[]); -} - -function buildNomMap(rows: PgNomRow[]): Map { - const map = new Map(); - for (const r of rows) { - if (r.codein) map.set(r.codein, r); - } - return map; -} - -// --------------------------------------------------------------------------- -// 5. Stock temps réel (cube_stock) -// --------------------------------------------------------------------------- - -/** - * Retourne le stock actuel par site pour chaque article du fournisseur. - * 1 requête SQL — remplace la partie stock du référentiel. - */ -export async function pgGetStockByFournisseur(codefou: string): Promise> { - // `site` est élagué pour la même raison que dans pgGetPrixVenteByFournisseur : - // il sert de clé de recherche (derniereLivraisonByStore, prix de repli). - const result = await pgNoParallel(sql` - SELECT - a.codein, - TRIM(cs.site::text) AS site, - cs.stockdispo::float, - cs.qte::float, - cs.valstock::float, - cs.prmp::float, - cs.pv::float, - cs.dernierevente::text, - cs.dernierereception::text - FROM cube_stock cs - JOIN articles a ON a.no_id = cs.artnoid - JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = ${codefou}) af - ON af.art_no_id = a.no_id - `); - - const map = new Map(); - for (const row of result.rows as unknown as PgStockRow[]) { - if (!map.has(row.codein)) map.set(row.codein, []); - map.get(row.codein)!.push(row); - } - return map; -} - -/** - * Prix de vente par article et par site (`cube_pv`). - * - * Pendant exact de `cube_pa` pour l'achat, rafraîchi chaque nuit depuis le cube - * MSSQL `Cube_PV`. C'est LA source du prix de vente : `article_infosup`, seule - * table lue jusqu'ici pour cette information, ne porte qu'un prix plancher de - * paramétrage (`prix_vente_mini`) qui reste vide en pratique. - * - * Retour : codein → { site → PV }. Les prix nuls sont écartés — un article non - * tarifé n'est pas un article à 0 €. - */ -export async function pgGetPrixVenteByFournisseur(codefou: string): Promise>> { - // Le site sert de CLÉ de recherche côté Grille (prixVenteByStore["292"]), d'où - // l'élagage : la synchronisation Apiflow écrit `cube_pv.site` brut, sans passer - // par le `safeStr` qu'elle applique à `cube_stock`. Une colonne MSSQL de largeur - // fixe remonterait complétée d'espaces, et la recherche échouerait en silence — - // le prix resterait visible en « tous magasins » et vide magasin par magasin. - const result = await pgNoParallel(sql` - SELECT - a.codein, - TRIM(cpv.site::text) AS site, - cpv.pv::float AS pv - FROM cube_pv cpv - JOIN articles a ON a.no_id = cpv.artnoid - JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = ${codefou}) af - ON af.art_no_id = a.no_id - WHERE cpv.pv IS NOT NULL AND cpv.pv > 0 - `); - - const map = new Map>(); - for (const row of result.rows as { codein: string; site: string; pv: number }[]) { - if (!row.codein || !row.site) continue; - const parSite = map.get(row.codein) ?? {}; - parSite[String(row.site)] = Number(row.pv); - map.set(row.codein, parSite); - } - console.log(`[pg-ff] Prix de vente : ${map.size} articles tarifés pour ${codefou}`); - return map; -} - -// --------------------------------------------------------------------------- -// 6. Commandes en cours -// --------------------------------------------------------------------------- - -/** - * Retourne les quantités commandées en cours pour chaque article du fournisseur. - * 1 requête SQL — remplace getCommandesByFournisseur(). - */ -export async function pgGetCommandesByFournisseur(codefou: string): Promise> { - // cdefou_vivant est une vue dénormalisée : articles_codein et cdefou_ligne_qtecde disponibles directement - const result = await pgNoParallel(sql` - SELECT - cv.articles_codein AS codein, - SUM(cv.cdefou_ligne_qtecde)::float AS qtecde - FROM cdefou_vivant cv - JOIN artfou1 af ON af.no_id = cv.artfou1_no_id AND af.code = ${codefou} - WHERE cv.articles_codein IS NOT NULL - GROUP BY cv.articles_codein - `); - - const map = new Map(); - for (const row of result.rows as unknown as { codein: string; qtecde: number }[]) { - if (row.codein) map.set(row.codein, Number(row.qtecde) || 0); - } - return map; -} - -// --------------------------------------------------------------------------- -// 7. Dashboard quotidien (hit parade + évolution N/N-1) -// --------------------------------------------------------------------------- - -export interface DashboardSiteStats { - site: string; - ca_hier: number; - ca_n1: number; - tickets_hier: number; - tickets_n1: number; - qte_hier: number; - marge_hier: number; - lignes_hier: number; -} - -export interface DashboardTopItem { - codein: string; - libelle1: string; - ca: number; - qte: number; - marge: number; - stock: number; - fournisseur: string; -} - -export interface DashboardSiteTop10 { - site: string; - ca: DashboardTopItem[]; - qte: DashboardTopItem[]; - marge: DashboardTopItem[]; -} - -export interface DashboardData { - dateHier: string; - dateN1: string; - sites: DashboardSiteStats[]; - top10BySite: DashboardSiteTop10[]; -} - -// --------------------------------------------------------------------------- -// Types internes API performance -// --------------------------------------------------------------------------- - -interface ApiPerfSite { - site: string; - ca_ttc: number; - ca_ttc_n1: number; - trafic: number; - trafic_n1: number; -} - -interface ApiPerfTopItem { - codein: string; - libelle1: string; - site: string; - codefou_reel?: string; - nom_fournisseur_reel?: string; - ca_ttc: number; - qte_vendue: number; - marge: number; -} - -interface ApiPerfDashboard { - date: string; - date_n1: string; - sites: ApiPerfSite[]; - top10_ca: ApiPerfTopItem[]; - top10_qte: ApiPerfTopItem[]; - top10_marge: ApiPerfTopItem[]; -} - -/** - * Retourne toutes les données nécessaires pour le dashboard quotidien. - * 100% via l'API REST api.ffnancy.fr — aucune requête PostgreSQL directe. - * N-1 : même jour de semaine (52 semaines = 364 jours avant hier). - */ -export async function pgGetDashboardData(): Promise { - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - const dateHier = yesterday.toISOString().split("T")[0]; - - // N-1 : même jour de semaine, même semaine N-1 → 364 jours = 52 semaines exactes - const n1Date = new Date(yesterday); - n1Date.setDate(n1Date.getDate() - 364); - const dateN1Param = n1Date.toISOString().split("T")[0]; - - const apiBase = `${FF_API_BASE}/api/performance`; - - - // Deux appels parallèles : données hier + données même jour N-1 - const [apiResult, apiN1Result] = await Promise.all([ - fetch(`${apiBase}/dashboard?date=${dateHier}`, ffFetchInit()) - .then(r => { if (!r.ok) throw new Error(`API dashboard ${r.status}: ${r.statusText}`); return r.json() as Promise; }), - fetch(`${apiBase}/dashboard?date=${dateN1Param}`, ffFetchInit()) - .then(r => r.ok ? r.json() as Promise : null) - .catch(() => null), - ]); - - // Construire map N-1 par site depuis l'appel séparé - const n1BySite = new Map(); - for (const s of apiN1Result?.sites ?? []) { - n1BySite.set(s.site, { ca: Number(s.ca_ttc) || 0, trafic: Number(s.trafic) || 0 }); - } - - const sites: DashboardSiteStats[] = (apiResult.sites ?? []).map(s => { - const n1 = n1BySite.get(s.site); - // Priorité : même jour de semaine N-1 (si > 0), sinon fallback sur n1 calendaire de l'API - const caN1 = (n1?.ca && n1.ca > 0) ? n1.ca : (Number(s.ca_ttc_n1) || 0); - const trafficN1 = (n1?.trafic && n1.trafic > 0) ? n1.trafic : (Number(s.trafic_n1) || 0); - return { - site: s.site, - ca_hier: Number(s.ca_ttc) || 0, - ca_n1: caN1, - tickets_hier: Number(s.trafic) || 0, - tickets_n1: trafficN1, - qte_hier: 0, - marge_hier: 0, - lignes_hier: 0, - }; - }); - - // Top 10 par magasin : grouper les items par site, trier, garder top 10 - const allItems = [ - ...(apiResult.top10_ca ?? []), - ...(apiResult.top10_qte ?? []), - ...(apiResult.top10_marge ?? []), - ]; - const allCodeins = [...new Set(allItems.map(i => i.codein).filter(Boolean))]; - - // Fournisseur réel : un seul appel sur les mouvements du jour (codefou_reel / nom_fournisseur_reel). - // Enrichissement : fournisseur réel via /dernier-fournisseur (primaire) + /referentiel (stock + fallback fournisseur). - // Les deux appels sont faits en parallèle par article. - const fouMap = new Map(); // codein → nom_fournisseur résolu - const stockMap = new Map(); - - - if (allCodeins.length > 0) { - try { - // Vagues de 12 articles au lieu de 5 : chaque article coûte deux allers- - // retours successifs, et la page d'accueil attendait une douzaine de vagues. - const VAGUE = 12; - for (let i = 0; i < allCodeins.length; i += VAGUE) { - const batch = allCodeins.slice(i, i + VAGUE); - await Promise.all(batch.map(async (codein) => { - try { - const artRes = await fetch(`${FF_API_BASE}/api/articles?codein=${encodeURIComponent(codein)}`, ffFetchInit()); - if (!artRes.ok) return; - const artData = await artRes.json(); - if (!artData.articles || artData.articles.length === 0) return; - - const noId = artData.articles[0].no_id; - if (!noId) return; - - const [refRes, dernierRes] = await Promise.all([ - fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/referentiel`, ffFetchInit()), - fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/dernier-fournisseur`, ffFetchInit()), - ]); - - // Stock (toujours depuis referentiel) - let refFournisseurFallback = ""; - if (refRes.ok) { - const refData = await refRes.json(); - let s292 = 0, s579 = 0, sTot = 0; - if (Array.isArray(refData.stock)) { - for (const s of refData.stock) { - const qte = Number(s.qte) || 0; - if (s.site === '292') s292 += qte; - else if (s.site === '579') s579 += qte; - sTot += qte; - } - } - stockMap.set(codein, { stock292: s292, stock579: s579, stockTotal: sTot }); - - // Fallback fournisseur (pas d'entrée en stock) : - // - 1 seul fournisseur → on l'utilise directement - // - plusieurs → celui avec preference = 1 - if (Array.isArray(refData.fournisseurs) && refData.fournisseurs.length > 0) { - type RefFou = { codefou: string; nom_fou: string | null; preference: number | null }; - const fous = refData.fournisseurs as RefFou[]; - const pick = fous.length === 1 - ? fous[0] - : (fous.find(f => f.preference === 1) ?? fous[0]); - refFournisseurFallback = pick.nom_fou?.trim() || pick.codefou?.trim() || ""; - } - } - - // Fournisseur réel : /dernier-fournisseur (dernière entrée en stock par site) - // Fallback : premier non-dépôt du référentiel - if (dernierRes.ok) { - const dernierData = await dernierRes.json(); - const entries: Array<{ site: string; nom_fournisseur: string }> = - dernierData.fournisseurs_par_site ?? []; - // Prendre le fournisseur du premier site disponible - const first = entries.find(e => e.nom_fournisseur); - if (first) { - fouMap.set(codein, String(first.nom_fournisseur).trim()); - } else if (refFournisseurFallback) { - fouMap.set(codein, refFournisseurFallback); - } - } else if (refFournisseurFallback) { - fouMap.set(codein, refFournisseurFallback); - } - } catch (err) { - console.error(`[Dashboard enrichment] Error for codein ${codein}:`, err); - } - })); - } - } catch (enrichErr: unknown) { - console.warn("[Dashboard] Enrichment unavailable:", enrichErr); - } - } - - const siteSet = [...new Set(allItems.map(i => i.site).filter(Boolean))].sort(); - - const buildTop10 = (items: ApiPerfTopItem[], sortKey: "ca" | "qte" | "marge"): DashboardTopItem[] => - items - .map(i => { - const stockEntry = stockMap.get(i.codein); - const stock = i.site === "292" ? (stockEntry?.stock292 ?? 0) - : i.site === "579" ? (stockEntry?.stock579 ?? 0) - : (stockEntry?.stockTotal ?? 0); - const fournisseur = fouMap.get(i.codein) ?? "—"; - return { - codein: i.codein, - libelle1: i.libelle1, - ca: Number(i.ca_ttc) || 0, - qte: Number(i.qte_vendue) || 0, - marge: Number(i.marge) || 0, - stock, - fournisseur, - }; - }) - .sort((a, b) => b[sortKey] - a[sortKey]) - .slice(0, 10); - - const top10BySite: DashboardSiteTop10[] = siteSet.map(site => { - const byS = (arr: ApiPerfTopItem[]) => arr.filter(i => i.site === site); - return { - site, - ca: buildTop10(byS(apiResult.top10_ca ?? []), "ca"), - qte: buildTop10(byS(apiResult.top10_qte ?? []), "qte"), - marge: buildTop10(byS(apiResult.top10_marge ?? []), "marge"), - }; - }); - - console.log(`[pg-ff] Dashboard: hier=${dateHier} n1=${dateN1Param} sites=${sites.length} n1_found=${n1BySite.size}`); - - return { - dateHier: apiResult.date ?? dateHier, - dateN1: apiN1Result?.date ?? dateN1Param, - sites, - top10BySite, - }; -} - -// --------------------------------------------------------------------------- -// Analytics — CA TTC par fournisseur et nomenclature -// --------------------------------------------------------------------------- - -export interface CaByFournisseurRow { - code: string; - nom: string; - site: string; - mois: string; - ca_ttc: number; -} - -export interface CaByNomenclatureRow { - code: string; - libelle: string; - site: string; - mois: string; - ca_ttc: number; -} - -/** - * Retourne le CA TTC par fournisseur et site pour deux mois donnés (mois et mois N-1). - * Attribution via artfou1.preference = true (fournisseur principal de l'article). - * CA NET : SUM(-mntmvtttc) — les retours clients sont déduits (même convention - * que pgGetMensuelByFournisseur / pgGetHitParade). Fournisseur préféré résolu - * via une sous-requête artfou1 dédupliquée (DISTINCT ON) : un seul code par - * article, aucune duplication possible des mouvements dans la somme. - */ -/** - * Bornes d'un mois « YYYY-MM » : [premier jour, premier jour du mois suivant[. - * - * Filtrer `datmvt` sur ces bornes plutôt que sur `TO_CHAR(datmvt, 'YYYY-MM')` - * donne exactement les mêmes lignes, mais sans formater en texte la date de - * chaque mouvement de l'historique (et reste utilisable par un index sur datmvt). - */ -function bornesMois(mois: string): { debut: string; fin: string } { - const [annee, m] = mois.split("-").map(Number); - const suivant = m === 12 ? `${annee + 1}-01` : `${annee}-${String(m + 1).padStart(2, "0")}`; - return { debut: `${mois}-01`, fin: `${suivant}-01` }; -} - -/** Mouvements du mois OU du mois N-1 (cf. `bornesMois`). */ -function filtreDeuxMois(mois: string, moisN1: string): SQL { - const a = bornesMois(mois); - const b = bornesMois(moisN1); - return sql`((m.datmvt >= ${a.debut}::date AND m.datmvt < ${a.fin}::date) - OR (m.datmvt >= ${b.debut}::date AND m.datmvt < ${b.fin}::date))`; -} - -export async function pgGetCaByFournisseur( - mois: string, - moisN1: string -): Promise { - const result = await pgNoParallel(sql` - SELECT - COALESCE(af.code, 'SANS_FOURNISSEUR')::text AS code, - COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS nom, - m.site, - TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, - SUM(-m.mntmvtttc)::float AS ca_ttc - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - LEFT JOIN ( - SELECT DISTINCT ON (art_no_id) art_no_id, code - FROM artfou1 - WHERE preference = 1 - ORDER BY art_no_id, code - ) af ON af.art_no_id = a.no_id - LEFT JOIN fouident fi ON fi.code = af.code - WHERE ${filtreDeuxMois(mois, moisN1)} - AND m.site IN ('292', '579') - AND m.genremvt = 3 - GROUP BY af.code, fi.nom, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') - ORDER BY af.code, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') - `); - - console.log(`[pg-ff] CaByFournisseur: ${result.rows.length} lignes pour ${mois}/${moisN1}`); - return result.rows as unknown as CaByFournisseurRow[]; -} - -/** - * Retourne le CA TTC par nomenclature et site pour deux mois donnés. - * CA NET : SUM(-mntmvtttc) — retours clients déduits (convention commune). - */ -export async function pgGetCaByNomenclature( - mois: string, - moisN1: string -): Promise { - const result = await pgNoParallel(sql` - SELECT - COALESCE(n.code, 'SANS_NOM')::text AS code, - COALESCE(n.libelle, 'Sans nomenclature')::text AS libelle, - m.site, - TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, - SUM(-m.mntmvtttc)::float AS ca_ttc - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id - WHERE ${filtreDeuxMois(mois, moisN1)} - AND m.site IN ('292', '579') - AND m.genremvt = 3 - GROUP BY n.no_id, n.code, n.libelle, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') - ORDER BY n.code, m.site, TO_CHAR(m.datmvt, 'YYYY-MM') - `); - - console.log(`[pg-ff] CaByNomenclature: ${result.rows.length} lignes pour ${mois}/${moisN1}`); - return result.rows as unknown as CaByNomenclatureRow[]; -} - -// --------------------------------------------------------------------------- -// Hit Parade — produits les plus vendus sur un mois donné -// --------------------------------------------------------------------------- - -export interface HitParadeRow { - codein: string; - libelle: string; - /** Référence fournisseur de l'article (artfou1.reference du fournisseur principal). */ - reference: string; - fournisseur: string; - nomenclature_code: string; - nomenclature: string; - site: string; - qte_vendue: number; - ca_ttc: number; - marge: number; - stock292: number; - stock579: number; - stockTotal: number; -} - -/** - * Retourne les ventes produit sur une période, avec fournisseur, qté, CA TTC, marge et stock par site. - * - * L'agrégation des ventes se fait UNIQUEMENT sur mvtart × articles (jointure 1:1), - * exactement comme pgGetMensuelByFournisseur (le calcul canonique de la Grille) : - * qte = SUM(-qtemvt), ca = SUM(-mntmvtttc), marge = SUM(margemvt) pour genremvt = 3 - * (ventes stockées en négatif — vérifié sur les mouvements réels de l'API). - * Les attributs (libellé, fournisseur préféré, nomenclature) et le stock sont - * joints APRÈS l'agrégation, par codein : aucune jointure ne peut dupliquer les - * mouvements et fausser les sommes, et le LATERAL fournisseur ne s'exécute que - * par article vendu (et non par ligne de mouvement). - * - * Cas vérifié (codein 487673, site 579, jan→juil 2026) : 31 ventes brutes, - * 7 retours clients → 24 net. L'ancien SUM(ABS(qtemvt)) affichait 38 (retours - * additionnés) ; SUM(-qtemvt) donne 24, identique à l'API mensuel officielle. - */ -export async function pgGetHitParade(dateDebut: string, dateFin: string): Promise { - const result = await pgNoParallel(sql` - WITH ventes AS ( - SELECT - TRIM(a.codein::text) AS codein, - m.site, - SUM(-m.qtemvt)::float AS qte_vendue, - SUM(-m.mntmvtttc)::float AS ca_ttc, - SUM(m.margemvt)::float AS marge - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date - AND m.site IN ('292', '579') - AND m.genremvt = 3 - AND a.codein IS NOT NULL - GROUP BY TRIM(a.codein::text), m.site - ), - attrs AS ( - SELECT DISTINCT ON (TRIM(a.codein::text)) - TRIM(a.codein::text) AS codein, - a.libelle1::text AS libelle, - COALESCE(TRIM(af.reference::text), '') AS reference, - COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, - COALESCE(n.code, '')::text AS nomenclature_code, - COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature - FROM articles a - LEFT JOIN LATERAL ( - SELECT af1.code, af1.reference - FROM artfou1 af1 - WHERE af1.art_no_id = a.no_id AND af1.preference = 1 - ORDER BY af1.code - LIMIT 1 - ) af ON TRUE - LEFT JOIN fouident fi ON fi.code = af.code - LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id - WHERE TRIM(a.codein::text) IN (SELECT codein FROM ventes) - ORDER BY TRIM(a.codein::text), a.no_id DESC - ), - stock_agg AS ( - SELECT - TRIM(a2.codein::text) AS codein, - SUM(CASE WHEN cs.site = '292' THEN cs.qte ELSE 0 END)::float AS stock292, - SUM(CASE WHEN cs.site = '579' THEN cs.qte ELSE 0 END)::float AS stock579, - SUM(cs.qte)::float AS stockTotal - FROM cube_stock cs - JOIN articles a2 ON a2.no_id = cs.artnoid - WHERE TRIM(a2.codein::text) IN (SELECT codein FROM ventes) - GROUP BY TRIM(a2.codein::text) - ) - SELECT - v.codein, - COALESCE(ar.libelle, '') AS libelle, - COALESCE(ar.reference, '') AS reference, - COALESCE(ar.fournisseur, 'Sans fournisseur') AS fournisseur, - COALESCE(ar.nomenclature_code, '') AS nomenclature_code, - COALESCE(ar.nomenclature, 'Sans nomenclature') AS nomenclature, - v.site, - v.qte_vendue, - v.ca_ttc, - v.marge, - COALESCE(s.stock292, 0) AS stock292, - COALESCE(s.stock579, 0) AS stock579, - COALESCE(s.stockTotal, 0) AS "stockTotal" - FROM ventes v - LEFT JOIN attrs ar ON ar.codein = v.codein - LEFT JOIN stock_agg s ON s.codein = v.codein - ORDER BY v.ca_ttc DESC - `); - - console.log(`[pg-ff] HitParade: ${result.rows.length} lignes pour ${dateDebut}→${dateFin}`); - return result.rows as unknown as HitParadeRow[]; -} - -// --------------------------------------------------------------------------- -// Stock par liste de codeins (pour Hit Parade) -// --------------------------------------------------------------------------- - -export interface StockBySite { - stock292: number; - stock579: number; - stockTotal: number; -} - -/** - * Retourne le stock actuel (stockdispo) par codein pour une liste de codeins. - * 1 seule requête bulk — utilisé pour enrichir le Hit Parade. - * - * On passe les codeins comme une SEULE chaîne CSV ($1) et on utilise - * string_to_array côté PG — cela évite l'expansion Drizzle en $1,$2,...$N - * qui déclencherait le PG error 54011 (ROW > 1664 entries). - */ -export async function pgGetStockForCodeins(codeins: string[]): Promise> { - if (codeins.length === 0) return new Map(); - - const result = await pgNoParallel( - sql`SELECT TRIM(a.codein::text) AS codein, cs.site, COALESCE(cs.qte, 0)::float AS stockdispo - FROM cube_stock cs - JOIN articles a ON a.no_id = cs.artnoid - WHERE TRIM(a.codein::text) IN (${sql.join(codeins.map(c => sql`${c}`), sql`, `)})` - ); - const rows = result.rows as { codein: string; site: string; stockdispo: number }[]; - - const map = new Map(); - for (const r of rows) { - if (!map.has(r.codein)) map.set(r.codein, { stock292: 0, stock579: 0, stockTotal: 0 }); - const entry = map.get(r.codein)!; - if (r.site === "292") entry.stock292 += r.stockdispo; - else if (r.site === "579") entry.stock579 += r.stockdispo; - entry.stockTotal += r.stockdispo; - } - return map; -} - -// --------------------------------------------------------------------------- -// Stock — fournisseur de la dernière entrée -// --------------------------------------------------------------------------- - -/** - * Colonnes candidates de `mvtart` reliant un mouvement de stock à un fournisseur. - * `mvtart` provient d'une réplication FF : le nom exact n'est pas garanti, on le - * détecte une fois par process (même principe que `getNomenclatureParentCol`). - * - target "artfou1" : la colonne est une FK vers `artfou1.no_id` - * - target "code" : la colonne contient directement le code fournisseur (`fouident.code`) - */ -const MVTART_FOU_CANDIDATES: { column: string; target: "artfou1" | "code" }[] = [ - { column: "artfou1_no_id", target: "artfou1" }, - { column: "artfou_no_id", target: "artfou1" }, - { column: "afou_no_id", target: "artfou1" }, - { column: "codefou", target: "code" }, - { column: "code_fou", target: "code" }, - { column: "fou_code", target: "code" }, - { column: "fournisseur", target: "code" }, -]; - -let _mvtartFouLink: { column: string; target: "artfou1" | "code" } | null | undefined = undefined; - -async function getMvtartFouLink() { - if (_mvtartFouLink !== undefined) return _mvtartFouLink; - try { - const meta = await db.execute(sql` - SELECT column_name FROM information_schema.columns - WHERE table_name = 'mvtart' - `); - const cols = new Set( - (meta.rows as { column_name: string }[]).map(r => r.column_name.toLowerCase()) - ); - // Le nom vient d'information_schema ET doit figurer dans la liste blanche - // ci-dessus : aucune valeur utilisateur n'atteint le SQL. - _mvtartFouLink = MVTART_FOU_CANDIDATES.find(c => cols.has(c.column)) ?? null; - } catch (e) { - console.error("[pg-ff] Détection mvtart.fournisseur impossible:", e); - _mvtartFouLink = null; - } - console.log("[pg-ff] mvtart → fournisseur:", _mvtartFouLink ?? "aucune colonne (repli artfou1.preference=1)"); - return _mvtartFouLink; -} - -/** - * Jointure latérale sur la DERNIÈRE entrée en stock (`genremvt IN (1,2)`) du - * couple article/site : elle expose `lastentry.datmvt` (date) et - * `lastentry.code` (code fournisseur, `NULL` si la base ne porte pas - * l'information). - * - * Règle métier : un article n'est rattaché qu'au fournisseur de sa dernière - * entrée. S'il est rentré en stock chez un autre fournisseur, il ne doit plus - * apparaître sous le précédent. - * - * La `LEFT JOIN artfou1` (et non `JOIN`) garantit qu'on retient bien la vraie - * dernière entrée, même si son fournisseur est inconnu. - */ -function lastEntryJoin( - link: { column: string; target: "artfou1" | "code" } | null, - artnoidExpr: SQL, - siteExpr: SQL, -): SQL { - const codeExpr = link - ? (link.target === "artfou1" ? sql`afe.code` : sql.raw(`me.${link.column}`)) - : sql`NULL::text`; - const joinArtfou = link && link.target === "artfou1" - ? sql`LEFT JOIN artfou1 afe ON afe.no_id = ${sql.raw(`me.${link.column}`)}` - : sql``; - return sql` - LEFT JOIN LATERAL ( - SELECT me.datmvt, ${codeExpr} AS code - FROM mvtart me - ${joinArtfou} - WHERE me.artnoid = ${artnoidExpr} - AND me.site = ${siteExpr} - AND me.genremvt IN (1, 2) - ORDER BY me.datmvt DESC, me.no_id DESC - LIMIT 1 - ) lastentry ON TRUE - ${link ? sql`LEFT JOIN fouident flast ON flast.code = lastentry.code` : sql``} - `; -} - -/** - * Libellé fournisseur affiché : celui de la dernière entrée quand la base le - * permet, sinon repli documenté sur `artfou1.preference = 1`. - */ -function fournisseurExpr(link: { column: string; target: "artfou1" | "code" } | null): SQL { - return link - ? sql`COALESCE(flast.nom, lastentry.code, f.nom, af.code)` - : sql`COALESCE(f.nom, af.code)`; -} - -/** - * Diagnostic (`/api/diag/stock-fournisseur`) : expose la colonne détectée, les - * colonnes réellement présentes sur `mvtart` et un échantillon comparant le - * fournisseur principal au fournisseur de la dernière entrée. - */ -export async function pgDiagFournisseurDerniereEntree() { - const link = await getMvtartFouLink(); - const meta = await db.execute(sql` - SELECT column_name, data_type FROM information_schema.columns - WHERE table_name = 'mvtart' ORDER BY ordinal_position - `); - let echantillon: unknown[] = []; - if (link) { - const r = await pgNoParallel(sql` - SELECT - a.codein, - cs.site, - COALESCE(f.nom, af.code) AS fournisseur_preference, - COALESCE(flast.nom, lastentry.code) AS fournisseur_derniere_entree, - lastentry.datmvt::text AS derniere_entree - FROM cube_stock cs - JOIN articles a ON a.no_id = cs.artnoid - JOIN ( - SELECT DISTINCT ON (art_no_id) art_no_id, code - FROM artfou1 - WHERE preference = 1 - ORDER BY art_no_id, code - ) af ON af.art_no_id = a.no_id - LEFT JOIN fouident f ON f.code = af.code - ${lastEntryJoin(link, sql`cs.artnoid`, sql`cs.site`)} - WHERE cs.qte < 0 - AND cs.site IN ('292', '579') - LIMIT 20 - `); - echantillon = r.rows; - } - return { - colonneDetectee: link, - candidatsTestes: MVTART_FOU_CANDIDATES.map(c => c.column), - colonnesMvtart: meta.rows, - echantillon, - }; -} - -// --------------------------------------------------------------------------- -// Stock négatif — tous magasins ou par site -// --------------------------------------------------------------------------- - -export interface PgStockNegatifRow { - codein: string; - libelle1: string; - fournisseur: string; - site: string; - stockdispo: number; - dernierevente: string | null; - derniereentree: string | null; -} - -export interface PgStockSansVenteRow { - codein: string; - libelle1: string; - fournisseur: string; - site: string; - stock_actuel: number; - derniere_entree: string | null; -} - -/** - * Retourne les articles ayant des entrées en stock (hors mois courant) - * mais aucune vente enregistrée sur ce site. - * Les articles dont le stock actuel est nul sont exclus (rien à régulariser). - */ -export async function pgGetStockSansVente(site?: string): Promise { - const link = await getMvtartFouLink(); - const siteFilter = site ? sql`AND m.site = ${site}` : sql``; - const result = await pgNoParallel(sql` - SELECT - a.codein, - COALESCE(a.libelle1, '') AS libelle1, - ${fournisseurExpr(link)} AS fournisseur, - m.site, - COALESCE(cs.qte::float, 0) AS stock_actuel, - MAX(m.datmvt)::text AS derniere_entree - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - JOIN ( - SELECT DISTINCT ON (art_no_id) art_no_id, code - FROM artfou1 - WHERE preference = 1 - ORDER BY art_no_id, code - ) af ON af.art_no_id = a.no_id - JOIN fouident f ON f.code = af.code - LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = m.site - ${lastEntryJoin(link, sql`a.no_id`, sql`m.site`)} - WHERE m.genremvt IN (1, 2) - AND m.site IN ('292', '579') - AND m.datmvt < date_trunc('month', CURRENT_DATE) - ${siteFilter} - AND NOT EXISTS ( - SELECT 1 FROM mvtart mv2 - WHERE mv2.artnoid = m.artnoid - AND mv2.site = m.site - AND mv2.genremvt = 3 - ) - GROUP BY a.codein, a.libelle1, ${fournisseurExpr(link)}, m.site, cs.qte - HAVING COALESCE(cs.qte::float, 0) <> 0 - ORDER BY MAX(m.datmvt) DESC - `); - return result.rows as PgStockSansVenteRow[]; -} - -export async function pgGetStockNegatif(site?: string): Promise { - const link = await getMvtartFouLink(); - const siteFilter = site ? sql`AND cs.site = ${site}` : sql``; - const result = await pgNoParallel(sql` - SELECT - a.codein, - COALESCE(a.libelle1, '') AS libelle1, - ${fournisseurExpr(link)} AS fournisseur, - cs.site, - cs.qte::float AS stockdispo, - cs.dernierevente::text, - lastentry.datmvt::text AS derniereentree - FROM cube_stock cs - JOIN articles a ON a.no_id = cs.artnoid - JOIN ( - SELECT DISTINCT ON (art_no_id) art_no_id, code - FROM artfou1 - WHERE preference = 1 - ORDER BY art_no_id, code - ) af ON af.art_no_id = a.no_id - JOIN fouident f ON f.code = af.code - ${lastEntryJoin(link, sql`cs.artnoid`, sql`cs.site`)} - WHERE cs.qte < 0 - AND cs.site IN ('292', '579') - ${siteFilter} - ORDER BY cs.qte ASC - `); - return result.rows as PgStockNegatifRow[]; -} - -// --------------------------------------------------------------------------- -// Stock sans vente depuis 6 mois -// --------------------------------------------------------------------------- - -export interface PgSansVente6MoisRow { - codein: string; - libelle1: string; - fournisseur: string; - site: string; - stock_actuel: number; - derniere_vente: string | null; - derniere_entree: string | null; - jours_sans_vente: number | null; -} - -/** - * Retourne les articles ayant du stock disponible (qte > 0) - * dont la dernière vente remonte à plus de 6 mois (ou jamais vendus) - * ET dont la dernière entrée en stock remonte aussi à plus de 6 mois. - */ -export async function pgGetSansVente6Mois(site?: string): Promise { - const link = await getMvtartFouLink(); - const siteFilter = site ? sql`AND cs.site = ${site}` : sql``; - const result = await pgNoParallel(sql` - SELECT - a.codein, - COALESCE(a.libelle1, '') AS libelle1, - ${fournisseurExpr(link)} AS fournisseur, - cs.site, - cs.qte::float AS stock_actuel, - cs.dernierevente::text AS derniere_vente, - CASE - WHEN cs.dernierevente IS NULL THEN NULL - ELSE (CURRENT_DATE - cs.dernierevente::date) - END AS jours_sans_vente, - lastentry.datmvt::text AS derniere_entree - FROM cube_stock cs - JOIN articles a ON a.no_id = cs.artnoid - JOIN ( - SELECT DISTINCT ON (art_no_id) art_no_id, code - FROM artfou1 - WHERE preference = 1 - ORDER BY art_no_id, code - ) af ON af.art_no_id = a.no_id - JOIN fouident f ON f.code = af.code - ${lastEntryJoin(link, sql`cs.artnoid`, sql`cs.site`)} - WHERE cs.site IN ('292', '579') - AND cs.qte > 0 - AND ( - cs.dernierevente IS NULL - OR cs.dernierevente < CURRENT_DATE - INTERVAL '6 months' - ) - AND NOT EXISTS ( - SELECT 1 FROM mvtart m2 - WHERE m2.artnoid = cs.artnoid - AND m2.site = cs.site - AND m2.genremvt IN (1, 2) - AND m2.datmvt >= CURRENT_DATE - INTERVAL '6 months' - ) - ${siteFilter} - ORDER BY cs.dernierevente ASC NULLS FIRST - `); - return result.rows as PgSansVente6MoisRow[]; -} - -// --------------------------------------------------------------------------- -// Commandes automatiques — par site + fournisseur avec franco -// --------------------------------------------------------------------------- - -export interface PgCommandeAutoRow { - site: string; - codefou: string; - nomfou: string; - franco: number; - nb_articles: number; - montant_cde: number; - franco_atteint: boolean | null; - ecart_franco: number; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function mapCommandeAutoRow(r: any): PgCommandeAutoRow { - return { - site: String(r.site ?? ""), - codefou: String(r.codefou ?? ""), - nomfou: String(r.nom_fou ?? r.nomfou ?? ""), - franco: Number(r.franco_ht ?? r.franco ?? 0), - nb_articles: Number(r.nb_articles ?? 0), - montant_cde: Number(r.montant_propo_ht ?? r.montant_cde ?? 0), - franco_atteint: r.franco_atteint === "OUI" ? true - : r.franco_atteint === "NON" ? false - : r.franco_atteint === "N/A" ? null - : r.franco_atteint != null ? Boolean(r.franco_atteint) - : null, - ecart_franco: Number(r.ecart_franco ?? 0), - }; -} - -/** - * Retourne la liste des fournisseurs en commande automatique via l'API Hostinger. - * GET https://api.ffnancy.fr/api/commandes-auto - * - * Pour les fournisseurs dont le franco est absent de la réponse globale (franco_ht null), - * on enrichit via le detail endpoint /api/commandes-auto/:codefou?site= qui contient - * toujours le résumé franco du fournisseur. - */ -export async function pgGetCommandesAuto(): Promise { - - try { - const res = await fetch(`${FF_API_BASE}/api/commandes-auto`, ffFetchInit()); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - // L'API retourne { count: N, propositions: [...] } ou un tableau direct - const rows: unknown[] = Array.isArray(data) - ? data - : (data?.propositions ?? data?.data ?? data?.items ?? data?.results ?? []); - - // Diagnostic : log les clés du premier row pour détecter les changements de format - if (rows.length > 0) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sample = rows[0] as any; - console.log(`[pg-ff] pgGetCommandesAuto sample keys:`, Object.keys(sample)); - console.log(`[pg-ff] pgGetCommandesAuto sample franco fields: franco_ht=${sample.franco_ht} franco=${sample.franco} franco_atteint=${sample.franco_atteint}`); - } - - console.log(`[pg-ff] pgGetCommandesAuto: ${rows.length} lignes (API Hostinger)`); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mapped = (rows as any[]).map(mapCommandeAutoRow); - - // Enrichissement : pour les fournisseurs sans franco dans la réponse globale, - // on appelle GET /api/commandes-auto/{codefou} (sans filtre site — franco est une - // propriété du fournisseur, pas du site) et on lit resume[0].franco_ht. - const missingFranco = mapped.filter(r => r.franco === 0 && r.codefou); - if (missingFranco.length > 0) { - console.log(`[pg-ff] pgGetCommandesAuto: ${missingFranco.length} lignes sans franco → enrichissement`); - // Dédupliquer par codefou (franco identique quel que soit le site) - const uniqueCodeFous = [...new Set(missingFranco.map(r => r.codefou))]; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const extractFranco = (detail: any): number => { - if (!detail || detail.error) return 0; - const resumeRaw = detail?.resume ?? detail?.summary ?? detail; - const resumeItem = Array.isArray(resumeRaw) ? resumeRaw[0] : resumeRaw; - return Number(resumeItem?.franco_ht ?? resumeItem?.franco ?? 0); - }; - - // 6 fournisseurs à la fois : chacun peut coûter deux appels successifs. - const details = await parLots(uniqueCodeFous, 6, async (codefou) => { - // 1. Essai sans filtre site - try { - const res2 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}`, ffFetchInit()); - if (res2.ok) { - const detail = await res2.json(); - const f = extractFranco(detail); - if (f > 0) return { codefou, franco: f }; - } - } catch { /* ignore */ } - // 2. Fallback site=000 - try { - const res3 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}?site=000`, ffFetchInit()); - if (res3.ok) { - const detail = await res3.json(); - const f = extractFranco(detail); - if (f > 0) return { codefou, franco: f }; - } - } catch { /* ignore */ } - return { codefou, franco: 0 }; - }); - - // Map codefou → franco enrichi - const francoMap = new Map(); - for (const result of details) { - if (result.status === "fulfilled" && result.value.franco > 0) { - francoMap.set(result.value.codefou, result.value.franco); - } - } - console.log(`[pg-ff] pgGetCommandesAuto: francos enrichis:`, Object.fromEntries(francoMap)); - - return mapped.map(r => { - if (r.franco === 0) { - const enriched = francoMap.get(r.codefou); - if (enriched && enriched > 0) return { ...r, franco: enriched }; - } - return r; - }); - } - - return mapped; - } catch (e) { - console.error("[pg-ff] pgGetCommandesAuto error:", (e as Error).message?.slice(0, 300)); - return []; - } -} - -// --------------------------------------------------------------------------- -// 12. Dernière réception par fournisseur ET par site (cadencier) -// --------------------------------------------------------------------------- - -/** - * Retourne la date de la dernière réception (entrée de marchandise) par - * fournisseur et par site, pour piloter le cadencier de commande. - * - * Source : mvtart (genremvt IN (1, 2) = réceptions, voir pgGetMensuelByFournisseur). - * La réception est rattachée à l'article ; on l'attribue au fournisseur - * principal de l'article (artfou1.preference = 1), comme partout ailleurs. - * - * Clé de la Map : `${codefou}|${site}`. Map vide en cas d'erreur - * (l'UI affichera alors « échéance inconnue »). - */ -export async function pgGetDerniereReceptionParFournisseur(codes?: string[]): Promise> { - const map = new Map(); - // Liste fournie (fournisseurs du cadencier) : on n'agrège que leurs articles, - // au lieu de tout l'historique de réceptions de tous les fournisseurs. - if (codes && codes.length === 0) return map; - const filtreFournisseurs = codes - ? sql`AND TRIM(af.code) IN (${sql.join(codes.map((c) => sql`${c.trim()}`), sql`, `)})` - : sql``; - try { - const result = await pgNoParallel(sql` - SELECT - af.code AS codefou, - m.site AS site, - MAX(m.datmvt) AS derniere - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 - WHERE m.genremvt IN (1, 2) - AND m.site IN ('292', '579') - AND m.datmvt IS NOT NULL - ${filtreFournisseurs} - GROUP BY af.code, m.site - `); - - for (const row of result.rows as { codefou: string; site: string; derniere: string | Date }[]) { - if (!row.codefou || !row.derniere) continue; - const iso = row.derniere instanceof Date - ? row.derniere.toISOString() - : new Date(row.derniere).toISOString(); - map.set(`${String(row.codefou).trim()}|${String(row.site ?? "").trim()}`, iso); - } - console.log(`[pg-ff] pgGetDerniereReceptionParFournisseur: ${map.size} entrées (mvtart)`); - return map; - } catch (e) { - console.error("[pg-ff] pgGetDerniereReceptionParFournisseur error:", (e as Error).message?.slice(0, 250)); - return map; - } -} - -// --------------------------------------------------------------------------- -// 13. Recherche & fiche produit (page /produits) -// --------------------------------------------------------------------------- - -/** - * Une ligne de résultat de recherche produit. Volontairement **légère** : - * aucune agrégation `mvtart` ici (le seq-scan du ILIKE domine déjà le coût). - * Le détail lourd n'est calculé qu'à l'ouverture de la fiche. - */ -export interface PgProduitSearchRow { - no_id: number; - codein: string; - code_centrale: string; - libelle1: string; - /** Nom du fournisseur principal (artfou1.preference = 1), sinon son code. */ - fournisseur: string; - codefou: string; - nomenclature_code: string; - nomenclature: string; - stock_total: number; - /** true si le produit a déjà des métriques réseau Qlik en cache. */ - has_reseau: boolean; - qte_reseau: number; - nb_magasins_reseau: number; -} - -/** - * Caractères accentués et leur équivalent non accentué, pour `translate()`. - * Les deux chaînes DOIVENT rester de même longueur, caractère par caractère. - * Postgres n'a pas `unaccent()` sans l'extension : on ne peut pas compter dessus - * sur la base FF, d'où cette table de correspondance explicite. - */ -const SQL_ACCENTS_FROM = "àâäãáåéèêëíìîïóòôöõúùûüçñýÿ"; -const SQL_ACCENTS_TO = "aaaaaaeeeeiiiiooooouuuucnyy"; - -/** - * Normalise un terme de recherche côté JS, de la même façon que - * `normalizedLibelleSql()` le fait côté SQL : minuscules, accents retirés, - * toute ponctuation ramenée à un espace. - */ -function normalizeSearchTerm(term: string): string { - return term - .toLowerCase() - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") // retire les diacritiques - .replace(/[^a-z0-9]+/g, " ") - .trim(); -} - -/** Expression SQL renvoyant le libellé normalisé (minuscules, sans accent ni ponctuation). */ -function normalizedLibelleSql() { - return sql`regexp_replace(translate(lower(COALESCE(a.libelle1, '')), ${SQL_ACCENTS_FROM}, ${SQL_ACCENTS_TO}), '[^a-z0-9]+', ' ', 'g')`; -} - -/** - * Recherche un produit par **libellé** ou par **code centrale** (préfixe). - * Si le terme est entièrement numérique, on tente aussi une correspondance - * exacte sur le `codein`. - * - * La recherche par libellé est **insensible aux accents et à la ponctuation**, - * et fonctionne **mot à mot** : « tapis anti-poussière » retrouve - * « TAPIS ANTI POUSSIERE », et « tapis poussiere » aussi (tous les mots doivent - * être présents, dans n'importe quel ordre). Sans cette normalisation, un - * `ILIKE` brut exigeait l'accent ET le tiret exacts et ne remontait rien. - * - * ⚠️ Il n'existe aucun index texte sur `articles.libelle1` (base FF resynchronisée - * chaque nuit depuis SQL Server — on n'y crée pas d'index). La requête fait donc - * un seq-scan avec normalisation par ligne : l'appelant impose un minimum de - * 3 caractères et une soumission explicite, jamais une recherche à la frappe. - */ -export async function pgSearchProduits(term: string, limit = 50): Promise { - const cleaned = term.trim(); - if (cleaned.length < 3) return []; - - const prefix = `${cleaned}%`; - // Mots de la recherche, normalisés. Tous doivent être présents (AND). - const tokens = normalizeSearchTerm(cleaned).split(" ").filter(Boolean); - const normLibelle = normalizedLibelleSql(); - const libelleClause = tokens.length > 0 - ? sql`(${sql.join(tokens.map((t) => sql`${normLibelle} LIKE ${`%${t}%`}`), sql` AND `)})` - : sql`FALSE`; - - // Un terme purement numérique peut être un codein : on l'ajoute au filtre - // (correspondance exacte, la colonne est space-padded d'où le TRIM). - const codeinClause = /^\d+$/.test(cleaned) - ? sql`OR TRIM(a.codein::text) = ${cleaned}` - : sql``; - - try { - const result = await pgNoParallel(sql` - WITH raw AS ( - SELECT DISTINCT ON (TRIM(a.codein::text)) - a.no_id, - TRIM(a.codein::text) AS codein, - COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale, - COALESCE(a.libelle1, '') AS libelle1, - a.nom_no_id - FROM articles a - WHERE a.codein IS NOT NULL - AND ( - ${libelleClause} - OR TRIM(a.artcentrale::text) ILIKE ${prefix} - ${codeinClause} - ) - ORDER BY TRIM(a.codein::text), a.no_id DESC - ), - matched AS ( - SELECT * FROM raw ORDER BY libelle1 LIMIT ${limit} - ) - SELECT - m.no_id, - m.codein, - m.code_centrale, - m.libelle1, - COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, - COALESCE(af.code, '')::text AS codefou, - COALESCE(n.code, '')::text AS nomenclature_code, - COALESCE(n.libelle, '')::text AS nomenclature, - COALESCE(st.stock_total, 0)::float AS stock_total, - (q.code_centrale IS NOT NULL) AS has_reseau, - COALESCE(q.qte_reseau, 0)::float AS qte_reseau, - COALESCE(q.nb_magasins_reseau, 0) AS nb_magasins_reseau - FROM matched m - LEFT JOIN LATERAL ( - SELECT af1.code - FROM artfou1 af1 - WHERE af1.art_no_id = m.no_id AND af1.preference = 1 - ORDER BY af1.code - LIMIT 1 - ) af ON TRUE - LEFT JOIN fouident fi ON fi.code = af.code - LEFT JOIN nomenclature n ON n.no_id = m.nom_no_id - LEFT JOIN LATERAL ( - SELECT SUM(cs.qte)::float AS stock_total - FROM cube_stock cs - WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579') - ) st ON TRUE - LEFT JOIN qlik_network_metrics q ON q.code_centrale = m.code_centrale - ORDER BY m.libelle1 - `); - - console.log(`[pg-ff] pgSearchProduits("${cleaned}"): ${result.rows.length} résultats`); - return result.rows as unknown as PgProduitSearchRow[]; - } catch (e) { - console.error("[pg-ff] pgSearchProduits error:", (e as Error).message?.slice(0, 250)); - return []; - } -} - -/** - * Rapproche une liste de **codes centraux** (issus de Qlik) du catalogue FF Nancy. - * - * C'est l'étape 2 de la recherche produit : Qlik dit quels articles le réseau - * travaille, cette requête dit lesquels nous référençons — et avec quel - * `codein`, quel fournisseur et quel stock. Un code absent du résultat est un - * produit réseau que Nancy ne référence pas (cas normal et intéressant). - * - * Les codeins sont renvoyés indexés par code centrale ; un même code centrale - * peut porter plusieurs codeins (ré-créations d'article), on ne garde que le - * `no_id` le plus récent. - */ -export async function pgGetProduitsByCodeCentrale( - codesCentraux: string[], -): Promise> { - const out = new Map(); - const codes = [...new Set(codesCentraux.map((c) => c.trim()).filter(Boolean))]; - if (codes.length === 0) return out; - - try { - const result = await pgNoParallel(sql` - WITH matched AS ( - SELECT DISTINCT ON (TRIM(a.artcentrale::text)) - a.no_id, - TRIM(a.codein::text) AS codein, - TRIM(a.artcentrale::text) AS code_centrale, - COALESCE(a.libelle1, '') AS libelle1, - a.nom_no_id - FROM articles a - WHERE TRIM(a.artcentrale::text) IN (${sql.join(codes.map((c) => sql`${c}`), sql`, `)}) - AND a.codein IS NOT NULL - ORDER BY TRIM(a.artcentrale::text), a.no_id DESC - ) - SELECT - m.no_id, - m.codein, - m.code_centrale, - m.libelle1, - COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, - COALESCE(af.code, '')::text AS codefou, - COALESCE(n.code, '')::text AS nomenclature_code, - COALESCE(n.libelle, '')::text AS nomenclature, - COALESCE(st.stock_total, 0)::float AS stock_total, - TRUE AS has_reseau, - 0::float AS qte_reseau, - 0 AS nb_magasins_reseau - FROM matched m - LEFT JOIN LATERAL ( - SELECT af1.code - FROM artfou1 af1 - WHERE af1.art_no_id = m.no_id AND af1.preference = 1 - ORDER BY af1.code - LIMIT 1 - ) af ON TRUE - LEFT JOIN fouident fi ON fi.code = af.code - LEFT JOIN nomenclature n ON n.no_id = m.nom_no_id - LEFT JOIN LATERAL ( - SELECT SUM(cs.qte)::float AS stock_total - FROM cube_stock cs - WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579') - ) st ON TRUE - `); - - for (const row of result.rows as unknown as PgProduitSearchRow[]) { - out.set(row.code_centrale, row); - } - console.log(`[pg-ff] pgGetProduitsByCodeCentrale: ${codes.length} codes Qlik → ${out.size} trouvés dans le catalogue Nancy`); - return out; - } catch (e) { - console.error("[pg-ff] pgGetProduitsByCodeCentrale error:", (e as Error).message?.slice(0, 250)); - return out; - } -} - -/** - * `codein` de l'article portant ce code centrale, ou `null` si Nancy ne le - * référence pas. Utilisé par la fiche produit ouverte depuis un résultat Qlik - * (URL `?cc=…`), qui n'a que le code centrale. - */ -export async function pgGetCodeinByCodeCentrale(codeCentrale: string): Promise { - const cleaned = codeCentrale.trim(); - if (!cleaned) return null; - try { - const result = await pgNoParallel(sql` - SELECT TRIM(a.codein::text) AS codein - FROM articles a - WHERE TRIM(a.artcentrale::text) = ${cleaned} - AND a.codein IS NOT NULL - ORDER BY a.no_id DESC - LIMIT 1 - `); - const row = (result.rows as unknown as { codein: string }[])[0]; - return row?.codein ?? null; - } catch (e) { - console.error("[pg-ff] pgGetCodeinByCodeCentrale error:", (e as Error).message?.slice(0, 250)); - return null; - } -} - -/** Identité, prix et nomenclature d'un produit (une seule ligne). */ -export interface PgProduitDetailRow { - no_id: number; - codein: string; - code_centrale: string; - libelle1: string; - nom_no_id: number | null; - gtin: string; - reference: string; - pcb: number | null; - /** article_infosup.prix_vente_mini */ - pv_central: number | null; - /** cube_pa.pa */ - pa: number | null; - code3: string | null; - libelle3: string | null; - code2: string | null; - libelle2: string | null; - code1: string | null; - libelle1nom: string | null; -} - -/** - * Fiche d'identité complète d'un produit à partir de son `codein`. - * Renvoie `null` si le codein est inconnu. - */ -export async function pgGetProduitDetail(codein: string): Promise { - const parentCol = await getNomenclatureParentCol(); - // Hiérarchie nomenclature — deux stratégies : - // - // 1. Colonne parent entière si elle existe (FK vers le no_id du niveau au-dessus). - // 2. Sinon, remontée par **préfixe de code** : la nomenclature FF est encodée - // hiérarchiquement, secteur = 2 chiffres, famille = 4, sous-famille = 6 - // (ex "330702" → "3307" → "33"). En production `nomenclature` n'expose que - // no_id / code / libelle / niveau / chemin_pere : aucune FK entière, et - // `chemin_pere` est un chemin texte. Sans ce repli, Secteur et Famille - // restaient vides sur la fiche. - const hierarchy = parentCol - ? sql` - LEFT JOIN nomenclature n2 ON n2.no_id = n3.${sql.raw(parentCol)} - LEFT JOIN nomenclature n1 ON n1.no_id = n2.${sql.raw(parentCol)} - ` - : sql` - LEFT JOIN LATERAL ( - SELECT nx.code, nx.libelle - FROM nomenclature nx - WHERE LENGTH(TRIM(n3.code)) >= 4 - AND TRIM(nx.code) = LEFT(TRIM(n3.code), 4) - LIMIT 1 - ) n2 ON TRUE - LEFT JOIN LATERAL ( - SELECT nx.code, nx.libelle - FROM nomenclature nx - WHERE LENGTH(TRIM(n3.code)) >= 2 - AND TRIM(nx.code) = LEFT(TRIM(n3.code), 2) - LIMIT 1 - ) n1 ON TRUE - `; - // Les colonnes sont identiques dans les deux stratégies (alias n1 / n2). - const hierarchyCols = sql`, - n2.code AS code2, - n2.libelle AS libelle2, - n1.code AS code1, - n1.libelle AS libelle1nom`; - - try { - const result = await pgNoParallel(sql` - SELECT - a.no_id, - TRIM(a.codein::text) AS codein, - COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale, - COALESCE(a.libelle1, '') AS libelle1, - a.nom_no_id, - COALESCE(ag.gtin, af.ean13, '')::text AS gtin, - COALESCE(af.reference, '')::text AS reference, - af.pcb::float AS pcb, - ai.prix_vente_mini::float AS pv_central, - pa.pa::float AS pa, - n3.code AS code3, - n3.libelle AS libelle3 - ${hierarchyCols} - FROM articles a - LEFT JOIN LATERAL ( - SELECT af1.code, af1.pcb, af1.reference, af1.ean13 - FROM artfou1 af1 - WHERE af1.art_no_id = a.no_id - ORDER BY af1.preference DESC NULLS LAST, af1.no_id DESC - LIMIT 1 - ) af ON TRUE - LEFT JOIN art_gtin ag ON ag.idarticle = a.no_id AND ag.preferentiel = 1 - LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id - LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id - LEFT JOIN nomenclature n3 ON n3.no_id = a.nom_no_id - ${hierarchy} - WHERE TRIM(a.codein::text) = ${codein} - ORDER BY a.no_id DESC - LIMIT 1 - `); - - const row = (result.rows as unknown as PgProduitDetailRow[])[0] ?? null; - console.log(`[pg-ff] pgGetProduitDetail(${codein}): ${row ? "trouvé" : "introuvable"}`); - return row; - } catch (e) { - console.error("[pg-ff] pgGetProduitDetail error:", (e as Error).message?.slice(0, 250)); - return null; - } -} - -/** Un fournisseur référencé pour l'article. */ -export interface PgProduitFournisseurRow { - codefou: string; - nomfou: string; - reference: string; - pcb: number | null; - /** true si artfou1.preference = 1 */ - principal: boolean; -} - -/** Tous les fournisseurs référencés pour un article (le principal en premier). */ -export async function pgGetFournisseursByCodein(codein: string): Promise { - try { - const result = await pgNoParallel(sql` - SELECT DISTINCT ON (af.code) - af.code::text AS codefou, - COALESCE(fi.nom, af.code)::text AS nomfou, - COALESCE(af.reference, '')::text AS reference, - af.pcb::float AS pcb, - (af.preference = 1) AS principal - FROM articles a - JOIN artfou1 af ON af.art_no_id = a.no_id - LEFT JOIN fouident fi ON fi.code = af.code - WHERE TRIM(a.codein::text) = ${codein} - ORDER BY af.code, af.preference DESC NULLS LAST, af.no_id DESC - `); - const rows = result.rows as unknown as PgProduitFournisseurRow[]; - // Le principal remonte en tête (le DISTINCT ON impose un tri par code). - rows.sort((x, y) => Number(y.principal) - Number(x.principal) || x.nomfou.localeCompare(y.nomfou, "fr")); - return rows; - } catch (e) { - console.error("[pg-ff] pgGetFournisseursByCodein error:", (e as Error).message?.slice(0, 250)); - return []; - } -} - -/** Stock temps réel par site pour un article. */ -export interface PgProduitStockRow { - site: string; - stockdispo: number; - qte: number; - valstock: number; - prmp: number; - dernierevente: string | null; - dernierereception: string | null; -} - -export async function pgGetStockByCodein(codein: string): Promise { - try { - const result = await pgNoParallel(sql` - SELECT - cs.site, - COALESCE(cs.stockdispo, 0)::float AS stockdispo, - COALESCE(cs.qte, 0)::float AS qte, - COALESCE(cs.valstock, 0)::float AS valstock, - COALESCE(cs.prmp, 0)::float AS prmp, - cs.dernierevente::text AS dernierevente, - cs.dernierereception::text AS dernierereception - FROM cube_stock cs - JOIN articles a ON a.no_id = cs.artnoid - WHERE TRIM(a.codein::text) = ${codein} - AND cs.site IN ('292', '579') - ORDER BY cs.site - `); - return result.rows as unknown as PgProduitStockRow[]; - } catch (e) { - console.error("[pg-ff] pgGetStockByCodein error:", (e as Error).message?.slice(0, 250)); - return []; - } -} - -/** Quantité totale en commande fournisseur pour un article. */ -export async function pgGetCommandesByCodein(codein: string): Promise { - try { - const result = await pgNoParallel(sql` - SELECT COALESCE(SUM(cv.cdefou_ligne_qtecde), 0)::float AS qtecde - FROM cdefou_vivant cv - WHERE TRIM(cv.articles_codein::text) = ${codein} - `); - const row = (result.rows as unknown as { qtecde: number }[])[0]; - return Number(row?.qtecde) || 0; - } catch (e) { - console.error("[pg-ff] pgGetCommandesByCodein error:", (e as Error).message?.slice(0, 250)); - return 0; - } -} - -/** Une gamme affectée à l'article pour une saison donnée. */ -export interface PgGammeHistoryRow { - saison_no_id: number; - saison_code: string | null; - saison_libelle: string | null; - gamme_code: string; - gamme_libelle: string | null; -} - -/** - * Historique complet des gammes de l'article, saison par saison (la plus - * récente en premier). Le reste de l'application ne lit que la saison active - * (`pgGetGammesByFournisseur`) ; ici on veut la trajectoire d'arbitrage. - */ -export async function pgGetGammeHistoryByCodein(codein: string): Promise { - try { - const result = await pgNoParallel(sql` - SELECT - s.no_id AS saison_no_id, - s.code::text AS saison_code, - s.libelle::text AS saison_libelle, - g.code::text AS gamme_code, - g.libelle::text AS gamme_libelle - FROM art_gamme_saison ags - JOIN articles a ON a.no_id = ags.artnoid - JOIN gammes g ON g.no_id = ags.idgamme - JOIN saisons s ON s.no_id = ags.idsaison - WHERE TRIM(a.codein::text) = ${codein} - ORDER BY s.no_id DESC - `); - return result.rows as unknown as PgGammeHistoryRow[]; - } catch (e) { - console.error("[pg-ff] pgGetGammeHistoryByCodein error:", (e as Error).message?.slice(0, 250)); - return []; - } -} - -/** - * Données mensuelles (ventes / CA / marge / stock fin de mois / réceptions) - * d'UN article, par site et par mois. - * - * Reprend **exactement** le calcul canonique de `pgGetMensuelByFournisseur` - * (la source de vérité de la Grille) : seule la sélection change — filtre sur - * le codein au lieu d'une jointure `artfou1`. Les chiffres affichés sur la - * fiche produit sont donc identiques à ceux de la Grille, par construction. - */ -export async function pgGetMensuelByCodein( - codein: string, - dateDebut: string, - dateFin: string -): Promise { - try { - const result = await pgNoParallel(sql` - WITH base AS ( - SELECT - TRIM(a.codein::text) AS codein, - m.site, - TO_CHAR(m.datmvt, 'YYYY-MM') AS mois, - m.qtemvt, - m.mntmvtttc, - m.margemvt, - m.genremvt, - m.qtestock, - ROW_NUMBER() OVER ( - PARTITION BY m.site, TO_CHAR(m.datmvt, 'YYYY-MM') - ORDER BY m.datmvt DESC - ) AS rn_last - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - WHERE TRIM(a.codein::text) = ${codein} - AND m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date - AND m.site IN ('292', '579') - ) - SELECT - codein, - site, - mois, - SUM(CASE WHEN genremvt = 3 THEN -qtemvt ELSE 0 END)::float AS qte_vendue, - SUM(CASE WHEN genremvt = 3 THEN -mntmvtttc ELSE 0 END)::float AS ca_ht, - SUM(CASE WHEN genremvt = 3 THEN margemvt ELSE 0 END)::float AS marge, - MAX(CASE WHEN rn_last = 1 THEN qtestock ELSE NULL END)::float AS stock_fin_mois, - SUM(CASE WHEN genremvt IN (1, 2) THEN qtemvt ELSE 0 END)::float AS qte_recue - FROM base - GROUP BY codein, site, mois - ORDER BY site, mois - `); - - console.log(`[pg-ff] pgGetMensuelByCodein(${codein}): ${result.rows.length} lignes`); - return result.rows as unknown as PgMensuelRow[]; - } catch (e) { - console.error("[pg-ff] pgGetMensuelByCodein error:", (e as Error).message?.slice(0, 250)); - return []; - } -} - -/** Un produit de la même famille, comparé local vs réseau. */ -export interface PgOpportuniteRow { - codein: string; - libelle1: string; - code_centrale: string; - fournisseur: string; - qte_reseau: number; - nb_magasins_reseau: number; - ca_reseau: number; - ca_par_magasin_reseau: number; - qte_locale: number; - nb_sites_locaux: number; - qte_reseau_par_magasin: number; - qte_locale_par_magasin: number; - /** Écart réseau − local, par magasin, sur 12 mois. Positif = opportunité. */ - ecart_par_magasin: number; -} - -/** - * Produits de la même nomenclature, classés par écart entre la performance - * réseau et la performance locale (quantités **par magasin**, pour neutraliser - * la différence d'échelle entre ~270 magasins et nos 2 sites). - * - * Périmètre volontairement borné aux articles **de notre catalogue** ayant déjà - * des métriques réseau en cache : la dimension Qlik interrogée est - * `Article Code`, on ne peut pas découvrir un produit que Nancy ne référence pas. - */ -export async function pgGetOpportunitesFamille( - nomNoId: number, - dateDebut: string, - dateFin: string, - limit = 30 -): Promise { - try { - const result = await pgNoParallel(sql` - WITH famille AS ( - SELECT DISTINCT ON (TRIM(a.codein::text)) - a.no_id, - TRIM(a.codein::text) AS codein, - TRIM(a.artcentrale::text) AS code_centrale, - COALESCE(a.libelle1, '') AS libelle1 - FROM articles a - WHERE a.nom_no_id = ${nomNoId} - AND a.codein IS NOT NULL - AND a.artcentrale IS NOT NULL - AND TRIM(a.artcentrale::text) <> '' - ORDER BY TRIM(a.codein::text), a.no_id DESC - ), - avec_reseau AS ( - SELECT - f.no_id, - f.codein, - f.code_centrale, - f.libelle1, - q.qte_reseau::float AS qte_reseau, - q.nb_magasins_reseau AS nb_magasins_reseau, - COALESCE(q.ca_reseau, 0)::float AS ca_reseau, - COALESCE(q.ca_par_magasin_reseau, 0)::float AS ca_par_magasin_reseau - FROM famille f - JOIN qlik_network_metrics q ON q.code_centrale = f.code_centrale - WHERE q.nb_magasins_reseau > 0 - AND q.qte_reseau > 0 - ), - ventes AS ( - SELECT - TRIM(a.codein::text) AS codein, - SUM(-m.qtemvt)::float AS qte_locale, - COUNT(DISTINCT m.site) AS nb_sites_locaux - FROM mvtart m - JOIN articles a ON a.no_id = m.artnoid - WHERE m.genremvt = 3 - AND m.site IN ('292', '579') - AND m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date - AND TRIM(a.codein::text) IN (SELECT codein FROM avec_reseau) - GROUP BY TRIM(a.codein::text) - ) - SELECT - r.codein, - r.libelle1, - r.code_centrale, - COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur, - r.qte_reseau, - r.nb_magasins_reseau, - r.ca_reseau, - r.ca_par_magasin_reseau, - COALESCE(v.qte_locale, 0)::float AS qte_locale, - COALESCE(v.nb_sites_locaux, 0) AS nb_sites_locaux, - (r.qte_reseau / r.nb_magasins_reseau)::float AS qte_reseau_par_magasin, - (COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1))::float AS qte_locale_par_magasin, - ( - (r.qte_reseau / r.nb_magasins_reseau) - - (COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1)) - )::float AS ecart_par_magasin - FROM avec_reseau r - LEFT JOIN ventes v ON v.codein = r.codein - LEFT JOIN LATERAL ( - SELECT af1.code - FROM artfou1 af1 - WHERE af1.art_no_id = r.no_id AND af1.preference = 1 - ORDER BY af1.code - LIMIT 1 - ) af ON TRUE - LEFT JOIN fouident fi ON fi.code = af.code - ORDER BY ecart_par_magasin DESC - LIMIT ${limit} - `); - - console.log(`[pg-ff] pgGetOpportunitesFamille(nom=${nomNoId}): ${result.rows.length} lignes`); - return result.rows as unknown as PgOpportuniteRow[]; - } catch (e) { - console.error("[pg-ff] pgGetOpportunitesFamille error:", (e as Error).message?.slice(0, 250)); - return []; - } -} diff --git a/src/lib/qlik-browser.ts b/src/lib/qlik-browser.ts deleted file mode 100644 index c91e360..0000000 --- a/src/lib/qlik-browser.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * CollectFlow — Chromium et file d'attente partagés par tous les accès Qlik - * (extraction réseau `qlik-playwright.ts`, recherche produit `qlik-search.ts`). - */ - -import "server-only"; -import { chromium, type Browser } from "playwright-core"; - -let browserPromise: Promise | null = null; - -/** - * Chromium partagé entre les extractions. - * - * Un navigateur tombé (plantage, mémoire, arrêt du processus enfant) restait - * mémorisé : toutes les extractions suivantes échouaient sur « Target closed » - * jusqu'au redémarrage du serveur. On le relance donc dès qu'il est déconnecté. - */ -export async function getQlikBrowser(): Promise { - if (browserPromise) { - const existant = await browserPromise.catch(() => null); - if (existant?.isConnected()) return existant; - browserPromise = null; - } - const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH; - const lancement = chromium.launch({ - headless: true, - executablePath: execPath || undefined, - args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"], - }); - browserPromise = lancement; - lancement - .then((b) => b.on("disconnected", () => { - console.warn("[qlik] Chromium déconnecté — il sera relancé à la prochaine extraction"); - if (browserPromise === lancement) browserPromise = null; - })) - .catch(() => { if (browserPromise === lancement) browserPromise = null; }); - return lancement; -} - -/** - * File unique des extractions. - * - * Le serveur Qlik abandonne les requêtes dès qu'il est trop sollicité. Une - * synchro manuelle lancée pendant la synchro de nuit, ou deux recherches - * produit simultanées, ouvraient deux sessions Engine en parallèle — et les - * deux échouaient. Les extractions passent désormais l'une après l'autre. - */ -let fileExtractions: Promise = Promise.resolve(); -let extractionsEnAttente = 0; - -export function dansLaFileQlik(libelle: string, tache: () => Promise): Promise { - if (extractionsEnAttente > 0) { - console.log(`[qlik] ${libelle} : en attente de ${extractionsEnAttente} extraction(s) en cours`); - } - extractionsEnAttente++; - const resultat = fileExtractions.then(tache, tache); - fileExtractions = resultat.then(() => undefined, () => undefined).finally(() => { extractionsEnAttente--; }); - return resultat; -} diff --git a/src/lib/qlik-client.ts b/src/lib/qlik-client.ts deleted file mode 100644 index aef5300..0000000 --- a/src/lib/qlik-client.ts +++ /dev/null @@ -1,386 +0,0 @@ -/** - * CollectFlow — Client Qlik Sense (donnees reseau ~270 magasins) - * - * Recupere par produit (cle = code centrale via la dimension "Article Code") : - * - CA reseau, Qte vendue reseau, Nb magasins travaillant le produit. - * - * Auth = NTLM (Qlik Sense Enterprise on Windows) via flux ticket SSO : - * 1. GET /hub/ (non authentifie) -> 302 vers le proxy forms, on extrait le targetId - * 2. NTLM sur /internal_windows_authentication/?targetId=... -> 302 /hub/?qlikTicket=XXX - * 3. GET /hub/?qlikTicket=XXX -> Qlik pose le cookie X-Qlik-Session - * Le cookie + Xrfkey servent ensuite pour QRS (REST) et l'Engine API (websocket). - * - * Extraction = Engine API (QIX) sur websocket, hypercube generique reference par - * master items (qLibraryId) -> pas besoin des expressions brutes. - * - * Tout est parametrable par variables d'environnement (defauts = discovery 2026-06-20). - */ - -import httpntlm from "httpntlm"; -import { WebSocket } from "ws"; -import { randomBytes } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { buildGridNetworkQlikDateFilter, chunkSerials, type QlikDateFilter } from "@/lib/qlik-date-range"; - -/** Lit les réglages Qlik depuis data/.db-config.json (configurés dans Paramètres). */ -function readQlikFileConfig(): { qlikHost?: string; qlikUser?: string; qlikPassword?: string } { - try { - const raw = readFileSync(join(process.cwd(), "data", ".db-config.json"), "utf-8"); - const c = JSON.parse(raw); - return { qlikHost: c.qlikHost, qlikUser: c.qlikUser, qlikPassword: c.qlikPassword }; - } catch { - return {}; - } -} - -// --------------------------------------------------------------------------- -// Config (env) — defauts issus de la discovery -// --------------------------------------------------------------------------- - -export interface QlikConfig { - host: string; - user: string; - password: string; - domain: string; // vide par defaut (NTLM sans domaine, comme requests_ntlm) - workstation: string; - appNetwork: string; // GUID app "Magasins Vision Consolidee" (article x reseau) - dimCodeArticleId: string; // master dimension "Article Code" - measCaId: string; // master measure "CA N" - measQteId: string; // master measure "Quantite N" - measNbMagId: string; // master measure "Magasin Ventes Nb N" - measCaMagId: string; // master measure "CA par Magasin N" - measMargePctId: string; // master measure "Marge % N" - tlsInsecure: boolean; - timeoutMs: number; -} - -export function getQlikConfig(): QlikConfig { - // Priorité : réglages UI (data/.db-config.json) > env > défaut - const f = readQlikFileConfig(); - return { - host: f.qlikHost || process.env.QLIK_HOST || "reporting-magasins.lafoirfouille.fr", - user: f.qlikUser || process.env.QLIK_USER || "FFSCH", - password: f.qlikPassword || process.env.QLIK_PWD || "", - domain: process.env.QLIK_DOMAIN ?? "", - workstation: process.env.QLIK_WORKSTATION ?? "", - appNetwork: process.env.QLIK_APP_NETWORK ?? "9872ee6e-d64a-4b43-984a-076bf1f7f647", - // qIds engine (master items de l'app "Magasins Vision Consolidée") - dimCodeArticleId: process.env.QLIK_DIM_CODE_ARTICLE_ID ?? "yesCP", - measCaId: process.env.QLIK_MEAS_CA_ID ?? "JhqJ", - measQteId: process.env.QLIK_MEAS_QTE_ID ?? "41516861-5997-4635-8187-3643a2bde422", - measNbMagId: process.env.QLIK_MEAS_NBMAG_ID ?? "yNBLjc", - // Master GUIDs de l'app "Magasins Vision Consolidée" (9872ee6e). Si l'engine - // les refuse (qId court attendu), override via env après discovery. - measCaMagId: process.env.QLIK_MEAS_CAMAG_ID ?? "16778c00-a4e0-41c7-ab1c-179810d78172", - measMargePctId: process.env.QLIK_MEAS_MARGEPCT_ID ?? "ea1e8feb-49ba-4eb3-8db6-72dcfb4c1222", - tlsInsecure: (process.env.QLIK_TLS_INSECURE ?? "true") === "true", - timeoutMs: Number(process.env.QLIK_TIMEOUT_MS ?? "60000"), - }; -} - -export interface NetworkMetric { - codeCentrale: string; - caReseau: number; - qteReseau: number; - nbMagasinsReseau: number; - /** CA moyen par magasin (productivité normalisée présence) */ - caParMagasinReseau: number; - /** Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) */ - margePctReseau: number; - periode?: string; - /** Quantité vendue réseau par mois : { "YYYY-MM": qté }. Rempli si sync avec filtre Date. */ - qteByMonth?: Record; - /** - * Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }. - * - * Le cube mensuel Qlik renvoie déjà ces 5 mesures par (Article Code, Mois). - * Lors d'une synchronisation datée validée, les 12 mois complets sont - * toujours présents ; un mois sans fait est explicitement représenté à 0. - */ - metricsByMonth?: Record; - /** - * Libellé de l'article côté Qlik. Renseigné uniquement par la recherche - * produit (`qlik-search.ts`), qui interroge la dimension libellé ; la sync - * fournisseur ne le connaît pas et le laisse `undefined`. - */ - libelleReseau?: string; - /** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */ - fournisseurReseau?: string; -} - -/** Les mesures réseau Qlik pour un produit sur un mois donné. */ -export interface QlikMonthMetrics { - /** Quantité vendue réseau — toujours présente. */ - qte: number; - /** CA réseau du mois. */ - ca?: number; - /** Nombre de magasins ayant vendu le produit ce mois-là. */ - nbMag?: number; - /** CA moyen par magasin sur le mois (mesure Qlik brute). */ - caMag?: number; - /** Taux de marge réseau du mois (ratio brut Qlik, ex 0.32 = 32%). */ - margePct?: number; -} - -/** - * Produit réseau tel que renvoyé par la **recherche** Qlik (page Recherche réseau). - * - * Différence clé avec `NetworkMetric` : ces produits viennent du catalogue Qlik - * du réseau, pas de notre catalogue local. Ils peuvent donc être totalement - * inconnus de FF Nancy — d'où le libellé, qui n'est pas récupérable en base. - */ -export interface NetworkProduct extends NetworkMetric { - /** Libellé article tel qu'il apparaît dans Qlik (dimension « Article »). */ - libelle: string; - fournisseur?: string; - famille?: string; -} - -function makeXrfkey(): string { - return randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0"); -} - -// --------------------------------------------------------------------------- -// 1. Auth NTLM -> ticket SSO -> cookie de session -// --------------------------------------------------------------------------- - -interface QlikSession { - cookie: string; - xrfkey: string; -} - -function ntlmGet(cfg: QlikConfig, path: string): Promise<{ statusCode: number; headers: Record; body: string }> { - return new Promise((resolve, reject) => { - httpntlm.get( - { - url: `https://${cfg.host}${path}`, - username: cfg.user, - password: cfg.password, - domain: cfg.domain, - workstation: cfg.workstation, - rejectUnauthorized: !cfg.tlsInsecure, - headers: { "User-Agent": "Mozilla/5.0 CollectFlow", "X-Qlik-Xrfkey": "" }, - } as unknown as Parameters[0], - (err: Error | null, res: { statusCode: number; headers: Record; body: string }) => - err ? reject(err) : resolve(res), - ); - }); -} - -export async function qlikNtlmSession(cfg = getQlikConfig()): Promise { - if (!cfg.user || !cfg.password) { - throw new Error("[qlik] QLIK_USER / QLIK_PWD manquants dans l'environnement"); - } - const xrfkey = makeXrfkey(); - const tlsRestore = process.env.NODE_TLS_REJECT_UNAUTHORIZED; - if (cfg.tlsInsecure) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; - try { - // 1. targetId - const hub = await fetch(`https://${cfg.host}/hub/`, { redirect: "manual" }); - const loc1 = hub.headers.get("location") ?? ""; - const targetId = (loc1.match(/targetId=([0-9a-f-]+)/i) ?? [])[1] ?? ""; - - // 2. NTLM -> ticket. Interne (reseau corp), /hub/ peut deja renvoyer 401 NTLM - // et poser le cookie directement ; on gere les 2 cas. - const winPath = targetId - ? `/internal_windows_authentication/?targetId=${targetId}&xrfkey=${xrfkey}` - : `/hub/?xrfkey=${xrfkey}`; - const ntlm = await ntlmGet(cfg, winPath); - - // Cookie posé directement (cas interne) ? - const directCookie = extractSessionCookie(ntlm.headers["set-cookie"]); - if (directCookie) return { cookie: directCookie, xrfkey }; - - // 3. Échange ticket -> cookie - const ticketLoc = String(ntlm.headers["location"] ?? ""); - if (!/qlikTicket=/.test(ticketLoc)) { - throw new Error(`[qlik] Auth NTLM sans ticket (HTTP ${ntlm.statusCode}) — verifier mot de passe`); - } - const exch = await fetch(ticketLoc, { redirect: "manual" }); - const sc = typeof exch.headers.getSetCookie === "function" ? exch.headers.getSetCookie() : []; - const cookie = sc.map((c) => c.split(";")[0]).filter((c) => /X-Qlik-Session/i.test(c)).join("; "); - if (!cookie) throw new Error("[qlik] ticket non echange contre un cookie de session"); - return { cookie, xrfkey }; - } finally { - if (cfg.tlsInsecure) process.env.NODE_TLS_REJECT_UNAUTHORIZED = tlsRestore; - } -} - -function extractSessionCookie(setCookie: unknown): string | null { - const arr = Array.isArray(setCookie) ? setCookie : setCookie ? [String(setCookie)] : []; - const c = arr.map((x) => String(x).split(";")[0]).find((x) => /X-Qlik-Session/i.test(x)); - return c ?? null; -} - -// --------------------------------------------------------------------------- -// 2. Websocket Engine API (QIX) + JSON-RPC minimal -// --------------------------------------------------------------------------- - -interface RpcConn { - rpc: (method: string, params: unknown, handle?: number) => Promise>; - close: () => void; -} - -function openEngine(appGuid: string, sess: QlikSession, cfg: QlikConfig): Promise { - const url = `wss://${cfg.host}/app/${encodeURIComponent(appGuid)}?Xrfkey=${sess.xrfkey}`; - return new Promise((resolve, reject) => { - const ws = new WebSocket(url, { - headers: { - Cookie: sess.cookie, - "X-Qlik-Xrfkey": sess.xrfkey, - // Le proxy Qlik valide l'Origin des websockets (whitelist) — un navigateur l'envoie toujours. - Origin: `https://${cfg.host}`, - "User-Agent": "Mozilla/5.0", - }, - rejectUnauthorized: !cfg.tlsInsecure, - handshakeTimeout: cfg.timeoutMs, - }); - let nextId = 1; - const pending = new Map) => void; reject: (e: Error) => void }>(); - const timer = setTimeout(() => { ws.terminate(); reject(new Error("[qlik] timeout engine")); }, cfg.timeoutMs); - - ws.on("message", (raw: Buffer) => { - let msg: Record; - try { msg = JSON.parse(raw.toString()); } catch { return; } - if (msg.method === "OnConnected") { clearTimeout(timer); resolve(conn); return; } - const id = msg.id as number | undefined; - if (id != null && pending.has(id)) { - const p = pending.get(id)!; - pending.delete(id); - if (msg.error) p.reject(new Error(`[qlik] RPC ${JSON.stringify(msg.error)}`)); - else p.resolve(msg.result as Record); - } - }); - ws.on("unexpected-response", (_req, res) => { - clearTimeout(timer); - let body = ""; - res.on("data", (d: Buffer) => (body += d.toString())); - res.on("end", () => reject(new Error( - `[qlik] ws HTTP ${res.statusCode} server=${res.headers["server"]} wwwauth=${res.headers["www-authenticate"]} body=${body.slice(0, 160)}`, - ))); - }); - ws.on("error", (e: Error) => { clearTimeout(timer); reject(e); }); - ws.on("close", () => { for (const p of pending.values()) p.reject(new Error("[qlik] ws ferme")); pending.clear(); }); - - const rpc = (method: string, params: unknown, handle = -1) => - new Promise>((res, rej) => { - const id = nextId++; - pending.set(id, { resolve: res, reject: rej }); - ws.send(JSON.stringify({ jsonrpc: "2.0", id, handle, method, params })); - }); - const conn: RpcConn = { rpc, close: () => ws.close() }; - }); -} - -// --------------------------------------------------------------------------- -// 3. Extraction hypercube (master items) -> NetworkMetric[] -// --------------------------------------------------------------------------- - -function hyperCubeDef(cfg: QlikConfig, height: number) { - return { - qInfo: { qType: "collectflow-network" }, - qHyperCubeDef: { - qDimensions: [{ qLibraryId: cfg.dimCodeArticleId, qNullSuppression: true }], - qMeasures: [ - { qLibraryId: cfg.measCaId }, - { qLibraryId: cfg.measQteId }, - { qLibraryId: cfg.measNbMagId }, - { qLibraryId: cfg.measCaMagId }, - { qLibraryId: cfg.measMargePctId }, - ], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 6, qHeight: height }], - qSuppressZero: false, - qSuppressMissing: true, - }, - }; -} - -type Matrix = Array>; - -export async function fetchNetworkMetrics( - codeCentraux?: string[], - cfg = getQlikConfig(), - dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(), -): Promise> { - if (!cfg.appNetwork) throw new Error("[qlik] QLIK_APP_NETWORK manquant"); - const wanted = codeCentraux ? new Set(codeCentraux) : null; - const out = new Map(); - - const sess = await qlikNtlmSession(cfg); - const conn = await openEngine(cfg.appNetwork, sess, cfg); - try { - const openRes = await conn.rpc("OpenDoc", { qDocName: cfg.appNetwork }); - const docHandle = (((openRes.qReturn as Record) ?? {}).qHandle as number) ?? 1; - - // Sélection du champ `Date` sur la fenêtre 12 mois complets glissants - // (alignement avec la timeline de la grille). Équivalent à un set - // analysis `{=…<=…"}>}` appliqué aux master measures. - let dateFieldHandle: number | null = null; - if (dateFilter) { - const gf = await conn.rpc("GetField", { qFieldName: "Date" }, docHandle); - dateFieldHandle = (((gf.qReturn as Record) ?? {}).qHandle as number); - if (typeof dateFieldHandle !== "number") { - console.warn(`[qlik] champ Date introuvable, extraction sans filtre date`); - dateFilter = null; - dateFieldHandle = null; - } else { - const batches = chunkSerials(dateFilter.dailySerials, 500); - for (let i = 0; i < batches.length; i++) { - const batch = batches[i]; - const sel = await conn.rpc( - "SelectValues", - { - qFieldValues: batch.map((s) => ({ qNum: s, qText: String(s) })), - qToggleMode: false, - qSoftLock: true, - }, - dateFieldHandle, - ); - console.log(`[qlik] date filter lot ${i + 1}/${batches.length} (${batch.length} serials) — ${dateFilter.label}`); - // Si SelectValues renvoie une erreur qError, on continue quand même : - // l'absence de champ Date typé numérique lèverait une erreur Engine explicite. - void sel; - } - } - } - - const pageHeight = 1400; // 6 colonnes * 1400 = 8400 cellules max - const createRes = await conn.rpc("CreateSessionObject", { qProp: hyperCubeDef(cfg, pageHeight) }, docHandle); - const objHandle = (((createRes.qReturn as Record) ?? {}).qHandle as number); - - let top = 0; - for (;;) { - const dataRes = await conn.rpc( - "GetHyperCubeData", - { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: pageHeight }] }, - objHandle, - ); - const pages = (dataRes.qDataPages as Array<{ qMatrix?: Matrix }>) ?? []; - const matrix: Matrix = pages[0]?.qMatrix ?? []; - if (matrix.length === 0) break; - - for (const row of matrix) { - const codeCentrale = (row[0]?.qText ?? "").trim(); - if (!codeCentrale) continue; - if (wanted && !wanted.has(codeCentrale)) continue; - out.set(codeCentrale, { - codeCentrale, - caReseau: Number(row[1]?.qNum ?? 0) || 0, - qteReseau: Number(row[2]?.qNum ?? 0) || 0, - nbMagasinsReseau: Number(row[3]?.qNum ?? 0) || 0, - caParMagasinReseau: Number(row[4]?.qNum ?? 0) || 0, - margePctReseau: Number(row[5]?.qNum ?? 0) || 0, - periode: dateFilter?.label, - }); - } - if (matrix.length < pageHeight) break; - top += matrix.length; - } - } finally { - conn.close(); - } - - console.log(`[qlik] fetchNetworkMetrics: ${out.size} produits reseau`); - return out; -} diff --git a/src/lib/qlik-date-range.ts b/src/lib/qlik-date-range.ts deleted file mode 100644 index 1de772c..0000000 --- a/src/lib/qlik-date-range.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * CollectFlow — Helper de fenêtre temporelle pour Qlik réseau. - * - * Aligne la timeline d'extraction Qlik réseau sur la timeline de la grille : - * - `monthsBack` mois complets glissants (défaut : 12) - * - exclut systématiquement le mois courant - * - * Référence utilisée : `buildLast12MonthsRange()` de `api-ff-client.ts` - * (même source que la grille). On duplique la logique ici (sans dépendance - * Next.js) pour rester testable en CLI pur Node. - * - * Qlik référence les dates par un sérial numérique (jours écoulés depuis - * l'epoch Qlik 1899-12-30). Repères validés : - * 2025-01-01 → 45658 - * 2025-06-19 → 45827 - * 2026-01-01 → 46023 - * 2026-06-19 → 46192 - * - * Le filtre côté Qlik s'applique via une sélection de champ `Date` (skill qlik) : - * - valeurs numériques = serials Qlik - * - la sélection par défaut (`{}`) est héritée par toutes - * les master measures qui référencent le champ `Date` (cas de l'app - * "Magasins Vision Consolidée"). - * - * Pourquoi `SelectValues` sur le champ plutôt que set analysis dans `qDef` ? - * - les master items sont référencés par `qLibraryId` (réutilisation sans - * devoir dupliquer l'expression) - * - `qLibraryId` + `qDef` ensemble n'est pas supporté par le moteur QIX - * - sélectionner toutes les dates de la fenêtre via `SelectValues` revient - * fonctionnellement à `{=…<=…"}>}` pour les mesures qui touchent - * ce champ, sans dépendre de l'expression interne des master items - * - * Pas de secrets ici. Pure fonction utilitaire testable en CLI Node. - */ - -/** - * Bornes acceptées pour `monthsBack`. On reste sur 1..12 pour ne pas casser - * la sémantique "12 mois glissants alignés sur la grille" : une valeur plus - * grande n'apporterait pas de signal réseau supplémentaire (la grille - * n'expose elle-même que ~12 mois). En-deçà de 1 mois, la fenêtre serait - * vide (mois courant exclu) ou incohérente. - */ -export const QLIK_MONTHS_BACK_MIN = 1; -export const QLIK_MONTHS_BACK_MAX = 12; -export const QLIK_MONTHS_BACK_DEFAULT = 12; - -/** - * Lit `process.env[name]` et renvoie un entier borné entre `min` et `max`. - * Renvoie `defaultValue` si la variable est absente / vide / non numérique. - * - * Volontairement exportée pour être réutilisée par `qlik-playwright.ts` et - * `sync/route.ts` (cohérence avec `envNumber` côté Playwright, mais en pur - * Node sans dépendance Next.js). - */ -export function envMonthsBack(name: string, defaultValue: number = QLIK_MONTHS_BACK_DEFAULT): number { - const raw = process.env[name]; - if (raw == null || raw.trim() === "") return defaultValue; - const parsed = Number(raw); - if (!Number.isFinite(parsed)) return defaultValue; - const clamped = Math.min(QLIK_MONTHS_BACK_MAX, Math.max(QLIK_MONTHS_BACK_MIN, Math.trunc(parsed))); - return clamped; -} - -/** Epoch Qlik = 1899-12-30 (équivalent Excel/Lotus 1-2-3). */ -const QLIK_EPOCH_MS = Date.UTC(1899, 11, 30); // 1899-12-30T00:00:00Z - -/** Repères de validation (cf. skill qlik). */ -export const QLIK_DATE_ANCHORS: ReadonlyArray<{ iso: string; serial: number }> = [ - { iso: "2025-01-01", serial: 45658 }, - { iso: "2025-06-19", serial: 45827 }, - { iso: "2026-01-01", serial: 46023 }, - { iso: "2026-06-19", serial: 46192 }, -]; - -/** - * Convertit une date ISO `YYYY-MM-DD` en sérial Qlik numérique. - * Inverse documenté : Qlik epoch = 1899-12-30 (jour 0). - * - * @throws si l'entrée n'est pas au format `YYYY-MM-DD`. - */ -export function isoDateToQlikSerial(isoDate: string): number { - const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate); - if (!m) throw new Error(`isoDateToQlikSerial: format attendu YYYY-MM-DD, reçu "${isoDate}"`); - const y = Number(m[1]); - const mo = Number(m[2]); - const d = Number(m[3]); - const ms = Date.UTC(y, mo - 1, d); - const serial = Math.round((ms - QLIK_EPOCH_MS) / 86_400_000); - return serial; -} - -/** - * Convertit un sérial Qlik en date ISO `YYYY-MM-DD` (utile pour tests/diagnostics). - */ -export function qlikSerialToIsoDate(serial: number): string { - const ms = QLIK_EPOCH_MS + serial * 86_400_000; - const d = new Date(ms); - const y = d.getUTCFullYear(); - const mo = String(d.getUTCMonth() + 1).padStart(2, "0"); - const da = String(d.getUTCDate()).padStart(2, "0"); - return `${y}-${mo}-${da}`; -} - -export interface QlikDateFilter { - /** Date ISO du 1er jour de la fenêtre (inclus). */ - dateDebut: string; - /** Date ISO du dernier jour de la fenêtre (inclus). */ - dateFin: string; - /** Sérial Qlik du 1er jour (= `Date >= …`). */ - qStart: number; - /** Sérial Qlik du dernier jour (= `Date <= …`). */ - qEnd: number; - /** Libellé humain `YYYY-MM_YYYY-MM` pour logs / cache. */ - label: string; - /** Liste exhaustive des serials journaliers `[qStart..qEnd]` pour SelectValues. */ - dailySerials: number[]; - /** Expression set analysis prête à intégrer dans un `qDef` de mesure. */ - setAnalysis: string; -} - -/** - * Calcule la fenêtre `monthsBack` mois complets (excluant mois courant). - * Par défaut, utilise `now = new Date()` et `monthsBack = 12`. - * - * Logique alignée sur `buildLast12MonthsRange()` de `src/lib/api-ff-client.ts` - * (défaut = 12 mois). Si la source officielle change, mettre à jour ici aussi - * (garder en sync). - * - * Bornes : `monthsBack` est clampé dans `[QLIK_MONTHS_BACK_MIN, QLIK_MONTHS_BACK_MAX]` - * (1..12). Une valeur invalide (NaN, ≤ 0, > 12) est ramenée aux bornes sans - * exception — on veut que la sync reste possible même si l'env est mal - * configuré. - */ -export function buildGridNetworkQlikDateFilter( - now: Date = new Date(), - monthsBack: number = QLIK_MONTHS_BACK_DEFAULT, -): QlikDateFilter { - const months = Math.min( - QLIK_MONTHS_BACK_MAX, - Math.max(QLIK_MONTHS_BACK_MIN, Math.trunc(Number.isFinite(monthsBack) ? monthsBack : QLIK_MONTHS_BACK_DEFAULT)), - ); - // Fin : dernier jour du mois précédent le mois courant - const endMonth = new Date(now.getFullYear(), now.getMonth(), 0); - // Début : `months` mois avant le mois courant (1er du mois) - const startMonth = new Date(now.getFullYear(), now.getMonth() - months, 1); - - // Ne jamais utiliser `toISOString()` ici : minuit local en Europe/Paris - // devient la veille en UTC et transformait par exemple 2025-07-01 en - // 2025-06-30, soit une extraction de 13 mois. Les bornes sont des dates - // calendaires Qlik, on les formate donc avec les composantes locales. - const localIso = (d: Date): string => - `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; - const dateDebut = localIso(startMonth); - const dateFin = localIso(endMonth); - - const qStart = isoDateToQlikSerial(dateDebut); - const qEnd = isoDateToQlikSerial(dateFin); - - if (qEnd < qStart) { - throw new Error( - `buildGridNetworkQlikDateFilter: fenêtre inversée qEnd=${qEnd} < qStart=${qStart} (dateDebut=${dateDebut} dateFin=${dateFin})`, - ); - } - - const dailySerials: number[] = []; - for (let s = qStart; s <= qEnd; s++) dailySerials.push(s); - - const label = `${dateDebut.slice(0, 7)}_${dateFin.slice(0, 7)}`; - const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`; - - return { dateDebut, dateFin, qStart, qEnd, label, dailySerials, setAnalysis }; -} - -/** - * Découpe les serials en lots pour appels `SelectValues` paginés. - * Par défaut 500 valeurs/appel (largement < limite Qlik ~10k par requête). - */ -export function chunkSerials(serials: number[], batchSize = 500): number[][] { - const out: number[][] = []; - for (let i = 0; i < serials.length; i += batchSize) { - out.push(serials.slice(i, i + batchSize)); - } - return out; -} - -/** - * Découpe une fenêtre temporelle en sous-fenêtres mensuelles calendaires - * **inclusives et continues**. - * - * But : éviter qu'un seul hypercube Qlik ne doive charger 365 jours × N - * codes d'un coup (provoque `code:15 Request aborted`). Avec cette découpe, - * chaque mois ≈ 28–31 jours × N codes → beaucoup plus léger pour l'Engine. - * - * Règles : - * - Le 1er mois commence à `filter.dateDebut` (peut être un jour quelconque, - * pas forcément le 1er calendaire). - * - Chaque mois intermédiaire est un mois calendaire **complet** (du 1er - * au dernier jour du mois). - * - Le dernier mois se termine à `filter.dateFin` (tronqué si pas fin de mois). - * - Les mois sont contigus : fin d'un mois + 1 = début du suivant. - * - Si `filter.dateDebut` et `filter.dateFin` tombent dans le même mois, - * on renvoie un seul mois (identique à `filter` mais re-calculé). - * - * Chaque mois retourné est un `QlikDateFilter` autonome : `qStart`/`qEnd`, - * `dailySerials` recomptés, `setAnalysis` et `label` au format `YYYY-MM`. - * - * Pré-condition : `dateDebut <= dateFin` au sens calendaire (pas vérifié ici, - * la validation est faite par `buildGridNetworkQlikDateFilter`). - */ -export function getMonthRanges(filter: QlikDateFilter): QlikDateFilter[] { - const start = filter.dateDebut; // YYYY-MM-DD - const end = filter.dateFin; // YYYY-MM-DD - - // [yyyy, mm, dd] depuis YYYY-MM-DD (déjà validé par isoDateToQlikSerial). - const sy = Number(start.slice(0, 4)); - const sm = Number(start.slice(5, 7)); - // const sd = Number(start.slice(8, 10)); // non utilisé directement - const ey = Number(end.slice(0, 4)); - const em = Number(end.slice(5, 7)); - const ed = Number(end.slice(8, 10)); - - // Dernier jour du mois (0-ième du mois suivant en UTC local Date). - const lastDayOfMonth = (y: number, m: number): number => { - // m: 1..12 ; on prend le jour 0 du mois suivant, qui donne le dernier jour du mois courant. - return new Date(y, m, 0).getDate(); - }; - - const pad = (n: number): string => String(n).padStart(2, "0"); - - const out: QlikDateFilter[] = []; - - // Itère mois par mois du mois de début au mois de fin (inclus). - let curY = sy; - let curM = sm; - // Sécurité anti-boucle infinie (au cas où). - const MAX_MONTHS = 1200; // 100 ans, large. - let safety = 0; - while (safety++ < MAX_MONTHS) { - const isFirst = curY === sy && curM === sm; - const isLast = curY === ey && curM === em; - - // Début du mois courant : si c'est le premier mois, on respecte `dateDebut` ; - // sinon on prend le 1er calendaire. - const monthStartDay = isFirst ? Number(start.slice(8, 10)) : 1; - const monthStartDate = `${curY}-${pad(curM)}-${pad(monthStartDay)}`; - - // Fin du mois courant : si c'est le dernier mois, on respecte `dateFin` ; - // sinon on prend le dernier jour calendaire du mois. - const monthEndDay = isLast ? ed : lastDayOfMonth(curY, curM); - const monthEndDate = `${curY}-${pad(curM)}-${pad(monthEndDay)}`; - - const qStart = isoDateToQlikSerial(monthStartDate); - const qEnd = isoDateToQlikSerial(monthEndDate); - - const dailySerials: number[] = []; - for (let s = qStart; s <= qEnd; s++) dailySerials.push(s); - - const label = `${curY}-${pad(curM)}`; - const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`; - - out.push({ dateDebut: monthStartDate, dateFin: monthEndDate, qStart, qEnd, label, dailySerials, setAnalysis }); - - if (isLast) break; - - // Avance d'un mois calendaire (gère le passage décembre → janvier). - curM += 1; - if (curM > 12) { curM = 1; curY += 1; } - } - - if (out.length === 0) { - // Fenêtre vide : on retourne un seul mois vide aligné sur dateDebut. - const qStart = filter.qStart; - const qEnd = filter.qEnd; - const label = start.slice(0, 7); - const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`; - out.push({ dateDebut: start, dateFin: end, qStart, qEnd, label, dailySerials: [], setAnalysis }); - } - - return out; -} diff --git a/src/lib/qlik-network-cache.ts b/src/lib/qlik-network-cache.ts deleted file mode 100644 index a27b78b..0000000 --- a/src/lib/qlik-network-cache.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * CollectFlow — Cache des metriques reseau Qlik (table qlik_network_metrics). - * - * Lecture par lot (jointure grille) + upsert masse (route de sync). - */ - -import "server-only"; - -import { db } from "@/db"; -import { qlikNetworkMetrics } from "@/db/schema"; -import { inArray, sql } from "drizzle-orm"; -import type { NetworkMetric, QlikMonthMetrics } from "@/lib/qlik-client"; - -export interface NetworkMetricCached { - caReseau: number; - qteReseau: number; - nbMagasinsReseau: number; - caParMagasinReseau: number; - margePctReseau: number; - /** Quantité réseau par mois : { "YYYY-MM": qté } — null si non synchronisé avec dates. */ - qteByMonth: Record | null; - /** - * Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }. - * null pour les lignes synchronisées avant l'ajout de la colonne — l'UI - * retombe alors sur `qteByMonth`. - */ - metricsByMonth: Record | null; - /** Période couverte par l'extraction (ex "2025-03_2026-02"). */ - periode: string | null; - /** Libellé de l'article côté Qlik — `null` tant qu'aucune recherche produit ne l'a renseigné. */ - libelleReseau: string | null; - /** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */ - fournisseurReseau: string | null; - fetchedAt: string | null; -} - -/** Lit les metriques reseau pour une liste de codes centraux. */ -export async function getNetworkMetricsByCodeCentrale( - codes: string[], -): Promise> { - const out = new Map(); - const unique = [...new Set(codes.filter(Boolean))]; - if (unique.length === 0) return out; - - // Découpage obligatoire : `inArray` produit un paramètre lié par valeur, or le - // protocole PostgreSQL en accepte 65535 au maximum. Un gros fournisseur peut - // dépasser 130 000 codes — la requête échouait alors purement et simplement. - const CHUNK = 2000; - const rows: Array = []; - for (let i = 0; i < unique.length; i += CHUNK) { - const batch = unique.slice(i, i + CHUNK); - const part = await db - .select() - .from(qlikNetworkMetrics) - .where(inArray(qlikNetworkMetrics.codeCentrale, batch)); - rows.push(...part); - } - - for (const r of rows) { - out.set(r.codeCentrale, { - caReseau: Number(r.caReseau ?? 0) || 0, - qteReseau: Number(r.qteReseau ?? 0) || 0, - nbMagasinsReseau: Number(r.nbMagasinsReseau ?? 0) || 0, - caParMagasinReseau: Number(r.caParMagasinReseau ?? 0) || 0, - margePctReseau: Number(r.margePctReseau ?? 0) || 0, - qteByMonth: (r.qteByMonth as Record | null) ?? null, - metricsByMonth: (r.metricsByMonth as Record | null) ?? null, - periode: r.periode ?? null, - libelleReseau: r.libelleReseau ?? null, - fournisseurReseau: r.fournisseurReseau ?? null, - fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null, - }); - } - return out; -} - -/** Upsert en masse des metriques reseau (depuis Qlik). */ -export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise { - if (metrics.length === 0) return 0; - const now = new Date(); - const values = metrics.map((m) => ({ - codeCentrale: m.codeCentrale, - caReseau: String(m.caReseau), - qteReseau: String(m.qteReseau), - nbMagasinsReseau: m.nbMagasinsReseau, - caParMagasinReseau: String(m.caParMagasinReseau), - margePctReseau: String(m.margePctReseau), - periode: m.periode ?? null, - qteByMonth: m.qteByMonth ?? null, - metricsByMonth: m.metricsByMonth ?? null, - libelleReseau: m.libelleReseau ?? null, - fournisseurReseau: m.fournisseurReseau ?? null, - fetchedAt: now, - })); - - // Upsert par paquets pour eviter les requetes trop volumineuses. - const chunk = 500; - let count = 0; - for (let i = 0; i < values.length; i += chunk) { - const batch = values.slice(i, i + chunk); - await db - .insert(qlikNetworkMetrics) - .values(batch) - .onConflictDoUpdate({ - target: qlikNetworkMetrics.codeCentrale, - set: { - caReseau: sql`excluded.ca_reseau`, - qteReseau: sql`excluded.qte_reseau`, - nbMagasinsReseau: sql`excluded.nb_magasins_reseau`, - caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`, - margePctReseau: sql`excluded.marge_pct_reseau`, - periode: sql`excluded.periode`, - // Le détail mensuel n'est produit que par l'extraction complète - // (dimension Mois). La recherche produit, elle, n'a que les - // agrégats : elle ne doit pas effacer un mensuel déjà extrait. - qteByMonth: sql`COALESCE(excluded.qte_by_month, ${qlikNetworkMetrics.qteByMonth})`, - metricsByMonth: sql`COALESCE(excluded.metrics_by_month, ${qlikNetworkMetrics.metricsByMonth})`, - // Seule la recherche produit connaît le libellé / le fournisseur - // réseau : une sync fournisseur ne doit pas les effacer. - libelleReseau: sql`COALESCE(excluded.libelle_reseau, ${qlikNetworkMetrics.libelleReseau})`, - fournisseurReseau: sql`COALESCE(excluded.fournisseur_reseau, ${qlikNetworkMetrics.fournisseurReseau})`, - fetchedAt: sql`excluded.fetched_at`, - }, - }); - count += batch.length; - } - return count; -} diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts deleted file mode 100644 index 3ac6926..0000000 --- a/src/lib/qlik-playwright.ts +++ /dev/null @@ -1,2500 +0,0 @@ -/** - * CollectFlow — Extraction Qlik via Playwright (Chromium headless). - * - * Le proxy Qlik refuse un websocket "raw" côté serveur (403). On ouvre donc - * l'app dans Chromium avec une session authentifiée (cookie X-Qlik-Session - * injecté via le flux ticket NTLM), on récupère le **qlik-csrf-token** que le - * client Qlik utilise pour SON websocket, puis on ouvre notre propre websocket - * Engine **in-page** avec ce token (le proxy l'accepte). - * - * Efficacité : on filtre d'abord Date par mois (~30 jours), puis on sélectionne - * les Article Code demandés par petits lots. Chaque cube reste borné à - * ~30 jours × batch d'articles, sans lire tout le catalogue Qlik. - */ - -import "server-only"; -import { getQlikBrowser as getBrowser, dansLaFileQlik as dansLaFile } from "@/lib/qlik-browser"; -import { getQlikConfig, qlikNtlmSession, type QlikConfig, type NetworkMetric, type QlikMonthMetrics } from "@/lib/qlik-client"; -import { buildGridNetworkQlikDateFilter, getMonthRanges, type QlikDateFilter } from "@/lib/qlik-date-range"; -import { assainirMetrique, controlerExtraction } from "@/lib/qlik-validation"; - -interface InPageResult { - ok: boolean; - rows?: Array>; - /** - * Métriques réseau par code central et par mois : { code: { "YYYY-MM": {…} } }. - * - * Le cube mensuel renvoie déjà les 6 mesures par (Article Code, Mois) ; on les - * conserve toutes. Seule `qte` est systématiquement présente : les mois N-1 - * dérivés de « Quantité COMP » n'ont que la quantité (COMP ne porte pas les - * autres mesures), et la passe complémentaire N-1 les complète quand le champ - * `Année` existe. - */ - monthly?: Record>; - size?: number; - error?: string; - // Compteurs agrégés pour le résumé timing final côté Node (cf. qlik-playwright). - months?: number; - batches?: number; - code15Retries?: number; - fallbackCount?: number; - /** true si le résultat vient d'un point de contrôle (extraction interrompue). */ - partiel?: boolean; - /** Diagnostics clés du chemin par expressions, répétés en fin de sync. */ - diagExpr?: string[]; -} - -function envNumber(name: string, defaultValue: number, min: number, max: number): number { - const raw = process.env[name]; - if (raw == null || raw.trim() === "") return defaultValue; - const parsed = Number(raw); - if (!Number.isFinite(parsed)) return defaultValue; - return Math.min(max, Math.max(min, Math.trunc(parsed))); -} - -function envNumberCsv(name: string, min: number, max: number): number[] { - const raw = process.env[name]; - if (!raw) return []; - return raw - .split(",") - .map((part) => Number(part.trim())) - .filter((value) => Number.isFinite(value)) - .map((value) => Math.min(max, Math.max(min, Math.trunc(value)))); -} - -function uniqueOrdered(values: number[]): number[] { - const seen = new Set(); - const out: number[] = []; - for (const value of values) { - if (seen.has(value)) continue; - seen.add(value); - out.push(value); - } - return out; -} - -export function fetchNetworkMetricsPlaywright( - codeCentraux?: string[], - cfg: QlikConfig = getQlikConfig(), - dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(), - fournisseur?: string, -): Promise> { - const libelle = fournisseur ? `fournisseur ${fournisseur}` : `${(codeCentraux ?? []).length} code(s)`; - return dansLaFile(libelle, () => extraire(codeCentraux, cfg, dateFilter, fournisseur)); -} - -async function extraire( - codeCentraux?: string[], - cfg: QlikConfig = getQlikConfig(), - dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(), - /** - * Code fournisseur FF (ex. `J009`), tel qu'il figure dans la base SQL / l'API. - * - * Quand il est fourni, on sélectionne LE fournisseur côté Qlik au lieu des - * dizaines de milliers de codes articles : `SelectValues` sur « Article Code » - * coûte 8 à 100 s par appel quel que soit le nombre de valeurs (le moteur - * balaie un symbole de plus d'un million d'entrées), et il faut le refaire à - * chaque passe annuelle. - * - * La bascule n'est retenue que si la sélection fournisseur **couvre** les - * codes demandés — sinon on revient à la sélection par codes. - */ - fournisseur?: string, -): Promise> { - if (!cfg.appNetwork) throw new Error("[qlik-pw] QLIK_APP_NETWORK manquant"); - if (!cfg.user || !cfg.password) throw new Error("[qlik-pw] identifiants Qlik manquants"); - - // Démarre l'horloge pour le résumé timing final (cf. bloc [qlik-pw][summary]). - const totalStart = Date.now(); - console.log(`[qlik-pw] host=${cfg.host} app=${cfg.appNetwork} — ${(codeCentraux ?? []).length} codes à sélectionner`); - if (dateFilter) { - console.log(`[qlik-pw] filtre Date=${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin}, ${dateFilter.setAnalysis})`); - } - const sess = await qlikNtlmSession(cfg); - console.log(`[qlik-pw] session NTLM OK, cookie=${sess.cookie.split("=")[0]}=…`); - const [cookieName, ...rest] = sess.cookie.split("="); - const cookieValue = rest.join("="); - const qlikSettleMs = envNumber("QLIK_SETTLE_MS", 150, 0, 2000); - const qlikCode15MaxAttempts = envNumber("QLIK_CODE15_MAX_ATTEMPTS", 4, 1, 8); - const qlikArticleBatchSize = envNumber("QLIK_ARTICLE_BATCH_SIZE", 150, 1, 500); - const csvFallbacks = envNumberCsv("QLIK_ARTICLE_BATCH_FALLBACKS", 1, 500); - const qlikArticleBatchFallbacks = uniqueOrdered([qlikArticleBatchSize, ...(csvFallbacks.length ? csvFallbacks : [75, 50]), 75, 50]); - // Chemin rapide : sélectionner TOUS les Article Code une seule fois, puis ne - // changer que la Date par mois (au lieu de re-sélectionner par lots de 150 chaque - // mois). Repli automatique sur le chemin par lots en cas de code 15. - // ACTIVÉ PAR DÉFAUT (validé fiable) — désactivable seulement avec QLIK_SELECT_ALL_CODES=0. - const qlikSelectAllCodes = !["0", "false", "no", "off"].includes((process.env.QLIK_SELECT_ALL_CODES ?? "").trim().toLowerCase()); - // Découpage mensuel réel via la dimension "Mois" (cube [Article Code, Mois]) : donne - // les vraies quantités par mois ET le vrai total (au lieu de 12× la valeur annuelle). - // Activé par défaut ; désactivable via QLIK_MONTH_DIM=0 (revient à l'itération par mois). - const qlikMonthDim = !["0", "false", "no", "off"].includes((process.env.QLIK_MONTH_DIM ?? "").trim().toLowerCase()); - const qlikMoisDimId = (process.env.QLIK_MOIS_DIM_ID ?? "pfGAwTs").trim(); - // Mesure "Quantité COMP" (année N-1) pour compléter les mois de l'année précédente - // → 12 mois glissants (Quantité N ne couvre que l'année en cours). - const qlikMeasQteCompId = (process.env.QLIK_MEAS_QTE_COMP_ID ?? "96862880-76cd-4957-bf25-b96901f2ac5f").trim(); - // Agrégations directes des champs de faits. Les master measures « N » ignorent - // la sélection Date (mesures « cumul année en cours ») : elles ne peuvent pas - // produire une fenêtre 12 mois glissants. Surchargeables si le modèle change. - // Agrégation directe des faits : chemin de SECOURS, utilisé quand aucune valeur - // de `Période` ne couvre les 12 mois. Activé par défaut, mais second choix : - // `Sum(quantite)` ignore `Type_Cal` et additionne les lignes de la période - // courante ET de la période de comparaison, alors que les master measures - // portent le bon filtre dès que `Période` est bien calé (cf. `choisirPeriode`). - const qlikUseExpr = !["0", "false", "no", "off"].includes((process.env.QLIK_USE_EXPR ?? "").trim().toLowerCase()); - const qlikExprQte = (process.env.QLIK_EXPR_QTE ?? "Sum(quantite)").trim(); - const qlikExprCa = (process.env.QLIK_EXPR_CA ?? "Sum(ca_ht)").trim(); - const qlikExprNbMag = (process.env.QLIK_EXPR_NBMAG ?? "Count(DISTINCT [Magasin Code])").trim(); - const qlikExprMarge = (process.env.QLIK_EXPR_MARGE ?? "Sum(marge)").trim(); - // Champs date candidats, essayés dans l'ordre. Un champ n'est retenu que s'il - // FILTRE réellement les faits (total non nul et strictement inférieur au total - // sans filtre) : personne n'avait pu le vérifier tant que seules des mesures - // insensibles à la sélection étaient utilisées. - // Champs Qlik susceptibles de porter le CODE fournisseur (et non son libellé) : - // c'est `code_fournisseur` qui remontait des valeurs de la forme « F005 », - // « A0491 » — même format que les codes de la base FF. - const qlikSupplierFields = [ - (process.env.QLIK_FIELD_CODE_FOURNISSEUR ?? "").trim(), - "code_fournisseur", "Fournisseur", "fournisseur_code_centrale", - ].filter((c, i, arr) => c && arr.indexOf(c) === i); - /** Part des codes demandés que la sélection fournisseur doit couvrir pour être retenue. */ - const qlikSupplierMinCoverage = Math.min(1, Math.max(0.5, Number(process.env.QLIK_SUPPLIER_MIN_COVERAGE ?? "0.99") || 0.99)); - - const qlikDateFields = [ - (process.env.QLIK_DATE_FIELD ?? "").trim(), - "Date", "Date calendrier", "Date_Key", - ].filter((c, i, arr) => c && arr.indexOf(c) === i); - const yearN = new Date().getFullYear(); - console.log(`[qlik-pw] tuning settle=${qlikSettleMs}ms code15Attempts=${qlikCode15MaxAttempts} batchFallbacks=${qlikArticleBatchFallbacks.join(">")} selectAllCodes=${qlikSelectAllCodes} monthDim=${qlikMonthDim} useExpr=${qlikUseExpr}`); - if (qlikUseExpr) { - console.log(`[qlik-pw] expressions mensuelles: qte="${qlikExprQte}" ca="${qlikExprCa}" nbMag="${qlikExprNbMag}" marge="${qlikExprMarge}"`); - console.log(`[qlik-pw] champs date candidats: ${JSON.stringify(qlikDateFields)}`); - } - - const browser = await getBrowser(); - console.log(`[qlik-pw] chromium lancé`); - const ctx = await browser.newContext({ - httpCredentials: { username: cfg.user, password: cfg.password, origin: `https://${cfg.host}` }, - ignoreHTTPSErrors: true, - }); - await ctx.addCookies([{ name: cookieName, value: cookieValue, domain: cfg.host, path: "/", secure: true }]); - - try { - const page = await ctx.newPage(); - page.on("console", (msg) => console.log(`[qlik-pw][page] ${msg.text()}`)); - page.on("pageerror", (e) => console.log(`[qlik-pw][pageerror] ${e.message}`)); - - // Capture le qlik-csrf-token depuis le websocket que le client Qlik ouvre - let csrfToken: string | null = null; - let resolveToken: (() => void) | null = null; - const tokenReady = new Promise((r) => { resolveToken = r; }); - page.on("websocket", (ws) => { - const m = ws.url().match(/qlik-csrf-token=([^&]+)/); - if (m && !csrfToken) { csrfToken = decodeURIComponent(m[1]); resolveToken?.(); } - }); - - await page.goto(`https://${cfg.host}/sense/app/${cfg.appNetwork}`, { waitUntil: "domcontentloaded", timeout: cfg.timeoutMs }) - .catch(() => { /* le client ouvre son ws ensuite */ }); - console.log(`[qlik-pw] page chargée, attente du qlik-csrf-token…`); - await Promise.race([tokenReady, page.waitForTimeout(15000)]); - if (!csrfToken) throw new Error("[qlik-pw] qlik-csrf-token introuvable (client Qlik non chargé ?)"); - const jeton: string = csrfToken; - console.log(`[qlik-pw] csrf-token capturé, ouverture du websocket Engine in-page…`); - - // Pré-calcul des mois côté Node (pour pouvoir logger X/Y et propager au script in-page). - const monthRanges: QlikDateFilter[] = dateFilter ? getMonthRanges(dateFilter) : []; - const monthlyPayload = monthRanges.map((m) => ({ - label: m.label, - dateDebut: m.dateDebut, - dateFin: m.dateFin, - serials: m.dailySerials, // ~30 valeurs → SelectValues en une fois - // Même fenêtre au format entier AAAAMMJJ : certains modèles Qlik - // n'exposent la date que par une clé numérique (`Date_Key`), pas par - // un champ date sérialisé. - ymd: m.dailySerials.map((serial) => { - const d = new Date(Date.UTC(1899, 11, 30) + serial * 86_400_000); - return d.getUTCFullYear() * 10000 + (d.getUTCMonth() + 1) * 100 + d.getUTCDate(); - }), - })); - - // Point de contrôle : le script in-page pousse ses résultats intermédiaires - // à chaque passe. Si la session Qlik meurt en cours de route (« Socket - // closed », « Execution context was destroyed »), on repart de là au lieu - // de tout perdre — les codes déjà traités ont des données complètes, la - // dimension Mois livrant tous leurs mois d'un coup. - let checkpoint: { rows?: Array>; monthly?: InPageResult["monthly"] } | null = null; - await page.exposeFunction("cfCheckpoint", (payload: { rows?: Array>; monthly?: InPageResult["monthly"] }) => { - checkpoint = payload; - return true; - }); - - const evaluer = () => page.evaluate( - ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN, useExpr, exprQte, exprCa, exprNbMag, exprMarge, champsDateCandidats, fournisseurCode, champsFournisseurCandidats, couvertureFournisseurMin }: { - app: string; dim: string; mca: string; mqte: string; mnb: string; mcamag: string; mmarge: string; - codes: string[]; token: string; - monthlyPayload: Array<{ label: string; dateDebut: string; dateFin: string; serials: number[]; ymd: number[] }>; - noDateMode: boolean; - qlikSettleMs: number; - qlikCode15MaxAttempts: number; - qlikArticleBatchFallbacks: number[]; - selectAllCodes: boolean; - monthDim: boolean; - moisDimId: string; - mqteComp: string; - yearN: number; - useExpr: boolean; - exprQte: string; - exprCa: string; - exprNbMag: string; - exprMarge: string; - champsDateCandidats: string[]; - fournisseurCode: string; - champsFournisseurCandidats: string[]; - couvertureFournisseurMin: number; - }) => - new Promise((resolve) => { - const loc = (window as unknown as { location: Location }).location; - const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`; - const ws = new WebSocket(url); - let id = 0; - const pend = new Map void; rej: (e: unknown) => void }>(); - const rpc = (method: string, params: unknown, handle = -1) => new Promise<{ [k: string]: unknown }>((res, rej) => { - const i = ++id; pend.set(i, { res, rej }); - ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params })); - }); - const ready = new Promise((res, rej) => { - ws.onmessage = (ev: MessageEvent) => { - const m = JSON.parse(ev.data as string); - if (m.method === "OnConnected") return res(); - if (m.id && pend.has(m.id)) { - const x = pend.get(m.id)!; - pend.delete(m.id); - if (m.error) x.rej(new Error(JSON.stringify(m.error))); - else x.res(m.result); - } - }; - ws.onerror = () => rej(new Error("ws error (403 ?)")); - setTimeout(() => rej(new Error("ws timeout")), 30000); - }); - // Qlik Engine code 15 = "Request aborted" : déclenché quand une requête - // (GetLayout / GetHyperCubeData / CreateSessionObject) entre en collision - // avec une évaluation de sélection encore en cours côté Engine. On retente - // quelques fois avec un court backoff et un log explicite de l'étape. - const isEngineCode15 = (e: unknown): boolean => { - const msg = String((e as Error)?.message ?? e); - return /"code"\s*:\s*15\b/.test(msg); - }; - const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - const nowMs = () => (typeof performance !== "undefined" ? performance.now() : Date.now()); - const timed = async (etape: string, method: string, fn: () => Promise, timings?: Record): Promise<{ value: T; ms: number }> => { - const start = nowMs(); - const value = await fn(); - const ms = Math.round(nowMs() - start); - const label = `${etape}:${method}`; - if (timings) timings[label] = (timings[label] ?? 0) + ms; - console.log(`[qlik-pw][timing] ${label} ms=${ms}`); - return { value, ms }; - }; - const timingSummary = (timings: Record) => JSON.stringify(timings); - const rpcWithRetry = async ( - etape: string, - method: string, - params: unknown, - handle: number, - onCode15Retry?: () => void, - ): Promise<{ [k: string]: unknown }> => { - let lastErr: unknown; - for (let attempt = 1; attempt <= qlikCode15MaxAttempts; attempt++) { - try { - return await rpc(method, params, handle); - } catch (e) { - lastErr = e; - if (!isEngineCode15(e) || attempt === qlikCode15MaxAttempts) throw e; - onCode15Retry?.(); - const backoff = qlikSettleMs * attempt; - console.log(`[qlik-pw] retry code15 étape=${etape} méthode=${method} tentative=${attempt}/${qlikCode15MaxAttempts} backoff=${backoff}ms`); - await sleep(backoff); - } - } - throw lastErr; - }; - - // Accumulateurs déclarés au niveau de l'IIFE (et non dans le `try`) : - // le `catch` doit pouvoir remonter ce qui a déjà été extrait. - const out: Array> = []; - // Quantité réseau par code central et par mois (label "YYYY-MM"). - // Accumulée en parallèle du total, sans toucher l'agrégation existante. - const monthlyByCode: Record> = {}; - // Diagnostics clés, RÉPÉTÉS en fin de sync : le log d'une sync fait - // des milliers de lignes de timings, et ce qui explique un échec se - // trouve tout au début — là où personne ne va le chercher. - const diagnostics: string[] = []; - const diag = (msg: string) => { diagnostics.push(msg); console.log("[qlik-pw][expr] " + msg); }; - - (async () => { - try { - await ready; - console.log("ws connecté (OnConnected)"); - const open = await rpc("OpenDoc", { qDocName: app }); - const doc = (open.qReturn as { qHandle: number }).qHandle; - console.log("OpenDoc OK handle=" + doc); - - // Handles des champs Date / Article Code. - let dfh = -1; - let fh = -1; - let yfh = -1; - /** - * Handle du CHAMP « Période ». - * - * ⚠️ `Clear` et `SelectValues` sont des méthodes de **champ**. Les - * appeler sur un objet de session renvoie - * `{"code":-32601,"parameter":"Clear","message":"Method not found"}`, - * ce qui a fait avorter en production la cartographie des périodes ET - * le chemin d'extraction par date brute. Le list object ne sert donc - * plus qu'à ÉNUMÉRER les valeurs ; les sélections passent par ici. - */ - let pfh = -1; - const df = await rpc("GetField", { qFieldName: "Date" }, doc); - dfh = (df.qReturn as { qHandle: number }).qHandle; - try { - const yf = await rpc("GetField", { qFieldName: "Année" }, doc); - yfh = (yf.qReturn as { qHandle: number }).qHandle; - } catch { yfh = -1; } - try { - const pf = await rpc("GetField", { qFieldName: "Période" }, doc); - pfh = (pf.qReturn as { qHandle: number }).qHandle; - } catch { pfh = -1; } - if (codes.length) { - const gf = await rpc("GetField", { qFieldName: "Article Code" }, doc); - fh = (gf.qReturn as { qHandle: number }).qHandle; - } - - // Résoudre les master measures par TITRE (le qLibraryId fiable = qInfo.qId, ≠ GUID QRS). - const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase(); - const ml = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } } } }, doc); - const mll = await rpc("GetLayout", {}, (ml.qReturn as { qHandle: number }).qHandle); - const items = (((mll.qLayout as { qMeasureList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string; expr?: string } }> } }).qMeasureList?.qItems) ?? []); - const byTitle = new Map(); - for (const it of items) { const t = it.qMeta?.title ?? it.qData?.title ?? ""; if (t) byTitle.set(norm(t), it.qInfo.qId); } - const pickMeasure = (title: string, fallback: string) => byTitle.get(norm(title)) ?? fallback; - const rCamag = pickMeasure("CA par Magasin N", mcamag); - const rMarge = pickMeasure("Marge % N", mmarge); - // Résolution par TITRE aussi pour CA / Quantité / Nb magasins : évite tout - // décalage si un qId d'env ne pointe pas sur la mesure attendue. - const rCa = pickMeasure("CA N", mca); - const rQte = pickMeasure("Quantité N", mqte); - const rNbMag = pickMeasure("Magasin Ventes Nb N", mnb); - console.log("[qlik-pw] mesures utilisées: " + JSON.stringify({ - "CA N": rCa, "Quantité N": rQte, "Magasin Ventes Nb N": rNbMag, - "CA par Magasin N": rCamag, "Marge % N": rMarge, - env: { mca, mqte, mnb }, - })); - console.log("measures résolues par titre: " + JSON.stringify({ rCamag, rMarge }) + " (sur " + items.length + " mesures)"); - // DUMP diagnostic : inventaire des mesures + dimensions (titre + id) pour - // identifier une mesure "Quantité" NON annuelle et/ou une dimension "Mois/Période" - // permettant un vrai découpage mensuel du réseau. - // L'EXPRESSION est indispensable au diagnostic : c'est elle qui dit - // si la mesure est bornée à une année (et comment), donc pourquoi des - // mois de la fenêtre glissante peuvent ressortir vides. - console.log("[qlik-pw][dump] MESURES (" + items.length + "): " + JSON.stringify(items.map((it) => ({ t: it.qMeta?.title ?? it.qData?.title ?? "", id: it.qInfo.qId })))); - for (const it of items) { - const titre = it.qMeta?.title ?? it.qData?.title ?? ""; - if (/^(CA N|Quantité N|Magasin Ventes Nb N|CA par Magasin N|Marge % N|Quantité COMP)$/i.test(titre.trim())) { - console.log("[qlik-pw][dump] expression « " + titre + " » = " + (it.qData?.expr ?? "?")); - } - } - try { - const dl = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "DimensionList" }, qDimensionListDef: { qType: "dimension", qData: { title: "/qMetaDef/title" } } } }, doc); - const dll = await rpc("GetLayout", {}, (dl.qReturn as { qHandle: number }).qHandle); - const ditems = (((dll.qLayout as { qDimensionList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string } }> } }).qDimensionList?.qItems) ?? []); - console.log("[qlik-pw][dump] DIMENSIONS (" + ditems.length + "): " + JSON.stringify(ditems.map((it) => ({ t: it.qMeta?.title ?? it.qData?.title ?? "", id: it.qInfo.qId })))); - } catch (e) { console.log("[qlik-pw][dump] DimensionList error: " + String((e as Error)?.message || e)); } - // L'hypercube session object est désormais créé JETABLE dans fetchCubeForSelection - // (un objet par lot) puis DestroySessionObject — corrige "Request aborted" (code 15) - // causé par la réutilisation du même objet sur des centaines de recalculs. - - // (déclaré au niveau de l'IIFE — voir plus haut) - // (monthlyByCode est déclaré au niveau de l'IIFE — voir plus haut) - const accMonthly = (batchRows: Array>, monthLabel: string) => { - for (const r of batchRows) { - const c = String(r[0] ?? "").trim(); - if (!c || c === "-") continue; - const q = Number(r[2]) || 0; - (monthlyByCode[c] ??= {}); - const cur = monthlyByCode[c][monthLabel]; - monthlyByCode[c][monthLabel] = { - qte: (cur?.qte ?? 0) + q, - ca: (cur?.ca ?? 0) + (Number(r[1]) || 0), - nbMag: Math.max(cur?.nbMag ?? 0, Number(r[3]) || 0), - caMag: Number(r[4]) || 0, - margePct: Number(r[5]) || 0, - }; - } - }; - // Compteurs agrégés pour le résumé timing final (loggué côté Node après la sync). - let aggMonths = 0; - let aggBatches = 0; - let aggCode15Retries = 0; - let aggFallbackCount = 0; - - // Sélectionne Date + Article Code puis pagine le cube. Renvoie le nb de lignes - // brutes lues (avant dédoublonnage côté Node). - // - // IMPORTANT : crée un hypercube session object JETABLE par lot (Date + Article Code - // déjà sélectionnés) puis le détruit. Réutiliser le même objet sur des centaines de - // lots force Qlik à recalculer en boucle et finit par "Request aborted" (code 15). - const PAGE = 1400; - // Petite latence après chaque mutation de sélection : laisser l'Engine - // finaliser l'évaluation de la sélection avant la prochaine requête layout/cube. - const createCubeForBatch = async (timings?: Record, onCode15Retry?: () => void): Promise<{ handle: number; id: string }> => { - const { value: obj } = await timed("createCube", "CreateSessionObject", () => rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net" }, qHyperCubeDef: { - qDimensions: [{ qLibraryId: dim }], - qMeasures: [{ qLibraryId: rCa }, { qLibraryId: rQte }, { qLibraryId: rNbMag }, { qLibraryId: rCamag }, { qLibraryId: rMarge }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 6, qHeight: PAGE }], - } } }, doc, onCode15Retry), timings); - const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - return { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; - }; - const destroyCube = async (cube: { handle: number; id: string }, timings?: Record) => { - if (!cube.id) return; - try { - await timed("destroyCube", "DestroySessionObject", () => rpcWithRetry("destroyCube", "DestroySessionObject", { qId: cube.id }, doc), timings); - } catch (e) { - // Non fatal : l'objet est de toute façon scopé à la session websocket. - // Un code 15 ici signifie que l'Engine l'a déjà libéré après un abort. - console.log("[qlik-pw] DestroySessionObject ignoré: " + String((e as Error)?.message || e)); - } - }; - const appendMatrix = (matrix: Array>, sink: Array>): number => { - for (const r of matrix) sink.push([ - String(r[0]?.qText ?? ""), - Number(r[1]?.qNum) || 0, - Number(r[2]?.qNum) || 0, - Number(r[3]?.qNum) || 0, - Number(r[4]?.qNum) || 0, - Number(r[5]?.qNum) || 0, - ]); - return matrix.length; - }; - const fetchCubeForSelection = async (sink: Array>, timings?: Record, onCode15Retry?: () => void): Promise => { - const cube = await createCubeForBatch(timings, onCode15Retry); - const oh = cube.handle; - try { - const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings); - const hyperCube = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array> }> } }).qHyperCube; - const size = hyperCube.qSize.qcy; - const firstPage = hyperCube.qDataPages?.find((page) => (page.qArea?.qTop ?? 0) === 0); - let got = 0; - let top = 0; - if (firstPage?.qMatrix?.length) { - got += appendMatrix(firstPage.qMatrix, sink); - top = PAGE; - } - for (; top < size; top += PAGE) { - const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: PAGE }] }, oh, onCode15Retry), timings); - const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; - if (!matrix.length) break; - got += appendMatrix(matrix, sink); - if (matrix.length < PAGE) break; - } - return got; - } finally { - await destroyCube(cube, timings); - } - }; - - // ─── Découpage mensuel réel via dimension "Mois" ──────────────────── - // Cube [Article Code, Mois] × 5 mesures. Chaque ligne = (code, mois, ca, - // qte, nbMag, caMag, marge). Le "Mois" est au format "YYYYMM" → "YYYY-MM". - const normMois = (m: string): string => (/^\d{6}$/.test(m) ? m.slice(0, 4) + "-" + m.slice(4) : m); - const PAGEM = 1200; // 8 colonnes × 1200 = 9600 cellules < 10000 (limite Qlik) - const fetchMonthCube = async ( - onRow: (code: string, mois: string, ca: number, qte: number, nbMag: number, caMag: number, marge: number, qteComp: number) => void, - timings?: Record, - onCode15Retry?: () => void, - ): Promise => { - const { value: obj } = await timed("createCube", "CreateSessionObject", () => rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois" }, qHyperCubeDef: { - qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }], - qMeasures: [{ qLibraryId: rCa }, { qLibraryId: rQte }, { qLibraryId: rNbMag }, { qLibraryId: rCamag }, { qLibraryId: rMarge }, { qLibraryId: mqteComp }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 8, qHeight: PAGEM }], - } } }, doc, onCode15Retry), timings); - const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; - const oh = cube.handle; - const process = (matrix: Array>): number => { - for (const r of matrix) { - onRow( - String(r[0]?.qText ?? ""), String(r[1]?.qText ?? ""), - Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0, Number(r[4]?.qNum) || 0, - Number(r[5]?.qNum) || 0, Number(r[6]?.qNum) || 0, Number(r[7]?.qNum) || 0, - ); - } - return matrix.length; - }; - try { - const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings); - const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array> }> } }).qHyperCube; - const size = hc.qSize.qcy; - let got = 0, top = 0; - const firstPage = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0); - if (firstPage?.qMatrix?.length) { got += process(firstPage.qMatrix); top = PAGEM; } - for (; top < size; top += PAGEM) { - const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 8, qHeight: PAGEM }] }, oh, onCode15Retry), timings); - const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; - if (!matrix.length) break; - got += process(matrix); - if (matrix.length < PAGEM) break; - } - return got; - } finally { - await destroyCube(cube, timings); - } - }; - - // ─── Cube mensuel par EXPRESSIONS (respecte la sélection Date) ───── - // - // Constat de production : les master measures « CA N » / « Quantité N » - // **ignorent la sélection Date**. Vérifié arithmétiquement : avec août - // 2025 seul sélectionné, le cube renvoyait 164 unités / 2 220,75 € — - // soit exactement la somme de janvier à juillet 2026. Ce sont des - // mesures « cumul année en cours », pas des mesures de période. - // - // Conséquences : les totaux réseau étaient un cumul année en cours - // (mois courant partiel inclus) et non 12 mois glissants, les mois de - // l'année précédente non couverts par « Quantité COMP » restaient - // vides, et la passe de rattrapage y recopiait le total de période. - // - // Aucune sélection ne peut corriger cela : on agrège donc directement - // les champs de faits, qui eux respectent les sélections. - const PAGEX = 1200; // 7 colonnes × 1200 = 8400 cellules < 10000 - const fetchMonthCubeExpr = async ( - eCa: string, - eQte: string, - eNbMag: string, - eMarge: string, - onRow: (code: string, mois: string, ca: number, qte: number, nbMag: number, marge: number, qteMaster: number) => void, - timings?: Record, - onCode15Retry?: () => void, - /** - * Inclure la master « Quantité N » (colonne de calibrage) ? - * - * ⚠️ Elle ne coûte rien quand elle vit, mais **annule tout le cube** - * quand elle est morte : mesuré en production, 4 expressions valides - * à 6 mois chacune et le cube complet à **0 ligne** après 25 s de - * calcul. L'appelant ne l'ajoute donc qu'après l'avoir sondée. - */ - avecMaster = true, - ): Promise => { - const largeur = avecMaster ? 7 : 6; - const { value: obj } = await timed("createCubeExpr", "CreateSessionObject", () => rpcWithRetry("createCubeExpr", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois-expr" }, qHyperCubeDef: { - qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }], - qMeasures: [ - { qDef: { qDef: eCa } }, - { qDef: { qDef: eQte } }, - { qDef: { qDef: eNbMag } }, - { qDef: { qDef: eMarge } }, - // Master « Quantité N », uniquement pour le calibrage : sur les - // mois de l'année en cours les deux doivent coïncider. - ...(avecMaster ? [{ qLibraryId: rQte }] : []), - ], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: PAGEX }], - } } }, doc, onCode15Retry), timings); - const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; - const oh = cube.handle; - const process = (matrix: Array>): number => { - for (const r of matrix) { - onRow( - String(r[0]?.qText ?? ""), String(r[1]?.qText ?? ""), - Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0, - Number(r[4]?.qNum) || 0, Number(r[5]?.qNum) || 0, - avecMaster ? Number(r[6]?.qNum) || 0 : 0, - ); - } - return matrix.length; - }; - try { - const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings); - const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array> }> } }).qHyperCube; - const size = hc.qSize.qcy; - let got = 0, top = 0; - const firstPage = hc.qDataPages?.find((pg) => (pg.qArea?.qTop ?? 0) === 0); - if (firstPage?.qMatrix?.length) { got += process(firstPage.qMatrix); top = PAGEX; } - for (; top < size; top += PAGEX) { - const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: largeur, qHeight: PAGEX }] }, oh, onCode15Retry), timings); - const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; - if (!matrix.length) break; - got += process(matrix); - if (matrix.length < PAGEX) break; - } - return got; - } finally { - await destroyCube(cube, timings); - } - }; - - /** - * Passe unique par expressions : sélectionne la fenêtre 12 mois et tous - * les codes, puis lit un seul cube [Article Code, Mois]. - * - * Renvoie false si le résultat est inexploitable (expression invalide, - * champ absent) — l'appelant repart alors sur les master measures. - */ - /** - * Total de contrôle : un cube 1 cellule, sans dimension, sur - * l'expression quantité. Sert à savoir si une sélection de dates - * FILTRE réellement les faits — ce que les master measures, qui - * l'ignorent, ne permettaient pas de vérifier. - */ - const totalDeControle = async (): Promise => { - const o = await rpc("CreateSessionObject", { qProp: { - qInfo: { qType: "cf-ctrl" }, - qHyperCubeDef: { - qDimensions: [], - qMeasures: [{ qDef: { qDef: exprQte } }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 1 }], - }, - } }, doc); - const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - try { - const lay = await rpc("GetLayout", {}, ret.qHandle); - const cell = (lay.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }) - .qHyperCube?.qDataPages?.[0]?.qMatrix?.[0]?.[0]; - return Number(cell?.qNum) || 0; - } finally { - const id = String(ret.qGenericId ?? ret.qId ?? ""); - if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } - } - }; - - /** - * Sélectionne la fenêtre sur un champ date candidat. - * - * Renvoie le handle pour que l'appelant puisse impérativement - * effacer une sélection rejetée avant d'essayer le candidat - * suivant. Sans ce Clear, un premier champ qui ne matche aucune - * date maintient l'ensemble courant à zéro et condamne tous les - * essais suivants à renvoyer zéro eux aussi. - */ - const selectionnerFenetreSur = async (champ: string, valeurs: number[]): Promise => { - let h: number; - try { - const gf = await rpc("GetField", { qFieldName: champ }, doc); - h = (gf.qReturn as { qHandle: number }).qHandle; - if (typeof h !== "number" || h < 0) return null; - } catch { return null; } - await rpc("Clear", {}, h); - await sleep(qlikSettleMs); - for (let i = 0; i < valeurs.length; i += 500) { - await rpc("SelectValues", { - qFieldValues: valeurs.slice(i, i + 500).map((v) => ({ qNum: v, qText: String(v) })), - qToggleMode: i > 0, - qSoftLock: true, - }, h); - } - await sleep(qlikSettleMs); - return h; - }; - - /** - * Sélectionne les 12 mois via la dimension maître « Mois ». - * - * Dans l'app Magasins Vision Consolidée, les champs Date exposés - * existent mais sont dissociés des faits de vente : leur sélection - * laisse Sum(quantite) à 100 %. La dimension maître utilisée par le - * cube, elle, porte les vraies valeurs associées. Un list object - * permet de sélectionner ses qElemNumber sans connaître le nom du - * champ sous-jacent ni le format dual exact. - */ - const selectionnerFenetreViaDimensionMois = async ( - moisAttendus: Set, - ): Promise<{ total: number; trouves: string[] } | null> => { - let objet: { handle: number; id: string } | null = null; - try { - const cree = await rpcWithRetry("createMonthList", "CreateSessionObject", { - qProp: { - qInfo: { qType: "cf-month-list" }, - qListObjectDef: { - qLibraryId: moisDimId, - qShowAlternatives: true, - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 100 }], - }, - }, - }, doc); - const ret = cree.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - objet = { - handle: ret.qHandle, - id: String(ret.qGenericId ?? ret.qId ?? ""), - }; - - const layout = await rpcWithRetry("layoutMonthList", "GetLayout", {}, objet.handle); - const lo = (layout.qLayout as { - qListObject?: { - qSize?: { qcy?: number }; - qDataPages?: Array<{ - qArea?: { qTop?: number }; - qMatrix?: Array>; - }>; - }; - }).qListObject; - const size = Number(lo?.qSize?.qcy) || 0; - const cellules: Array<{ qText?: string; qElemNumber?: number }> = []; - for (const page of lo?.qDataPages ?? []) { - for (const row of page.qMatrix ?? []) if (row[0]) cellules.push(row[0]); - } - for (let top = cellules.length; top < size; top += 500) { - const data = await rpcWithRetry("monthListData", "GetListObjectData", { - qPath: "/qListObjectDef", - qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(500, size - top) }], - }, objet.handle); - const matrix = (data.qDataPages as Array<{ - qMatrix?: Array>; - }>)?.[0]?.qMatrix ?? []; - for (const row of matrix) if (row[0]) cellules.push(row[0]); - if (matrix.length === 0) break; - } - - const parMois = new Map(); - for (const cellule of cellules) { - const mois = normMois(String(cellule.qText ?? "").trim()); - const elem = Number(cellule.qElemNumber); - if (moisAttendus.has(mois) && Number.isInteger(elem) && elem >= 0) { - parMois.set(mois, elem); - } - } - const trouves = [...parMois.keys()].sort(); - console.log( - "[qlik-pw][expr] dimension Mois : " + trouves.length + "/" + moisAttendus.size + - " valeurs trouvées " + JSON.stringify(trouves), - ); - if (trouves.length !== moisAttendus.size) return null; - - const selection = await rpcWithRetry("selectMonthList", "SelectListObjectValues", { - qPath: "/qListObjectDef", - qValues: [...parMois.values()], - qToggleMode: false, - qSoftLock: true, - }, objet.handle); - console.log("[qlik-pw][expr] sélection dimension Mois → " + JSON.stringify(selection.qReturn)); - await sleep(qlikSettleMs); - const total = await totalDeControle(); - return { total, trouves }; - } catch (e) { - console.log("[qlik-pw][expr] sélection dimension Mois impossible : " + String((e as Error)?.message || e)); - return null; - } finally { - if (objet?.id) { - try { - await rpc("DestroySessionObject", { qId: objet.id }, doc); - } catch { /* objet limité à la session */ } - } - } - }; - - /** - * Cale le champ `Période` du modèle sur la fenêtre 12 mois glissants. - * - * L'app n'est pas un modèle « faits + calendrier » classique : TOUTES - * ses mesures sont bornées par un type de calendrier — - * - * « Quantité N » = Sum({} quantite) - * « Quantité COMP » = Sum({} quantite) - * - * `Type_Cal` marque les lignes de la période analysée (`N`) et celles - * de la période de comparaison (`COMP`), et c'est le champ `Période` - * (4 valeurs, une sélectionnée) qui décide de quelle période il s'agit. - * - * Cela explique tout ce qu'on observait : - * - sélectionner `Date` ne change RIEN (le périmètre vient du pont de - * périodes, pas du champ date) — mesuré : 100 % du total avant comme - * après ; - * - « Quantité N » ne renvoie que les mois de la période courante - * (janvier→juillet avec « année en cours »), d'où août→décembre - * vides ; - * - « Quantité COMP » ne couvre que les mois ayant un comparable dans - * cette période, d'où janvier→juillet de l'année précédente ; - * - `Sum(quantite)` brut mélange les lignes N et COMP : il double - * compte, et ne peut pas se substituer aux mesures. - * - * La solution n'est donc pas de contourner le modèle mais de le - * piloter : on essaie chaque valeur de `Période`, on mesure combien de - * mois de la fenêtre elle rend réellement disponibles, et on garde la - * meilleure. Aucun libellé n'est deviné — c'est la couverture mesurée - * qui décide. - */ - /** - * Vrai quand le périmètre articles est posé par une sélection - * fournisseur : dans ce cas, plus aucun `SelectValues` sur - * « Article Code » n'est émis (ni par passe annuelle, ni par - * l'agrégation directe) — c'est tout l'intérêt de la bascule. - */ - let parFournisseur = false; - - /** - * Sélectionne LE fournisseur plutôt que ses dizaines de milliers de - * codes articles. - * - * `SelectValues` sur « Article Code » coûte 8 à 100 s **par appel**, - * quasi indépendamment du nombre de valeurs — le moteur balaie un - * symbole de plus d'un million d'entrées — et il faut le refaire à - * chaque passe annuelle. Une valeur de fournisseur produit le même - * périmètre en un appel trivial. - * - * La bascule n'est retenue QUE si la sélection couvre les codes - * demandés : un article rattaché à plusieurs fournisseurs, ou un - * référencement Qlik différent du référencement FF, doit faire - * revenir à la sélection par codes plutôt que perdre des articles. - */ - const selectionnerParFournisseur = async (): Promise => { - if (!fournisseurCode || codes.length === 0) return false; - const attendus = new Set(codes.map((c) => c.trim())); - /** - * Au-delà de ce nombre de codes visibles, la sélection fournisseur - * ne restreint rien (elle laisse tout le catalogue) : inutile de - * finir la lecture, la réponse est déjà connue. - */ - const plafond = attendus.size * 3; - - /** Codes articles visibles sous la sélection courante. */ - const lireCodesVisibles = async (): Promise> => { - const vus = new Set(); - const o = await rpc("CreateSessionObject", { qProp: { - qInfo: { qType: "cf-codes-fou" }, - qHyperCubeDef: { - qDimensions: [{ qLibraryId: dim, qNullSuppression: true }], - qMeasures: [], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 5000 }], - }, - } }, doc); - const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - try { - const lay = await rpc("GetLayout", {}, ret.qHandle); - const hc = (lay.qLayout as { qHyperCube?: { qSize?: { qcy?: number }; qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; - const total = Number(hc?.qSize?.qcy) || 0; - for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) { - const c = String(r[0]?.qText ?? "").trim(); - if (c && c !== "-") vus.add(c); - } - for (let top = vus.size ? 5000 : 0; top < total; top += 5000) { - const d = await rpc("GetHyperCubeData", { - qPath: "/qHyperCubeDef", - qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(5000, total - top) }], - }, ret.qHandle); - const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; - if (matrix.length === 0) break; - for (const r of matrix) { - const c = String(r[0]?.qText ?? "").trim(); - if (c && c !== "-") vus.add(c); - } - // Inutile de lire tout le catalogue pour conclure : au-delà - // du plafond, la sélection ne restreint manifestement rien. - // Mesuré : 1 207 205 codes lus en 240 pages, ×3 champs = - // 87 s perdues avant un rejet joué d'avance. - if (vus.size > plafond) break; - } - return vus; - } finally { - const id = String(ret.qGenericId ?? ret.qId ?? ""); - if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } - } - }; - - for (const champ of champsFournisseurCandidats) { - let h = -1; - try { - const gf = await rpc("GetField", { qFieldName: champ }, doc); - h = (gf.qReturn as { qHandle: number }).qHandle; - if (typeof h !== "number" || h < 0) continue; - } catch { continue; } - - await rpc("Clear", {}, h); - await sleep(qlikSettleMs); - const sel = await rpc("SelectValues", { - qFieldValues: [{ qText: fournisseurCode }], - qToggleMode: false, - qSoftLock: true, - }, h); - await sleep(qlikSettleMs); - if (sel.qReturn === false) { - diag(" champ « " + champ + " » : valeur « " + fournisseurCode + " » inconnue"); - await rpc("Clear", {}, h); - continue; - } - - const visibles = await lireCodesVisibles(); - // Un vrai périmètre fournisseur est de l'ordre de grandeur des - // codes demandés. Mesuré en production : « J009 » laissait - // 1 207 205 articles visibles, soit tout le catalogue — la - // sélection n'avait rien restreint, et la « couverture » de 97 % - // ne faisait que constater que le catalogue contient nos codes. - if (visibles.size > plafond) { - diag( - " champ « " + champ + " » = « " + fournisseurCode + " » → plus de " + plafond + - " articles visibles pour " + attendus.size + " demandés : la sélection ne restreint rien, ignorée", - ); - await rpc("Clear", {}, h); - await sleep(qlikSettleMs); - continue; - } - let couverts = 0; - for (const c of attendus) if (visibles.has(c)) couverts++; - const couverture = attendus.size > 0 ? couverts / attendus.size : 0; - diag( - " champ « " + champ + " » = « " + fournisseurCode + " » → " + visibles.size + - " articles Qlik, couvre " + couverts + "/" + attendus.size + - " codes demandés (" + Math.round(couverture * 100) + "%)", - ); - if (couverture >= couvertureFournisseurMin) { - diag("sélection PAR FOURNISSEUR retenue sur « " + champ + " » — " + attendus.size + " SelectValues économisés par passe"); - return true; - } - await rpc("Clear", {}, h); - await sleep(qlikSettleMs); - } - diag("aucun champ fournisseur ne couvre les codes demandés → sélection par codes articles"); - return false; - }; - - /** Valeur de `Période` à poser avant la passe d'une année donnée. */ - const periodeParAnnee = new Map(); - - /** - * Pose (ou efface, si `texte` est nul) la sélection `Période`. - * - * Passe par le HANDLE DU CHAMP, comme pour `Année` et `Date` : c'est la - * seule API qui expose `Clear` et `SelectValues`. Ne propage jamais - * d'erreur — une `Période` qu'on n'arrive pas à poser doit dégrader le - * résultat, pas interrompre l'extraction (leçon de production : un - * `Clear` en échec a fait avorter deux chemins entiers). - */ - const appliquerPeriode = async (texte: string | null): Promise => { - if (pfh === -1) return texte == null; - try { - await rpc("Clear", {}, pfh); - await sleep(qlikSettleMs); - if (texte == null) return true; - const sel = await rpc("SelectValues", { - qFieldValues: [{ qText: texte }], - qToggleMode: false, - qSoftLock: true, - }, pfh); - await sleep(qlikSettleMs); - return sel.qReturn !== false; - } catch (e) { - diag(" sélection Période « " + (texte ?? "(aucune)") + " » impossible : " + String((e as Error)?.message || e)); - return false; - } - }; - - /** - * Choisit la valeur de `Période` sous laquelle l'extraction aura lieu. - * - * CE QUE DIT LA MESURE (production, 2026-08-01, sonde paginée) : - * - * Période « (aucune) » → master exposée : 2024-01…2026-07 (31 mois) - * - * Sans aucune `Période` sélectionnée, « Quantité N » couvre **31 mois**, - * et une seule passe (`Année=2026`) sert les 12 mois de la fenêtre. - * L'état libre est donc le bon — encore fallait-il pouvoir le voir. - * - * Deux erreurs successives l'avaient masqué, toutes deux dans l'outil - * de mesure et non dans le modèle Qlik : - * 1. la sonde ne lisait que les 60 premières lignes du cube `[Mois]`, - * donc jamais les mois récents → « 0/12 » pour toute valeur ; - * 2. `Clear` était appelée sur le list object au lieu du champ, ce qui - * faisait avorter la cartographie entière avant son premier essai. - * - * D'où le principe retenu ici : **ne rien présumer, tout mesurer**. La - * couverture est évaluée par année et dans le contexte exact où elle - * sera exploitée (`monthDimPath` pose la même valeur), l'état sans - * `Période` étant un candidat comme un autre — et, en l'occurrence, le - * gagnant. - */ - const choisirPeriode = async (annees: number[]): Promise => { - const fenetre = new Set(monthlyPayload.map((m) => m.label)); - let lo: { qHandle: number; qGenericId?: string; qId?: string } | null = null; - try { - const obj = await rpc("CreateSessionObject", { qProp: { - qInfo: { qType: "cf-periodes" }, - qListObjectDef: { - qDef: { qFieldDefs: ["Période"] }, - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 50 }], - }, - } }, doc); - lo = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const lay = await rpc("GetLayout", {}, lo.qHandle); - const matrix = ((lay.qLayout as { qListObject?: { qDataPages?: Array<{ qMatrix?: Array> }> } }) - .qListObject?.qDataPages?.[0]?.qMatrix) ?? []; - const valeurs = matrix - .map((r) => ({ texte: String(r[0]?.qText ?? ""), elem: r[0]?.qElemNumber ?? -1 })) - .filter((v) => v.texte && v.elem >= 0); - diag("valeurs de « Période » : " + JSON.stringify(valeurs.map((v) => v.texte))); - if (valeurs.length === 0) return 0; - - /** - * Couverture réelle : quels mois « Quantité N » rend-elle vivants ? - * - * ⚠️ Le cube est PAGINÉ intégralement. La version précédente ne - * lisait que les 60 premières lignes : la dimension Mois porte - * tout l'historique de l'app, les mois récents tombaient hors - * de la première page, et la sonde annonçait 0/12 quelle que - * soit la `Période` — alors que la master vivait (mesuré : - * total 9 721 153). C'est ce faux zéro qui faisait effacer - * `Période` et condamnait toute l'extraction. - * - * Renvoie les mois vivants DE LA FENÊTRE, et journalise aussi - * ce que la dimension expose hors fenêtre : sans cela on ne - * distingue pas « le modèle ne donne rien » de « le modèle - * donne autre chose que ce qu'on attend ». - */ - const mesurerCouverture = async (): Promise<{ fenetre: string[]; tous: string[] }> => { - const sonde = await rpc("CreateSessionObject", { qProp: { - qInfo: { qType: "cf-periode-sonde" }, - qHyperCubeDef: { - qDimensions: [{ qLibraryId: moisDimId }], - qMeasures: [{ qLibraryId: rQte }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 500 }], - }, - } }, doc); - const sRet = sonde.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - try { - const sl = await rpc("GetLayout", {}, sRet.qHandle); - const hc = (sl.qLayout as { qHyperCube?: { qSize?: { qcy?: number }; qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; - const lignes: Array> = [...(hc?.qDataPages?.[0]?.qMatrix ?? [])]; - const total = Number(hc?.qSize?.qcy) || 0; - for (let top = lignes.length; top < total; top += 500) { - const d = await rpc("GetHyperCubeData", { - qPath: "/qHyperCubeDef", - qPages: [{ qTop: top, qLeft: 0, qWidth: 2, qHeight: Math.min(500, total - top) }], - }, sRet.qHandle); - const m = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; - if (m.length === 0) break; - lignes.push(...m); - } - const vivants = lignes - .filter((r) => (Number(r[1]?.qNum) || 0) > 0) - .map((r) => normMois(String(r[0]?.qText ?? ""))) - .sort(); - return { fenetre: vivants.filter((m) => fenetre.has(m)), tous: vivants }; - } finally { - const sid = String(sRet.qGenericId ?? sRet.qId ?? ""); - if (sid) { try { await rpc("DestroySessionObject", { qId: sid }, doc); } catch { /* noop */ } } - } - }; - - /** Résumé compact d'une liste de mois : « 2025-01…2025-12 (12) ». */ - const resume = (mois: string[]) => - mois.length === 0 ? "aucun" : mois[0] + "…" + mois[mois.length - 1] + " (" + mois.length + ")"; - - /** - * Couverture d'une `Période` telle qu'elle sera RÉELLEMENT - * exploitée : une passe par année, comme `monthDimPath`, puis - * union. C'est le seul chiffre qui a un sens ici — mesurer - * sans année revient à juger « Année à date » sur la seule - * année en cours (6/12) et à la rejeter à tort. - */ - // ─── Une `Période` PAR ANNÉE ───────────────────────────── - // - // C'est le point que la version globale manquait : la bonne - // valeur dépend de l'année analysée. « Année à date » convient - // à 2026 (janvier → mois courant) mais pas à 2025, dont on veut - // les 12 mois. On cartographie donc, pour chaque année de la - // fenêtre, la valeur qui sert le PLUS de mois attendus de cette - // année-là — l'état sans `Période` étant un candidat comme un - // autre. - const couvertsGlobal = new Set(); - for (const annee of annees) { - const attendusAnnee = [...fenetre].filter((m) => m.startsWith(String(annee) + "-")); - diag(" année " + annee + " — mois attendus " + JSON.stringify(attendusAnnee.sort())); - - if (yfh !== -1) { - await rpc("Clear", {}, yfh); - await sleep(qlikSettleMs); - await rpc("SelectValues", { - qFieldValues: [{ qNum: annee, qText: String(annee) }], - qToggleMode: false, - qSoftLock: true, - }, yfh); - await sleep(qlikSettleMs); - } - - // `null` = état sans `Période`, candidat comme un autre. - const candidats: Array = [null, ...valeurs.map((v) => v.texte)]; - let meilleure: { texte: string | null; servis: string[] } | null = null; - for (const c of candidats) { - const libelle = c ?? "(aucune)"; - // Un candidat qui échoue ne doit pas emporter toute la - // cartographie : on le note et on passe au suivant. - let servis: string[] = []; - try { - const pose = await appliquerPeriode(c); - if (!pose && c !== null) { - diag(" Période « " + libelle + " » → non sélectionnable, ignorée"); - continue; - } - const mois = await mesurerCouverture(); - servis = mois.fenetre.filter((m) => attendusAnnee.includes(m)); - diag( - " Période « " + libelle + " » → sert " + servis.length + "/" + attendusAnnee.length + - " mois attendus " + JSON.stringify(servis) + " ; master exposée : " + resume(mois.tous), - ); - } catch (e) { - diag(" Période « " + libelle + " » → erreur " + String((e as Error)?.message || e)); - continue; - } - if (!meilleure || servis.length > meilleure.servis.length) { - meilleure = { texte: c, servis }; - } - if (servis.length >= attendusAnnee.length) break; // année complète - } - - if (meilleure) { - periodeParAnnee.set(annee, { texte: meilleure.texte }); - for (const m of meilleure.servis) couvertsGlobal.add(m); - diag( - " → année " + annee + " sera extraite sous Période « " + (meilleure.texte ?? "(aucune)") + " » (" + - meilleure.servis.length + "/" + attendusAnnee.length + " mois)", - ); - } - } - - if (yfh !== -1) { - // Chaque passe pose sa propre année. - await rpc("Clear", {}, yfh); - await sleep(qlikSettleMs); - } - return couvertsGlobal.size; - } catch (e) { - diag("choix de « Période » impossible : " + String((e as Error)?.message || e)); - return 0; - } finally { - // Le list object est conservé jusqu'au bout : le détruire - // annulerait la sélection qu'il porte. - void lo; - } - }; - - /** - * Teste UNE expression isolément sur un petit cube `[Mois]`. - * - * Une seule mesure invalide suffit à faire renvoyer **zéro ligne** à - * tout un hypercube — observé en production : le cube à 5 mesures - * calculait 26 s puis rendait 0 ligne, alors que `Sum(quantite)` seul - * donnait 35 424 324. Impossible de savoir laquelle sans les isoler. - * - * Renvoie l'expression si elle produit des lignes, sinon `"0"` — une - * colonne neutre qui préserve les indices sans casser le cube. - */ - const validerExpression = async (nom: string, expr: string): Promise => { - let ret: { qHandle: number; qGenericId?: string; qId?: string } | null = null; - try { - const o = await rpc("CreateSessionObject", { qProp: { - qInfo: { qType: "cf-test-expr" }, - qHyperCubeDef: { - qDimensions: [{ qLibraryId: moisDimId }], - qMeasures: [{ qDef: { qDef: expr } }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 30 }], - }, - } }, doc); - ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const lay = await rpc("GetLayout", {}, ret.qHandle); - const hc = (lay.qLayout as { qHyperCube?: { qSize?: { qcy?: number }; qError?: unknown; qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; - const matrix = hc?.qDataPages?.[0]?.qMatrix ?? []; - const lignes = Number(hc?.qSize?.qcy) || 0; - let total = 0; - let texteErreur = ""; - for (const r of matrix) { - total += Number(r[1]?.qNum) || 0; - const t = String(r[1]?.qText ?? ""); - if (!texteErreur && /error|erreur|garbage|^-$/i.test(t)) texteErreur = t; - } - if (hc?.qError) texteErreur = texteErreur || JSON.stringify(hc.qError); - if (lignes === 0 || texteErreur) { - diag(" mesure « " + nom + " » = " + expr + " → INVALIDE (" + (texteErreur || "cube vide") + ")"); - return "0"; - } - diag(" mesure « " + nom + " » = " + expr + " → " + lignes + " mois, total=" + Math.round(total)); - return expr; - } catch (e) { - diag(" mesure « " + nom + " » = " + expr + " → ERREUR " + String((e as Error)?.message || e)); - return "0"; - } finally { - const id = String(ret?.qGenericId ?? ret?.qId ?? ""); - if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } - } - }; - - /** - * La master « Quantité N » produit-elle la moindre ligne sur `[Mois]` - * dans l'état de sélection courant ? Même sonde que - * `validerExpression`, mais par `qLibraryId`. - */ - const masterQteVivante = async (): Promise => { - let ret: { qHandle: number; qGenericId?: string; qId?: string } | null = null; - try { - const o = await rpc("CreateSessionObject", { qProp: { - qInfo: { qType: "cf-test-master" }, - qHyperCubeDef: { - qDimensions: [{ qLibraryId: moisDimId }], - qMeasures: [{ qLibraryId: rQte }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 30 }], - }, - } }, doc); - ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const lay = await rpc("GetLayout", {}, ret.qHandle); - const hc = (lay.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; - let total = 0; - for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) total += Number(r[1]?.qNum) || 0; - diag(" master « Quantité N » (calibrage) → total=" + Math.round(total)); - return total > 0; - } catch (e) { - diag(" master « Quantité N » → ERREUR " + String((e as Error)?.message || e)); - return false; - } finally { - const id = String(ret?.qGenericId ?? ret?.qId ?? ""); - if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } - } - }; - - /** - * Somme de `expr` VENTILÉE par la dimension Mois. - * - * Comparée au total sans dimension, elle dit si la dimension Mois - * démultiplie les faits à travers le pont de périodes — auquel cas - * l'agrégation directe est inexploitable. - */ - const totalVentileParMois = async (expr: string): Promise => { - let ret: { qHandle: number; qGenericId?: string; qId?: string } | null = null; - try { - const o = await rpc("CreateSessionObject", { qProp: { - qInfo: { qType: "cf-ventil-mois" }, - qHyperCubeDef: { - qDimensions: [{ qLibraryId: moisDimId }], - qMeasures: [{ qDef: { qDef: expr } }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 200 }], - }, - } }, doc); - ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const lay = await rpc("GetLayout", {}, ret.qHandle); - const hc = (lay.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; - let total = 0; - for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) total += Number(r[1]?.qNum) || 0; - return total; - } catch { - return 0; - } finally { - const id = String(ret?.qGenericId ?? ret?.qId ?? ""); - if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } - } - }; - - /** - * Cube `[Article Code] × 4 expressions`, SANS dimension Mois. - * - * C'est tout l'intérêt : la dimension Mois passe par le pont de - * périodes et rattache une même ligne de fait à plusieurs contextes - * (mesuré : ×5,3). Sans elle, `Sum(quantite)` porte une fois et une - * seule sur les faits visibles — donc sur le mois sélectionné. - */ - const fetchCodeCubeExpr = async ( - eCa: string, eQte: string, eNbMag: string, eMarge: string, - onRow: (code: string, ca: number, qte: number, nbMag: number, marge: number) => void, - timings?: Record, - ): Promise => { - const LARGEUR = 5; - const PAGE = 1800; // 5 × 1800 = 9000 cellules < 10000 - const { value: obj } = await timed("createCubeCode", "CreateSessionObject", () => rpcWithRetry("createCubeCode", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-code-expr" }, qHyperCubeDef: { - qDimensions: [{ qLibraryId: dim }], - qMeasures: [ - { qDef: { qDef: eCa } }, - { qDef: { qDef: eQte } }, - { qDef: { qDef: eNbMag } }, - { qDef: { qDef: eMarge } }, - ], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: LARGEUR, qHeight: PAGE }], - } } }, doc), timings); - const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; - const traiter = (matrix: Array>): number => { - for (const r of matrix) { - onRow( - String(r[0]?.qText ?? ""), - Number(r[1]?.qNum) || 0, Number(r[2]?.qNum) || 0, - Number(r[3]?.qNum) || 0, Number(r[4]?.qNum) || 0, - ); - } - return matrix.length; - }; - try { - const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, cube.handle), timings); - const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array> }> } }).qHyperCube; - const size = hc.qSize.qcy; - let got = 0, top = 0; - const p0 = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0); - if (p0?.qMatrix?.length) { got += traiter(p0.qMatrix); top = PAGE; } - for (; top < size; top += PAGE) { - const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: LARGEUR, qHeight: PAGE }] }, cube.handle), timings); - const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; - if (!matrix.length) break; - got += traiter(matrix); - if (matrix.length < PAGE) break; - } - return got; - } finally { - await destroyCube(cube, timings); - } - }; - - /** - * EXTRACTION MOIS PAR MOIS SUR LE CHAMP DATE BRUT. - * - * Le chemin que rien n'avait encore essayé, et le seul qui n'emprunte - * ni le pont de périodes ni les master measures : - * - * - on sélectionne les jours d'UN mois sur le champ date ; - * - on lit un cube `[Article Code]` sans dimension Mois, donc sans - * démultiplication ; - * - `Type_Cal`, `Période` et `Année` sont effacés : les expressions - * brutes n'ont pas besoin du pont, elles agrègent les faits visibles. - * - * Il n'y a donc plus de « mois inatteignable » : chaque mois est un - * filtre de dates, pas un contexte de calendrier. - * - * CONTRÔLE D'INTÉGRITÉ. La somme des 12 mois ne doit pas dépasser le - * total sans filtre de date : si la dimension ou le champ démultipliait - * les faits, elle exploserait (le pont donne ×5,3). Et chaque mois doit - * être servi. Sinon rien n'est écrit. - */ - const moisParDateBrute = async (): Promise => { - const timings: Record = {}; - - // Aucun contexte de calendrier : on veut les faits nus. - if (yfh !== -1) { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); } - await appliquerPeriode(null); - if (dfh !== -1) { await rpc("Clear", {}, dfh); await sleep(qlikSettleMs); } - - if (codes.length && fh !== -1 && !parFournisseur) { - await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); - await sleep(qlikSettleMs); - await timed("selectCodeAll", "SelectValues", () => rpcWithRetry("selectCodeAll", "SelectValues", { - qFieldValues: codes.map((c) => ({ qText: c })), - qToggleMode: false, - qSoftLock: true, - }, fh, () => { aggCode15Retries++; }), timings); - await sleep(qlikSettleMs); - } - - const sansFiltre = await totalDeControle(); - diag("[date brute] contrôle « " + exprQte + " » sans filtre date = " + Math.round(sansFiltre)); - if (sansFiltre <= 0) { - diag("[date brute] ÉCHEC : l'expression quantité ne renvoie rien (corriger QLIK_EXPR_QTE)"); - return false; - } - - // ─── Quel champ date filtre les faits ? ────────────────── - // - // ⚠️ On teste avec UN SEUL MOIS, pas avec la fenêtre entière. - // Toute la table de faits tient dans les 12 mois (mesuré : 100 % - // du total) : sélectionner la fenêtre ne réduit donc RIEN, et - // l'ancien test « le total doit baisser » rejetait tous les - // champs, y compris les bons. - const moisTest = monthlyPayload[Math.floor(monthlyPayload.length / 2)]; - let champDate: string | null = null; - let handleDate = -1; - for (const cand of champsDateCandidats) { - const valeurs = /key/i.test(cand) ? moisTest.ymd : moisTest.serials; - const h = await selectionnerFenetreSur(cand, valeurs); - if (h == null) { diag("[date brute] champ « " + cand + " » : inexistant"); continue; } - const t = await totalDeControle(); - const part = sansFiltre > 0 ? t / sansFiltre : 0; - diag( - "[date brute] champ « " + cand + " » sur le seul mois " + moisTest.label + " → " + - Math.round(t) + " (" + (part * 100).toFixed(1) + "% du total)", - ); - // Un mois isolé doit peser une fraction du total : ni zéro - // (le champ ne touche pas les faits) ni la totalité (il ne - // filtre pas). - if (t > 0 && part < 0.5) { champDate = cand; handleDate = h; break; } - await rpc("Clear", {}, h); - await sleep(qlikSettleMs); - } - if (!champDate || handleDate === -1) { - diag( - "[date brute] ÉCHEC : aucun champ date ne filtre les faits mois par mois " + - "(essayés : " + JSON.stringify(champsDateCandidats) + ")", - ); - return false; - } - diag("[date brute] champ date retenu : « " + champDate + " »"); - - // À partir d'ici la série sera reconstruite entièrement : les - // passes annuelles (master measures) et la série brute n'ont pas - // la même sémantique, on ne les mélange pas. - out.length = 0; - for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; - - const totauxMois: Record = {}; - let sommeMois = 0; - for (let i = 0; i < monthlyPayload.length; i++) { - const m = monthlyPayload[i]; - const valeurs = /key/i.test(champDate) ? m.ymd : m.serials; - await rpc("Clear", {}, handleDate); - await sleep(qlikSettleMs); - await rpcWithRetry("selectDate", "SelectValues", { - qFieldValues: valeurs.map((v) => ({ qNum: v, qText: String(v) })), - qToggleMode: false, - qSoftLock: true, - }, handleDate, () => { aggCode15Retries++; }); - await sleep(qlikSettleMs); - - let totalDuMois = 0; - const lignes = await fetchCodeCubeExpr(exprCa, exprQte, exprNbMag, exprMarge, (code, ca, qte, nbMag, marge) => { - if (!code || code === "-") return; - if (qte === 0 && ca === 0) return; - totalDuMois += qte; - out.push([code, ca, qte, nbMag, nbMag > 0 ? ca / nbMag : 0, ca > 0 ? marge / ca : 0]); - (monthlyByCode[code] ??= {}); - monthlyByCode[code][m.label] = { - qte, ca, nbMag, - caMag: nbMag > 0 ? ca / nbMag : 0, - margePct: ca > 0 ? marge / ca : 0, - }; - }, timings); - totauxMois[m.label] = totalDuMois; - sommeMois += totalDuMois; - aggBatches++; - console.log( - "[qlik-pw][date brute] mois " + (i + 1) + "/" + monthlyPayload.length + " " + m.label + - " → " + lignes + " articles, quantité=" + Math.round(totalDuMois), - ); - await pousserCheckpoint("date brute " + m.label); - } - aggMonths = monthlyPayload.length; - - diag("[date brute] quantités par mois : " + JSON.stringify( - Object.fromEntries(Object.entries(totauxMois).map(([k, v]) => [k, Math.round(v)])), - )); - - // ─── Intégrité ─────────────────────────────────────────── - const vides = Object.entries(totauxMois).filter(([, v]) => v <= 0).map(([k]) => k); - if (vides.length) { - diag("[date brute] ÉCHEC : mois sans aucune vente " + JSON.stringify(vides)); - return false; - } - if (sommeMois > sansFiltre * 1.02) { - diag( - "[date brute] ÉCHEC : la somme des 12 mois (" + Math.round(sommeMois) + - ") dépasse le total sans filtre (" + Math.round(sansFiltre) + ") — les faits sont " + - "démultipliés, les ventes réseau seraient fausses", - ); - return false; - } - diag( - "[date brute] OK — somme des 12 mois = " + Math.round(sommeMois) + " pour un total sans filtre de " + - Math.round(sansFiltre) + " (" + Math.round((sommeMois / sansFiltre) * 100) + "%), " + - Object.keys(monthlyByCode).length + " articles", - ); - console.log("[qlik-pw][date brute] timings=" + timingSummary(timings)); - return true; - }; - - const moisParExpressions = async (): Promise => { - const timings: Record = {}; - const marqueOut = out.length; - try { - if (yfh !== -1) { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); } - - // Les codes d'abord : les totaux de contrôle portent alors sur - // le même périmètre que l'extraction. Sauf si le fournisseur - // pose déjà ce périmètre — le re-sélectionner par codes ne - // changerait rien au résultat et coûterait une minute. - if (codes.length && fh !== -1 && !parFournisseur) { - await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); - await sleep(qlikSettleMs); - await timed("selectCodeAll", "SelectValues", () => rpcWithRetry("selectCodeAll", "SelectValues", { - qFieldValues: codes.map((c) => ({ qText: c })), - qToggleMode: false, - qSoftLock: true, - }, fh, () => { aggCode15Retries++; }), timings); - await sleep(qlikSettleMs); - } - - // ─── Quel champ date filtre RÉELLEMENT les faits ? ─────── - // - // Les master measures ignorant la sélection Date, personne - // n'avait jamais pu vérifier que « Date » filtrait quoi que - // ce soit. Un champ valide doit donner un total non nul ET - // strictement inférieur au total sans filtre. - const sansFiltre = await totalDeControle(); - diag("contrôle « " + exprQte + " » sans filtre date = " + Math.round(sansFiltre)); - if (sansFiltre <= 0) { - diag("ÉCHEC : « " + exprQte + " » renvoie 0 même sans filtre → expression ou champ invalide (corriger QLIK_EXPR_QTE)"); - return false; - } - - const allSerials = monthlyPayload.flatMap((m) => m.serials); - const allYmd = monthlyPayload.flatMap((m) => m.ymd); - const moisFenetre = new Set(monthlyPayload.map((m) => m.label)); - let champDate: string | null = null; - let totalFenetre = 0; - for (const cand of champsDateCandidats) { - const valeurs = /key/i.test(cand) ? allYmd : allSerials; - const handleCand = await selectionnerFenetreSur(cand, valeurs); - if (handleCand == null) { - diag(" champ « " + cand + " » : inexistant"); - continue; - } - const filtre = await totalDeControle(); - const ratio = sansFiltre > 0 ? filtre / sansFiltre : 0; - diag(" champ « " + cand + " » → " + Math.round(filtre) + " (" + Math.round(ratio * 100) + "% du total sans filtre)"); - // ⚠️ 100 % ne veut PAS dire « ce champ ne filtre rien » : toute - // la table de faits tient dans les 12 mois (mesuré), donc - // sélectionner la fenêtre entière ne retire rien. Exiger une - // baisse rejetait donc les champs valides. Ici la sélection - // ne sert qu'à borner la fenêtre : un total non nul suffit, - // et on préfère simplement un champ qui filtre s'il existe. - if (filtre > 0) { - champDate = cand; - totalFenetre = filtre; - if (ratio < 0.995) break; - diag(" (la fenêtre couvre 100 % des faits — normal si l'app ne porte que ces 12 mois)"); - break; - } - // Un candidat rejeté ne doit jamais polluer l'essai - // suivant (cas observé : Date=0 puis tous les autres=0). - await rpc("Clear", {}, handleCand); - await sleep(qlikSettleMs); - } - if (!champDate) { - // Les champs date exposés sont dissociés des faits : la - // dimension maître « Mois », elle, porte les vraies valeurs. - const selectionMois = await selectionnerFenetreViaDimensionMois(moisFenetre); - const ratio = selectionMois && sansFiltre > 0 ? selectionMois.total / sansFiltre : 0; - if (selectionMois) { - diag(" dimension « Mois » → " + Math.round(selectionMois.total) + " (" + Math.round(ratio * 100) + "% du total sans filtre)"); - } - if (selectionMois && selectionMois.total > 0 && ratio < 0.995) { - champDate = "dimension Mois"; - totalFenetre = selectionMois.total; - } - } - if (!champDate) { - diag( - "ÉCHEC : ni les champs date ni la dimension Mois ne filtrent les faits " + - "(champs essayés : " + JSON.stringify(champsDateCandidats) + ") → impossible de borner la fenêtre 12 mois.", - ); - return false; - } - diag("champ date retenu : « " + champDate + " »"); - - // Chaque expression est validée SÉPARÉMENT : une seule mesure - // invalide fait rendre zéro ligne à tout l'hypercube, sans - // qu'aucun message ne dise laquelle. - diag("validation des expressions, une par une :"); - const vQte = await validerExpression("quantité", exprQte); - if (vQte === "0") { - diag("ÉCHEC : l'expression quantité est inutilisable, rien à extraire"); - return false; - } - const vCa = await validerExpression("CA", exprCa); - const vNbMag = await validerExpression("magasins", exprNbMag); - const vMarge = await validerExpression("marge", exprMarge); - - // La master « Quantité N » n'est là que pour le calibrage, mais - // une master morte suffit à vider tout l'hypercube. On la sonde - // donc comme les autres, et on l'omet plutôt que de perdre - // l'extraction entière pour une colonne de contrôle. - const avecMaster = await masterQteVivante(); - if (!avecMaster) { - diag(" master « Quantité N » morte sous cette sélection → colonne de calibrage retirée du cube"); - } - - // ─── La dimension Mois démultiplie-t-elle les faits ? ──── - // - // Le pont de périodes rattache une même ligne de fait à - // plusieurs contextes. Ventilé par `Mois`, `Sum(quantite)` peut - // donc dépasser le total sans dimension — mesuré : 179 574 616 - // contre 33 945 285, soit ×5,3. Écrire cela dans le cache - // donnerait des ventes réseau cinq fois trop hautes : on refuse. - const totalVentile = await totalVentileParMois(vQte); - if (totalVentile > totalFenetre * 1.05) { - diag( - "ÉCHEC : ventilée par Mois, « " + vQte + " » totalise " + Math.round(totalVentile) + - " contre " + Math.round(totalFenetre) + " sans dimension (×" + - (totalFenetre > 0 ? (totalVentile / totalFenetre).toFixed(1) : "?") + - "). La dimension Mois passe par le pont de périodes et démultiplie les faits — " + - "l'agrégation directe donnerait des ventes réseau fausses, elle est refusée.", - ); - return false; - } - - // Seuls les mois de la fenêtre nous intéressent ; la dimension - // Mois peut en exposer d'autres si une sélection déborde. - let totalQte = 0; - let calibrees = 0, calibrOk = 0; - let horsFenetre = 0; - let echantillon = false; - - const got = await timed("cube", "expr", () => fetchMonthCubeExpr(vCa, vQte, vNbMag, vMarge, (code, mois, ca, qte, nbMag, marge, qteMaster) => { - if (!code || code === "-") return; - const mm = normMois(mois); - if (!echantillon) { - echantillon = true; - // Échantillon AVANT filtrage : si le mois ne tombe pas dans - // la fenêtre, c'est le format de la dimension Mois qui est - // en cause, pas les mesures. - diag("échantillon brut: " + JSON.stringify({ - code, moisBrut: mois, moisNormalise: mm, dansLaFenetre: moisFenetre.has(mm), - ca, qte, nbMag, marge, "Quantité N (master)": qteMaster, - })); - } - if (!moisFenetre.has(mm)) { horsFenetre++; return; } - // Calibrage sur l'année en cours, seul périmètre où la master - // measure est censée être juste. - if (avecMaster && parseInt(mm.slice(0, 4), 10) === yearN && (qte > 0 || qteMaster > 0)) { - calibrees++; - if (Math.abs(qte - qteMaster) <= Math.max(1, qteMaster * 0.02)) calibrOk++; - } - totalQte += qte; - // Totaux = somme des mois de la fenêtre (et non un cumul année). - out.push([code, ca, qte, nbMag, nbMag > 0 ? ca / nbMag : 0, ca > 0 ? marge / ca : 0]); - (monthlyByCode[code] ??= {}); - // Agrège au lieu d'écraser : protège contre deux valeurs - // brutes de la dimension Mois qui se normaliseraient vers - // la même clé YYYY-MM. - const precedent = monthlyByCode[code][mm]; - const qteCumulee = (precedent?.qte ?? 0) + qte; - const caCumule = (precedent?.ca ?? 0) + ca; - const margeMontant = ((precedent?.margePct ?? 0) * (precedent?.ca ?? 0)) + marge; - monthlyByCode[code][mm] = { - qte: qteCumulee, - ca: caCumule, - nbMag: Math.max(precedent?.nbMag ?? 0, nbMag), - caMag: nbMag > 0 ? ca / nbMag : 0, - margePct: caCumule > 0 ? margeMontant / caCumule : 0, - }; - }, timings, undefined, avecMaster), timings); - - aggMonths = monthlyPayload.length; - aggBatches++; - diag( - got.value + " lignes (" + horsFenetre + " hors fenêtre), quantité totale=" + Math.round(totalQte) + - ", calibrage année " + yearN + " : " + calibrOk + "/" + calibrees + " mois conformes à « Quantité N »", - ); - console.log("[qlik-pw][expr] timings=" + timingSummary(timings)); - - if (totalQte <= 0) { - diag( - "ÉCHEC : quantité totale nulle sur la fenêtre alors que le contrôle global valait " + - Math.round(sansFiltre) + " — " + horsFenetre + " ligne(s) écartées comme hors fenêtre. " + - "Si ce nombre est élevé, c'est le format de la dimension Mois qui ne correspond pas aux libellés attendus.", - ); - out.length = marqueOut; - for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; - return false; - } - // Contrôle global indépendant : le cube sans dimension, - // calculé sur exactement les mêmes sélections, doit être - // égal à la somme de toutes les lignes mensuelles paginées. - const ecartGlobal = Math.abs(totalQte - totalFenetre); - const toleranceGlobale = Math.max(0.001, Math.abs(totalFenetre) * 1e-9); - if (ecartGlobal > toleranceGlobale) { - console.error( - "[qlik-pw][expr] INTÉGRITÉ REFUSÉE : somme mensuelle=" + totalQte + - " mais total Qlik fenêtre=" + totalFenetre + " (écart=" + ecartGlobal + ")", - ); - out.length = marqueOut; - for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; - return false; - } - - // Contrôle et normalisation PAR ARTICLE. Chaque article - // retourné possède ensuite exactement les 12 clés de la - // fenêtre ; une clé absente du cube Qlik signifie qu'aucun - // fait n'existe pour ce mois, donc une vraie valeur 0. - const totauxParCode: Record = {}; - for (let i = marqueOut; i < out.length; i++) { - const ligne = out[i]; - const code = String(ligne[0] ?? "").trim(); - if (code) totauxParCode[code] = (totauxParCode[code] ?? 0) + (Number(ligne[2]) || 0); - } - for (const [code, mois] of Object.entries(monthlyByCode)) { - for (const label of moisFenetre) { - mois[label] ??= { qte: 0, ca: 0, nbMag: 0, caMag: 0, margePct: 0 }; - } - // Ne conserve aucune clé hors de la fenêtre glissante. - for (const label of Object.keys(mois)) { - if (!moisFenetre.has(label)) delete mois[label]; - } - const somme12 = Array.from(moisFenetre).reduce((s, label) => s + (Number(mois[label]?.qte) || 0), 0); - const totalArticle = totauxParCode[code] ?? 0; - if (Math.abs(somme12 - totalArticle) > Math.max(0.001, Math.abs(totalArticle) * 1e-9)) { - console.error( - "[qlik-pw][expr] INTÉGRITÉ ARTICLE REFUSÉE code=" + code + - " somme12=" + somme12 + " total=" + totalArticle, - ); - out.length = marqueOut; - for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; - return false; - } - } - if (calibrees > 20 && calibrOk / calibrees < 0.5) { - console.warn( - "[qlik-pw][expr] ⚠ calibrage faible (" + calibrOk + "/" + calibrees + ") : " + exprQte + - " diverge de « Quantité N ». Vérifier l'expression (QLIK_EXPR_QTE).", - ); - } - return true; - } catch (e) { - diag("ÉCHEC : " + String((e as Error)?.message || e) + " → repli sur les master measures"); - out.length = marqueOut; - for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; - return false; - } - }; - - // Sélectionne toute la fenêtre de dates une fois, puis parcourt les codes par - // lots ; un seul cube [Article Code, Mois] par lot donne TOUS les mois d'un coup. - // selectYear = null → passe principale (année N + N-1 dérivée via COMP). - // selectYear = 2025 → passe complémentaire : on sélectionne l'année pour - // récupérer directement ses mois (seul moyen d'obtenir août→déc N-1, que - // COMP ne peut pas fournir puisque l'année N ne va pas jusque-là). - let loggedSampleRow = false; - const monthDimPath = async (selectYear: number): Promise => { - const moisFenetre = new Set(monthlyPayload.map((m) => m.label)); - - /** - * Traitement d'une ligne du cube [Article Code, Mois]. - * - * Une passe = UNE année explicitement sélectionnée, exactement ce - * que fait un utilisateur dans l'app (« je sélectionne 2026 et - * j'obtiens N + la comparaison N-1 »). Chaque passe n'écrit que - * les mois DE LA FENÊTRE, et les totaux s'additionnent sur ces - * mois-là : deux années différentes ne peuvent donc pas se - * doubler, puisqu'elles ne partagent aucun mois. - */ - const onRowMois = (code: string, mois: string, ca: number, qte: number, nbMag: number, caMag: number, marge: number, qteComp: number) => { - if (!code || code === "-") return; - const mm = normMois(mois); - if (!moisFenetre.has(mm)) return; - out.push([code, ca, qte, nbMag, caMag, marge]); - const y = parseInt(mm.slice(0, 4), 10); - if (!loggedSampleRow) { - loggedSampleRow = true; - console.log("[qlik-pw][mesures] échantillon ligne mensuelle: " + JSON.stringify({ - code, mois: mm, "CA N": ca, "Quantité N (utilisée pour la courbe)": qte, - "Magasin Ventes Nb N": nbMag, "CA par Magasin N": caMag, "Marge % N": marge, "Quantité COMP": qteComp, - })); - } - (monthlyByCode[code] ??= {}); - // Les 5 mesures du mois courant de la ligne. Le cube les renvoie - // déjà toutes : les conserver ne coûte aucune requête Qlik en plus. - const full = { qte, ca, nbMag, caMag, margePct: marge }; - // « Quantité COMP » n'est plus nécessaire : chaque année de la - // fenêtre est extraite directement par sa propre passe, ce qui - // donne les 5 mesures et non la seule quantité. - void y; void qteComp; - // Une passe ultérieure ne doit JAMAIS écraser un mois servi par - // un zéro : hors de son année, la passe voit la combinaison - // (article, mois) mais toutes ses mesures sont nulles. - const precedent = monthlyByCode[code][mm]; - if (precedent && (precedent.qte ?? 0) > 0 && qte <= 0) return; - monthlyByCode[code][mm] = full; - }; - - if (yfh === -1) throw new Error("champ « Année » introuvable : impossible d'extraire année par année"); - - // La `Période` qui sert cette année-là — pas la même pour toutes : - // « Année à date » convient à l'année en cours, une autre valeur à - // une année révolue (cf. choisirPeriode). - const periodeAnnee = periodeParAnnee.get(selectYear); - if (periodeAnnee) { - console.log("[qlik-pw] (mois) Période « " + (periodeAnnee.texte ?? "(aucune)") + " » pour l'année " + selectYear); - await appliquerPeriode(periodeAnnee.texte); - } - - await rpc("Clear", {}, yfh); - await sleep(qlikSettleMs); - const ysel = await rpc("SelectValues", { qFieldValues: [{ qNum: selectYear, qText: String(selectYear) }], qToggleMode: false, qSoftLock: true }, yfh); - console.log("[qlik-pw] (mois) sélection Année=" + selectYear + " → " + JSON.stringify(ysel.qReturn)); - await sleep(qlikSettleMs); - const allSerials = monthlyPayload.flatMap((m) => m.serials); - if (dfh !== -1 && allSerials.length) { - await rpc("Clear", {}, dfh); - await sleep(qlikSettleMs); - await rpc("SelectValues", { qFieldValues: allSerials.map((s) => ({ qNum: s, qText: String(s) })), qToggleMode: false, qSoftLock: true }, dfh); - await sleep(qlikSettleMs); - } - aggMonths = monthlyPayload.length; - - // ─── Chemin rapide : UNE seule sélection Article Code ──── - // - // Mesuré en production : `SelectValues` sur « Article Code » - // coûte 8 à 100 s **par appel**, quasi indépendamment du - // nombre de valeurs (le moteur balaie un symbole de plus d'un - // million d'entrées). Avec 7 200 codes en lots de 300, cela - // faisait 24 appels, ~15 min de sync, et la session Qlik - // mourait avant la fin (« Socket closed » puis « Execution - // context was destroyed »). - // - // La dimension Mois donne déjà tous les mois d'un coup : rien - // n'oblige à découper les codes. On tente donc un seul appel - // pour tous les codes et un seul cube paginé — la lecture des - // pages, elle, coûte ~50 ms. Repli sur les lots si l'Engine - // refuse (cube trop gros, code 15, saturation mémoire). - const marqueOut = out.length; - if ((codes.length || parFournisseur) && fh !== -1) { - const timingsAll: Record = {}; - try { - // Le périmètre fournisseur est posé une fois pour toutes - // avant la première passe : il survit aux sélections - // Année/Date, qui portent sur d'autres champs. - if (!parFournisseur) { - await timed("clearCodeAll", "Clear", () => rpc("Clear", {}, fh), timingsAll); - await sleep(qlikSettleMs); - await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", { - qFieldValues: codes.map((c) => ({ qText: c })), - qToggleMode: false, - qSoftLock: true, - }, fh), timingsAll); - await sleep(qlikSettleMs); - } - const got = await timed("cube", "all", () => fetchMonthCube(onRowMois, timingsAll), timingsAll); - aggBatches++; - console.log( - "[qlik-pw][timing] (mois) chemin rapide — " + - (parFournisseur ? "périmètre fournisseur (0 sélection code)" : codes.length + " codes en 1 sélection") + - ", rows=" + got.value + - " cumul=" + out.length + " timings=" + timingSummary(timingsAll), - ); - return; - } catch (e) { - // Les lignes déjà lues seraient recomptées par le repli : - // on les retire (les écritures mensuelles, elles, sont - // des affectations idempotentes). - out.length = marqueOut; - console.log("[qlik-pw] (mois) chemin rapide échoué → repli par lots : " + String((e as Error)?.message || e)); - } - } - - // Lots plus petits = cubes plus légers (moins de pression mémoire côté - // moteur Qlik, qui a tendance à saturer "Out of memory" sur les gros cubes). - const fallbackSizes = [300, 150, 75]; - const totalCodes = codes.length; - let codeIndex = 0, batchNumber = 0, fallbackIndex = 0; - while (codes.length ? codeIndex < totalCodes : batchNumber === 0) { - const batch = codes.length ? codes.slice(codeIndex, Math.min(codeIndex + fallbackSizes[fallbackIndex], totalCodes)) : []; - const timings: Record = {}; - let code15Retries = 0; - batchNumber++; - const bp = "[qlik-pw] (mois) lot " + batchNumber + " [" + codeIndex + "," + (codeIndex + batch.length) + ") size=" + batch.length; - try { - if (batch.length && fh !== -1) { - await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); - await sleep(qlikSettleMs); - await timed("selectCode", "SelectValues", () => rpc("SelectValues", { qFieldValues: batch.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh), timings); - await sleep(qlikSettleMs); - } - const got = await timed("cube", "batch", () => fetchMonthCube(onRowMois, timings, () => { code15Retries++; }), timings); - aggBatches++; - aggCode15Retries += code15Retries; - console.log("[qlik-pw][timing] " + bp + " — rows=" + got.value + " cumul=" + out.length + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); - if (!codes.length) break; - codeIndex += batch.length; - fallbackIndex = 0; - } catch (batchErr) { - const nextSize = fallbackSizes[fallbackIndex + 1]; - if (codes.length && isEngineCode15(batchErr) && nextSize && batch.length > nextSize) { - console.log("[qlik-pw] " + bp + " — fallback code15 " + batch.length + "→" + nextSize); - fallbackIndex += 1; - aggFallbackCount++; - continue; - } - throw new Error(bp + " — " + String((batchErr as Error)?.message || batchErr)); - } - } - }; - - // Chemin rapide (flag QLIK_SELECT_ALL_CODES) : sélectionne TOUS les - // Article Code une seule fois, puis n'itère que la sélection Date par - // mois avec UN cube paginé (au lieu de re-sélectionner les codes par lots - // de 150 à chaque mois — la SelectValues répétée coûte ~22 s × 120). - // Renvoie true si le mois complet a réussi ; false si repli code 15 (les - // résultats partiels et sélections sont réinitialisés avant le chemin par lots). - const trySelectAllCodesPath = async (): Promise => { - const savedLen = out.length; - try { - if (codes.length && fh !== -1) { - await timed("clearCodeAll", "Clear", () => rpc("Clear", {}, fh), {}); - await sleep(qlikSettleMs); - const { value: sel } = await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", { - qFieldValues: codes.map((c) => ({ qText: c })), - qToggleMode: false, - qSoftLock: true, - }, fh), {}); - console.log("[qlik-pw] select-all — SelectValues Article Code (" + codes.length + " codes) → " + JSON.stringify(sel.qReturn)); - await sleep(qlikSettleMs); - } - const total = monthlyPayload.length; - console.log("[qlik-pw] chemin rapide select-all — " + total + " mois, 1 sélection Article Code globale"); - for (let mi = 0; mi < total; mi++) { - const m = monthlyPayload[mi]; - const prefix = "[qlik-pw] (rapide) mois " + (mi + 1) + "/" + total + " " + m.label + " (" + m.dateDebut + "→" + m.dateFin + ", " + m.serials.length + "j)"; - const monthStart = nowMs(); - const monthTimings: Record = {}; - aggMonths = mi + 1; - let code15Retries = 0; - // Sélection Date du mois uniquement (la sélection Article Code reste active). - const { value: dsel } = await timed("selectDate", "SelectValues", () => rpc("SelectValues", { - qFieldValues: m.serials.map((s) => ({ qNum: s, qText: String(s) })), - qToggleMode: false, - qSoftLock: true, - }, dfh), monthTimings); - console.log(prefix + " — SelectValues Date " + m.serials.length + " serials → " + JSON.stringify(dsel.qReturn)); - await sleep(qlikSettleMs); - // Un seul cube paginé pour tous les articles du mois. - const batchRows: Array> = []; - const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, monthTimings, () => { code15Retries++; }), monthTimings); - out.push(...batchRows); - accMonthly(batchRows, m.label); - aggBatches++; - aggCode15Retries += code15Retries; - console.log("[qlik-pw][timing] " + prefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(monthTimings)); - // Clear Date uniquement — on garde la sélection Article Code pour le mois suivant. - if (mi < total - 1) { - try { await timed("clearDate", "Clear", () => rpc("Clear", {}, dfh), monthTimings); } - catch (e) { console.log(prefix + " — Clear Date ignoré: " + String((e as Error)?.message || e)); } - } - console.log("[qlik-pw][timing] " + prefix + " — done ms=" + Math.round(nowMs() - monthStart) + " timings=" + timingSummary(monthTimings)); - } - return true; - } catch (e) { - if (isEngineCode15(e)) { - console.log("[qlik-pw] chemin rapide échoué (code 15) → repli sur le chemin par lots"); - out.length = savedLen; - for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; - aggMonths = 0; aggBatches = 0; aggCode15Retries = 0; - try { await rpc("Clear", {}, dfh); if (fh !== -1) await rpc("Clear", {}, fh); } catch { /* noop */ } - await sleep(qlikSettleMs); - return false; - } - throw e; - } - }; - - // La passe de « rattrapage des mois vides » a été SUPPRIMÉE. - // - // Elle ré-extrayait un mois vide en ne sélectionnant que ses dates. - // Or les master measures ignorent la sélection Date : le cube lui - // renvoyait le total de période, qu'elle recopiait dans chacun des - // mois manquants. D'où les plateaux identiques d'août à décembre - // (87 648 sur douze mois pour un article, etc.) et les tendances - // « -162 % » qu'ils fabriquaient. - // - // Un mois qu'on ne sait pas extraire doit rester ABSENT. - - /** Pousse l'état courant vers Node (voir `cfCheckpoint`). */ - const pousserCheckpoint = async (etape: string) => { - try { - const fn = (window as unknown as { cfCheckpoint?: (p: unknown) => Promise }).cfCheckpoint; - if (!fn) return; - await fn({ rows: out, monthly: monthlyByCode }); - console.log("[qlik-pw] point de contrôle « " + etape + " » : " + out.length + " lignes"); - } catch (e) { - console.log("[qlik-pw] point de contrôle « " + etape + " » ignoré : " + String((e as Error)?.message || e)); - } - }; - - if (!noDateMode) { - // ÉTAPE 0 — poser le périmètre articles, une fois pour toutes. - // - // Le code fournisseur FF (base SQL / API) existe aussi dans Qlik : - // une seule valeur y désigne le même périmètre que les dizaines de - // milliers de codes articles qu'on envoyait jusqu'ici. La bascule - // n'est retenue que si la couverture mesurée le justifie, sinon on - // retombe sur la sélection par codes (cf. selectionnerParFournisseur). - parFournisseur = await selectionnerParFournisseur(); - - const attendus = monthlyPayload.map((m) => m.label); - // Années de la fenêtre, la PLUS RÉCENTE D'ABORD — c'est l'usage de - // l'app (« je sélectionne 2026 »), et cela met les mois les plus - // utiles en tête des points de contrôle. - const annees = [...new Set(attendus.map((m) => Number(m.slice(0, 4))))].sort((a, b) => b - a); - - // ÉTAPE 1 — caler le champ `Période` du modèle sur la fenêtre. - // - // Toutes les mesures de l'app sont bornées par `Type_Cal` - // (`Sum({} quantite)`), lui-même piloté par - // `Période`. C'est cette sélection — et elle seule — qui détermine - // quels mois sont atteignables. Sous la période par défaut, - // « Quantité N » ne couvre que l'année en cours : d'où les mois - // d'août à décembre vides quelle que soit la sélection de dates. - const couvertureMaster = await choisirPeriode(annees); - - // ÉTAPE 2 — extraire, puis REFUSER tout résultat incomplet. - // - // Un cache partiel est pire que pas de cache : il se lit comme des - // ventes nulles. Les deux chemins sont donc validés sur le même - // critère — les 12 mois de la fenêtre, ou rien. - const moisManquants = () => { - const vus = new Set(); - for (const parMois of Object.values(monthlyByCode)) { - for (const [mois, v] of Object.entries(parMois)) { - if ((v?.qte ?? 0) > 0) vus.add(mois); - } - } - return attendus.filter((m) => !vus.has(m)); - }; - const couvre12 = () => moisManquants().length === 0; - - // ÉTAPE 2 — extraire ANNÉE PAR ANNÉE, comme dans l'app. - // - // Dans l'interface, on sélectionne « 2026 » et on obtient l'année - // plus sa comparaison N-1. C'est le mécanisme natif du modèle : - // `Type_Cal='N'` désigne l'année sélectionnée. Il suffit donc - // d'enchaîner les années couvertes par la fenêtre — ici 2025 puis - // 2026 — pour reconstituer les 12 mois glissants. - // - // Chaque passe est faite SOUS la `Période` retenue : c'est elle qui - // donne son sens à `Type_Cal='N'`. Avec `Année=2025`, « Année à - // date » vaut l'année 2025 entière (elle est passée) ; avec - // `Année=2026`, elle s'arrête au mois courant. L'union des deux - // passes reconstitue donc bien les 12 mois glissants. - diag("extraction année par année : " + JSON.stringify(annees)); - - let extraitParAnnee = false; - if (monthDim) { - try { - for (const annee of annees) { - const manquantsAvant = moisManquants(); - await monthDimPath(annee); - const manquantsApres = moisManquants(); - const gagnes = manquantsAvant.filter((m) => !manquantsApres.includes(m)); - diag( - " année " + annee + " → mois de la fenêtre désormais servis : " + JSON.stringify(gagnes) + - " ; encore manquants : " + JSON.stringify(manquantsApres), - ); - await pousserCheckpoint("année " + annee); - if (manquantsApres.length === 0) break; // fenêtre complète, inutile de continuer - } - extraitParAnnee = couvre12(); - if (!extraitParAnnee) { - diag( - "l'extraction année par année ne couvre pas les 12 mois (manquants : " + - JSON.stringify(moisManquants()) + ") — essai de l'agrégation directe", - ); - } - } catch (e) { - diag("extraction année par année interrompue : " + String((e as Error)?.message || e)); - } - } - - // REPLI 1 — mois par mois sur le champ date brut. - // - // Passe en premier car c'est le seul chemin sans pont de périodes : - // aucun mois n'y est « inatteignable », puisqu'un mois y est un - // filtre de dates et non un contexte de calendrier. - if (!extraitParAnnee && useExpr) { - try { - if (await moisParDateBrute()) { - extraitParAnnee = couvre12(); - if (extraitParAnnee) { - diag("extraction par date brute retenue — les " + attendus.length + " mois sont servis"); - await pousserCheckpoint("date brute complète"); - } else { - diag("extraction par date brute incomplète (manquants : " + JSON.stringify(moisManquants()) + ")"); - } - } - } catch (e) { - diag("extraction par date brute interrompue : " + String((e as Error)?.message || e)); - } - } - - if (!extraitParAnnee) { - if (!(monthDim && useExpr && (await moisParExpressions()))) { - throw new Error( - "extraction 12 mois refusée : ni l'extraction année par année " + - "(couverture master annoncée = " + couvertureMaster + "/" + attendus.length + ") " + - "ni l'agrégation directe des faits n'ont couvert la fenêtre — mois manquants : " + - JSON.stringify(moisManquants()), - ); - } - await pousserCheckpoint("expressions vérifiées"); - } - - if (!couvre12()) { - throw new Error( - "extraction 12 mois refusée : la fenêtre " + JSON.stringify(attendus) + - " n'est pas entièrement couverte (manquants : " + JSON.stringify(moisManquants()) + - ") — cache laissé inchangé", - ); - } - } else if (noDateMode) { - // Aucun filtre Date : extraction en un seul appel (tous les codes d'un coup). - // On sélectionne uniquement Article Code ; Date reste libre. - const timings: Record = {}; - let code15Retries = 0; - console.log("mode sans dateFilter — sélection de " + codes.length + " codes en une fois"); - if (codes.length && fh !== -1) { - const { value: sel } = await timed("selectCode", "SelectValues", () => rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh), timings); - console.log("SelectValues Article Code → " + JSON.stringify(sel.qReturn)); - } - const batchRows: Array> = []; - const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, timings, () => { code15Retries++; }), timings); - out.push(...batchRows); - aggMonths = 1; - aggBatches = 1; - aggCode15Retries = code15Retries; - console.log("[qlik-pw][timing] noDate rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); - } else if (selectAllCodes && (await trySelectAllCodesPath())) { - // Chemin rapide terminé avec succès (voir trySelectAllCodesPath). - // Si false (repli code 15), on tombe dans le chemin par lots ci-dessous. - } else { - // Itération par mois × lots de codes : pour chaque mois, SelectValues Date - // (~30 serials), puis SelectValues Article Code par petits lots. Le cube - // reste borné à ~30 jours × batch contrôlé, sans lire tout le catalogue Qlik. - const total = monthlyPayload.length; - const fallbackSizes = qlikArticleBatchFallbacks.length ? qlikArticleBatchFallbacks : [150, 75, 50]; - const fallbackLabel = fallbackSizes.join(">"); - const estimatedBatches = codes.length ? Math.ceil(codes.length / fallbackSizes[0]) : 1; - console.log("itération par mois — " + total + " mois × ~" + estimatedBatches + " lot(s), batchFallbacks=" + fallbackLabel); - for (let mi = 0; mi < total; mi++) { - const m = monthlyPayload[mi]; - const prefix = "[qlik-pw] mois " + (mi + 1) + "/" + total + " " + m.label + " (" + m.dateDebut + "→" + m.dateFin + ", " + m.serials.length + "j)"; - const monthStart = nowMs(); - const monthTimings: Record = {}; - aggMonths = mi + 1; - try { - // 1) Sélection Date pour le mois (un seul SelectValues, ~30 valeurs). - const { value: dsel } = await timed("selectDate", "SelectValues", () => rpc("SelectValues", { - qFieldValues: m.serials.map((s) => ({ qNum: s, qText: String(s) })), - qToggleMode: false, - qSoftLock: true, - }, dfh), monthTimings); - console.log(prefix + " — SelectValues Date " + m.serials.length + " serials → " + JSON.stringify(dsel.qReturn)); - // Laisser l'Engine finaliser l'évaluation de la sélection Date - // avant la première SelectValues Article Code du 1er lot. - await sleep(qlikSettleMs); - - // 2) Sélection Article Code par lots contrôlés. - let codeIndex = 0; - let batchNumber = 0; - let fallbackIndex = 0; - const totalCodes = codes.length || 0; - while (codes.length ? codeIndex < totalCodes : batchNumber === 0) { - const batchSize = codes.length ? fallbackSizes[fallbackIndex] : 0; - const batch = codes.length ? codes.slice(codeIndex, Math.min(codeIndex + batchSize, totalCodes)) : []; - const batchStart = codeIndex; - const batchEnd = codeIndex + batch.length; - const timings: Record = {}; - let code15Retries = 0; - batchNumber++; - const batchPrefix = prefix + " — lot " + batchNumber + " [" + batchStart + "," + batchEnd + ") size=" + batch.length; - try { - if (fh !== -1) { - await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); - // Laisser l'Engine appliquer le Clear avant la nouvelle sélection. - await sleep(qlikSettleMs); - const { value: csel } = await timed("selectCode", "SelectValues", () => rpc("SelectValues", { - qFieldValues: batch.map((c) => ({ qText: c })), - qToggleMode: false, - qSoftLock: true, - }, fh), timings); - console.log(batchPrefix + " — SelectValues Article Code → " + JSON.stringify(csel.qReturn)); - // Laisser l'Engine finaliser l'évaluation de la sélection - // avant de créer / interroger le cube (évite code 15 sur le 1er lot). - await sleep(qlikSettleMs); - } - const batchRows: Array> = []; - const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, timings, () => { code15Retries++; }), timings); - out.push(...batchRows); - accMonthly(batchRows, m.label); - aggBatches++; - aggCode15Retries += code15Retries; - console.log("[qlik-pw][timing] " + batchPrefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); - if (!codes.length) break; - codeIndex = batchEnd; - fallbackIndex = 0; - } catch (batchErr) { - const msg = String((batchErr as Error)?.message || batchErr); - const nextFallbackIndex = fallbackIndex + 1; - const nextSize = fallbackSizes[nextFallbackIndex]; - if (codes.length && isEngineCode15(batchErr) && nextSize && batch.length > nextSize) { - console.log("[qlik-pw][timing] " + batchPrefix + " — fallback code15 " + batch.length + "→" + nextSize + " retries15=" + code15Retries + " timings=" + timingSummary(timings)); - fallbackIndex = nextFallbackIndex; - aggFallbackCount++; - continue; - } - console.error(batchPrefix + " — ABORT — " + msg); - throw new Error(batchPrefix + " — " + msg); - } - } - } catch (monthErr) { - const msg = String((monthErr as Error)?.message || monthErr); - console.error(prefix + " — ABORT — " + msg); - throw new Error(prefix + " — " + msg); - } - // 3) Clear Date + Article Code avant le mois suivant (soulage l'Engine). - if (mi < total - 1) { - try { - await timed("clearDate", "Clear", () => rpc("Clear", {}, dfh), monthTimings); - if (fh !== -1) await timed("clearCodeMonth", "Clear", () => rpc("Clear", {}, fh), monthTimings); - } catch (clearErr) { - console.log(prefix + " — Clear ignoré: " + String((clearErr as Error)?.message || clearErr)); - } - } - console.log("[qlik-pw][timing] " + prefix + " — done ms=" + Math.round(nowMs() - monthStart) + " cumul=" + out.length + " timings=" + timingSummary(monthTimings)); - } - } - - // La sonde de diagnostic du cube [Article Code, Mois] a été retirée : - // elle refaisait une sélection complète des codes et des 365 jours en - // fin de sync (des dizaines de secondes) alors que le découpage - // mensuel est validé et en production depuis. - - ws.close(); - resolve({ ok: true, rows: out, monthly: monthlyByCode, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount, diagExpr: diagnostics }); - } catch (e) { - try { ws.close(); } catch { /* noop */ } - // On remonte ce qui a été extrait : les codes déjà traités ont - // toutes leurs données, seul le reliquat manque. - resolve({ ok: false, error: String((e as Error)?.message || e), rows: out, monthly: monthlyByCode, size: out.length, diagExpr: diagnostics }); - } - })(); - }), - { - app: cfg.appNetwork, - dim: cfg.dimCodeArticleId, - mca: cfg.measCaId, - mqte: cfg.measQteId, - mnb: cfg.measNbMagId, - mcamag: cfg.measCaMagId, - mmarge: cfg.measMargePctId, - codes: codeCentraux ?? [], - token: jeton, - monthlyPayload, - noDateMode: !dateFilter, - qlikSettleMs, - qlikCode15MaxAttempts, - qlikArticleBatchFallbacks, - selectAllCodes: qlikSelectAllCodes, - monthDim: qlikMonthDim, - moisDimId: qlikMoisDimId, - mqteComp: qlikMeasQteCompId, - yearN, - useExpr: qlikUseExpr, - exprQte: qlikExprQte, - exprCa: qlikExprCa, - exprNbMag: qlikExprNbMag, - exprMarge: qlikExprMarge, - champsDateCandidats: qlikDateFields, - fournisseurCode: (fournisseur ?? "").trim(), - champsFournisseurCandidats: qlikSupplierFields, - couvertureFournisseurMin: qlikSupplierMinCoverage, - }, - ); - - // Reprise globale sur erreur transitoire. - // - // Le moteur Qlik abandonne les requêtes longues quand il est chargé - // (« code 15 / Request aborted »). Observé sur un même fournisseur, avec - // le même code : réussite à 4 h du matin (OpenDoc en 78 s), échec à 6 h - // (OpenDoc en 233 s, puis abandon). Les reprises par requête de - // `rpcWithRetry` ne suffisent pas — c'est toute l'extraction qui tombe. - // - // La pause est volontairement longue : relancer aussitôt ne ferait - // qu'ajouter de la charge au serveur qui vient déjà de renoncer. - const extractionEssais = 1 + envNumber("QLIK_EXTRACTION_RETRIES", 1, 0, 3); - const extractionPauseMs = envNumber("QLIK_EXTRACTION_RETRY_PAUSE_MS", 60_000, 0, 600_000); - const estTransitoire = (m: string) => /"code"\s*:\s*15\b/.test(m) || /request aborted/i.test(m); - const pause = (ms: number) => new Promise((r) => setTimeout(r, ms)); - - let result: InPageResult; - let essai = 0; - for (;;) { - essai++; - const reste = essai < extractionEssais; - try { - const candidat = (await evaluer()) as InPageResult; - if (candidat.ok || !reste || !estTransitoire(String(candidat.error ?? ""))) { - result = candidat; - break; - } - console.warn( - `[qlik-pw] tentative ${essai}/${extractionEssais} abandonnée par le moteur (${String(candidat.error).slice(0, 160)})` - + ` — nouvelle tentative dans ${Math.round(extractionPauseMs / 1000)} s`, - ); - } catch (e) { - const msg = String((e as Error)?.message || e); - if (reste && estTransitoire(msg)) { - console.warn( - `[qlik-pw] tentative ${essai}/${extractionEssais} interrompue (${msg.slice(0, 160)})` - + ` — nouvelle tentative dans ${Math.round(extractionPauseMs / 1000)} s`, - ); - } else { - // Comportement d'origine, une fois les reprises épuisées. - // Une fenêtre 12 mois n'est publiable que si l'extraction est - // complète et a passé tous les contrôles. Conserver un checkpoint - // partiel fabriquerait précisément des graphiques à 2 ou 3 mois. - if (dateFilter) { - throw new Error(`[qlik-pw] extraction 12 mois interrompue, cache inchangé : ${msg}`); - } - const secours = checkpoint as { rows?: Array>; monthly?: InPageResult["monthly"] } | null; - if (secours?.rows?.length) { - console.error(`[qlik-pw] extraction interrompue (${msg.slice(0, 200)}) — reprise du dernier point de contrôle : ${secours.rows.length} lignes`); - result = { ok: true, rows: secours.rows, monthly: secours.monthly, size: secours.rows.length, partiel: true }; - break; - } - throw e; - } - } - checkpoint = null; // la nouvelle tentative repart d'une extraction vierge - await pause(extractionPauseMs); - } - - if (!result.ok) { - if (dateFilter) { - throw new Error(`[qlik-pw] extraction 12 mois refusée, cache inchangé : ${result.error}`); - } - // Même logique qu'un crash : un résultat partiel vaut mieux que rien. - if (result.rows?.length) { - console.error(`[qlik-pw] extraction en échec (${String(result.error).slice(0, 200)}) — ${result.rows.length} lignes déjà extraites conservées`); - result = { ...result, ok: true, partiel: true }; - } else { - throw new Error(`[qlik-pw] ${result.error}`); - } - } - if (result.partiel) { - if (dateFilter) { - throw new Error("[qlik-pw] extraction 12 mois partielle refusée, cache inchangé"); - } - console.warn("[qlik-pw] ⚠ résultat PARTIEL : certains codes n'ont pas été extraits, relancer la sync pour compléter"); - } - - // Résumé timing final : synthèse compacte de la sync pour audit/debug. - // Les logs détaillés par étape restent en place (timings batch/mois) ; - // ici on consolide les compteurs agrégés et la durée totale. - const totalMs = Date.now() - totalStart; - if (result.diagExpr?.length) { - console.log("[qlik-pw][diag] ─── diagnostic du chemin par expressions ───"); - for (const d of result.diagExpr) console.log("[qlik-pw][diag] " + d); - } - console.log( - `[qlik-pw][summary] rows=${result.size ?? 0} months=${result.months ?? 0} batches=${result.batches ?? 0} code15Retries=${result.code15Retries ?? 0} fallbackCount=${result.fallbackCount ?? 0} totalMs=${totalMs}`, - ); - - const wanted = codeCentraux ? new Set(codeCentraux) : null; - const out = new Map(); - const margeWeight = new Map(); - for (const r of result.rows ?? []) { - const code = String(r[0]).trim(); - if (!code || code === "-") continue; - if (wanted && !wanted.has(code)) continue; - - const ca = Number(r[1]) || 0; - const qte = Number(r[2]) || 0; - const nbMag = Number(r[3]) || 0; - const margePct = Number(r[5]) || 0; - const prev = out.get(code); - if (!prev) { - out.set(code, { - codeCentrale: code, - caReseau: ca, - qteReseau: qte, - nbMagasinsReseau: nbMag, - caParMagasinReseau: nbMag > 0 ? ca / nbMag : 0, - margePctReseau: margePct, - periode: dateFilter?.label, - }); - margeWeight.set(code, ca); - continue; - } - - prev.caReseau += ca; - prev.qteReseau += qte; - prev.nbMagasinsReseau = Math.max(prev.nbMagasinsReseau, nbMag); - const weight = margeWeight.get(code) ?? 0; - const nextWeight = weight + ca; - prev.margePctReseau = nextWeight > 0 ? ((prev.margePctReseau * weight) + (margePct * ca)) / nextWeight : Math.max(prev.margePctReseau, margePct); - prev.caParMagasinReseau = prev.nbMagasinsReseau > 0 ? prev.caReseau / prev.nbMagasinsReseau : 0; - margeWeight.set(code, nextWeight); - } - // Rattache le détail mensuel (par code central) au métrique agrégé. - // On produit DEUX représentations : - // - `qteByMonth` : { "YYYY-MM": qté } — format historique, dont dépend la - // colonne « Tendance / Réseau » de la Grille. Inchangé. - // - `metricsByMonth` : toutes les mesures du mois, pour la fiche produit. - const monthly = result.monthly ?? {}; - - // Mois RÉELLEMENT couverts par l'extraction = union des mois vus sur - // l'ensemble des codes. Distinguer « couvert, zéro vente » de « non - // extrait » est indispensable : sans cela, un article vendu seulement - // depuis mai voyait sa tendance calculée sur deux points (« +4 100 % »), - // et un mois non extrait passait pour une absence de vente. - const couverts = new Set(); - for (const parMois of Object.values(monthly)) { - for (const mois of Object.keys(parMois)) couverts.add(mois); - } - const moisCouverts = [...couverts].sort(); - console.log(`[qlik-pw] mois couverts par l'extraction (${moisCouverts.length}) : ${JSON.stringify(moisCouverts)}`); - - for (const [code, metric] of out) { - const mm = monthly[code] ?? {}; - const qteByMonth: Record = {}; - const metricsByMonth: Record = {}; - for (const mois of moisCouverts) { - const v = mm[mois]; - // Zéro EXPLICITE sur un mois couvert sans vente : c'est une - // information, au même titre qu'une vente. Un mois non couvert - // reste absent et sera ignoré par la tendance. - qteByMonth[mois] = Number(v?.qte) || 0; - // ⚠️ Toutes les mesures à zéro, pas seulement la quantité. Écrire - // `{ qte: 0 }` seul laissait `nbMag` absent sur les mois sans - // vente : la courbe « magasins vendeurs » exigeant les 12 mois, - // elle disparaissait dès qu'un produit avait un mois creux. - metricsByMonth[mois] = v ?? { qte: 0, ca: 0, nbMag: 0, caMag: 0, margePct: 0 }; - } - metric.qteByMonth = qteByMonth; - metric.metricsByMonth = metricsByMonth; - } - // Diagnostic CONCLUSIF : combien de codes ont des mois qui VARIENT vs identiques. - // Si presque tous varient → la mesure respecte le mois (feature OK). - // Si tous identiques → la mesure Qlik ignore la sélection Date. - let nVar = 0, nFlat = 0; - let exVar: NetworkMetric | null = null, exFlat: NetworkMetric | null = null; - for (const m of out.values()) { - const bm = m.qteByMonth; - if (!bm) continue; - const vals = Object.values(bm); - if (vals.length < 2) continue; - const allEq = vals.every((v) => v === vals[0]); - if (allEq) { nFlat++; if (!exFlat) exFlat = m; } - else { nVar++; if (!exVar) exVar = m; } - } - console.log(`[qlik-pw] qteByMonth distribution: ${nVar} codes VARIENT / ${nFlat} identiques (total ${nVar + nFlat})`); - if (exVar) console.log(`[qlik-pw] exemple VARIE ${exVar.codeCentrale}:`, JSON.stringify(exVar.qteByMonth)); - if (exFlat) console.log(`[qlik-pw] exemple IDENTIQUE ${exFlat.codeCentrale}:`, JSON.stringify(exFlat.qteByMonth)); - console.log(`[qlik-pw] ${out.size} produits réseau (cube ${result.size})`); - - // Contrôle de plausibilité AVANT toute écriture : des données complètes - // mais fausses (dernier mois pas encore chargé, mesure insensible au - // mois) laisseraient le cache faux jusqu'à la synchro suivante. - const corrections = [...out.values()].flatMap(assainirMetrique); - if (corrections.length) { - console.warn(`[qlik-pw] ${corrections.length} valeur(s) impossible(s) corrigée(s) : ${corrections.slice(0, 5).join(" ; ")}`); - } - if (dateFilter) { - const controle = controlerExtraction(out.values(), monthRanges.map((m) => m.label)); - for (const a of controle.avertissements) console.warn(`[qlik-pw][contrôle] ${a}`); - if (!controle.ok) throw new Error(`[qlik-pw] ${controle.motif} — cache inchangé`); - console.log(`[qlik-pw][contrôle] extraction validée (${out.size} codes, ${monthRanges.length} mois)`); - } - // Échantillon brut pour vérifier que les 4 nouvelles mesures (cols 4-7) renvoient des valeurs - const sample = (result.rows ?? []).slice(0, 3).map((r) => ({ - code: r[0], ca: r[1], qte: r[2], nbMag: r[3], caMag: r[4], margePct: r[5], - })); - console.log(`[qlik-pw] échantillon lignes:`, JSON.stringify(sample)); - return out; - } finally { - await ctx.close(); - } -} diff --git a/src/lib/qlik-search.ts b/src/lib/qlik-search.ts deleted file mode 100644 index 5502798..0000000 --- a/src/lib/qlik-search.ts +++ /dev/null @@ -1,662 +0,0 @@ -/** - * CollectFlow — Recherche produit **dans Qlik Sense** (source primaire). - * - * La recherche produit part du réseau, pas du catalogue Nancy : on interroge - * d'abord l'app Qlik « Magasins Vision Consolidée » pour trouver les articles - * qui correspondent au terme saisi (libellé ou code centrale), puis l'appelant - * rapproche les codes centraux trouvés de la base FF Nancy. - * - * Pourquoi Qlik d'abord : le réseau (~270 magasins) référence beaucoup plus de - * produits que Nancy. Chercher dans la base locale d'abord masquait tous les - * produits que le réseau travaille et que nous ne référençons pas — c'est - * précisément ce qu'on veut voir. - * - * Technique : même plomberie que `qlik-playwright.ts` (Chromium headless + - * websocket Engine in-page avec le qlik-csrf-token), car le proxy Qlik refuse - * un websocket « raw » côté serveur. - * - * ## Comment la sélection est construite - * - * La recherche d'un list object Qlik (`SearchListObjectFor`) applique un **OU** - * entre les mots : « tapis anti » a ramené 17 696 libellés sur l'app FF, dont - * l'immense majorité ne contient que « anti ». Sélectionner ces 17 696 valeurs - * faisait ensuite exploser le cube (`code 15 — Request aborted`). - * - * On procède donc en trois temps : - * 1. une recherche par mot, pour retenir le **plus sélectif** (celui qui - * ramène le moins de valeurs) ; - * 2. lecture de ces valeurs et filtrage **ET** côté client (tous les mots - * présents, insensible casse/accents) ; - * 3. sélection des seules valeurs retenues, **par numéro d'élément** - * (`SelectListObjectValues`) — exact, sans ré-appariement de texte. - * - * Le résultat final est revérifié une dernière fois avant d'être renvoyé : une - * liste sans rapport avec la recherche est pire que pas de résultat du tout. - */ - -import "server-only"; -import { getQlikBrowser as getBrowser, dansLaFileQlik } from "@/lib/qlik-browser"; -import { getQlikConfig, qlikNtlmSession, type QlikConfig } from "@/lib/qlik-client"; -import { buildGridNetworkQlikDateFilter, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range"; - -/** - * Un article trouvé côté Qlik, **avec ses mesures réseau sur 12 mois glissants**. - * - * Les mesures sortent du même cube que l'identité : une seule session Qlik pour - * toute la recherche. Les extraire dans une seconde session concurrente faisait - * abandonner le moteur (`code 15 — Request aborted`). - */ -export interface QlikArticleMatch { - /** Valeur de la dimension « Article Code » — clé de jointure de la Grille. */ - codeCentrale: string; - /** - * Champ `article_no_centrale` quand l'app l'expose : second candidat pour la - * jointure avec `articles.artcentrale`, les deux ne portant pas toujours le - * même format. - */ - codeCentraleAlt: string; - /** Libellé réseau, si l'app expose un champ libellé article. */ - libelle: string; - /** Fournisseur réseau, si l'app expose un champ fournisseur. */ - fournisseur: string; - /** CA réseau sur la fenêtre 12 mois glissants. */ - caReseau: number; - qteReseau: number; - nbMagasinsReseau: number; - caParMagasinReseau: number; - /** Taux de marge réseau, ratio brut Qlik (0.32 = 32 %). */ - margePctReseau: number; -} - -export interface QlikSearchOutcome { - matches: QlikArticleMatch[]; - /** Champ Qlik effectivement interrogé (diagnostic UI). */ - champUtilise: string | null; - /** true si Qlik a renvoyé plus de résultats que la limite demandée. */ - tronque: boolean; - /** - * Message de diagnostic quand la recherche aboutit à zéro article alors que - * Qlik a bien répondu (filtre non appliqué, terme trop large…). `null` en - * fonctionnement nominal. - */ - avertissement: string | null; - /** Fenêtre couverte par les mesures (`YYYY-MM_YYYY-MM`). */ - periode: string | null; -} - -/** - * Nombre maximum d'articles renvoyés par une recherche. - * - * Chaque code trouvé est ensuite ré-extrait avec son détail mensuel : au-delà - * de quelques dizaines, la seconde passe devient longue et le résultat - * illisible. L'utilisateur affine son terme. - */ -export const QLIK_SEARCH_MAX_RESULTS = 40; - -/** - * Champs candidats pour le libellé article, par ordre de préférence. - * - * Sur l'app FF, `Article` est le libellé métier et `article_libelle_ticket` le - * libellé (tronqué) imprimé sur le ticket. On essaie le premier qui donne des - * résultats : si un candidat ne matche rien, on passe au suivant. - */ -const CHAMPS_LIBELLE_PREFERES = ["Article", "article_libelle_ticket"]; - -/** Champs candidats pour le fournisseur, par ordre de préférence (nom avant code). */ -const CHAMPS_FOURNISSEUR_PREFERES = ["Fournisseur", "code_fournisseur"]; - -/** - * Champ portant le code centrale « brut » de l'article, quand il existe. - * Second candidat de jointure avec `articles.artcentrale` : sur l'app FF, la - * dimension « Article Code » ne renvoie pas le même format. - */ -const CHAMP_CODE_CENTRALE_ALT = "article_no_centrale"; - -interface InPageSearchResult { - ok: boolean; - error?: string; - /** [code, codeAlt, libellé, fournisseur, ca, qte, nbMag, caMag, margePct] */ - rows?: Array<[string, string, string, string, number, number, number, number, number]>; - champUtilise?: string | null; - /** Nombre d'articles uniques correspondant au terme (avant plafonnement). */ - total?: number; - /** Lignes brutes lues dans le cube (avant garde-fou). */ - lues?: number; - /** Lignes écartées parce qu'elles ne correspondaient pas au terme. */ - rejetees?: number; - /** true si le terme ramenait trop de valeurs pour être traité intégralement. */ - tropLarge?: boolean; - /** Journal compact des étapes, remonté dans les logs serveur. */ - trace?: string[]; -} - -/** - * Cherche dans Qlik les articles correspondant à `term`. - * - * Ne fait **aucune** mesure : on veut juste la liste des codes centraux (plus - * libellé / fournisseur si l'app les expose). Les métriques réseau sont - * ensuite extraites par `fetchNetworkMetricsPlaywright`, qui sait déjà produire - * le détail mensuel sur 12 mois glissants. - */ -export function searchQlikArticles( - term: string, - limit: number = QLIK_SEARCH_MAX_RESULTS, - cfg: QlikConfig = getQlikConfig(), -): Promise { - // Terme trop court : réponse vide immédiate, sans attendre la file. - if (term.trim().length < 3) return rechercher(term, limit, cfg); - // Même file que les extractions : deux sessions Engine simultanées font - // abandonner le serveur Qlik, et les deux requêtes échouent. - return dansLaFileQlik(`recherche « ${term.trim()} »`, () => rechercher(term, limit, cfg)); -} - -async function rechercher( - term: string, - limit: number, - cfg: QlikConfig, -): Promise { - const cleaned = term.trim(); - if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false, avertissement: null, periode: null }; - if (!cfg.appNetwork) throw new Error("[qlik-search] QLIK_APP_NETWORK manquant"); - if (!cfg.user || !cfg.password) throw new Error("[qlik-search] identifiants Qlik manquants"); - - const started = Date.now(); - // Latence laissée à l'Engine après chaque mutation de sélection. Même réglage - // que l'extraction (`QLIK_SETTLE_MS`) : sans elle, l'objet créé juste après - // est calculé sur l'état de sélection précédent. - const settleMs = Math.min(2000, Math.max(0, Number(process.env.QLIK_SETTLE_MS ?? "250") || 250)); - // Même fenêtre que la Grille : 12 mois complets glissants, mois courant exclu. - const dateFilter = buildGridNetworkQlikDateFilter( - new Date(), - QLIK_MONTHS_BACK_DEFAULT, - ); - const sess = await qlikNtlmSession(cfg); - const [cookieName, ...rest] = sess.cookie.split("="); - const cookieValue = rest.join("="); - - const browser = await getBrowser(); - const ctx = await browser.newContext({ - httpCredentials: { username: cfg.user, password: cfg.password, origin: `https://${cfg.host}` }, - ignoreHTTPSErrors: true, - }); - await ctx.addCookies([{ name: cookieName, value: cookieValue, domain: cfg.host, path: "/", secure: true }]); - - try { - const page = await ctx.newPage(); - page.on("console", (msg) => { - // Le client Qlik est très bavard au chargement (thèmes, extensions) : - // on ne relaie que ce qui vient de notre script. - const t = msg.text(); - if (t.startsWith("[cf]")) console.log(`[qlik-search][page] ${t}`); - }); - - let csrfToken: string | null = null; - let resolveToken: (() => void) | null = null; - const tokenReady = new Promise((r) => { resolveToken = r; }); - page.on("websocket", (ws) => { - const m = ws.url().match(/qlik-csrf-token=([^&]+)/); - if (m && !csrfToken) { csrfToken = decodeURIComponent(m[1]); resolveToken?.(); } - }); - - await page.goto(`https://${cfg.host}/sense/app/${cfg.appNetwork}`, { waitUntil: "domcontentloaded", timeout: cfg.timeoutMs }) - .catch(() => { /* le client Qlik ouvre son ws juste après */ }); - await Promise.race([tokenReady, page.waitForTimeout(15000)]); - if (!csrfToken) throw new Error("[qlik-search] qlik-csrf-token introuvable (client Qlik non chargé ?)"); - - const result = (await page.evaluate( - ({ app, dim, token, terme, limite, settleMs, serialsDate, mCa, mQte, mNbMag, mCaMag, mMarge, - champCodeAlt, champsLibellePreferes, champsFournisseurPreferes, champLibelleForce, champFournisseurForce }: { - app: string; dim: string; token: string; terme: string; limite: number; settleMs: number; - serialsDate: number[]; - mCa: string; mQte: string; mNbMag: string; mCaMag: string; mMarge: string; - champCodeAlt: string; - champsLibellePreferes: string[]; champsFournisseurPreferes: string[]; - champLibelleForce: string; champFournisseurForce: string; - }) => - new Promise((resolve) => { - /** Valeurs lues au maximum dans le champ recherché (2 pages Engine). */ - const MAX_VALEURS_LUES = 10000; - /** - * Valeurs sélectionnées au maximum. - * - * Mesuré en production : sélectionner les 13 446 libellés contenant - * « tapis » a mis 81 s puis 486 s, et faisait abandonner le cube - * (code 15). Le cube étant trié par quantité réseau décroissante et - * l'affichage plafonné à quelques dizaines de lignes, 300 libellés - * suffisent largement. - */ - const MAX_VALEURS_SELECTION = 300; - const PAGE_VALEURS = 5000; - - const loc = (window as unknown as { location: Location }).location; - const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`; - const ws = new WebSocket(url); - const trace: string[] = []; - let id = 0; - const pend = new Map void; rej: (e: unknown) => void }>(); - const rpc = (method: string, params: unknown, handle = -1) => new Promise<{ [k: string]: unknown }>((res, rej) => { - const i = ++id; pend.set(i, { res, rej }); - ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params })); - }); - const ready = new Promise((res, rej) => { - ws.onmessage = (ev: MessageEvent) => { - const m = JSON.parse(ev.data as string); - if (m.method === "OnConnected") return res(); - if (m.id && pend.has(m.id)) { - const x = pend.get(m.id)!; - pend.delete(m.id); - if (m.error) x.rej(new Error(JSON.stringify(m.error))); - else x.res(m.result); - } - }; - ws.onerror = () => rej(new Error("ws error (403 ?)")); - setTimeout(() => rej(new Error("ws timeout")), 30000); - }); - - const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - /** Qlik Engine code 15 = « Request aborted » : requête entrée en collision avec une évaluation. */ - const estCode15 = (e: unknown) => /"code"\s*:\s*15\b/.test(String((e as Error)?.message ?? e)); - const rpcRetry = async (method: string, params: unknown, handle: number, tentatives = 3) => { - let derniere: unknown; - for (let i = 1; i <= tentatives; i++) { - try { - return await rpc(method, params, handle); - } catch (e) { - derniere = e; - if (!estCode15(e) || i === tentatives) throw e; - trace.push("code15 sur " + method + ", tentative " + i + "/" + tentatives); - await sleep(settleMs * (i + 1)); - } - } - throw derniere; - }; - - const normaliser = (s: string) => s - .toLowerCase() - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/[^a-z0-9]+/g, " ") - .trim(); - const mots = normaliser(terme).split(" ").filter(Boolean); - - (async () => { - try { - await ready; - const open = await rpc("OpenDoc", { qDocName: app }); - const doc = (open.qReturn as { qHandle: number }).qHandle; - - // ─── 1. Inventaire des champs ──────────────────────────── - const fl = await rpc("CreateSessionObject", { - qProp: { - qInfo: { qType: "FieldList" }, - qFieldListDef: { qShowSystem: false, qShowHidden: false, qShowSemantic: true, qShowSrcTables: true }, - }, - }, doc); - const fll = await rpc("GetLayout", {}, (fl.qReturn as { qHandle: number }).qHandle); - const champs = ((((fll.qLayout as { qFieldList?: { qItems?: Array<{ qName: string }> } }).qFieldList?.qItems) ?? []) - .map((f) => f.qName)); - - const has = (nom: string, re: RegExp) => re.test(nom); - const champCode = champs.find((c) => c === "Article Code") - ?? champs.find((c) => has(c, /article/i) && has(c, /code/i)) - ?? "Article Code"; - - // Candidats libellé : surcharge d'env, puis noms connus de - // l'app FF, puis heuristique. On essaiera chacun dans l'ordre - // jusqu'à en trouver un qui ramène des valeurs. - const candidatsLibelle = [ - champLibelleForce, - ...champsLibellePreferes, - ...champs.filter((c) => has(c, /article/i) && has(c, /(libell|designation|désignation|denomination|dénomination)/i)), - ...champs.filter((c) => has(c, /(libell|designation|désignation)/i) && !has(c, /(magasin|region|région|famille|rayon|fournisseur)/i)), - ].filter((c, i, arr) => c && champs.includes(c) && arr.indexOf(c) === i); - - const champFournisseur = [ - champFournisseurForce, - ...champsFournisseurPreferes, - ...champs.filter((c) => has(c, /fournisseur/i)), - ...champs.filter((c) => has(c, /(fabricant|supplier)/i)), - ].find((c) => c && champs.includes(c)) ?? null; - - trace.push("code=" + champCode + " libellé candidats=" + JSON.stringify(candidatsLibelle) + " fournisseur=" + champFournisseur); - - // ─── 2. Recherche + sélection sur le champ le plus adapté ─ - // - // `SearchListObjectFor` applique un OU entre les mots. On - // cherche donc mot par mot pour retenir le plus sélectif, - // puis on filtre en ET côté client avant de sélectionner. - const chercherEtSelectionner = async (champ: string): Promise<{ retenues: number; brut: number; tropLarge: boolean }> => { - const lo = await rpcRetry("CreateSessionObject", { - qProp: { - qInfo: { qType: "cf-search-lb" }, - qListObjectDef: { - qDef: { qFieldDefs: [champ] }, - qInitialDataFetch: [], - }, - }, - }, doc); - const ret = lo.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; - const loId = String(ret.qGenericId ?? ret.qId ?? ""); - const lh = ret.qHandle; - try { - // 2a. Mot le plus sélectif (une recherche par mot, on ne lit que le compteur). - let meilleurMot = terme; - let meilleurNb = Number.POSITIVE_INFINITY; - for (const mot of (mots.length > 1 ? mots : [terme])) { - await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: mot }, lh); - const lay = await rpcRetry("GetLayout", {}, lh); - const nb = ((lay.qLayout as { qListObject?: { qSize?: { qcy?: number } } }).qListObject?.qSize?.qcy) ?? 0; - trace.push(" « " + mot + " » dans " + champ + " → " + nb + " valeur(s)"); - if (nb > 0 && nb < meilleurNb) { meilleurNb = nb; meilleurMot = mot; } - } - if (!Number.isFinite(meilleurNb) || meilleurNb === 0) return { retenues: 0, brut: 0, tropLarge: false }; - - // 2b. Relecture des valeurs du mot retenu. On relance la - // recherche systématiquement : la boucle ci-dessus a pu - // laisser le list object sur un autre mot. - await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: meilleurMot }, lh); - const aLire = Math.min(meilleurNb, MAX_VALEURS_LUES); - const elems: number[] = []; - let lues = 0; - for (let top = 0; top < aLire; top += PAGE_VALEURS) { - const d = await rpcRetry("GetListObjectData", { - qPath: "/qListObjectDef", - qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(PAGE_VALEURS, aLire - top) }], - }, lh); - const matrix = ((d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix) ?? []; - if (matrix.length === 0) break; - lues += matrix.length; - for (const ligne of matrix) { - const cell = ligne[0]; - const texte = String(cell?.qText ?? ""); - const elem = cell?.qElemNumber; - if (elem == null || elem < 0) continue; - // Filtre ET : tous les mots du terme présents. - const n = normaliser(texte); - if (!mots.every((m) => n.includes(m))) continue; - elems.push(elem); - if (elems.length >= MAX_VALEURS_SELECTION) break; - } - if (elems.length >= MAX_VALEURS_SELECTION) break; - } - trace.push(" mot retenu « " + meilleurMot + " » : " + lues + " valeur(s) lues → " + elems.length + " après filtre ET"); - if (elems.length === 0) { - return { retenues: 0, brut: meilleurNb, tropLarge: meilleurNb > MAX_VALEURS_LUES }; - } - - // 2c. Sélection par numéro d'élément : exact, aucun - // ré-appariement de texte possible. - await rpcRetry("SelectListObjectValues", { - qPath: "/qListObjectDef", - qValues: elems, - qToggleMode: false, - qSoftLock: true, - }, lh); - await sleep(settleMs); - return { retenues: elems.length, brut: meilleurNb, tropLarge: meilleurNb > MAX_VALEURS_LUES }; - } finally { - if (loId) { try { await rpc("DestroySessionObject", { qId: loId }, doc); } catch { /* noop */ } } - } - }; - - await rpc("ClearAll", { qLockedAlso: false }, doc); - await sleep(settleMs); - - let champChoisi: string | null = null; - let tropLarge = false; - for (const candidat of candidatsLibelle) { - const r = await chercherEtSelectionner(candidat); - tropLarge = tropLarge || r.tropLarge; - if (r.retenues > 0) { champChoisi = candidat; break; } - await rpc("ClearAll", { qLockedAlso: false }, doc); - await sleep(settleMs); - } - if (!champChoisi) { - // Repli code centrale (terme saisi = un code). - const r = await chercherEtSelectionner(champCode); - if (r.retenues > 0) champChoisi = champCode; - tropLarge = tropLarge || r.tropLarge; - } - if (!champChoisi) { - ws.close(); - return resolve({ ok: true, rows: [], champUtilise: null, total: 0, lues: 0, rejetees: 0, tropLarge, trace }); - } - trace.push("champ retenu = " + champChoisi); - - // Trace des sélections réellement actives : si elle est vide, - // la sélection n'a pas pris côté Engine. - try { - const cs = await rpc("CreateSessionObject", { - qProp: { qInfo: { qType: "cf-search-sel" }, qSelectionObjectDef: {} }, - }, doc); - const csl = await rpc("GetLayout", {}, (cs.qReturn as { qHandle: number }).qHandle); - const sel = (((csl.qLayout as { qSelectionObject?: { qSelections?: Array<{ qField?: string; qSelectedCount?: number; qTotal?: number }> } }) - .qSelectionObject?.qSelections) ?? []) - .map((s) => `${s.qField}:${s.qSelectedCount}/${s.qTotal}`); - trace.push("sélections actives=" + JSON.stringify(sel)); - } catch (e) { - trace.push("sélections actives: lecture impossible (" + String((e as Error)?.message || e) + ")"); - } - - // ─── 3. Fenêtre 12 mois glissants ──────────────────────── - // Sélectionnée APRÈS les libellés : restreindre les dates avant - // la recherche masquerait les articles non vendus sur la période. - if (serialsDate.length > 0) { - try { - const gd = await rpc("GetField", { qFieldName: "Date" }, doc); - const dh = (gd.qReturn as { qHandle: number }).qHandle; - for (let i = 0; i < serialsDate.length; i += 500) { - const lot = serialsDate.slice(i, i + 500); - await rpcRetry("SelectValues", { - qFieldValues: lot.map((v) => ({ qNum: v, qText: String(v) })), - qToggleMode: i > 0, - qSoftLock: true, - }, dh); - } - await sleep(settleMs); - trace.push("fenêtre Date sélectionnée (" + serialsDate.length + " jours)"); - } catch (e) { - trace.push("sélection Date impossible, mesures sur toute la période : " + String((e as Error)?.message || e)); - } - } - - // ─── 4. Identité + mesures réseau, dans UN SEUL cube ───── - // - // Les mesures sont dans le même cube que l'identité, et donc - // dans la même session Qlik : une seconde session concurrente - // pour les extraire faisait abandonner le moteur (code 15). - // - // Les master measures sont résolues par TITRE : le qLibraryId - // fiable est `qInfo.qId`, qui n'est pas le GUID du QRS — même - // approche que l'extraction de la Grille. - const norm = (x: string) => x.replace(/\s+/g, " ").trim().toLowerCase(); - const ml = await rpcRetry("CreateSessionObject", { - qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title" } } }, - }, doc); - const mll = await rpcRetry("GetLayout", {}, (ml.qReturn as { qHandle: number }).qHandle); - const items = (((mll.qLayout as { qMeasureList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string } }> } }).qMeasureList?.qItems) ?? []); - const parTitre = new Map(); - for (const it of items) { - const t = it.qMeta?.title ?? it.qData?.title ?? ""; - if (t) parTitre.set(norm(t), it.qInfo.qId); - } - const mesure = (titre: string, repli: string) => parTitre.get(norm(titre)) ?? repli; - const idCa = mesure("CA N", mCa); - const idQte = mesure("Quantité N", mQte); - const idNbMag = mesure("Magasin Ventes Nb N", mNbMag); - const idCaMag = mesure("CA par Magasin N", mCaMag); - const idMarge = mesure("Marge % N", mMarge); - - const champLibelleAffiche = champChoisi === champCode ? candidatsLibelle[0] ?? null : champChoisi; - const champAlt = champs.includes(champCodeAlt) ? champCodeAlt : null; - const dims: Array<{ qLibraryId?: string; qDef?: { qFieldDefs: string[] } }> = [{ qLibraryId: dim }]; - const idxAlt = champAlt ? dims.push({ qDef: { qFieldDefs: [champAlt] } }) - 1 : -1; - const idxLibelle = champLibelleAffiche ? dims.push({ qDef: { qFieldDefs: [champLibelleAffiche] } }) - 1 : -1; - const idxFournisseur = champFournisseur ? dims.push({ qDef: { qFieldDefs: [champFournisseur] } }) - 1 : -1; - - const nbDims = dims.length; - // Colonnes mesures, dans l'ordre : CA, Qté, NbMag, CA/mag, Marge %. - const idxQteCol = nbDims + 1; - const largeur = nbDims + 5; - // Cube trié par quantité réseau décroissante : la première page - // contient donc directement les meilleures ventes du réseau, et - // on n'a pas besoin de lire tout l'ensemble sélectionné. - const hauteur = Math.max(50, Math.min(400, Math.floor(9000 / largeur))); - - const defCube = (trie: boolean) => ({ - qProp: { - qInfo: { qType: "cf-search" }, - qHyperCubeDef: { - qDimensions: dims.map((d) => ({ ...d, qNullSuppression: true })), - qMeasures: [ - { qLibraryId: idCa }, - trie ? { qLibraryId: idQte, qSortBy: { qSortByNumeric: -1 } } : { qLibraryId: idQte }, - { qLibraryId: idNbMag }, - { qLibraryId: idCaMag }, - { qLibraryId: idMarge }, - ], - ...(trie ? { qInterColumnSortOrder: [idxQteCol, 0] } : {}), - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: hauteur }], - qSuppressZero: false, - qSuppressMissing: true, - }, - }, - }); - let cube: { [k: string]: unknown }; - try { - cube = await rpcRetry("CreateSessionObject", defCube(true), doc); - } catch (e) { - // Le tri par mesure est un confort (meilleures ventes en - // tête) : si l'Engine refuse cette forme, on repart sans. - if (estCode15(e)) throw e; - trace.push("tri par quantité refusé, cube non trié : " + String((e as Error)?.message || e)); - cube = await rpcRetry("CreateSessionObject", defCube(false), doc); - } - const ch = (cube.qReturn as { qHandle: number }).qHandle; - const layout = await rpcRetry("GetLayout", {}, ch); - const hc = (layout.qLayout as { - qHyperCube: { - qSize: { qcy: number }; - qDataPages?: Array<{ qMatrix?: Array> }>; - }; - }).qHyperCube; - const matrix = hc.qDataPages?.[0]?.qMatrix ?? []; - - // ─── 4. Garde-fou : le résultat DOIT correspondre au terme ── - const termeCode = terme.trim().toLowerCase(); - const correspond = (code: string, libelle: string): boolean => { - if (mots.length === 0) return true; - const c = code.toLowerCase(); - if (c === termeCode || c.startsWith(termeCode)) return true; - if (!libelle) return champChoisi === champCode; - const l = normaliser(libelle); - return mots.every((m) => l.includes(m)); - }; - - type Ligne = [string, string, string, string, number, number, number, number, number]; - const texte = (r: Array<{ qText?: string }>, i: number) => { - if (i < 0) return ""; - const v = String(r[i]?.qText ?? "").trim(); - return v === "-" ? "" : v; - }; - const parCode = new Map(); - let rejetees = 0; - for (const r of matrix) { - const code = String(r[0]?.qText ?? "").trim(); - if (!code || code === "-") continue; - const codeAlt = texte(r, idxAlt); - const libelle = texte(r, idxLibelle); - const fournisseur = texte(r, idxFournisseur); - if (!correspond(code, libelle)) { rejetees++; continue; } - const nombre = (i: number) => Number(r[nbDims + i]?.qNum) || 0; - const ligne: Ligne = [ - code, codeAlt, libelle, fournisseur, - nombre(0), nombre(1), nombre(2), nombre(3), nombre(4), - ]; - const existant = parCode.get(code); - if (!existant) { parCode.set(code, ligne); continue; } - // Même article chez plusieurs fournisseurs : une seule ligne. - // Les mesures sont identiques (elles ne dépendent pas de la - // dimension fournisseur), on complète juste les libellés. - if (!existant[1] && codeAlt) existant[1] = codeAlt; - if (!existant[2] && libelle) existant[2] = libelle; - if (!existant[3] && fournisseur) existant[3] = fournisseur; - } - const uniques = [...parCode.values()]; - const rows = uniques.slice(0, limite); - trace.push( - "cube=" + hc.qSize.qcy + " lignes lues=" + matrix.length + - " rejetées(hors terme)=" + rejetees + - " articles uniques=" + uniques.length + " retenus=" + rows.length, - ); - if (rows.length > 0) { - trace.push("échantillon=" + JSON.stringify(rows.slice(0, 3))); - } - - ws.close(); - resolve({ - ok: true, rows, champUtilise: champChoisi, total: uniques.length, - lues: matrix.length, rejetees, tropLarge, trace, - }); - } catch (e) { - try { ws.close(); } catch { /* noop */ } - resolve({ ok: false, error: String((e as Error)?.message || e), trace }); - } - })(); - }), - { - app: cfg.appNetwork, - dim: cfg.dimCodeArticleId, - token: csrfToken, - terme: cleaned, - limite: limit, - settleMs, - serialsDate: dateFilter.dailySerials, - mCa: cfg.measCaId, - mQte: cfg.measQteId, - mNbMag: cfg.measNbMagId, - mCaMag: cfg.measCaMagId, - mMarge: cfg.measMargePctId, - champCodeAlt: CHAMP_CODE_CENTRALE_ALT, - champsLibellePreferes: CHAMPS_LIBELLE_PREFERES, - champsFournisseurPreferes: CHAMPS_FOURNISSEUR_PREFERES, - champLibelleForce: (process.env.QLIK_FIELD_ARTICLE_LIBELLE ?? "").trim(), - champFournisseurForce: (process.env.QLIK_FIELD_FOURNISSEUR ?? "").trim(), - }, - )) as InPageSearchResult; - - for (const t of result.trace ?? []) console.log(`[qlik-search] ${t}`); - if (!result.ok) throw new Error(`[qlik-search] ${result.error}`); - - const matches: QlikArticleMatch[] = (result.rows ?? []).map( - ([codeCentrale, codeCentraleAlt, libelle, fournisseur, caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, margePctReseau]) => ({ - codeCentrale, codeCentraleAlt, libelle, fournisseur, - caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, margePctReseau, - }), - ); - console.log( - `[qlik-search] "${cleaned}" → ${matches.length} article(s) (total ${result.total ?? 0}, champ=${result.champUtilise}) en ${Date.now() - started} ms`, - ); - - // Zéro article alors que Qlik a répondu : on explique pourquoi plutôt que - // d'afficher un « aucun résultat » qui laisserait croire à une absence - // réelle dans le réseau. - let avertissement: string | null = null; - if (matches.length === 0 && (result.rejetees ?? 0) > 0) { - avertissement = "Qlik a répondu sans appliquer le filtre de recherche : les articles renvoyés ne correspondaient pas au terme et ont été écartés. Voir les logs [qlik-search]."; - } else if (matches.length === 0 && result.tropLarge) { - avertissement = "Le terme est trop large pour Qlik (plus de 10 000 libellés correspondants). Ajoutez un mot plus précis."; - } - if (avertissement) console.warn(`[qlik-search] ${avertissement}`); - - return { - matches, - champUtilise: result.champUtilise ?? null, - tronque: (result.total ?? 0) > matches.length, - avertissement, - periode: dateFilter.label, - }; - } finally { - await ctx.close(); - } -} diff --git a/src/lib/qlik-validation.ts b/src/lib/qlik-validation.ts deleted file mode 100644 index 6140bcd..0000000 --- a/src/lib/qlik-validation.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * CollectFlow — Contrôles de plausibilité d'une extraction réseau Qlik. - * - * L'extracteur vérifie déjà que les 12 mois de la fenêtre sont servis. Il ne - * peut pas voir deux pièges, observés ou attendus en production, qui donnent - * des chiffres faux mais complets : - * - * 1. **Dernier mois pas encore chargé dans Qlik.** Les premiers jours du mois, - * le mois qui vient de se terminer peut n'être chargé qu'en partie. Il est - * « couvert » (quelques ventes), donc accepté — et toutes les tendances du - * fournisseur plongent d'un coup, jusqu'à la synchro suivante, une semaine - * ou plus après. - * 2. **Mesure insensible au mois.** Quand une mesure ignore la sélection de - * dates, chaque mois reçoit la même valeur (le total de l'année). Le - * diagnostic existait dans les journaux ; rien n'empêchait l'écriture. - * - * Dans les deux cas, garder le cache précédent vaut mieux qu'écrire des - * données fausses : l'extraction est refusée, et le planificateur la reprend - * une nuit suivante. - * - * Module pur (aucune dépendance d'exécution) : testable hors serveur. - */ - -import type { NetworkMetric } from "@/lib/qlik-client"; - -export interface ControleExtraction { - ok: boolean; - /** Raison du refus, lisible par l'administrateur (statut de synchro). */ - motif?: string; - /** Anomalies corrigées ou tolérées, à journaliser. */ - avertissements: string[]; -} - -export interface OptionsControle { - /** - * Jusqu'à quel jour du mois le dernier mois est soupçonné d'être incomplet. - * Au-delà, une baisse est prise pour ce qu'elle est : une baisse. - */ - jourLimiteDernierMois?: number; - /** Part minimale du dernier mois par rapport à la moyenne des 3 précédents. */ - ratioDernierMoisMin?: number; - /** Volume mensuel moyen en dessous duquel le contrôle n'a pas de sens. */ - volumeMinimal?: number; - /** Part maximale de séries plates (même valeur 12 mois) parmi les séries vendues. */ - partSeriesPlatesMax?: number; -} - -const DEFAUTS: Required = { - jourLimiteDernierMois: 10, - ratioDernierMoisMin: 0.4, - volumeMinimal: 30, - partSeriesPlatesMax: 0.5, -}; - -const fini = (v: unknown): number => { - const n = Number(v); - return Number.isFinite(n) ? n : 0; -}; - -/** - * Corrige sur place les valeurs impossibles d'une métrique (NaN, infini, - * nombre de magasins négatif) et renvoie la liste des corrections. - */ -export function assainirMetrique(m: NetworkMetric): string[] { - const corrections: string[] = []; - const champs = ["caReseau", "qteReseau", "nbMagasinsReseau", "caParMagasinReseau", "margePctReseau"] as const; - for (const champ of champs) { - if (!Number.isFinite(Number(m[champ]))) { - corrections.push(`${m.codeCentrale} : ${champ} non numérique (${String(m[champ])}) remplacé par 0`); - m[champ] = 0; - } - } - if (m.nbMagasinsReseau < 0) { - corrections.push(`${m.codeCentrale} : nombre de magasins négatif (${m.nbMagasinsReseau}) remplacé par 0`); - m.nbMagasinsReseau = 0; - } - m.nbMagasinsReseau = Math.round(m.nbMagasinsReseau); - m.caParMagasinReseau = m.nbMagasinsReseau > 0 ? m.caReseau / m.nbMagasinsReseau : 0; - - if (m.qteByMonth) { - for (const [mois, v] of Object.entries(m.qteByMonth)) { - if (!Number.isFinite(Number(v))) { - corrections.push(`${m.codeCentrale} : quantité ${mois} non numérique remplacée par 0`); - m.qteByMonth[mois] = 0; - } - } - } - if (m.metricsByMonth) { - for (const [mois, v] of Object.entries(m.metricsByMonth)) { - m.metricsByMonth[mois] = { - qte: fini(v?.qte), - ca: fini(v?.ca), - nbMag: Math.max(0, Math.round(fini(v?.nbMag))), - caMag: fini(v?.caMag), - margePct: fini(v?.margePct), - }; - } - } - return corrections; -} - -/** - * Contrôle d'ensemble d'une extraction 12 mois, AVANT écriture du cache. - * - * `fenetre` : les mois attendus (« YYYY-MM »), du plus ancien au plus récent. - */ -export function controlerExtraction( - metriques: Iterable, - fenetre: string[], - now: Date = new Date(), - options: OptionsControle = {}, -): ControleExtraction { - const o = { ...DEFAUTS, ...options }; - const avertissements: string[] = []; - const liste = [...metriques]; - if (liste.length === 0 || fenetre.length < 4) return { ok: true, avertissements }; - - // ── 1. Séries plates : la mesure a ignoré la sélection de mois ────────── - let vendues = 0; - let plates = 0; - for (const m of liste) { - const valeurs = fenetre.map((mois) => fini(m.qteByMonth?.[mois])); - if (!valeurs.some((v) => v !== 0)) continue; - vendues++; - if (valeurs.every((v) => v === valeurs[0])) plates++; - } - if (vendues >= 5 && plates / vendues > o.partSeriesPlatesMax) { - return { - ok: false, - motif: `${plates} séries sur ${vendues} ont la même quantité les 12 mois : la mesure Qlik ignore le mois, données refusées`, - avertissements, - }; - } - if (plates > 0) avertissements.push(`${plates} série(s) identique(s) sur 12 mois parmi ${vendues} vendues`); - - // ── 2. Dernier mois manifestement incomplet ───────────────────────────── - const totaux = fenetre.map((mois) => liste.reduce((t, m) => t + fini(m.qteByMonth?.[mois]), 0)); - const dernier = totaux[totaux.length - 1]; - const precedents = totaux.slice(-4, -1); - const reference = precedents.reduce((t, v) => t + v, 0) / precedents.length; - if ( - now.getDate() <= o.jourLimiteDernierMois && - reference >= o.volumeMinimal && - dernier < reference * o.ratioDernierMoisMin - ) { - const pct = Math.round((dernier / reference) * 100); - return { - ok: false, - motif: `le dernier mois (${fenetre[fenetre.length - 1]}) ne pèse que ${pct} % des 3 mois précédents : ` - + `probablement pas encore entièrement chargé dans Qlik, données refusées (nouvel essai une prochaine nuit)`, - avertissements, - }; - } - - return { ok: true, avertissements }; -} diff --git a/src/lib/rpc.ts b/src/lib/rpc.ts new file mode 100644 index 0000000..5fc35a7 --- /dev/null +++ b/src/lib/rpc.ts @@ -0,0 +1,78 @@ +/** + * CollectFlow — Appels du navigateur vers le serveur Rust. + * + * - `callAction` remplace les anciennes « server actions » Next.js : chaque + * fonction garde son nom et sa signature, son corps devient + * `POST /api/actions/{nom}` avec le tableau des arguments. + * - `fetchPage` lit les données qu'une ancienne page serveur calculait avant + * rendu : `GET /api/pages/{nom}?…`. + * + * Une réponse en erreur (`{ error }`, statut 4xx/5xx) est relevée en `Error`, + * comme l'exception de l'action serveur l'était. Une session expirée (401) + * renvoie à la page de connexion, comme le faisait le middleware. + */ + +export type ParamsPage = Record | URLSearchParams; + +/** Redirige vers la connexion (session absente ou expirée). */ +function versConnexion(): void { + if (typeof window !== "undefined" && window.location.pathname !== "/login") { + window.location.assign("/login"); + } +} + +async function lireReponse(res: Response): Promise { + let body: unknown = null; + const texte = await res.text(); + if (texte) { + try { + body = JSON.parse(texte); + } catch { + body = null; + } + } + if (!res.ok) { + if (res.status === 401) versConnexion(); + const message = (body as { error?: unknown } | null)?.error; + throw new Error(typeof message === "string" && message ? message : res.statusText || `Erreur HTTP ${res.status}`); + } + return body as T; +} + +/** Appelle l'action serveur `name` avec ses arguments positionnels. */ +export async function callAction(name: string, args: unknown[] = []): Promise { + // JSON.stringify remplace `undefined` par `null` dans un tableau : on retire + // les arguments absents en fin de liste pour que le serveur voie « absent ». + const liste = [...args]; + while (liste.length > 0 && liste[liste.length - 1] === undefined) liste.pop(); + const res = await fetch(`/api/actions/${encodeURIComponent(name)}`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + credentials: "same-origin", + body: JSON.stringify(liste), + }); + return lireReponse(res); +} + +/** Construit la chaîne de requête d'une page (valeurs vides omises). */ +export function queryString(params?: ParamsPage): string { + if (!params) return ""; + const sp = new URLSearchParams(); + const entrees = params instanceof URLSearchParams ? [...params.entries()] : Object.entries(params); + for (const [k, v] of entrees) { + if (v === undefined || v === null || v === "") continue; + sp.append(k, String(v)); + } + const s = sp.toString(); + return s ? `?${s}` : ""; +} + +/** Données de la page serveur `name` (cf. `server/PORTING.md`, section Pages). */ +export async function fetchPage(name: string, params?: ParamsPage): Promise { + const res = await fetch(`/api/pages/${encodeURIComponent(name)}${queryString(params)}`, { + headers: { Accept: "application/json" }, + credentials: "same-origin", + cache: "no-store", + }); + return lireReponse(res); +} diff --git a/src/lib/throttled-json-storage.ts b/src/lib/throttled-json-storage.ts index aff5cbc..5a1a467 100644 --- a/src/lib/throttled-json-storage.ts +++ b/src/lib/throttled-json-storage.ts @@ -1,5 +1,3 @@ -"use client"; - import { createJSONStorage, type PersistStorage, type StorageValue } from "zustand/middleware"; /** diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..b23d1fc --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,8 @@ +import { createRoot } from "react-dom/client"; +import "./app/globals.css"; +import { AppRouter } from "./router"; + +const racine = document.getElementById("root"); +if (!racine) throw new Error("Élément #root introuvable dans index.html."); + +createRoot(racine).render(); diff --git a/src/middleware.ts b/src/middleware.ts deleted file mode 100644 index 6f4a357..0000000 --- a/src/middleware.ts +++ /dev/null @@ -1,48 +0,0 @@ -import NextAuth from "next-auth"; -import { authConfig } from "@/lib/auth.config"; -import { NextResponse } from "next/server"; - -const { auth } = NextAuth(authConfig); - -export default auth((req) => { - const isLoggedIn = !!req.auth; - const { nextUrl } = req; - - const isApiAuthRoute = nextUrl.pathname.startsWith("/api/auth"); - const isPublicRoute = nextUrl.pathname === "/login" || nextUrl.pathname.startsWith("/public/"); - // L'API publique s'authentifie elle-même (clé d'API ou session) et rend ses - // propres erreurs JSON. Sans cette exemption, un script non authentifié - // recevrait une redirection 302 + la page HTML de login au lieu d'un 401. - const isPublicApiRoute = nextUrl.pathname.startsWith("/api/v1"); - - // 1. Laisser passer les requêtes d'auth API - if (isApiAuthRoute) return NextResponse.next(); - - // 1 bis. Laisser passer /api/v1 — l'authentification est faite dans les handlers - if (isPublicApiRoute) return NextResponse.next(); - - // 2. Rediriger vers /login si non connecté et route non publique - if (!isLoggedIn && !isPublicRoute) { - return NextResponse.redirect(new URL("/login", nextUrl)); - } - - // 3. Déjà connecté et sur /login : page d'accueil (la même que « / »). - if (isLoggedIn && isPublicRoute) { - return NextResponse.redirect(new URL("/dashboard", nextUrl)); - } - - // 4. Protection par rôle (Admin seulement pour les paramètres) - const isAdminRoute = nextUrl.pathname.startsWith("/settings") || nextUrl.pathname.startsWith("/admin"); - const userRole = (req.auth?.user as any)?.role; - - if (isAdminRoute && userRole !== "admin") { - return NextResponse.redirect(new URL("/dashboard", nextUrl)); - } - - return NextResponse.next(); -}); - -export const config = { - // Protège toutes les routes sauf fichiers statiques et assets - matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.png$|.*\\.jpg$).*)"], -}; diff --git a/src/router.tsx b/src/router.tsx new file mode 100644 index 0000000..2ebaefd --- /dev/null +++ b/src/router.tsx @@ -0,0 +1,134 @@ +/** + * CollectFlow — Table des routes de la SPA. + * + * Reprend une à une les pages de l'ancien routeur Next.js (`src/app/**`) : + * même adresse, même titre d'onglet, même silhouette de chargement + * (`loading.tsx`), même mise en page du groupe `(dashboard)` (menu + en-tête) + * et même page d'erreur (`error.tsx`) ou « introuvable » (`not-found.tsx`). + * + * Le serveur Rust garde les accès : non connecté → /login, connecté sur / ou + * /login → /dashboard, /settings et /admin/* réservés aux administrateurs. + */ + +import { Suspense, useEffect, type ComponentType } from "react"; +import { BrowserRouter, Navigate, Outlet, Route, Routes, useLocation } from "react-router"; +import { isNotFoundError } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { Providers } from "@/app/providers"; +import { RouteErrorBoundary } from "@/components/shared/route-error-boundary"; + +import HomePage from "@/app/page"; +import NotFound from "@/app/not-found"; +import LoginPage from "@/app/(auth)/login/page"; +import DashboardLayout from "@/app/(dashboard)/layout"; +import DashboardError from "@/app/(dashboard)/error"; +import DashboardNotFound from "@/app/(dashboard)/not-found"; +import AdminSynchronisationPage from "@/app/(dashboard)/admin/synchronisation/page"; +import AnalyticsPage from "@/app/(dashboard)/analytics/page"; +import AnalyticsLoading from "@/app/(dashboard)/analytics/loading"; +import CommandesAutoPage from "@/app/(dashboard)/commandes-auto/page"; +import CommandesAutoLoading from "@/app/(dashboard)/commandes-auto/loading"; +import DashboardPage from "@/app/(dashboard)/dashboard/page"; +import DashboardLoading from "@/app/(dashboard)/dashboard/loading"; +import ExportsPage from "@/app/(dashboard)/exports/page"; +import GridPage from "@/app/(dashboard)/grid/page"; +import GridLoading from "@/app/(dashboard)/grid/loading"; +import HistoriquePage from "@/app/(dashboard)/historique/page"; +import HitParadePage from "@/app/(dashboard)/hit-parade/page"; +import HitParadeLoading from "@/app/(dashboard)/hit-parade/loading"; +import ProduitsPage from "@/app/(dashboard)/produits/page"; +import ProduitsLoading from "@/app/(dashboard)/produits/loading"; +import PublicitesPage from "@/app/(dashboard)/publicites/page"; +import PublicitesLoading from "@/app/(dashboard)/publicites/loading"; +import SettingsPage from "@/app/(dashboard)/settings/page"; +import SettingsLoading from "@/app/(dashboard)/settings/loading"; +import SnapshotsPage from "@/app/(dashboard)/snapshots/page"; +import StockNegatifPage from "@/app/(dashboard)/stock-negatif/page"; +import StockNegatifLoading from "@/app/(dashboard)/stock-negatif/loading"; + +interface PageRoute { + path: string; + /** Titre de l'onglet (« · CollectFlow ») ; absent : « CollectFlow ». */ + title?: string; + Page: ComponentType; + /** Silhouette affichée pendant le premier chargement des données (`loading.tsx`). */ + Loading?: ComponentType; +} + +/** Pages du groupe `(dashboard)` : menu latéral et en-tête autour. */ +const PAGES_TABLEAU_DE_BORD: PageRoute[] = [ + { path: "/dashboard", title: "Accueil", Page: DashboardPage, Loading: DashboardLoading }, + { path: "/grid", title: "Révision d'assortiment", Page: GridPage, Loading: GridLoading }, + { path: "/produits", title: "Recherche produit", Page: ProduitsPage, Loading: ProduitsLoading }, + { path: "/analytics", title: "Ventes par mois", Page: AnalyticsPage, Loading: AnalyticsLoading }, + { path: "/hit-parade", title: "Meilleures ventes", Page: HitParadePage, Loading: HitParadeLoading }, + { path: "/stock-negatif", title: "Stocks à surveiller", Page: StockNegatifPage, Loading: StockNegatifLoading }, + { path: "/commandes-auto", title: "Commandes fournisseurs", Page: CommandesAutoPage, Loading: CommandesAutoLoading }, + { path: "/publicites", title: "Publicités", Page: PublicitesPage, Loading: PublicitesLoading }, + { path: "/historique", title: "Historique", Page: HistoriquePage }, + { path: "/settings", title: "Paramètres", Page: SettingsPage, Loading: SettingsLoading }, + { path: "/admin/synchronisation", title: "Synchronisation", Page: AdminSynchronisationPage }, + // Anciennes adresses, conservées pour les favoris. + { path: "/snapshots", Page: SnapshotsPage }, + { path: "/exports", Page: ExportsPage }, +]; + +/** Titre de l'onglet du navigateur pour la page affichée. */ +function useDocumentTitle(title: string | undefined): void { + useEffect(() => { + document.title = title ? `${title} · CollectFlow` : "CollectFlow"; + }, [title]); +} + +/** Une page : son titre, et sa silhouette de chargement tant que ses données manquent. */ +function RoutePage({ title, Page, Loading }: Omit) { + useDocumentTitle(title); + return ( + : null}> + + + ); +} + +/** + * Mise en page `(dashboard)` : menu et en-tête, puis la frontière d'erreur de + * la page (`error.tsx`, ou `not-found.tsx` après `notFound()`), remise à zéro à + * chaque changement de page. + */ +function DashboardShell() { + const { pathname } = useLocation(); + const { status } = useSession(); + // Session absente ou expirée : la connexion, comme le faisait le middleware. + if (status === "unauthenticated") return ; + return ( + + + isNotFoundError(error) ? : + } + > + + + + ); +} + +export function AppRouter() { + return ( + + + + } /> + } /> + }> + {PAGES_TABLEAU_DE_BORD.map(({ path, ...page }) => ( + } /> + ))} + + } /> + + + + ); +} diff --git a/src/shims/next-auth-react.ts b/src/shims/next-auth-react.ts new file mode 100644 index 0000000..4068c2a --- /dev/null +++ b/src/shims/next-auth-react.ts @@ -0,0 +1,123 @@ +/** + * CollectFlow — Remplaçant de `next-auth/react` pour la SPA. + * + * La session est tenue par le serveur Rust (cookie HTTP-only) ; l'interface la + * lit sur `GET /api/auth/session` et se déconnecte par `POST /api/auth/logout`. + * Mêmes exports que ceux utilisés de next-auth : `SessionProvider`, + * `useSession`, `signOut`. + */ + +import { createContext, createElement, startTransition, use, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useRefreshVersion } from "@/lib/page-data"; + +export interface SessionUser { + id: string; + name: string; + role: string; +} + +export interface Session { + user: SessionUser; +} + +export type SessionStatus = "loading" | "authenticated" | "unauthenticated"; + +interface SessionContextValue { + data: Session | null; + status: SessionStatus; + /** Promesse de la session courante (pour suspendre une page en attendant). */ + promise: Promise; + update: () => Promise; +} + +/** + * Lit la session sur le serveur. Serveur injoignable ou réponse illisible : la + * session connue est conservée (une coupure réseau ne déconnecte pas). + */ +async function lireSession(connue: Session | null = null): Promise { + try { + const res = await fetch("/api/auth/session", { credentials: "same-origin", cache: "no-store" }); + if (!res.ok) return connue; + const body = (await res.json()) as { user?: SessionUser | null }; + return body?.user ? { user: body.user } : null; + } catch { + return connue; + } +} + +const SessionContext = createContext(null); + +/** Dernière session lue (une seule application par onglet). */ +let sessionConnue: Session | null = null; + +/** + * Charge la session au démarrage, puis la relit à chaque `router.refresh()` + * (sans repasser par l'état « loading » : l'interface ne clignote pas). + */ +export function SessionProvider({ children }: { children: ReactNode; refetchOnWindowFocus?: boolean }) { + const [promise, setPromise] = useState>(() => lireSession()); + const [etat, setEtat] = useState<{ data: Session | null; status: SessionStatus }>({ data: null, status: "loading" }); + const version = useRefreshVersion(); + const premiere = useRef(true); + + useEffect(() => { + let actif = true; + promise.then((s) => { + sessionConnue = s; + if (actif) setEtat({ data: s, status: s ? "authenticated" : "unauthenticated" }); + }); + return () => { + actif = false; + }; + }, [promise]); + + useEffect(() => { + if (premiere.current) { + premiere.current = false; + return; + } + // Rafraîchissement de la page : la session (rôle relu en base) aussi, en + // transition pour qu'une page qui l'attend reste affichée. + const p = lireSession(sessionConnue); + startTransition(() => setPromise(p)); + }, [version]); + + const update = useCallback(() => { + const p = lireSession(sessionConnue); + startTransition(() => setPromise(p)); + return p; + }, []); + + const value = useMemo(() => ({ ...etat, promise, update }), [etat, promise, update]); + return createElement(SessionContext, { value }, children); +} + +/** Session courante, comme `useSession()` de next-auth. */ +export function useSession(): Pick { + const ctx = useContext(SessionContext); + if (!ctx) throw new Error("useSession doit être utilisé dans ."); + return { data: ctx.data, status: ctx.status, update: ctx.update }; +} + +/** + * Session déjà chargée : suspend la page tant qu'elle ne l'est pas (les pages + * serveur la connaissaient avant leur rendu). + */ +export function useSessionResolue(): Session | null { + const ctx = useContext(SessionContext); + if (!ctx) throw new Error("useSessionResolue doit être utilisé dans ."); + return use(ctx.promise); +} + +/** Déconnexion : supprime le cookie de session puis, sauf `redirect: false`, va à la connexion. */ +export async function signOut(options: { redirect?: boolean; callbackUrl?: string } = {}): Promise { + try { + const res = await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" }); + await res.text(); + } catch { + /* le cookie expirera de lui-même ; on quitte quand même l'application */ + } + if (options.redirect !== false) { + window.location.assign(options.callbackUrl ?? "/login"); + } +} diff --git a/src/shims/next-link.tsx b/src/shims/next-link.tsx new file mode 100644 index 0000000..4722d37 --- /dev/null +++ b/src/shims/next-link.tsx @@ -0,0 +1,32 @@ +/** + * CollectFlow — Remplaçant de `next/link` pour la SPA (React Router). + * + * Mêmes props que celles utilisées de `next/link` : `href`, `prefetch`, + * `replace`, `scroll`, plus tous les attributs d'un ``. + */ + +import type { AnchorHTMLAttributes, Ref } from "react"; +import { Link as RouterLink } from "react-router"; + +export interface LinkProps extends Omit, "href"> { + href: string; + /** Sans effet : la SPA a déjà tout le code des pages. */ + prefetch?: boolean | null; + replace?: boolean; + /** `false` : garder la position de défilement. */ + scroll?: boolean; + ref?: Ref; +} + +/** Adresse hors application (autre origine, mailto:, fichier à télécharger…). */ +function estExterne(href: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//"); +} + +export default function Link({ href, prefetch: _prefetch, replace, scroll, ref, ...rest }: LinkProps) { + void _prefetch; + if (estExterne(href) || rest.download !== undefined) { + return ; + } + return ; +} diff --git a/src/shims/next-navigation.ts b/src/shims/next-navigation.ts new file mode 100644 index 0000000..59515f5 --- /dev/null +++ b/src/shims/next-navigation.ts @@ -0,0 +1,156 @@ +/** + * CollectFlow — Remplaçant de `next/navigation` pour la SPA (React Router). + * + * - `useRouter()` : push / replace / back / forward / refresh / prefetch. + * `refresh()` relit les données de la page affichée (cf. `lib/page-data`). + * - `usePathname()`, `useSearchParams()` : suivent aussi les + * `history.replaceState` / `pushState` faits directement par l'interface + * (changement d'onglet, de magasin…), que Next.js répercutait sur ces hooks + * sans recharger la page. React Router, lui, ne les voit pas. + * - `redirect()`, `notFound()` : pour un composant en cours de rendu. + */ + +import { useMemo, useSyncExternalStore } from "react"; +import { useLocation, useNavigate } from "react-router"; +import { refreshPage } from "@/lib/page-data"; + +// ─── Écritures directes dans l'historique ─────────────────────────────────── + +interface EtatRouteur { + key: string; + idx: number; +} + +function estEtatRouteur(v: unknown): v is EtatRouteur { + return !!v && typeof v === "object" && "key" in v && "idx" in v; +} + +/** Dernière URL écrite par l'interface, rattachée à l'entrée d'historique du routeur. */ +let urlDirecte: { key: string; pathname: string; search: string } | null = null; +let versionDirecte = 0; +const abonnes = new Set<() => void>(); + +function patcherHistorique(methode: "pushState" | "replaceState"): void { + const original = window.history[methode]; + window.history[methode] = function (this: History, data: unknown, unused: string, url?: string | URL | null) { + const precedent = this.state as unknown; + // React Router écrit un état { usr, key, idx } avec une clé neuve : ce + // n'est pas une écriture « directe » de l'interface. + const parRouteur = estEtatRouteur(data) && (!estEtatRouteur(precedent) || data.key !== precedent.key); + let etat = data; + if (!parRouteur && data == null && estEtatRouteur(precedent)) { + // `replaceState(null, …)` effaçait l'index du routeur : on le garde. + etat = precedent; + } + original.call(this, etat, unused, url); + if (!parRouteur) { + const cle = estEtatRouteur(etat) ? etat.key : "default"; + urlDirecte = { key: cle, pathname: window.location.pathname, search: window.location.search }; + versionDirecte++; + abonnes.forEach((f) => f()); + } + } as History[typeof methode]; +} + +if (typeof window !== "undefined" && !(window as { __cfHistoryPatched?: boolean }).__cfHistoryPatched) { + (window as { __cfHistoryPatched?: boolean }).__cfHistoryPatched = true; + patcherHistorique("pushState"); + patcherHistorique("replaceState"); +} + +function sAbonner(f: () => void): () => void { + abonnes.add(f); + return () => abonnes.delete(f); +} + +/** Emplacement affiché : celui du routeur, ou la dernière réécriture directe de cette entrée. */ +function useEmplacement(): { pathname: string; search: string } { + const location = useLocation(); + useSyncExternalStore(sAbonner, () => versionDirecte, () => 0); + if (urlDirecte && urlDirecte.key === location.key) { + return { pathname: urlDirecte.pathname, search: urlDirecte.search }; + } + return { pathname: location.pathname, search: location.search }; +} + +// ─── Hooks ────────────────────────────────────────────────────────────────── + +export type ReadonlyURLSearchParams = Omit; + +export function usePathname(): string { + return useEmplacement().pathname; +} + +export function useSearchParams(): ReadonlyURLSearchParams { + const { search } = useEmplacement(); + return useMemo(() => new URLSearchParams(search), [search]); +} + +export interface NavigateOptions { + scroll?: boolean; +} + +export interface AppRouterInstance { + push(href: string, options?: NavigateOptions): void; + replace(href: string, options?: NavigateOptions): void; + back(): void; + forward(): void; + refresh(): void; + prefetch(href: string): void; +} + +export function useRouter(): AppRouterInstance { + const navigate = useNavigate(); + return useMemo( + () => ({ + push: (href, options) => { + void navigate(href, { preventScrollReset: options?.scroll === false }); + }, + replace: (href, options) => { + void navigate(href, { replace: true, preventScrollReset: options?.scroll === false }); + }, + back: () => { + void navigate(-1); + }, + forward: () => { + void navigate(1); + }, + refresh: () => refreshPage(), + prefetch: () => {}, + }), + [navigate], + ); +} + +// ─── redirect / notFound ──────────────────────────────────────────────────── + +/** Erreur levée par `notFound()` : la frontière d'erreur affiche « Page introuvable ». */ +export class NotFoundError extends Error { + constructor() { + super("NEXT_NOT_FOUND"); + this.name = "NotFoundError"; + } +} + +export function isNotFoundError(e: unknown): e is NotFoundError { + return e instanceof NotFoundError; +} + +/** + * Redirection depuis un rendu : remplace l'entrée d'historique puis prévient le + * routeur (événement `popstate`), et interrompt le rendu en cours. + */ +export function redirect(url: string): never { + window.history.replaceState(null, "", url); + window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); + // Suspend le composant jusqu'au changement de page (jamais résolue). + throw new Promise(() => {}); +} + +export function permanentRedirect(url: string): never { + return redirect(url); +} + +export function notFound(): never { + throw new NotFoundError(); +} diff --git a/src/types/ff.ts b/src/types/ff.ts new file mode 100644 index 0000000..c65aaaa --- /dev/null +++ b/src/types/ff.ts @@ -0,0 +1,306 @@ +/** + * CollectFlow — Formes des données renvoyées par le serveur (pages et actions). + * + * Recopiées des anciens modules serveur Next.js (`lib/pg-ff-client.ts`, + * `lib/qlik-network-cache.ts`, `lib/api-ff-client.ts`, `lib/gammes-a-valider.ts`) + * lors du passage au serveur Rust : l'interface n'en garde que les types, le + * serveur renvoie exactement ces formes JSON. + */ + +// ─── Base FF (recopie PostgreSQL) ──────────────────────────────────────────── + +export interface DashboardSiteStats { + site: string; + ca_hier: number; + ca_n1: number; + tickets_hier: number; + tickets_n1: number; + qte_hier: number; + marge_hier: number; + lignes_hier: number; +} + +export interface DashboardTopItem { + codein: string; + libelle1: string; + ca: number; + qte: number; + marge: number; + stock: number; + fournisseur: string; +} + +export interface DashboardSiteTop10 { + site: string; + ca: DashboardTopItem[]; + qte: DashboardTopItem[]; + marge: DashboardTopItem[]; +} + +export interface DashboardData { + dateHier: string; + dateN1: string; + sites: DashboardSiteStats[]; + top10BySite: DashboardSiteTop10[]; +} + +export interface CaByFournisseurRow { + code: string; + nom: string; + site: string; + mois: string; + ca_ttc: number; +} + +export interface CaByNomenclatureRow { + code: string; + libelle: string; + site: string; + mois: string; + ca_ttc: number; +} + +export interface HitParadeRow { + codein: string; + libelle: string; + /** Référence fournisseur de l'article (artfou1.reference du fournisseur principal). */ + reference: string; + fournisseur: string; + nomenclature_code: string; + nomenclature: string; + site: string; + qte_vendue: number; + ca_ttc: number; + marge: number; + stock292: number; + stock579: number; + stockTotal: number; +} + +export interface PgStockNegatifRow { + codein: string; + libelle1: string; + fournisseur: string; + site: string; + stockdispo: number; + dernierevente: string | null; + derniereentree: string | null; +} + +export interface PgStockSansVenteRow { + codein: string; + libelle1: string; + fournisseur: string; + site: string; + stock_actuel: number; + derniere_entree: string | null; +} + +export interface PgSansVente6MoisRow { + codein: string; + libelle1: string; + fournisseur: string; + site: string; + stock_actuel: number; + derniere_vente: string | null; + derniere_entree: string | null; + jours_sans_vente: number | null; +} + +export interface PgCommandeAutoRow { + site: string; + codefou: string; + nomfou: string; + franco: number; + nb_articles: number; + montant_cde: number; + franco_atteint: boolean | null; + ecart_franco: number; +} + +/** + * Une ligne de résultat de recherche produit. Volontairement **légère** : + * aucune agrégation `mvtart` ici (le seq-scan du ILIKE domine déjà le coût). + * Le détail lourd n'est calculé qu'à l'ouverture de la fiche. + */ +export interface PgProduitSearchRow { + no_id: number; + codein: string; + code_centrale: string; + libelle1: string; + /** Nom du fournisseur principal (artfou1.preference = 1), sinon son code. */ + fournisseur: string; + codefou: string; + nomenclature_code: string; + nomenclature: string; + stock_total: number; + /** true si le produit a déjà des métriques réseau Qlik en cache. */ + has_reseau: boolean; + qte_reseau: number; + nb_magasins_reseau: number; +} + +/** Identité, prix et nomenclature d'un produit (une seule ligne). */ +export interface PgProduitDetailRow { + no_id: number; + codein: string; + code_centrale: string; + libelle1: string; + nom_no_id: number | null; + gtin: string; + reference: string; + pcb: number | null; + /** article_infosup.prix_vente_mini */ + pv_central: number | null; + /** cube_pa.pa */ + pa: number | null; + code3: string | null; + libelle3: string | null; + code2: string | null; + libelle2: string | null; + code1: string | null; + libelle1nom: string | null; +} + +/** Un fournisseur référencé pour l'article. */ +export interface PgProduitFournisseurRow { + codefou: string; + nomfou: string; + reference: string; + pcb: number | null; + /** true si artfou1.preference = 1 */ + principal: boolean; +} + +/** Stock temps réel par site pour un article. */ +export interface PgProduitStockRow { + site: string; + stockdispo: number; + qte: number; + valstock: number; + prmp: number; + dernierevente: string | null; + dernierereception: string | null; +} + +/** Une gamme affectée à l'article pour une saison donnée. */ +export interface PgGammeHistoryRow { + saison_no_id: number; + saison_code: string | null; + saison_libelle: string | null; + gamme_code: string; + gamme_libelle: string | null; +} + +/** Un produit de la même famille, comparé local vs réseau. */ +export interface PgOpportuniteRow { + codein: string; + libelle1: string; + code_centrale: string; + fournisseur: string; + qte_reseau: number; + nb_magasins_reseau: number; + ca_reseau: number; + ca_par_magasin_reseau: number; + qte_locale: number; + nb_sites_locaux: number; + qte_reseau_par_magasin: number; + qte_locale_par_magasin: number; + /** Écart réseau − local, par magasin, sur 12 mois. Positif = opportunité. */ + ecart_par_magasin: number; +} + +// ─── Réseau Qlik ───────────────────────────────────────────────────────────── + +/** Les mesures réseau Qlik pour un produit sur un mois donné. */ +export interface QlikMonthMetrics { + /** Quantité vendue réseau — toujours présente. */ + qte: number; + /** CA réseau du mois. */ + ca?: number; + /** Nombre de magasins ayant vendu le produit ce mois-là. */ + nbMag?: number; + /** CA moyen par magasin sur le mois (mesure Qlik brute). */ + caMag?: number; + /** Taux de marge réseau du mois (ratio brut Qlik, ex 0.32 = 32%). */ + margePct?: number; +} + +export interface NetworkMetricCached { + caReseau: number; + qteReseau: number; + nbMagasinsReseau: number; + caParMagasinReseau: number; + margePctReseau: number; + /** Quantité réseau par mois : { "YYYY-MM": qté } — null si non synchronisé avec dates. */ + qteByMonth: Record | null; + /** + * Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }. + * null pour les lignes synchronisées avant l'ajout de la colonne — l'UI + * retombe alors sur `qteByMonth`. + */ + metricsByMonth: Record | null; + /** Période couverte par l'extraction (ex "2025-03_2026-02"). */ + periode: string | null; + /** Libellé de l'article côté Qlik — `null` tant qu'aucune recherche produit ne l'a renseigné. */ + libelleReseau: string | null; + /** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */ + fournisseurReseau: string | null; + fetchedAt: string | null; +} + +// ─── API FF Nancy ──────────────────────────────────────────────────────────── + +export interface FfMensuelVentes { + nb_passages: string; + qte_vendue: string; // négatif (ex: "-2.000") + ca_ht: string; // négatif (ex: "-26.6600") + ca_ttc: string; + marge: string; // positif (ex: "12.7600") + taux_marge: string; +} + +export interface FfMensuelReceptions { + nb_receptions: string; + qte_recue: string; +} + +export interface FfMensuelEntry { + mois: string; // "YYYY-MM" + site: string; // code site ("292", "579") + stock_fin_mois: string; // stock fin de mois + prmp_fin_mois: string; // PRMP fin de mois + ventes: FfMensuelVentes | null; + receptions: FfMensuelReceptions | null; +} + +export interface FfSyncTable { + nom: string; + derniereSync: string; + nbLignes?: number; + /** "ok" ou état d'erreur renvoyé par la synchronisation. */ + statut?: string; + erreur?: string | null; +} + +export interface FfSyncStatus { + lastSync: string; + tables: FfSyncTable[]; +} + +// ─── Gammes proposées par l'API, à valider ─────────────────────────────────── + +export interface PropositionGamme { + codein: string; + /** Gamme proposée. */ + gamme: string; + /** Gamme enregistrée au moment de la proposition. */ + gammePrecedente: string | null; +} + +export interface GammeAValider extends PropositionGamme { + auteur: string | null; + /** Date ISO de la proposition. */ + proposeeLe: string; +} + diff --git a/src/types/httpntlm.d.ts b/src/types/httpntlm.d.ts deleted file mode 100644 index 0191dc0..0000000 --- a/src/types/httpntlm.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -declare module "httpntlm" { - interface NtlmOptions { - url: string; - username: string; - password: string; - domain?: string; - workstation?: string; - headers?: Record; - rejectUnauthorized?: boolean; - [key: string]: unknown; - } - interface NtlmResponse { - statusCode: number; - headers: Record; - body: string; - } - type Cb = (err: Error | null, res: NtlmResponse) => void; - const httpntlm: { - get(opts: NtlmOptions, cb: Cb): void; - post(opts: NtlmOptions, cb: Cb): void; - }; - export default httpntlm; -} diff --git a/tsconfig.json b/tsconfig.json index cf9c65d..8ff1d11 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2017", + "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, + "allowJs": false, "skipLibCheck": true, "strict": true, "noEmit": true, @@ -12,23 +12,14 @@ "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], + "types": ["vite/client"], "paths": { + "next/link": ["./src/shims/next-link.tsx"], + "next/navigation": ["./src/shims/next-navigation.ts"], + "next-auth/react": ["./src/shims/next-auth-react.ts"], "@/*": ["./src/*"] } }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts", - "**/*.mts" - ], - "exclude": ["node_modules"] + "include": ["src", "vite.config.mts"], + "exclude": ["node_modules", "dist", "server"] } diff --git a/vite.config.mts b/vite.config.mts new file mode 100644 index 0000000..b08e4c6 --- /dev/null +++ b/vite.config.mts @@ -0,0 +1,38 @@ +import { fileURLToPath, URL } from "node:url"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +const src = (chemin: string) => fileURLToPath(new URL(`./src/${chemin}`, import.meta.url)); + +/** + * Interface CollectFlow : SPA React compilée dans `dist/`, servie par le + * serveur Rust (`STATIC_DIR`). Tailwind 4 passe par PostCSS (postcss.config.mjs). + * + * Les modules `next/link`, `next/navigation` et `next-auth/react` sont + * remplacés par de petites adaptations à React Router et à l'authentification + * du serveur Rust (src/shims) : les composants restent inchangés. + */ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: [ + { find: /^next\/link$/, replacement: src("shims/next-link.tsx") }, + { find: /^next\/navigation$/, replacement: src("shims/next-navigation.ts") }, + { find: /^next-auth\/react$/, replacement: src("shims/next-auth-react.ts") }, + { find: /^@\//, replacement: src("") }, + ], + }, + server: { + port: 3000, + proxy: { + "/api": { target: "http://localhost:5643", changeOrigin: false }, + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + sourcemap: false, + // exceljs et jspdf (chargés à la demande) dépassent le seuil par défaut. + chunkSizeWarningLimit: 1000, + }, +});