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
@@ -0,0 +1,210 @@
import { useMemo, useRef, useState } from "react";
import { normalize } from "../lib/normalize";
import { cn } from "../lib/utils";
import { useClickOutside } from "./useClickOutside";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
export interface MultiSelectComboboxProps {
values: string[];
onChange: (values: string[]) => void;
options?: string[]; // گزینه‌های کش‌شده (مشخصه‌های استفاده‌شده قبلی)
placeholder?: string;
className?: string;
inputClassName?: string;
allowCreate?: boolean; // اجازه‌ی تایپ مقدار جدید (پیش‌فرض true)
}
// چند-انتخابی خلاق (creatable multi-select) با چیپ‌ها:
// - چیپ‌های قابل حذف با ×
// - input بدون حاشیه بعد از آخرین چیپ (فوکوس خودکار)
// - منوی autocomplete با فیلتر زنده + گزینه‌ی «ایجاد '{query}'»
// - Enter برای افزودن، Backspace برای حذف آخرین چیپ، جلوگیری از تکراری
export function MultiSelectCombobox({
values,
onChange,
options = [],
placeholder,
className,
inputClassName,
allowCreate = true,
}: MultiSelectComboboxProps) {
const [text, setText] = useState("");
const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState(-1);
const [pendingRemove, setPendingRemove] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
// گزینه‌ها = کش + مقادیر انتخاب‌شده (تا چیپ‌های قبلی در لیست هم دیده شوند)
const all = useMemo(() => {
const set = new Set<string>();
options.forEach((o) => o && set.add(o));
values.forEach((v) => v && set.add(v));
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [options, values]);
const q = useMemo(() => normalize(text), [text]);
const filtered = useMemo(() => {
if (!q) return all;
return all.filter((x) => normalize(x).includes(q));
}, [all, q]);
const exact = q.length > 0 && filtered.some((x) => normalize(x) === q);
const showCreate = allowCreate && q.length > 0 && !exact;
const listCount = filtered.length + (showCreate ? 1 : 0);
const add = (val: string) => {
const v = val.trim();
if (!v) return;
if (values.some((x) => normalize(x) === normalize(v))) {
setText("");
setHighlight(-1);
inputRef.current?.focus();
return; // تکراری — اضافه نمی‌شود
}
onChange([...values, v]);
setText("");
setHighlight(-1);
inputRef.current?.focus();
};
const remove = (v: string) => {
onChange(values.filter((x) => x !== v));
inputRef.current?.focus();
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
if (highlight >= 0 && highlight < filtered.length) {
add(filtered[highlight]);
} else if (text.trim()) {
add(text.trim());
}
return;
}
if (e.key === "Backspace" && !text && values.length) {
e.preventDefault();
remove(values[values.length - 1]);
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
setHighlight((h) => (h + 1) % Math.max(listCount, 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
} else if (e.key === "Escape") {
setOpen(false);
}
};
return (
<div ref={ref} className={cn("relative", className)}>
<div
className="flex min-h-9 w-full flex-wrap items-center gap-1 rounded-lg border border-line bg-panel px-2 py-1 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/40"
onClick={() => inputRef.current?.focus()}
>
{values.map((v) => (
<span key={v} className="spec-chip">
<span className="max-w-[140px] truncate">{v}</span>
<button
type="button"
className="spec-chip-x"
title="حذف"
onClick={(e) => {
e.stopPropagation();
setPendingRemove(v);
}}
>
×
</button>
</span>
))}
<input
ref={inputRef}
type="text"
className={cn(
"min-w-[80px] flex-1 border-0 bg-transparent px-0.5 py-0.5 text-[12px] text-ink caret-accent outline-none placeholder:text-muted",
inputClassName,
)}
value={text}
placeholder={values.length ? "" : placeholder}
autoComplete="off"
onChange={(e) => {
setText(e.target.value);
setHighlight(-1);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
/>
</div>
{open && (
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
{listCount === 0 && (
<div className="px-3 py-1.5 text-[12px] text-muted">
{allowCreate && text.trim() ? (
<span>
برای افزودن «<b>{text.trim()}</b>» Enter بزنید
</span>
) : (
"موردی یافت نشد"
)}
</div>
)}
{filtered.map((it, i) => (
<button
type="button"
key={it}
className={cn(
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
i === highlight && "bg-slate-bg",
)}
onMouseDown={(e) => {
e.preventDefault(); // حفظ فوکوس input
add(it);
}}
onMouseEnter={() => setHighlight(i)}
>
{it}
</button>
))}
{showCreate && (
<button
type="button"
className={cn(
"flex w-full cursor-pointer items-center gap-1 border-t border-line px-3 py-1.5 text-start text-[13px] text-accent hover:bg-slate-bg",
highlight === filtered.length && "bg-slate-bg",
)}
onMouseDown={(e) => {
e.preventDefault();
add(text.trim());
}}
onMouseEnter={() => setHighlight(filtered.length)}
>
<span className="font-bold">+</span>
<span>
افزودن «<b>{text.trim()}</b>»
</span>
</button>
)}
</div>
)}
<ConfirmDeleteDialog
open={pendingRemove !== null}
itemName={pendingRemove || ""}
onConfirm={() => {
if (pendingRemove !== null) remove(pendingRemove);
setPendingRemove(null);
}}
onCancel={() => setPendingRemove(null)}
/>
</div>
);
}