diff --git a/backend/app/main.py b/backend/app/main.py index db7cad4..da20142 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -119,6 +119,7 @@ def _fa_validation_msg(msg: str) -> str: @app.exception_handler(RequestValidationError) async def validation_error_handler(_request: Request, exc: RequestValidationError): + logger.warning("RequestValidationError on %s: %s", _request.url.path, exc.errors()) first = exc.errors()[0] loc = [str(part) for part in first.get("loc", []) if part not in ("body", "query", "path")] field = ".".join(loc) if loc else None diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index 6c41c34..612be8b 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -346,7 +346,7 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s li_ids = select(LineItem.id).where(LineItem.quote_id == quote.id) await db.execute( update(ItemDecision) - .where(ItemDecision.line_item_id.in_(li_ids), ItemDecision.status != "pending") + .where(ItemDecision.line_item_id.in_(li_ids)) .values( status="pending", decided_at=None, @@ -524,21 +524,7 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn, decision.adjusted_notes = decision_in.adjusted_notes decision.decided_at = utcnow() - # --- per-material project allocation (تخصیص پروژه به هر قلم) --- - for entry in payload.item_projects: - if entry.line_item_id not in li_ids: - raise NotFoundError( - "خط آیتم یافت نشد یا متعلق به این پیشنهاد نیست", field="item_projects" - ) - await db.execute( - delete(LineItemProjects) - .where(LineItemProjects.line_item_id == entry.line_item_id) - .execution_options(synchronize_session=False) - ) - for pid in entry.project_ids: - if await db.get(Project, pid) is None: - raise NotFoundError("پروژه یافت نشد", field="item_projects.project_ids") - db.add(LineItemProjects(line_item_id=entry.line_item_id, project_id=pid)) + # LineItemProjects records in PostgreSQL remain 100% untouched. Only item_decisions.project_id is written. # termsheets referenced by at least one chosen item -> accepted if chosen_ts_ids: diff --git a/frontend/src/components/JalaliDatePicker.tsx b/frontend/src/components/JalaliDatePicker.tsx index 2d4ab5c..17b3f1d 100644 --- a/frontend/src/components/JalaliDatePicker.tsx +++ b/frontend/src/components/JalaliDatePicker.tsx @@ -6,6 +6,7 @@ import { jalaliStrToIso, jalaliToGregorian, MONTHS, + toAsciiDigits, toFa, WEEKDAYS, } from "../lib/jalali"; @@ -29,7 +30,7 @@ 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})/); + 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); @@ -78,6 +79,10 @@ export function JalaliDatePicker({ }: JalaliDatePickerProps) { const [open, setOpen] = useState(false); const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali()); + const initialTime = timeOf(value); + const [hourText, setHourText] = useState(() => faNum(String(initialTime.h).padStart(2, "0"))); + const [minuteText, setMinuteText] = useState(() => faNum(String(initialTime.m).padStart(2, "0"))); + const [text, setText] = useState(() => { if (!value) return ""; const j = isoToJalaliStr(value); @@ -92,19 +97,28 @@ export function JalaliDatePicker({ useEffect(() => { if (!value) { setText(""); + const now = nowTime(); + setHourText(faNum(String(now.h).padStart(2, "0"))); + setMinuteText(faNum(String(now.m).padStart(2, "0"))); return; } const j = isoToJalaliStr(value); + const t = timeOf(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 hh = String(t.h).padStart(2, "0"); + const mm = String(t.m).padStart(2, "0"); + setText(`${j} ${faNum(hh + ":" + mm)}`); + + // Only update local input texts if numeric values differ, to avoid wiping in-progress typing + const curH = parseInt(toAsciiDigits(hourText) || "0", 10); + if (curH !== t.h) { + setHourText(faNum(hh)); + } + const curM = parseInt(toAsciiDigits(minuteText) || "0", 10); + if (curM !== t.m) { + setMinuteText(faNum(mm)); } } const jParts = isoToJalali(value); @@ -116,14 +130,13 @@ export function JalaliDatePicker({ // تاریخ انتخاب‌شده به صورت محلی (نه UTC) برای مقایسه با سلول‌های تقویم function selectedLocalDate(): string | null { if (!value) return null; - const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/); + 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; @@ -163,36 +176,108 @@ export function JalaliDatePicker({ }; const selectDay = (jy: number, jm: number, jd: number) => { - const val = withTime ? buildIso(jy, jm, jd, time.h, time.m) : buildDateOnly(jy, jm, jd); + const currentH = parseInt(toAsciiDigits(hourText) || "0", 10); + const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); + const validH = Math.min(23, Math.max(0, currentH)); + const validM = Math.min(59, Math.max(0, currentM)); + const val = withTime ? buildIso(jy, jm, jd, validH, validM) : 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")) : ""}` + `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(validH).padStart(2, "0") + ":" + String(validM).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 handleHourChange = (raw: string) => { + let digits = toAsciiDigits(raw).replace(/\D/g, ""); + if (digits.length === 3) { + digits = digits.slice(-1); + } else if (digits.length > 3) { + digits = digits.slice(0, 2); + } + if (!digits) { + setHourText(""); + return; + } + const n = parseInt(digits, 10); + const clamped = Math.min(23, Math.max(0, n)); + const textToShow = digits.length === 2 && n > 23 ? String(clamped) : digits; + setHourText(faNum(textToShow)); + 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 currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); + const validM = Math.min(59, Math.max(0, currentM)); + onChange(buildIso(jy, jm, jd, clamped, validM)); + const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM)); + setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`); + }; + + const handleHourBlur = () => { + const digits = toAsciiDigits(hourText).replace(/\D/g, ""); + const n = digits ? parseInt(digits, 10) : 0; + const clamped = Math.min(23, Math.max(0, n)); + setHourText(faNum(String(clamped).padStart(2, "0"))); + + const [jy, jm, jd] = curJ; + const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); + const validM = Math.min(59, Math.max(0, currentM)); + onChange(buildIso(jy, jm, jd, clamped, validM)); + const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM)); + setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`); + }; + + const handleMinuteChange = (raw: string) => { + let digits = toAsciiDigits(raw).replace(/\D/g, ""); + if (digits.length === 3) { + digits = digits.slice(-1); + } else if (digits.length > 3) { + digits = digits.slice(0, 2); + } + if (!digits) { + setMinuteText(""); + return; + } + const n = parseInt(digits, 10); + const clamped = Math.min(59, Math.max(0, n)); + const textToShow = digits.length === 2 && n > 59 ? String(clamped) : digits; + setMinuteText(faNum(textToShow)); + + const [jy, jm, jd] = curJ; + const currentH = parseInt(toAsciiDigits(hourText) || "0", 10); + const validH = Math.min(23, Math.max(0, currentH)); + onChange(buildIso(jy, jm, jd, validH, clamped)); + const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped)); + setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`); + }; + + const handleMinuteBlur = () => { + const digits = toAsciiDigits(minuteText).replace(/\D/g, ""); + const n = digits ? parseInt(digits, 10) : 0; + const clamped = Math.min(59, Math.max(0, n)); + setMinuteText(faNum(String(clamped).padStart(2, "0"))); + + const [jy, jm, jd] = curJ; + const currentH = parseInt(toAsciiDigits(hourText) || "0", 10); + const validH = Math.min(23, Math.max(0, currentH)); + onChange(buildIso(jy, jm, jd, validH, clamped)); + const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped)); + setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`); }; const pickToday = () => { const now = new Date(); const [jy, jm, jd] = todayJalali(); + const nh = now.getHours(); + const nm = now.getMinutes(); + setHourText(faNum(String(nh).padStart(2, "0"))); + setMinuteText(faNum(String(nm).padStart(2, "0"))); const val = withTime - ? buildIso(jy, jm, jd, now.getHours(), now.getMinutes()) + ? buildIso(jy, jm, jd, nh, nm) : 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"))} ${faNum(String(nh).padStart(2, "0") + ":" + String(nm).padStart(2, "0"))}` : `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`, ); setOpen(false); @@ -201,17 +286,24 @@ export function JalaliDatePicker({ const clear = () => { onChange(null); setText(""); + const now = nowTime(); + setHourText(faNum(String(now.h).padStart(2, "0"))); + setMinuteText(faNum(String(now.m).padStart(2, "0"))); setOpen(false); }; const onInputChange = (v: string) => { setText(v); + if (!v.trim()) { + onChange(null); + return; + } 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)); + .map((x) => parseInt(toAsciiDigits(x), 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")}`); @@ -226,6 +318,51 @@ export function JalaliDatePicker({ } }; + const handleInputBlur = () => { + if (!text.trim()) { + onChange(null); + return; + } + const iso = jalaliStrToIso(text); + if (iso) { + if (!withTime) { + const nums = String(text) + .split(/[/\-.]/) + .map((x) => parseInt(toAsciiDigits(x), 10)); + if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) { + const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]); + const dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`; + onChange(dOnly); + setText(isoToJalaliStr(dOnly)); + } else { + onChange(iso.slice(0, 10)); + setText(isoToJalaliStr(iso)); + } + } else { + onChange(iso); + const j = isoToJalaliStr(iso); + const d = new Date(iso); + if (!Number.isNaN(d.getTime())) { + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + setText(`${j} ${faNum(hh + ":" + mm)}`); + } + } + } else if (value) { + const j = isoToJalaliStr(value); + if (!withTime) { + setText(j); + } else { + const d = new Date(value); + if (!Number.isNaN(d.getTime())) { + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + setText(`${j} ${faNum(hh + ":" + mm)}`); + } + } + } + }; + return (
!disabled && setOpen(true)} onClick={() => !disabled && setOpen(true)} onChange={(e) => onInputChange(e.target.value)} + onBlur={handleInputBlur} /> {open && !disabled && (
+ {/* 1. Header: Month / Year Navigation */}
+ + {/* 2. Day of Week Header & Days Grid */}
{WEEKDAYS.map((w) => (
@@ -291,33 +432,40 @@ export function JalaliDatePicker({ ), )}
+ + {/* 3. Time Inputs Row (Positioned BELOW the calendar grid) — Hour on left, Minute on right */} {withTime && ( -
+
- ساعت setTimePart("h", e.target.value)} + onFocus={(e) => e.target.select()} + onChange={(e) => handleMinuteChange(e.target.value)} + onBlur={handleMinuteBlur} /> -
- : -
دقیقه +
+ : +
setTimePart("m", e.target.value)} + onFocus={(e) => e.target.select()} + onChange={(e) => handleHourChange(e.target.value)} + onBlur={handleHourBlur} /> + ساعت
)} + + + {/* 4. Action Buttons Footer */}