Files
ERP/frontend/src/pages/Wizard/Step4Projects.tsx
T
Milad 4a3c226518 feat(org): panel-based access system replacing roles (procurement/inventory)
- new tables user_panels (panel: procurement/inventory/pm + level full/limited)
  and user_project_members (M:N membership, separate from pm responsibility)
- UserContext carries panels + member_project_ids; /users/me and the users
  list include them
- admin endpoints: PUT /users/{id}/panels, PUT /users/{id}/panel-projects
- inventory scope: panel inventory 'full' -> manager scope (all projects, NO
  stock change); 'limited' -> supervisor scope (own memberships, stock change
  allowed); legacy roles kept as migration fallback; admin untouched
- procurement writes: _procurement_writer is now panel-aware (admin, legacy
  procurement_manager, or procurement 'full') across all 18 write routers;
  quote CREATE additionally allows 'limited' users restricted to their member
  projects (validated server-side)
- frontend: panel-aware HomeRoute (inventory -> warehouse panel, procurement
  limited -> minimal RequesterHome), wizard project options restricted for
  limited users, change-inventory button/route gated to inventory 'limited',
  organization panel gains the panels & access-levels management section
2026-09-29 13:37:59 +03:30

247 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import { DndContext, PointerSensor, TouchSensor, useDraggable, useDroppable, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { toast } from "sonner";
import { faNum } from "../../lib/formatters";
import { normalize } from "../../lib/normalize";
import { itemRowLabel, type ItemDraft } from "./Step2LineItems";
import { cn } from "../../lib/utils";
export interface Step4Props {
items: ItemDraft[];
projects: string[];
assignment: Record<string, string[]>; // tempId → project names
onAssign: (tempId: string, proj: string) => void;
onUnassign: (tempId: string, proj: string) => void;
onAddProject: (name: string) => void;
/** سطح limited تدارکات: افزودن پروژه جدید ممنوع */
canAddProject?: boolean;
}
const ALL_KEY = "__all__";
function DraggableItem({
item,
label,
assignedCount,
assignedNames,
picked,
isAll,
onPick,
}: {
item?: ItemDraft;
label: string;
assignedCount: number;
assignedNames: string[];
picked: boolean;
isAll: boolean;
onPick: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: isAll ? "item-" + ALL_KEY : "item-" + item!.tempId,
data: { tempId: isAll ? ALL_KEY : item!.tempId, isAll },
});
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
onClick={onPick}
className={cn(
"dd-item",
!isAll && assignedCount === 0 && "dd-unassigned",
picked && "picked",
isDragging && "dragging",
isAll && "border-accent/40 bg-accent-bg/30",
)}
style={{ transform: CSS.Translate.toString(transform) }}
>
<div className="min-w-0">
<span className="dd-label">{label}</span>
{assignedNames.length > 0 && (
<div className="mt-0.5 text-[10px] text-muted">لینک‌شده به: {assignedNames.join("، ")}</div>
)}
</div>
<span className="dd-count">{isAll ? "" : assignedCount ? faNum(assignedCount) + " پروژه" : "بدون پروژه"}</span>
</div>
);
}
function DroppableProject({
name,
tags,
onTagRemove,
onPickProject,
}: {
name: string;
tags: { tempId: string; label: string }[];
onTagRemove: (tempId: string) => void;
onPickProject: () => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: "proj-" + name, data: { proj: name } });
return (
<div
ref={setNodeRef}
onClick={onPickProject}
className={cn("dd-project", isOver && "drop-hover")}
>
<div className="dp-name">🏗️ {name}</div>
<div className="dp-tags">
{tags.length ? (
tags.map((t) => (
<span key={t.tempId} className="dd-tag">
{t.label}{" "}
<button
type="button"
className="dd-tag-x"
title="حذف از این پروژه"
onClick={(e) => {
e.stopPropagation();
onTagRemove(t.tempId);
}}
>
×
</button>
</span>
))
) : (
<div className="dd-proj-empty">قلمی برای این پروژه رها نشده</div>
)}
</div>
</div>
);
}
// مرحله ۴ — تخصیص پروژه‌ها با درگ‌اندروپ (یا کلیک-قلم بعد کلیک-پروژه)
export function Step4Projects({ items, projects, assignment, onAssign, onUnassign, onAddProject, canAddProject = true }: Step4Props) {
const [picked, setPicked] = useState<string | null>(null);
const [newProj, setNewProj] = useState("");
// فاصله‌ی حداقلی قبل از شروع درگ — تا کلیک ساده روی قلم، «انتخاب قلم» باشد
// نه شروع درگ (بدون این، dnd-kit رویداد click را می‌بلعد و کلیک-انتخاب کار نمی‌کند)
// TouchSensor با تاخیر فعال‌سازی: درگ لمسی روی موبایل کار می‌کند بدون اینکه
// اسکرول صفحه را بدزدد (کلیک-قلم بعد کلیک-پروژه هم‌چنان مسیر جایگزین است).
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 8 } }),
);
// افزودن پروژه به‌صورت درجا — بدون رفتن به صفحه‌ی پروژه‌ها
const addProjectInline = () => {
const name = newProj.trim();
if (!name) return;
const dup = projects.some((p) => normalize(p) === normalize(name));
if (dup) {
toast.error("این پروژه قبلاً ثبت شده است.");
return;
}
onAddProject(name);
setNewProj("");
toast.success("پروژه افزوده شد.");
};
const assignAll = (proj: string) => {
items.forEach((it) => onAssign(it.tempId, proj));
};
const onDragEnd = (e: DragEndEvent) => {
const tempId = (e.active.data.current as { tempId?: string; isAll?: boolean } | undefined)?.tempId;
const proj = (e.over?.data.current as { proj?: string } | undefined)?.proj;
if (!proj) return;
if (tempId === ALL_KEY) {
// همه‌ی اقلام به این پروژه
assignAll(proj);
} else if (tempId) {
onAssign(tempId, proj);
}
};
return (
<div data-field="project">
<h3 className="mb-1 text-[15px] font-bold">
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۴</span>
پروژه‌ها
</h3>
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'یک تماس می‌تواند درباره‌ی چند پروژه باشد. هر قلم را روی پروژه‌ی مربوطه <b>بکشید و رها کنید</b> (یا روی قلم کلیک کنید، بعد روی پروژه کلیک کنید). هر قلم می‌تواند روی چند پروژه باشد. گزینه‌ی «همه اقلام» همه‌ی اقلام را به پروژه‌ی موردنظر اختصاص می‌دهد.' }} />
<DndContext sensors={sensors} onDragEnd={onDragEnd}>
<div className="dd-panels">
<div className="dd-col dd-items-col">
<h4 className="mb-1 text-[13px] font-bold">اقلام</h4>
<div className="dd-hint mb-2 text-[11px] text-muted">
قلم را بکشید و روی پروژه رها کنید — یا روی قلم کلیک کنید، بعد روی پروژه کلیک کنید
</div>
{/* همه اقلام — اختصاص همه به یک پروژه */}
<DraggableItem
label="همه اقلام"
assignedCount={0}
assignedNames={[]}
picked={picked === ALL_KEY}
isAll
onPick={() => setPicked(picked === ALL_KEY ? null : ALL_KEY)}
/>
{items.map((it) => (
<DraggableItem
key={it.tempId}
item={it}
label={itemRowLabel(it)}
assignedCount={(assignment[it.tempId] || []).length}
assignedNames={assignment[it.tempId] || []}
picked={picked === it.tempId}
isAll={false}
onPick={() => setPicked(picked === it.tempId ? null : it.tempId)}
/>
))}
</div>
<div className="dd-col dd-projects-col">
<h4 className="mb-1 text-[13px] font-bold">پروژه‌ها</h4>
{projects.map((pname) => (
<DroppableProject
key={pname}
name={pname}
tags={items
.filter((it) => (assignment[it.tempId] || []).includes(pname))
.map((it) => ({ tempId: it.tempId, label: itemRowLabel(it) }))}
onTagRemove={(tempId) => onUnassign(tempId, pname)}
onPickProject={() => {
if (picked) {
if (picked === ALL_KEY) assignAll(pname);
else onAssign(picked, pname);
setPicked(null);
}
}}
/>
))}
{!projects.length && (
<div className="dd-empty">هنوز پروژه‌ای تعریف نشده است — از پایین یک پروژه اضافه کنید.</div>
)}
{/* افزودن پروژه به‌صورت درجا — زیر لیست پروژه‌ها (سطح limited: ممنوع) */}
<div className="mt-3 rounded-md border border-dashed border-line p-2" hidden={!canAddProject}>
<div className="mb-1.5 text-[11.5px] font-semibold text-muted">افزودن پروژه‌ی جدید:</div>
<div className="flex items-center gap-2">
<input
type="text"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="نام پروژه"
autoComplete="off"
value={newProj}
onChange={(e) => setNewProj(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addProjectInline();
}
}}
/>
<button type="button" className="btn-line-file shrink-0 bg-accent text-white hover:bg-accent-dark" onClick={addProjectInline}>
+ افزودن
</button>
</div>
</div>
</div>
</div>
</DndContext>
</div>
);
}