feat(pm): multi-project PMs (1:N) with project switching, scoping validation and org multi-select

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.
This commit is contained in:
2026-09-15 19:23:50 +03:30
parent 3f4bbee6de
commit 1f16e44874
18 changed files with 673 additions and 122 deletions
+53 -13
View File
@@ -69,6 +69,19 @@ function authHeaders(init?: RequestInit): Record<string, string> {
return headers;
}
// پروژه‌ی فعالِ مدیر پروژه (چند-پروژه‌ای، 1:N): برای همه‌ی مسیرهای پنل انبار
// صریحاً ارسال می‌شود تا بک‌اند داده‌های همان پروژه را برگرداند/اعتبارسنجی کند.
// ادمین/مدیر تدارکات بدون پارامتر (همه‌ی پروژه‌ها) باقی می‌ماند.
function activeProjectId(): string | null {
const { user } = useAuthStore.getState();
return user?.role === "pm" ? user.project_id : null;
}
function projectQuery(projectId?: string | null): string {
const pid = projectId ?? activeProjectId();
return pid ? `?project_id=${pid}` : "";
}
// ترجمه‌ی پیام‌های رایج اعتبارسنجی FastAPI (422) به فارسی
const VALIDATION_FA = (msg: string): string => {
const lower = msg.toLowerCase();
@@ -1450,6 +1463,8 @@ export interface UserRow {
role_label: string | null;
project_id: string | null;
project_name: string | null;
// 1:N — every project this PM manages (oldest first).
projects?: { id: string; name: string }[];
created_at: string;
}
@@ -1476,6 +1491,7 @@ export const apiV26 = {
role_label: string | null;
project_id: string | null;
project_name: string | null;
projects?: { id: string; name: string }[];
};
};
}>("/auth/login", { method: "POST", body: JSON.stringify(body) });
@@ -1507,6 +1523,7 @@ export const apiV26 = {
id: string; casdoor_id: string; first_name: string; last_name: string; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null; role_label: string | null;
project_id: string | null; project_name: string | null;
projects?: { id: string; name: string }[];
}>("/users/me");
return {
id: data.id,
@@ -1518,9 +1535,13 @@ export const apiV26 = {
role_label: data.role_label,
project_id: data.project_id,
project_name: data.project_name,
projects: data.projects ?? [],
display_name: `${data.first_name} ${data.last_name}`.trim() || data.casdoor_id,
};
},
// پروژه‌های تخصیص‌یافته به کاربر جاری (PM) — مبنای سوییچر پروژه + اعتبارسنجی
myProjects: async (): Promise<{ id: string; name: string }[]> =>
getData<{ id: string; name: string }[]>("/users/me/projects"),
// --- Organization panel (admin / procurement manager) ---
list: async (): Promise<UserRow[]> => {
const data = await getData<UserRow[]>("/users");
@@ -1543,33 +1564,44 @@ export const apiV26 = {
},
},
// 1:N تخصیص مدیر پروژه ↔ پروژه: هر پروژه حداکثر یک PM دارد؛
// pm_user_id=null یعنی جداسازی مدیر از پروژه. فقط ادمین/مدیر تدارکات.
projects: {
setPm: async (projectId: string, userId: string | null): Promise<void> => {
await patchData(`/projects/${projectId}`, { pm_user_id: userId });
},
},
warehouses: {
list: async (projectId?: string): Promise<Warehouse[]> => {
const qs = projectId ? `?project_id=${projectId}&page_size=100` : "?page_size=100";
const pid = projectId ?? activeProjectId() ?? undefined;
const qs = pid ? `?project_id=${pid}&page_size=100` : "?page_size=100";
const data = await getData<Parameters<typeof rawWarehouse>[0][]>(`/inventory/warehouses${qs}`);
return data.map(rawWarehouse);
},
create: async (body: { project_id: string; name: string; description?: string | null }): Promise<Warehouse> => {
const d = await postData<Parameters<typeof rawWarehouse>[0]>("/inventory/warehouses", body);
const d = await postData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses${projectQuery()}`, body);
return rawWarehouse(d);
},
update: async (id: string, body: { name?: string; description?: string | null; project_id?: string }): Promise<Warehouse> => {
const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}`, body);
const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}${projectQuery()}`, body);
return rawWarehouse(d);
},
delete: async (id: string): Promise<void> => {
await deleteData(`/inventory/warehouses/${id}`);
await deleteData(`/inventory/warehouses/${id}${projectQuery()}`);
},
},
inventory: {
list: async (params: { project_id?: string; category_id?: string; warehouse_id?: string; material_id?: string; sort?: string } = {}): Promise<InventoryRow[]> => {
const qs = new URLSearchParams({ page_size: "100", ...cleanParams(params) }).toString();
const pid = params.project_id ?? activeProjectId() ?? undefined;
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
return data.map(rawInventory);
},
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise<InventoryTransaction[]> => {
const qs = new URLSearchParams({ page_size: "100", ...cleanParams(params) }).toString();
const pid = params.project_id ?? activeProjectId() ?? undefined;
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
},
createTransaction: async (body: {
@@ -1578,26 +1610,34 @@ export const apiV26 = {
physical_qty?: number | null; unit_id?: number | null; specs?: Record<string, string> | null;
note?: string | null; transaction_date?: string | null;
}): Promise<InventoryTransaction[]> => {
return postData<InventoryTransaction[]>("/inventory/transactions", body);
return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
},
types: async (): Promise<{ type: TransactionType; label: string }[]> => {
return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types");
},
materialOptions: async (category_id: string, warehouse_id?: string): Promise<{ id: string; name: string; brands: { id: string; name: string }[] }[]> => {
const qs = warehouse_id ? `?category_id=${category_id}&warehouse_id=${warehouse_id}` : `?category_id=${category_id}`;
return getData<{ id: string; name: string; brands: { id: string; name: string }[] }[]>(`/inventory/materials/options${qs}`);
const params: Record<string, string> = { category_id };
if (warehouse_id) params.warehouse_id = warehouse_id;
const pid = activeProjectId();
if (pid) params.project_id = pid;
return getData<{ id: string; name: string; brands: { id: string; name: string }[] }[]>(
`/inventory/materials/options?${new URLSearchParams(params).toString()}`,
);
},
},
pm: {
onTheWay: async (): Promise<PendingArrival[]> => getData<PendingArrival[]>("/pm/on-the-way"),
// پروژه فعال به‌صورت صریح ارسال می‌شود؛ بک‌اند تعلق آن به PM را بررسی می‌کند
onTheWay: async (projectId?: string | null): Promise<PendingArrival[]> =>
getData<PendingArrival[]>(projectId ? `/pm/on-the-way?project_id=${projectId}` : "/pm/on-the-way"),
confirmReceipt: async (body: {
item_decision_id: string; received_qty: number;
allocations: { warehouse_id: string; quantity: number }[];
received_at?: string | null; on_time?: boolean | null; quality_note?: string | null;
}): Promise<{ receipt_id: string; state: string; posted_transactions: string[] }> =>
postData("/pm/receipts", body),
receipts: async (): Promise<ConfirmedReceipt[]> => getData<ConfirmedReceipt[]>("/pm/receipts"),
}, projectId?: string | null): Promise<{ receipt_id: string; state: string; posted_transactions: string[] }> =>
postData(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts", body),
receipts: async (projectId?: string | null): Promise<ConfirmedReceipt[]> =>
getData<ConfirmedReceipt[]>(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"),
},
// ساخت موجودیت‌های کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه):