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.
223 lines
7.4 KiB
TypeScript
223 lines
7.4 KiB
TypeScript
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>
|
||
);
|
||
}
|