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.
This commit is contained in:
@@ -12,6 +12,7 @@ export interface MultiSelectComboboxProps {
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
allowCreate?: boolean; // اجازهی تایپ مقدار جدید (پیشفرض true)
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// چند-انتخابی خلاق (creatable multi-select) با چیپها:
|
||||
@@ -27,6 +28,7 @@ export function MultiSelectCombobox({
|
||||
className,
|
||||
inputClassName,
|
||||
allowCreate = true,
|
||||
disabled = false,
|
||||
}: MultiSelectComboboxProps) {
|
||||
const [text, setText] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -105,46 +107,56 @@ export function MultiSelectCombobox({
|
||||
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()}
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
className="spec-chip-x"
|
||||
title="حذف"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingRemove(v);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{!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",
|
||||
"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={() => setOpen(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={() => !disabled && setOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (disabled) return;
|
||||
onKeyDown(e);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
{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">
|
||||
|
||||
Reference in New Issue
Block a user