Files
ERP/frontend/src/components/Combobox.tsx
T
whqreza a181a8753d feat(quotes,units): official-invoice badge, conversion text wrapping, seeded fixed conversions, universal proforma, EditModal fixes
1) Quote card header: an official/unofficial badge (رسمی / غیررسمی) renders next to the supplier name from quotes.is_official (the «فاکتور رسمی» checkbox) — green for official, muted slate for unofficial.

2) Conversion Factors sidebar: the material group and variant labels no longer truncate with ellipsis — whitespace-normal + break-words so long Persian names wrap cleanly to the next line.

3) Global fixed conversions are now seeded in the unified seeder (app/db/init_db.py — the same file that seeds categories, base units and the superuser path), NOT hardcoded in the frontend: تن→کیلوگرم (1000), کیلوگرم→گرم (1000), هزار عدد→عدد (1000). The supporting units (گرم, هزار عدد) were added to the idempotent BASE_UNITS seed; inserts use ON CONFLICT (from_unit, to_unit) DO NOTHING against the uq_standard_conversion key so every startup is safe. Verified live: 3 rows after first boot AND still 3 rows after an api restart.

4) Universal proforma attachments: the Edit modal now has a پیش‌فاکتورها section (list/upload/delete via the existing MinIO-backed files API) alongside the invoices section, active at every quote lifecycle stage — including accepted/arrived and receipts-locked quotes (the section is deliberately not gated by hasReceipts; the backend already permits uploads in any status). View/delete of existing proformas unchanged elsewhere.

5) EditModal fixes: (a) the supplier Combobox no longer opens on mount — Radix Dialog autofocus was triggering its onFocus; Combobox gains an openOnFocus prop (default true) and the modal passes false, so the dropdown now opens only on explicit click or typing; (b) the line-item material field binds to the material's own name (m.name) instead of its category relation, and the save path matches materials by name+brand+specs accordingly.

Validated: python compile, api + frontend image builds (tsc + vite) clean; seeding verified against the live local DB.
2026-09-19 13:44:09 +03:30

195 lines
6.3 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 { 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;
// با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند —
// فقط کلیک یا تایپِ صریح کاربر بازش می‌کند.
openOnFocus?: boolean;
}
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,
openOnFocus = true,
}: 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);
if (!open) setOpen(true);
onInputChange?.(e.target.value);
}}
onFocus={() => {
if (openOnFocus) setOpen(true);
}}
onClick={() => 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>
);
}