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.
102 lines
3.4 KiB
TypeScript
102 lines
3.4 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { groupCommas, parseNum, toWords } from "../lib/numutil";
|
|
import { cn } from "../lib/utils";
|
|
|
|
export interface NumberInputProps {
|
|
value: number | null;
|
|
onChange: (n: number | null) => void;
|
|
placeholder?: string;
|
|
className?: string;
|
|
showWords?: boolean; // نمایش معادل فارسی زیر input
|
|
inputMode?: "numeric" | "decimal" | undefined;
|
|
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد)
|
|
disabled?: boolean;
|
|
}
|
|
|
|
// ورودی عددی با فرمتدهی خودکار (groupCommas) و نمایش کلمات فارسی
|
|
export function NumberInput({
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
className,
|
|
showWords = false,
|
|
inputMode = "numeric",
|
|
maxDecimals,
|
|
disabled = false,
|
|
}: NumberInputProps) {
|
|
// فرمت با رعایت حداکثر اعشار (گرد کردن)
|
|
const formatValue = (n: number): string => {
|
|
let v = n;
|
|
if (maxDecimals != null) {
|
|
const f = Math.pow(10, maxDecimals);
|
|
v = Math.round(n * f) / f;
|
|
}
|
|
return groupCommas(v, false);
|
|
};
|
|
|
|
const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : ""));
|
|
|
|
// همگامسازی وقتی value از بیرون عوض میشود (مثلاً محاسبهی خودکار قیمت کل).
|
|
// null از بیرون متن کاربر را پاک نمیکند — تا تایپ میانی مثل «0.» از بین نرود.
|
|
useEffect(() => {
|
|
const cur = parseNum(text);
|
|
if (value != null && value !== cur) {
|
|
setText(formatValue(value));
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [value]);
|
|
|
|
const commit = (raw: string) => {
|
|
const n = parseNum(raw);
|
|
if (n != null) {
|
|
const formatted = formatValue(n);
|
|
setText(formatted);
|
|
onChange(n);
|
|
} else {
|
|
setText(raw);
|
|
onChange(null);
|
|
}
|
|
};
|
|
|
|
const n = parseNum(text);
|
|
|
|
return (
|
|
<div className={cn("min-w-0", className)}>
|
|
<input
|
|
type="text"
|
|
disabled={disabled}
|
|
className="num-input 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-60"
|
|
value={text}
|
|
placeholder={placeholder}
|
|
inputMode={inputMode}
|
|
onChange={(e) => {
|
|
const raw = e.target.value;
|
|
// فرمتدهی خودکار هنگام تایپ — اما جداکنندهی اعشارِ وسط تایپ
|
|
// («0.» یا «1.») دستنخورده میماند تا کاربر بتواند اعشار بزند
|
|
const parsed = parseNum(raw);
|
|
const trailingSep = /[.,،/٬٫]$/.test(raw);
|
|
if (parsed != null && !trailingSep) {
|
|
setText(formatValue(parsed));
|
|
onChange(parsed);
|
|
} else {
|
|
setText(raw);
|
|
onChange(parsed);
|
|
}
|
|
}}
|
|
onBlur={() => commit(text)}
|
|
/>
|
|
{showWords && (
|
|
<span className={cn("num-pill", n == null && "empty")}>
|
|
{n != null ? (
|
|
<>
|
|
{groupCommas(n, true)} <span className="num-words">({toWords(n)})</span>
|
|
</>
|
|
) : (
|
|
""
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|