Files
ERP/frontend/src/components/MultiSelectCombobox.tsx
T
whqreza 5084623be5 feat(quotes): lock quotes with posted receipts; per-item target project + title dedup
Quotes that already have warehouse receipts can no longer be edited or rolled back:
- backend: quote_has_receipts() guard — replace_quote_children and every backward status transition now return 409 with «این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.»
- reopening to «offered» additionally clears item_decisions.project_id (the purchased-project link belongs to acceptance, not to a reopened offer)
- frontend EditModal: hasReceipts locks the whole form — disabled inputs and date pickers (JalaliDatePicker/NumberInput/MilestoneRow/MultiSelectCombobox gained disabled support), add/remove controls for items, termsheets and milestones hidden, status change blocked, amber warning banner; quote deletion now goes through a confirmation dialog (useDeleteQuote)
- date-only semantics: JalaliDatePicker keeps/emits YYYY-MM-DD when withTime=false (typing, «امروز», day selection) and toDateOnly no longer mangles pure date strings

Per-item target project (candidate projects stay intact):
- expand_service exposes the decision target project (decision.project + line_item.target_project)
- the accept payload derives item_decisions.project_id from that target project and sends item_projects: [] so line_item_projects keeps the original candidates untouched
- AcceptModal/EditModal select the target project per line item

Material titles:
- quoteUtils.formatMaterialTitle() composes one normalized, deduplicated «name + brand + specs» title, replacing materialLabel/materialSpecs/specsLabel usage across dashboard cards, quote detail and the modals

Verified: python compile + api/frontend image builds (tsc + vite) clean.
2026-09-16 19:14:06 +03:30

223 lines
7.4 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 { 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)
disabled?: boolean;
}
// چند-انتخابی خلاق (creatable multi-select) با چیپ‌ها:
// - چیپ‌های قابل حذف با ×
// - input بدون حاشیه بعد از آخرین چیپ (فوکوس خودکار)
// - منوی autocomplete با فیلتر زنده + گزینه‌ی «ایجاد '{query}'»
// - Enter برای افزودن، Backspace برای حذف آخرین چیپ، جلوگیری از تکراری
export function MultiSelectCombobox({
values,
onChange,
options = [],
placeholder,
className,
inputClassName,
allowCreate = true,
disabled = false,
}: 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={cn(
"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",
disabled && "cursor-not-allowed opacity-60 bg-canvas/40",
)}
onClick={() => !disabled && inputRef.current?.focus()}
>
{values.map((v) => (
<span key={v} className="spec-chip">
<span className="max-w-[140px] truncate">{v}</span>
{!disabled && (
<button
type="button"
className="spec-chip-x"
title="حذف"
onClick={(e) => {
e.stopPropagation();
setPendingRemove(v);
}}
>
×
</button>
)}
</span>
))}
<input
ref={inputRef}
type="text"
disabled={disabled}
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 disabled:cursor-not-allowed",
inputClassName,
)}
value={text}
placeholder={values.length ? "" : placeholder}
autoComplete="off"
onChange={(e) => {
if (disabled) return;
setText(e.target.value);
setHighlight(-1);
setOpen(true);
}}
onFocus={() => !disabled && setOpen(true)}
onKeyDown={(e) => {
if (disabled) return;
onKeyDown(e);
}}
/>
</div>
{open && !disabled && (
<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>
);
}