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:
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
daysInMonth,
|
||||
gregorianToJalali,
|
||||
@@ -14,11 +14,12 @@ import { cn } from "../lib/utils";
|
||||
import { useClickOutside } from "./useClickOutside";
|
||||
|
||||
export interface JalaliDatePickerProps {
|
||||
value: string | null; // ISO date (با time اختیاری)
|
||||
onChange: (iso: string | null) => void;
|
||||
value: string | null; // ISO date (یا YYYY-MM-DD با time اختیاری)
|
||||
onChange: (val: string | null) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function todayJalali(): [number, number, number] {
|
||||
@@ -28,6 +29,13 @@ function todayJalali(): [number, number, number] {
|
||||
|
||||
function isoToJalali(iso: string | null): [number, number, number] | null {
|
||||
if (!iso) return null;
|
||||
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (m) {
|
||||
const gy = parseInt(m[1], 10);
|
||||
const gm = parseInt(m[2], 10);
|
||||
const gd = parseInt(m[3], 10);
|
||||
return gregorianToJalali(gy, gm, gd);
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||
@@ -39,6 +47,12 @@ function buildIso(jy: number, jm: number, jd: number, h: number, m: number): str
|
||||
return new Date(gy, gm - 1, gd, h, m).toISOString();
|
||||
}
|
||||
|
||||
// ساخت تاریخ استاندارد YYYY-MM-DD بدون مؤلفه زمان
|
||||
function buildDateOnly(jy: number, jm: number, jd: number): string {
|
||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// وقتی هنوز زمان مشخصی ست نشده، ساعت/دقیقهی فعلی سیستم پیشفرض است
|
||||
// (نه ۰۰:۰۰) — دکمهی «امروز» و انتخاب روز، همین زمان را نگه میدارند.
|
||||
function nowTime(): { h: number; m: number } {
|
||||
@@ -60,6 +74,7 @@ export function JalaliDatePicker({
|
||||
placeholder,
|
||||
className,
|
||||
withTime = false,
|
||||
disabled = false,
|
||||
}: JalaliDatePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
|
||||
@@ -74,11 +89,35 @@ export function JalaliDatePicker({
|
||||
return `${j} ${faNum(hh + ":" + mm)}`;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setText("");
|
||||
return;
|
||||
}
|
||||
const j = isoToJalaliStr(value);
|
||||
if (!withTime) {
|
||||
setText(j);
|
||||
} else {
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
setText(j);
|
||||
} else {
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
||||
}
|
||||
}
|
||||
const jParts = isoToJalali(value);
|
||||
if (jParts) setCurJ(jParts);
|
||||
}, [value, withTime]);
|
||||
|
||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||
|
||||
// تاریخ انتخابشده به صورت محلی (نه UTC) برای مقایسه با سلولهای تقویم
|
||||
function selectedLocalDate(): string | null {
|
||||
if (!value) return null;
|
||||
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
@@ -124,9 +163,11 @@ export function JalaliDatePicker({
|
||||
};
|
||||
|
||||
const selectDay = (jy: number, jm: number, jd: number) => {
|
||||
const isoWithTime = buildIso(jy, jm, jd, time.h, time.m);
|
||||
onChange(isoWithTime);
|
||||
setText(`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(time.h).padStart(2, "0") + ":" + String(time.m).padStart(2, "0")) : ""}`);
|
||||
const val = withTime ? buildIso(jy, jm, jd, time.h, time.m) : buildDateOnly(jy, jm, jd);
|
||||
onChange(val);
|
||||
setText(
|
||||
`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(time.h).padStart(2, "0") + ":" + String(time.m).padStart(2, "0")) : ""}`
|
||||
);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
@@ -145,11 +186,10 @@ export function JalaliDatePicker({
|
||||
const pickToday = () => {
|
||||
const now = new Date();
|
||||
const [jy, jm, jd] = todayJalali();
|
||||
// بدون withTime فقط تاریخ امروز ثبت میشود — ساعت همیشه نیمهشب
|
||||
const isoWithTime = withTime
|
||||
const val = withTime
|
||||
? buildIso(jy, jm, jd, now.getHours(), now.getMinutes())
|
||||
: buildIso(jy, jm, jd, 0, 0);
|
||||
onChange(isoWithTime);
|
||||
: buildDateOnly(jy, jm, jd);
|
||||
onChange(val);
|
||||
setText(
|
||||
withTime
|
||||
? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(now.getHours()).padStart(2, "0") + ":" + String(now.getMinutes()).padStart(2, "0"))}`
|
||||
@@ -168,7 +208,19 @@ export function JalaliDatePicker({
|
||||
setText(v);
|
||||
const iso = jalaliStrToIso(v);
|
||||
if (iso) {
|
||||
onChange(iso);
|
||||
if (!withTime) {
|
||||
const nums = String(v)
|
||||
.split(/[/\-.]/)
|
||||
.map((x) => parseInt(String(x).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10));
|
||||
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
|
||||
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
|
||||
onChange(`${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`);
|
||||
} else {
|
||||
onChange(iso.slice(0, 10));
|
||||
}
|
||||
} else {
|
||||
onChange(iso);
|
||||
}
|
||||
const j = isoToJalali(iso);
|
||||
if (j) setCurJ([j[0], j[1], j[2]]);
|
||||
}
|
||||
@@ -178,15 +230,16 @@ export function JalaliDatePicker({
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
<input
|
||||
type="text"
|
||||
className="flex h-9 w-full rounded-md 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={disabled}
|
||||
className="flex h-9 w-full rounded-md 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 ?? "۱۴۰۳/۰۵/۱۲"}
|
||||
autoComplete="off"
|
||||
onFocus={() => setOpen(true)}
|
||||
onClick={() => setOpen(true)}
|
||||
onFocus={() => !disabled && setOpen(true)}
|
||||
onClick={() => !disabled && setOpen(true)}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
/>
|
||||
{open && (
|
||||
{open && !disabled && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md",
|
||||
|
||||
@@ -6,10 +6,11 @@ export interface MilestoneRowProps {
|
||||
milestone: Milestone;
|
||||
onChange: (m: Milestone) => void;
|
||||
onRemove: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
||||
export function MilestoneRow({ milestone, onChange, onRemove }: MilestoneRowProps) {
|
||||
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
|
||||
return (
|
||||
<div className="milestone-row">
|
||||
<JalaliDatePicker
|
||||
@@ -17,6 +18,7 @@ export function MilestoneRow({ milestone, onChange, onRemove }: MilestoneRowProp
|
||||
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
||||
placeholder="تاریخ"
|
||||
className="w-36 shrink-0"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="ms-amount-wrap">
|
||||
<NumberInput
|
||||
@@ -24,11 +26,14 @@ export function MilestoneRow({ milestone, onChange, onRemove }: MilestoneRowProp
|
||||
onChange={(amount) => onChange({ ...milestone, amount })}
|
||||
placeholder="مبلغ (تومان)"
|
||||
showWords
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="ms-x" title="حذف" onClick={onRemove}>
|
||||
×
|
||||
</button>
|
||||
{!disabled && (
|
||||
<button type="button" className="ms-x" title="حذف" onClick={onRemove}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface NumberInputProps {
|
||||
showWords?: boolean; // نمایش معادل فارسی زیر input
|
||||
inputMode?: "numeric" | "decimal" | undefined;
|
||||
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد)
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// ورودی عددی با فرمتدهی خودکار (groupCommas) و نمایش کلمات فارسی
|
||||
@@ -21,6 +22,7 @@ export function NumberInput({
|
||||
showWords = false,
|
||||
inputMode = "numeric",
|
||||
maxDecimals,
|
||||
disabled = false,
|
||||
}: NumberInputProps) {
|
||||
// فرمت با رعایت حداکثر اعشار (گرد کردن)
|
||||
const formatValue = (n: number): string => {
|
||||
@@ -62,7 +64,8 @@ export function NumberInput({
|
||||
<div className={cn("min-w-0", className)}>
|
||||
<input
|
||||
type="text"
|
||||
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={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}
|
||||
|
||||
Reference in New Issue
Block a user