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
+186
View File
@@ -0,0 +1,186 @@
import { useEffect, useMemo, useState } from "react";
import { normalize } from "../lib/normalize";
import { cn } from "../lib/utils";
import { useClickOutside } from "./useClickOutside";
export interface ComboboxItem {
id: string;
label: string;
}
// منبع آیتم‌ها: رشته، {id,name} یا {id,label}
export type ComboboxSourceItem = string | { id: string; name?: string; label?: string };
export type ComboboxSource = ComboboxSourceItem[] | (() => ComboboxSourceItem[]);
export interface ComboboxProps {
value?: string;
onChange?: (item: ComboboxItem) => void;
items: ComboboxSource; // آرایه از string یا {id, name}
allowAdd?: boolean; // دکمه‌ی "+ افزودن به عنوان جدید"
addSuffix?: string; // متن بعد از +
placeholder?: string;
className?: string;
disabled?: boolean;
inputClassName?: string;
onAdd?: (val: string) => void;
onInputChange?: (val: string) => void;
onBlur?: () => void;
}
function toItem(x: ComboboxSourceItem): ComboboxItem {
if (x == null) return { id: "", label: "" };
if (typeof x === "string") return { id: x, label: x };
const label = String(x.name != null ? x.name : x.label != null ? x.label : x.id ?? "");
return { id: String(x.id ?? label), label };
}
// جستجو-و-انتخاب با تایپ: فیلتر با normalize، ناوبری با کلیدها،
// گزینه‌ی "+ افزودن به عنوان جدید" وقتی متنی با هیچ آیتمی match نمی‌شود
export function Combobox({
value,
onChange,
items,
allowAdd = true,
addSuffix = "به عنوان گزینه جدید",
placeholder,
className,
disabled,
inputClassName,
onAdd,
onInputChange,
onBlur,
}: ComboboxProps) {
const [text, setText] = useState(value ?? "");
const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState(-1);
useEffect(() => {
setText(value ?? "");
}, [value]);
const all = useMemo<ComboboxItem[]>(() => {
const src = typeof items === "function" ? items() : items;
return src.map(toItem);
}, [items]);
const q = useMemo(() => normalize(text), [text]);
const filtered = useMemo(() => {
if (!q) return all;
return all.filter((x) => normalize(x.label).includes(q));
}, [all, q]);
const showAdd =
allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q);
const listCount = filtered.length + (showAdd ? 1 : 0);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
const pick = (it: ComboboxItem) => {
setText(it.label);
setOpen(false);
setHighlight(-1);
onChange?.(it);
onInputChange?.(it.label);
};
const addCurrent = () => {
const val = text.trim();
if (!val) return;
setOpen(false);
setHighlight(-1);
onAdd?.(val);
onInputChange?.(val);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open) {
if (e.key === "ArrowDown" || e.key === "Enter") setOpen(true);
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
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 === "Enter") {
e.preventDefault();
if (highlight >= 0 && highlight < filtered.length) {
pick(filtered[highlight]);
} else if (highlight === filtered.length && showAdd) {
addCurrent();
}
} else if (e.key === "Escape") {
setOpen(false);
}
};
return (
<div ref={ref} className={cn("relative", className)}>
<input
type="text"
className={cn(
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
inputClassName,
)}
value={text}
placeholder={placeholder}
autoComplete="off"
disabled={disabled}
onChange={(e) => {
setText(e.target.value);
setHighlight(-1);
onInputChange?.(e.target.value);
}}
onFocus={() => setOpen(true)}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>
{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">موردی یافت نشد</div>
)}
{filtered.map((it, i) => (
<button
type="button"
key={it.id + "|" + i}
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
pick(it);
}}
onMouseEnter={() => setHighlight(i)}
>
{it.label}
</button>
))}
{showAdd && (
<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();
addCurrent();
}}
onMouseEnter={() => setHighlight(filtered.length)}
>
<span className="font-bold">+</span>
<span>
«<b>{text.trim()}</b>» {addSuffix}
</span>
</button>
)}
</div>
)}
</div>
);
}