From e4ae34ddbce58219a5aa3d219a678886fc4bd9cc Mon Sep 17 00:00:00 2001 From: ali_mohaghegh Date: Thu, 3 Sep 2026 15:00:58 +0330 Subject: [PATCH] feat(sync): live cross-module cache invalidation (no manual refresh) - procurement store refreshes right after a warehouse receipt (arrivals) and after role/project changes (organization/admin), so the ledger reflects warehouse actions immediately - refetchOnWindowFocus enabled on the shared query client - BroadcastChannel (lib/crossTab.ts): mutations broadcast 'quotes' / 'inventory'; all warehouse pages + the store hook resync open tabs automatically (receipt in one tab updates the ledger in another) - warehouse mutations (stock changes) notify other tabs; same-page refresh behavior unchanged --- frontend/src/hooks/index.ts | 2 +- frontend/src/hooks/useStoreData.ts | 29 +++++++--- frontend/src/lib/crossTab.ts | 54 +++++++++++++++++++ frontend/src/lib/queryClient.ts | 15 ++++++ frontend/src/main.tsx | 12 +---- frontend/src/pages/Inventory/ArrivalsPage.tsx | 15 +++--- .../pages/Inventory/ChangeInventoryPage.tsx | 17 +++--- .../src/pages/Inventory/OrganizationPage.tsx | 12 ++--- .../src/pages/Inventory/useInventoryData.ts | 9 ++++ 9 files changed, 122 insertions(+), 43 deletions(-) create mode 100644 frontend/src/lib/crossTab.ts create mode 100644 frontend/src/lib/queryClient.ts diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 15eece7..b209538 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1,4 +1,4 @@ -export { useStoreData, useInvalidateStore } from "./useStoreData"; +export { useStoreData, useInvalidateStore, refreshProcurementStore } from "./useStoreData"; export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./useQuotes"; export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers"; export { useMaterials, useAddMaterial } from "./useMaterials"; diff --git a/frontend/src/hooks/useStoreData.ts b/frontend/src/hooks/useStoreData.ts index ce7b241..278deaf 100644 --- a/frontend/src/hooks/useStoreData.ts +++ b/frontend/src/hooks/useStoreData.ts @@ -4,12 +4,16 @@ import { useEffect } from "react"; import { useCallback } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { api } from "../lib/api"; import { useAppStore } from "../store"; +import { notifyDataChanged, useDataChanged } from "../lib/crossTab"; +import { queryClient } from "../lib/queryClient"; export const STORE_KEY = ["store"] as const; +export { queryClient }; + // یک بار در ریشه‌ی برنامه صدا زده می‌شود: لود + mirror در Zustand export function useStoreData(): void { const setData = useAppStore((s) => s.setData); @@ -21,12 +25,25 @@ export function useStoreData(): void { useEffect(() => { if (data) setData(data); }, [data, setData]); + + // تب‌های دیگر (رسید انبار / تغییر نقش و...) → بازخوانی خودکار store + useDataChanged( + useCallback(() => { + void queryClient.invalidateQueries({ queryKey: STORE_KEY }); + }, []), + ["quotes"], + ); } -// بعد از هر mutation برای همگرایی با حقیقت +// بعد از هر mutation برای همگرایی با حقیقت (+ اعلام به تب‌های دیگر) export function useInvalidateStore(): () => void { - const qc = useQueryClient(); - return useCallback(() => { - void qc.invalidateQueries({ queryKey: STORE_KEY }); - }, [qc]); + return useCallback(refreshProcurementStore, []); +} + +// نسخه‌ی تابعی (خارج از کامپوننت هم قابل صدا زدن) — بعد از تغییر داده‌های +// مرتبط با پیشنهادها (مثلاً رسید انبار) خودِ store را تازه می‌کند تا دفتر +// بدون refresh دستی به‌روز شود؛ و به تب‌های دیگر هم اعلام می‌کند. +export function refreshProcurementStore(): void { + void queryClient.invalidateQueries({ queryKey: STORE_KEY }); + notifyDataChanged("quotes"); } diff --git a/frontend/src/lib/crossTab.ts b/frontend/src/lib/crossTab.ts new file mode 100644 index 0000000..9174eaf --- /dev/null +++ b/frontend/src/lib/crossTab.ts @@ -0,0 +1,54 @@ +// ============================================================ +// همگام‌سازی بین تب‌ها (BroadcastChannel) — بعد از هر mutation که روی +// داده‌های «پیشنهادها» یا «انبار» اثر می‌گذارد، به تب‌های دیگر اعلام می‌شود +// تا آن‌ها هم داده‌های خود را تازه کنند (بدون نیاز به refresh دستی). +// BroadcastChannel در خودِ همان تب پیام را نمی‌دهد — هم‌تبه‌ها همان‌جا +// refresh می‌شوند و این فقط برای تب‌های دیگر است. +// ============================================================ + +import { useEffect } from "react"; + +const CHANNEL = "procurement-data-sync"; + +export type DataScope = "quotes" | "inventory"; + +let _channel: BroadcastChannel | null = null; + +function channel(): BroadcastChannel | null { + if (typeof BroadcastChannel === "undefined") return null; + if (!_channel) { + try { + _channel = new BroadcastChannel(CHANNEL); + } catch { + _channel = null; + } + } + return _channel; +} + +/** بعد از تغییر داده در این تب، به بقیه‌ی تب‌ها اعلام کن. */ +export function notifyDataChanged(scope: DataScope): void { + const ch = channel(); + if (!ch) return; + try { + ch.postMessage({ scope }); + } catch { + /* نادیده — پیام فقط به‌روزرسانی است */ + } +} + +/** گوش دادن به تغییرات تب‌های دیگر (scope دلخواه یا همه). */ +export function useDataChanged(onEvent: (scope: DataScope) => void, scopes?: DataScope[]): void { + useEffect(() => { + const ch = channel(); + if (!ch) return; + const handler = (ev: MessageEvent) => { + const scope = (ev.data as { scope?: DataScope } | null)?.scope; + if (!scope) return; + if (scopes && !scopes.includes(scope)) return; + onEvent(scope); + }; + ch.addEventListener("message", handler); + return () => ch.removeEventListener("message", handler); + }, [onEvent, scopes?.join(",")]); +} diff --git a/frontend/src/lib/queryClient.ts b/frontend/src/lib/queryClient.ts new file mode 100644 index 0000000..35b4851 --- /dev/null +++ b/frontend/src/lib/queryClient.ts @@ -0,0 +1,15 @@ +// ============================================================ +// TanStack Query client — مشترک در کل برنامه (و helperهای سوییچر ماژول‌ها) +// ============================================================ + +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + // بازخوانی هنگام بازگشت به تب — تا داده‌های ماژول دیگر تازه بماند + refetchOnWindowFocus: true, + }, + }, +}); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 57ab0c4..40ae341 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,19 +1,11 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; import { Toaster } from "sonner"; import App from "./App"; import "./styles/globals.css"; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - refetchOnWindowFocus: false, - }, - }, -}); +import { queryClient } from "./lib/queryClient"; createRoot(document.getElementById("root")!).render( diff --git a/frontend/src/pages/Inventory/ArrivalsPage.tsx b/frontend/src/pages/Inventory/ArrivalsPage.tsx index f57f466..162bb22 100644 --- a/frontend/src/pages/Inventory/ArrivalsPage.tsx +++ b/frontend/src/pages/Inventory/ArrivalsPage.tsx @@ -1,15 +1,13 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { useLocation } from "react-router-dom"; -import { Warehouse as WarehouseIcon } from "lucide-react"; import { toast } from "sonner"; import { Header, PageLayout } from "../../components/layout/Header"; import { EmptyState } from "../../components/EmptyState"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { useAuthStore } from "../../store/auth"; -import { PanelMenu } from "../../components/PanelMenu"; -import { accountMenuItems, inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu"; +import { inventorySubheader } from "./panelMenu"; import { apiV26 } from "../../lib/api"; +import { refreshProcurementStore } from "../../hooks"; import type { PendingArrival, Warehouse } from "../../lib/types"; import { faNum, fmtDate } from "../../lib/formatters"; @@ -22,8 +20,6 @@ interface AllocRow { key: number; qty: string; whId: string; } interface ItemDraft { received: string; allocs: AllocRow[]; } export function ArrivalsPage() { - const location = useLocation(); - const logout = useLogout(); const user = useAuthStore((s) => s.user); const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; const isPM = user?.role === "pm"; @@ -193,6 +189,9 @@ export function ArrivalsPage() { }); } toast.success("رسید (کامل یا جزیی) ثبت شد و به انبارها وارد شد ✓ — اقلام باقی‌مانده همچنان در راه هستند"); + // همگام‌سازی با دفتر تدارکات: store پیشنهادها را تازه کن تا کارت‌های + // پیشنهاد (بدون refresh دستی) رسید را نشان دهند. + refreshProcurementStore(); setDrafts((d) => { const next = { ...d }; submittable.forEach((it) => delete next[it.item_decision_id]); @@ -214,7 +213,7 @@ export function ArrivalsPage() { if (!isAdmin && !isPM) { return (
-
} /> +
@@ -229,8 +228,6 @@ export function ArrivalsPage() { sub={inventorySubheader(user)} backTo="/inventory" backLabel="بازگشت به داشبورد" - icon={} - menu={} />
diff --git a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx index 01cca3b..b3be8f9 100644 --- a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx +++ b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx @@ -1,12 +1,11 @@ import { useEffect, useMemo, useState } from "react"; -import { useLocation, useSearchParams } from "react-router-dom"; -import { Warehouse as WarehouseIcon } from "lucide-react"; +import { useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { Header, PageLayout } from "../../components/layout/Header"; import { useAuthStore } from "../../store/auth"; -import { PanelMenu } from "../../components/PanelMenu"; -import { inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu"; +import { inventorySubheader } from "./panelMenu"; import { apiV26 } from "../../lib/api"; +import { notifyDataChanged } from "../../lib/crossTab"; import { useInventoryData, unitName, whName } from "./useInventoryData"; import { JalaliDatePicker } from "../../components/JalaliDatePicker"; import type { InventoryTransaction, TransactionType } from "../../lib/types"; @@ -46,8 +45,6 @@ function jalDateLabel(d: Date): string { // تغییر موجودی — پورت کامل صفحه‌ی ارسالی (همه انواع تراکنش) export function ChangeInventoryPage() { - const location = useLocation(); - const logout = useLogout(); const user = useAuthStore((s) => s.user); const data = useInventoryData(); const [params] = useSearchParams(); @@ -186,6 +183,8 @@ export function ChangeInventoryPage() { try { for (const op of ops) await postOne(op); toast.success("ثبت شد ✓"); + // اعلام به تب‌های دیگر (داشبورد/کنترل روزانه/... باز خودشان را تازه می‌کنند) + notifyDataChanged("inventory"); setRows([]); setMatTxt(""); setBrandTxt(""); @@ -394,7 +393,7 @@ export function ChangeInventoryPage() { if (isAdmin) { return (
-
} /> +
🔒
@@ -408,7 +407,7 @@ export function ChangeInventoryPage() { if (!isPM) { return (
-
} /> +
این بخش فقط برای مدیر پروژه است.
@@ -426,8 +425,6 @@ export function ChangeInventoryPage() { sub={inventorySubheader(user)} backTo="/inventory" backLabel="بازگشت به داشبورد" - icon={} - menu={} /> {/* انتخاب نوع تراکنش */} diff --git a/frontend/src/pages/Inventory/OrganizationPage.tsx b/frontend/src/pages/Inventory/OrganizationPage.tsx index 8e7f50b..77bb8b9 100644 --- a/frontend/src/pages/Inventory/OrganizationPage.tsx +++ b/frontend/src/pages/Inventory/OrganizationPage.tsx @@ -1,12 +1,11 @@ import { useCallback, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Warehouse as WarehouseIcon } from "lucide-react"; import { toast } from "sonner"; import { Header, PageLayout } from "../../components/layout/Header"; import { useAuthStore } from "../../store/auth"; -import { PanelMenu } from "../../components/PanelMenu"; -import { accountMenuItems, inventorySubheader, useLogout } from "./panelMenu"; +import { inventorySubheader } from "./panelMenu"; import { apiV26, type UserRow } from "../../lib/api"; +import { refreshProcurementStore } from "../../hooks"; import { useInventoryData } from "./useInventoryData"; import { faNum } from "../../lib/formatters"; @@ -21,7 +20,6 @@ const ROLE_LABELS: Record = { // سازمان‌دهی (فقط ادمین / مدیر تدارکات) — کاربران، مدیران پروژه و انبارهای هر پروژه export function OrganizationPage() { const navigate = useNavigate(); - const logout = useLogout(); const user = useAuthStore((s) => s.user); const data = useInventoryData(); @@ -74,7 +72,7 @@ export function OrganizationPage() { if (!canManage) { return (
-
} /> +
این بخش فقط برای ادمین / مدیر تدارکات است.
@@ -90,6 +88,7 @@ export function OrganizationPage() { await apiV26.users.updateRole(u.id, role); toast.success(`نقش «${ROLE_LABELS[role ?? ""] ?? "بدون نقش"}» برای ${u.first_name} ${u.last_name} ثبت شد ✓`); await loadUsers(); + refreshProcurementStore(); } catch (err) { toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش"); } finally { @@ -105,6 +104,7 @@ export function OrganizationPage() { toast.success(project ? `«${u.first_name} ${u.last_name}» به پروژه «${project.name}» متصل شد ✓` : "مدیر پروژه از پروژه جدا شد"); await loadUsers(); await data.refresh(); + refreshProcurementStore(); } catch (err) { toast.error(err instanceof Error ? err.message : "خطا در اتصال مدیر به پروژه"); } finally { @@ -158,8 +158,6 @@ export function OrganizationPage() { sub={inventorySubheader(user)} backTo="/inventory" backLabel="بازگشت به داشبورد" - icon={} - menu={} />
diff --git a/frontend/src/pages/Inventory/useInventoryData.ts b/frontend/src/pages/Inventory/useInventoryData.ts index dc1171e..9f7c727 100644 --- a/frontend/src/pages/Inventory/useInventoryData.ts +++ b/frontend/src/pages/Inventory/useInventoryData.ts @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { apiV26 } from "../../lib/api"; import { useAuthStore } from "../../store/auth"; +import { useDataChanged } from "../../lib/crossTab"; import type { InventoryRow, InventoryTransaction, Warehouse } from "../../lib/types"; export interface LookupData { @@ -63,6 +64,14 @@ export function useInventoryData(): InventoryData { void load(); }, [load]); + // تب‌های دیگر (ثبت تراکنش/رسید در تب دوم) → بازخوانی خودکار همین صفحه + useDataChanged( + useCallback(() => { + void load(); + }, [load]), + ["quotes", "inventory"], + ); + const scopedWarehouses = useMemo( () => user?.role === "admin" || user?.role === "procurement_manager"