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
This commit is contained in:
ali_mohaghegh
2026-09-03 15:00:58 +03:30
parent f9ec68815e
commit e4ae34ddbc
9 changed files with 122 additions and 43 deletions
@@ -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 (
<div className="min-h-screen">
<Header title="کالاهای در راه" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
<Header title="کالاهای در راه" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<EmptyState emoji="🔒" text="این بخش فقط برای مدیر پروژه و مدیر تدارکات است." />
</PageLayout>
@@ -229,8 +228,6 @@ export function ArrivalsPage() {
sub={inventorySubheader(user)}
backTo="/inventory"
backLabel="بازگشت به داشبورد"
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<PanelMenu title="پنل مدیریت انبار" items={isAdmin ? accountMenuItems(logout) : pmMenuItems(logout)} />}
/>
<PageLayout className="flex flex-col gap-4">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
@@ -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 (
<div className="min-h-screen">
<Header title="تغییر موجودی" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
<Header title="تغییر موجودی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<div className="rounded-xl border border-line bg-panel p-8 text-center">
<div className="text-3xl">🔒</div>
@@ -408,7 +407,7 @@ export function ChangeInventoryPage() {
if (!isPM) {
return (
<div className="min-h-screen">
<Header title="تغییر موجودی" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
<Header title="تغییر موجودی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<div className="text-[13px] text-muted">این بخش فقط برای مدیر پروژه است.</div>
</PageLayout>
@@ -426,8 +425,6 @@ export function ChangeInventoryPage() {
sub={inventorySubheader(user)}
backTo="/inventory"
backLabel="بازگشت به داشبورد"
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<PanelMenu title="پنل مدیریت انبار" items={pmMenuItems(logout)} />}
/>
<PageLayout className="flex flex-col gap-3">
{/* انتخاب نوع تراکنش */}
@@ -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<string, string> = {
// سازمان‌دهی (فقط ادمین / مدیر تدارکات) — کاربران، مدیران پروژه و انبارهای هر پروژه
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 (
<div className="min-h-screen">
<Header title="سازمان‌دهی" menu={<PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} />} />
<Header title="سازمان‌دهی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<div className="text-[13px] text-muted">این بخش فقط برای ادمین / مدیر تدارکات است.</div>
</PageLayout>
@@ -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={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} />}
/>
<PageLayout className="flex flex-col gap-3">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
@@ -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"