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.
334 lines
13 KiB
TypeScript
334 lines
13 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import {
|
||
daysInMonth,
|
||
gregorianToJalali,
|
||
isoToJalaliStr,
|
||
jalaliStrToIso,
|
||
jalaliToGregorian,
|
||
MONTHS,
|
||
toFa,
|
||
WEEKDAYS,
|
||
} from "../lib/jalali";
|
||
import { faNum } from "../lib/formatters";
|
||
import { cn } from "../lib/utils";
|
||
import { useClickOutside } from "./useClickOutside";
|
||
|
||
export interface JalaliDatePickerProps {
|
||
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] {
|
||
const now = new Date();
|
||
return gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||
}
|
||
|
||
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());
|
||
}
|
||
|
||
// ساخت ISO با زمان محلی (تاریخ شمسی + ساعت:دقیقه)
|
||
function buildIso(jy: number, jm: number, jd: number, h: number, m: number): string {
|
||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||
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 } {
|
||
const d = new Date();
|
||
return { h: d.getHours(), m: d.getMinutes() };
|
||
}
|
||
|
||
function timeOf(iso: string | null): { h: number; m: number } {
|
||
if (!iso) return nowTime();
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return nowTime();
|
||
return { h: d.getHours(), m: d.getMinutes() };
|
||
}
|
||
|
||
// تقویم شمسی — پورت Jalali.buildPicker به React (با پشتیبانی ساعت:دقیقه)
|
||
export function JalaliDatePicker({
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
className,
|
||
withTime = false,
|
||
disabled = false,
|
||
}: JalaliDatePickerProps) {
|
||
const [open, setOpen] = useState(false);
|
||
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
|
||
const [text, setText] = useState<string>(() => {
|
||
if (!value) return "";
|
||
const j = isoToJalaliStr(value);
|
||
if (!withTime) return j;
|
||
const d = new Date(value);
|
||
if (Number.isNaN(d.getTime())) return j;
|
||
const hh = String(d.getHours()).padStart(2, "0");
|
||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||
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")}`;
|
||
}
|
||
const selectedIso = selectedLocalDate();
|
||
const time = timeOf(value);
|
||
|
||
const grid = useMemo(() => {
|
||
const [jy, jm] = curJ;
|
||
const firstG = jalaliToGregorian(jy, jm, 1);
|
||
const firstWd = (new Date(firstG[0], firstG[1] - 1, firstG[2]).getDay() + 1) % 7; // شنبه = 0
|
||
const dim = daysInMonth(jy, jm);
|
||
const today = new Date();
|
||
const tIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||
const cells: { d: number; iso: string; isToday: boolean }[] = [];
|
||
for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false });
|
||
for (let d = 1; d <= dim; d++) {
|
||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, d);
|
||
const iso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||
cells.push({ d, iso, isToday: iso === tIso });
|
||
}
|
||
return cells;
|
||
}, [curJ]);
|
||
|
||
const nav = (dir: string) => {
|
||
setCurJ(([jy, jm]) => {
|
||
let ny = jy;
|
||
let nm = jm;
|
||
if (dir === "m-1") nm = nm === 12 ? 1 : nm + 1;
|
||
if (dir === "m+1") nm = nm === 1 ? 12 : nm - 1;
|
||
if (dir === "y-1") ny += 1;
|
||
if (dir === "y+1") ny -= 1;
|
||
if (nm < 1) {
|
||
nm = 12;
|
||
ny -= 1;
|
||
}
|
||
if (nm > 12) {
|
||
nm = 1;
|
||
ny += 1;
|
||
}
|
||
return [ny, nm, 1];
|
||
});
|
||
};
|
||
|
||
const selectDay = (jy: number, jm: number, jd: number) => {
|
||
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);
|
||
};
|
||
|
||
const setTimePart = (part: "h" | "m", raw: string) => {
|
||
const n = parseInt(raw.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10);
|
||
if (Number.isNaN(n)) return;
|
||
const h = part === "h" ? Math.min(23, Math.max(0, n)) : time.h;
|
||
const m = part === "m" ? Math.min(59, Math.max(0, n)) : time.m;
|
||
const [jy, jm, jd] = curJ;
|
||
onChange(buildIso(jy, jm, jd, h, m));
|
||
// بهروزرسانی نمایش
|
||
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, h, m));
|
||
setText(`${j} ${faNum(String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"))}`);
|
||
};
|
||
|
||
const pickToday = () => {
|
||
const now = new Date();
|
||
const [jy, jm, jd] = todayJalali();
|
||
const val = withTime
|
||
? buildIso(jy, jm, jd, now.getHours(), now.getMinutes())
|
||
: 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"))}`
|
||
: `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`,
|
||
);
|
||
setOpen(false);
|
||
};
|
||
|
||
const clear = () => {
|
||
onChange(null);
|
||
setText("");
|
||
setOpen(false);
|
||
};
|
||
|
||
const onInputChange = (v: string) => {
|
||
setText(v);
|
||
const iso = jalaliStrToIso(v);
|
||
if (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]]);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div ref={ref} className={cn("relative", className)}>
|
||
<input
|
||
type="text"
|
||
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={() => !disabled && setOpen(true)}
|
||
onClick={() => !disabled && setOpen(true)}
|
||
onChange={(e) => onInputChange(e.target.value)}
|
||
/>
|
||
{open && !disabled && (
|
||
<div
|
||
className={cn(
|
||
"absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md",
|
||
// حداکثر عرض = viewport (موبایل باریک سرریز نمیشود؛ دسکتاپ بیتغییر)
|
||
"max-w-[calc(100vw-2rem)]",
|
||
withTime ? "w-80 p-3" : "w-64 p-2",
|
||
)}
|
||
>
|
||
<div className="mb-1.5 flex items-center justify-between gap-1">
|
||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
|
||
«
|
||
</button>
|
||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m+1")}>
|
||
‹
|
||
</button>
|
||
<span className="text-[13px] font-bold">
|
||
{MONTHS[curJ[1] - 1]} {toFa(curJ[0])}
|
||
</span>
|
||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m-1")}>
|
||
›
|
||
</button>
|
||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y-1")}>
|
||
»
|
||
</button>
|
||
</div>
|
||
<div className="grid grid-cols-7 gap-0.5 text-center">
|
||
{WEEKDAYS.map((w) => (
|
||
<div key={w} className="py-0.5 text-[10px] font-bold text-muted">
|
||
{w}
|
||
</div>
|
||
))}
|
||
{grid.map((cell, i) =>
|
||
cell.d === 0 ? (
|
||
<div key={i} />
|
||
) : (
|
||
<button
|
||
key={i}
|
||
type="button"
|
||
className={cn(
|
||
"cursor-pointer rounded py-0.5 text-[12px] hover:bg-slate-bg max-md:py-1.5",
|
||
cell.isToday && !selectedIso && "bg-blue-bg font-bold text-[#1d4ed8]",
|
||
cell.isToday && selectedIso && "font-bold text-[#1d4ed8]",
|
||
cell.iso === selectedIso && "bg-accent text-white hover:bg-accent-dark",
|
||
)}
|
||
onClick={() => selectDay(curJ[0], curJ[1], cell.d)}
|
||
>
|
||
{toFa(cell.d)}
|
||
</button>
|
||
),
|
||
)}
|
||
</div>
|
||
{withTime && (
|
||
<div className="mt-2 flex items-center justify-center gap-4 border-t border-line pt-2.5">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
|
||
<input
|
||
type="text"
|
||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-10 max-md:w-16"
|
||
value={faNum(String(time.h).padStart(2, "0"))}
|
||
inputMode="numeric"
|
||
maxLength={2}
|
||
onChange={(e) => setTimePart("h", e.target.value)}
|
||
/>
|
||
</div>
|
||
<span className="text-muted">:</span>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
|
||
<input
|
||
type="text"
|
||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-10 max-md:w-16"
|
||
value={faNum(String(time.m).padStart(2, "0"))}
|
||
inputMode="numeric"
|
||
maxLength={2}
|
||
onChange={(e) => setTimePart("m", e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="mt-2 flex justify-between border-t border-line pt-2">
|
||
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-accent hover:bg-slate-bg" onClick={pickToday}>
|
||
امروز
|
||
</button>
|
||
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-muted hover:bg-slate-bg" onClick={clear}>
|
||
پاک کردن
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|