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
+23 -3
View File
@@ -1,9 +1,14 @@
import { PanelMenu } from "./PanelMenu";
import { useLocation } from "react-router-dom";
import { PanelMenu, type MenuItem } from "./PanelMenu";
import { useLogout } from "../pages/Inventory/panelMenu";
import { useAuthStore } from "../store/auth";
// منوی همبرگری مشترک بین دو ماژول (پنل انبار / دفتر تدارکات) —
// دقیقاً مثل منوی دفتر: عنوان «پنل‌ها»، ماژول فعلی با استایل فعال (پر)
// + «✓ فعلی»، ماژول دیگر به‌عنوان سوییچر، و در پایان جداکننده + خروج.
// مدیر پروژه (1:N): زیر «مدیریت انبار» یک آکاردئون باز می‌شود که همه‌ی
// پروژه‌های تخصیص‌یافته را نشان می‌دهد؛ پروژه‌ی فعال با «✓ فعلی» مشخص است و
// انتخاب هر پروژه فقط «پروژه‌ی فعال» را عوض می‌کند (نشست/توکن دست‌نخورده).
export function AppSwitcherMenu({
activeModule,
canSwitchToProcurement = true,
@@ -12,13 +17,28 @@ export function AppSwitcherMenu({
canSwitchToProcurement?: boolean; // مدیر پروژه به دفتر تدارکات دسترسی ندارد
}) {
const logout = useLogout();
const items = [];
const { pathname } = useLocation();
const user = useAuthStore((s) => s.user);
const setActiveProject = useAuthStore((s) => s.setActiveProject);
const items: MenuItem[] = [];
const isPm = user?.role === "pm";
const pmProjects = isPm ? user?.projects ?? [] : [];
const projectChildren: MenuItem[] | undefined =
activeModule === "warehouse" && isPm
? pmProjects.map((p) => ({
label: p.name,
to: pathname, // همان بخش فعلی؛ فقط پروژه عوض می‌شود
active: p.id === user?.project_id,
onClick: () => setActiveProject(p.id),
}))
: undefined;
if (activeModule === "procurement") {
items.push({ label: "مدیریت انبار", to: "/inventory" });
items.push({ label: "مدیریت تدارکات", to: "/", active: true });
} else {
items.push({ label: "مدیریت انبار", to: "/inventory", active: true });
items.push({ label: "مدیریت انبار", to: "/inventory", active: true, children: projectChildren });
if (canSwitchToProcurement) {
items.push({ label: "مدیریت تدارکات", to: "/" });
}
+75 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { Menu, X } from "lucide-react";
import { ChevronDown, Menu, X } from "lucide-react";
import { cn } from "../lib/utils";
export interface MenuItem {
@@ -9,11 +9,14 @@ export interface MenuItem {
active?: boolean;
divider?: boolean;
onClick?: () => void;
// آیتم بازشو (آکاردئونی) — مثلاً فهرست پروژه‌های مدیر پروژه زیر «مدیریت انبار»
children?: MenuItem[];
}
// منوی همبرگری — بالای راست صفحات؛ نمایش پنل فعلی + ناوبری بین پنل‌ها
export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string }) {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState<number | null>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -42,11 +45,81 @@ export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string
{items.map((it, i) =>
it.divider ? (
<div key={i} className="my-1 border-t border-line" />
) : it.children?.length ? (
<div key={i}>
<div className={cn("flex items-center rounded-lg", it.active && "bg-accent")}>
{it.to ? (
<Link
to={it.to}
onClick={() => {
setOpen(false);
it.onClick?.();
}}
className={cn(
"flex flex-1 items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
it.active ? "font-bold text-white" : "text-ink hover:bg-slate-bg",
)}
>
{it.label}
{it.active && <span className="text-[10px] opacity-80">✓ فعلی</span>}
</Link>
) : (
<button
type="button"
onClick={() => {
setOpen(false);
it.onClick?.();
}}
className="flex flex-1 items-center gap-3 rounded-lg px-3 py-2 text-start text-[13px] text-ink hover:bg-slate-bg"
>
{it.label}
</button>
)}
<button
type="button"
aria-label={expanded === i ? "بستن فهرست پروژه‌ها" : "نمایش پروژه‌ها"}
aria-expanded={expanded === i}
onClick={() => setExpanded(expanded === i ? null : i)}
className={cn(
"me-1 inline-flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md",
it.active ? "text-white hover:bg-white/15" : "text-muted hover:bg-slate-bg",
)}
>
<ChevronDown
className={cn("h-4 w-4 transition-transform", expanded === i && "rotate-180")}
/>
</button>
</div>
{expanded === i && (
<div className="mt-0.5 flex flex-col gap-0.5 border-s border-line ps-2 ms-3">
{it.children.map((c, j) => (
<Link
key={j}
to={c.to ?? "#"}
onClick={() => {
setOpen(false);
c.onClick?.();
}}
className={cn(
"flex items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-[12.5px] no-underline",
c.active ? "bg-accent-bg font-bold text-accent" : "text-ink hover:bg-slate-bg",
)}
>
<span className="truncate">{c.label}</span>
{c.active && <span className="shrink-0 text-[10px]">✓ فعلی</span>}
</Link>
))}
</div>
)}
</div>
) : it.to ? (
<Link
key={i}
to={it.to}
onClick={() => setOpen(false)}
onClick={() => {
setOpen(false);
it.onClick?.();
}}
className={cn(
"flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
it.active ? "bg-accent font-bold text-white" : "text-ink hover:bg-slate-bg",
@@ -0,0 +1,129 @@
import { useMemo, useRef, useState } from "react";
import { X } from "lucide-react";
import { normalize } from "../lib/normalize";
import { cn } from "../lib/utils";
import { useClickOutside } from "./useClickOutside";
export interface ProjectOption {
id: string;
name: string;
}
// چند-انتخابیِ جست‌وجوپذیرِ پروژه‌ها (1:N — مدیر پروژه ↔ پروژه‌ها):
// - چیپ‌های پروژه با دکمه‌ی × (جداسازی فوری مدیر از پروژه)
// - جست‌وجوی زنده‌ی فارسی (normalize: ی/ك عربی، نیم‌فاصله و فاصله‌ها)
// - پروژه‌های انتخاب‌شده از فهرست پنهان می‌شوند تا انتخاب تکراری رخ ندهد
export function ProjectMultiSelect({
options,
values,
onChange,
placeholder = "انتخاب پروژه(ها)...",
disabled = false,
}: {
options: ProjectOption[];
values: string[];
onChange: (ids: string[]) => void;
placeholder?: string;
disabled?: boolean;
}) {
const [text, setText] = useState("");
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const ref = useClickOutside<HTMLDivElement>(() => {
setOpen(false);
setText("");
});
const byId = useMemo(() => new Map(options.map((o) => [o.id, o])), [options]);
const selected = values.map((id) => byId.get(id)).filter(Boolean) as ProjectOption[];
const q = normalize(text);
const available = useMemo(
() =>
options.filter((o) => !values.includes(o.id) && (!q || normalize(o.name).includes(q))),
[options, values, q],
);
const add = (id: string) => {
onChange([...values, id]);
setText("");
inputRef.current?.focus();
};
const remove = (id: string) => onChange(values.filter((v) => v !== id));
return (
<div ref={ref} className="relative min-w-52">
<div
className={cn(
"flex min-h-9 flex-wrap items-center gap-1 rounded-lg border border-line bg-panel px-2 py-1 text-[12px]",
disabled ? "opacity-60" : "cursor-text",
)}
onClick={() => {
if (!disabled) {
setOpen(true);
inputRef.current?.focus();
}
}}
>
{selected.map((p) => (
<span
key={p.id}
className="inline-flex max-w-full items-center gap-1 rounded-md bg-accent-bg px-2 py-0.5 font-semibold text-accent"
>
<span className="truncate">{p.name}</span>
{!disabled && (
<button
type="button"
aria-label={`جدا کردن ${p.name}`}
onClick={(e) => {
e.stopPropagation();
remove(p.id);
}}
className="cursor-pointer rounded p-0.5 hover:bg-accent/10"
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
<input
ref={inputRef}
type="text"
dir="rtl"
disabled={disabled}
value={text}
onChange={(e) => {
setText(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder={selected.length ? "" : placeholder}
className="h-7 min-w-24 flex-1 border-0 bg-transparent px-1 text-[12px] outline-none placeholder:text-muted/70"
/>
</div>
{open && !disabled && (
<div className="absolute z-40 mt-1 max-h-56 w-full min-w-52 overflow-auto rounded-xl border border-line bg-panel p-1 shadow-lg">
{available.length ? (
available.map((o) => (
<button
key={o.id}
type="button"
onClick={() => add(o.id)}
className="flex w-full cursor-pointer items-center rounded-lg px-2.5 py-1.5 text-start text-[12.5px] text-ink hover:bg-slate-bg"
>
{o.name}
</button>
))
) : (
<div className="px-2.5 py-2 text-[11.5px] text-muted">
{options.length === 0
? "پروژه‌ای وجود ندارد"
: q
? "پروژه‌ای یافت نشد"
: "همه‌ی پروژه‌ها متصل شده‌اند"}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,80 @@
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}</>;
}