feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user