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.
130 lines
4.4 KiB
TypeScript
130 lines
4.4 KiB
TypeScript
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>
|
|
);
|
|
}
|