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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user