Task 1 — PM 1:N + access-loss handling:
- projects.pm_user_id had no UNIQUE constraint (verified live + in schema); added a lookup index via init_db
- auth: UserContext.project_ids = every project of a PM (oldest first); project_id stays the first for compatibility
- GET /users/me/projects — assigned projects of the caller (drives the switcher + revocation validation)
- login / /users/me / /users list now return the full projects array per user
- /inventory/* and /pm/* scopes accept an explicit ?project_id=… and verify the PM manages it (403 otherwise); without the param they fall back to the first project; admin/manager use it as an optional filter
- POST /users/{id}/project is multi-aware: assigns one project (replacing that project's previous PM) and KEEPS the user's other assignments; null clears all
- PATCH /projects/{id} accepts pm_user_id (admin / procurement manager only)
- frontend: auth store holds projects + the active project (persisted via the existing localStorage store); RequirePmProjects re-validates on load, falls back when the active project was revoked, and shows a dedicated no-projects screen with logout (no redirect loop, no re-auth)
- Panels menu: «مدیریت انبار» gets a ChevronDown accordion listing the PM's projects with «✓ فعلی»; selecting one switches only the active project and reloads the section data
- inventory/pm API calls now send the active project_id explicitly
Task 2 — Organization page:
- new searchable ProjectMultiSelect combobox (chips with ×, live Persian filtering via normalize, assigned projects hidden) replaces the single select for PM users
- add = PATCH project pm_user_id=<user>; × = pm_user_id=null; table and project cards sync immediately; RBAC unchanged (role column admin-only, combobox editable for admin + procurement manager)
Verified locally: 17 scripted checks — multi-project assignment without unique conflicts, /users/me/projects, explicit project_id authorization (foreign project 403 on /inventory, /pm/on-the-way, /pm/receipts), PM blocked from pm_user_id changes, legacy assign endpoint keeps other assignments, revocation removes the project from the list, zero-project PM gets an empty list and a guarded inventory endpoint. All test data cleaned up afterwards.
125 lines
4.4 KiB
TypeScript
125 lines
4.4 KiB
TypeScript
// ============================================================
|
|
// Inventory data hook — loads all dash data (scoped by role/project)
|
|
// ============================================================
|
|
|
|
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 {
|
|
categories: { id: string; slug: string; name: string }[];
|
|
units: { id: number; code: string; name_fa: string | null }[];
|
|
brands: { id: string; name: string }[];
|
|
materials: { id: string; name: string; category: { id: string; name: string } | null; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[];
|
|
projects: { id: string; name: string; pm_user_id: string | null; pm_name?: string | null }[];
|
|
}
|
|
|
|
export interface InventoryData {
|
|
warehouses: Warehouse[];
|
|
inventory: InventoryRow[];
|
|
transactions: InventoryTransaction[];
|
|
lookup: LookupData;
|
|
loading: boolean;
|
|
refresh: () => Promise<void>;
|
|
}
|
|
|
|
export function useInventoryData(): InventoryData {
|
|
const user = useAuthStore((s) => s.user);
|
|
const isPM = user?.role === "pm";
|
|
const projectId = isPM ? user.project_id || undefined : undefined;
|
|
|
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
|
const [inventory, setInventory] = useState<InventoryRow[]>([]);
|
|
const [transactions, setTransactions] = useState<InventoryTransaction[]>([]);
|
|
const [lookup, setLookup] = useState<LookupData>({
|
|
categories: [], units: [], brands: [], materials: [], projects: [],
|
|
});
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const [cats, units, brands, mats, projs, whs, inv, txs] = await Promise.all([
|
|
apiV26.lookup.categories(),
|
|
apiV26.lookup.units(),
|
|
apiV26.lookup.brands(),
|
|
apiV26.lookup.materials(),
|
|
apiV26.lookup.projects(),
|
|
apiV26.warehouses.list(projectId),
|
|
apiV26.inventory.list({ project_id: projectId }),
|
|
apiV26.inventory.transactions({ project_id: projectId }),
|
|
]);
|
|
setLookup({ categories: cats, units, brands, materials: mats, projects: projs });
|
|
setWarehouses(whs);
|
|
setInventory(inv);
|
|
setTransactions(txs);
|
|
} catch {
|
|
// پروژهی فعال ممکن است همین حالا باطل شده باشد — گیتِ پروژه در سطح مسیر
|
|
// (RequirePmProjects) پروژهی جایگزین/صفحهی خالی را نشان میدهد.
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [projectId]);
|
|
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
void load();
|
|
}, [load]);
|
|
|
|
// تبهای دیگر (ثبت تراکنش/رسید در تب دوم) → بازخوانی خودکار همین صفحه
|
|
useDataChanged(
|
|
useCallback(() => {
|
|
void load();
|
|
}, [load]),
|
|
["quotes", "inventory"],
|
|
);
|
|
|
|
const scopedWarehouses = useMemo(
|
|
() =>
|
|
user?.role === "admin" || user?.role === "procurement_manager"
|
|
? warehouses
|
|
: warehouses.filter((w) => String(w.project_id) === String(user?.project_id || "")),
|
|
[warehouses, user?.role, user?.project_id],
|
|
);
|
|
|
|
return {
|
|
warehouses: scopedWarehouses,
|
|
inventory,
|
|
transactions,
|
|
lookup,
|
|
loading,
|
|
refresh: load,
|
|
};
|
|
}
|
|
|
|
export function unitName(units: LookupData["units"], id: number | null | undefined): string {
|
|
const u = units.find((x) => x.id === id);
|
|
return u ? u.name_fa || u.code : "—";
|
|
}
|
|
|
|
export function brandName(brands: LookupData["brands"], id: string | null | undefined): string {
|
|
const b = brands.find((x) => x.id === id);
|
|
return b ? b.name : "—";
|
|
}
|
|
|
|
export function materialName(materials: LookupData["materials"], id: string): string {
|
|
const m = materials.find((x) => x.id === id);
|
|
return m ? m.name : "—";
|
|
}
|
|
|
|
export function catName(categories: LookupData["categories"], id: string | null | undefined): string {
|
|
const c = categories.find((x) => x.id === id);
|
|
return c ? c.name : "—";
|
|
}
|
|
|
|
export function projName(projects: LookupData["projects"], id: string | null | undefined): string {
|
|
const p = projects.find((x) => x.id === id);
|
|
return p ? p.name : "—";
|
|
}
|
|
|
|
export function whName(warehouses: Warehouse[], id: string | null | undefined): string {
|
|
const w = warehouses.find((x) => x.id === id);
|
|
return w ? w.name : "—";
|
|
}
|