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.
81 lines
3.4 KiB
TypeScript
81 lines
3.4 KiB
TypeScript
import { useEffect, useState, type ReactNode } from "react";
|
|
import { apiV26 } from "../lib/api";
|
|
import { useAuthStore } from "../store/auth";
|
|
import { useLogout } from "../pages/Inventory/panelMenu";
|
|
|
|
// گیتِ پروژههای مدیر پروژه (PM → 1:N):
|
|
// - لیست پروژههای تخصیصیافته را از بکاند میگیرد و پروژهی فعالِ ذخیرهشده را
|
|
// اعتبارسنجی میکند؛ اگر پروژه باطل شده باشد به اولین پروژه برمیگردد
|
|
// (localStorage هم با همان store بهروز میشود).
|
|
// - اگر هیچ پروژهای تخصیص نیافته باشد: صفحهی خالی با پیام + دکمهی خروج
|
|
// (بدون حلقهی ریدایرکت و بدون باطلکردن نشست).
|
|
// - حالت توسعه (بدون توکن) از همین پروژهی انتخابیِ dev استفاده میکند.
|
|
export function RequirePmProjects({ children }: { children: ReactNode }) {
|
|
const user = useAuthStore((s) => s.user);
|
|
const isDev = useAuthStore((s) => s.isDev);
|
|
const setProjects = useAuthStore((s) => s.setProjects);
|
|
const logout = useLogout();
|
|
|
|
const isPm = user?.role === "pm";
|
|
const [state, setState] = useState<"loading" | "ok" | "empty">(isPm ? "loading" : "ok");
|
|
|
|
useEffect(() => {
|
|
if (!user || user.role !== "pm") {
|
|
setState("ok");
|
|
return;
|
|
}
|
|
if (isDev) {
|
|
setState(user.project_id ? "ok" : "empty");
|
|
return;
|
|
}
|
|
let alive = true;
|
|
setState("loading");
|
|
void apiV26.users
|
|
.myProjects()
|
|
.then((list) => {
|
|
if (!alive) return;
|
|
setProjects(list); // پروژهی فعال را اعتبارسنجی/جایگزین میکند
|
|
setState(list.length ? "ok" : "empty");
|
|
})
|
|
.catch(() => {
|
|
// خطای شبکه نباید کاربر را قفل کند — با وضعیت فعلی ادامه میدهیم
|
|
if (alive) setState("ok");
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [user?.id, user?.role, isDev]);
|
|
|
|
if (state === "loading") {
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center text-sm text-muted">
|
|
در حال بارگذاری پروژهها…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (state === "empty") {
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
|
|
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-8 text-center shadow-sm">
|
|
<h1 className="text-[16px] font-extrabold">هیچ پروژهای به شما تخصیص داده نشده است</h1>
|
|
<p className="mt-2 text-[12.5px] leading-6 text-muted">
|
|
در حال حاضر هیچ پروژهای به شما تخصیص داده نشده است. برای دسترسی به پنل انبار، از مدیر
|
|
سیستم بخواهید پروژهای را به شما اختصاص دهد.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={logout}
|
|
className="mt-5 inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-lg bg-accent px-4 text-[13px] font-bold text-white transition-colors hover:bg-accent-dark"
|
|
>
|
|
خروج از حساب
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return <>{children}</>;
|
|
}
|