feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login

This commit is contained in:
ali_mohaghegh
2026-08-25 19:08:15 +03:30
parent 170aed66ea
commit 4e105c5cd5
103 changed files with 16352 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
import { useState } from "react";
import { DndContext, PointerSensor, 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;
}
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 }: Step4Props) {
const [picked, setPicked] = useState<string | null>(null);
const [newProj, setNewProj] = useState("");
// فاصله‌ی حداقلی قبل از شروع درگ — تا کلیک ساده روی قلم، «انتخاب قلم» باشد
// نه شروع درگ (بدون این، dnd-kit رویداد click را می‌بلعد و کلیک-انتخاب کار نمی‌کند)
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 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>
<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>
)}
{/* افزودن پروژه به‌صورت درجا — زیر لیست پروژه‌ها */}
<div className="mt-3 rounded-md border border-dashed border-line p-2">
<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>
);
}