diff --git a/backend/app/services/inventory_service.py b/backend/app/services/inventory_service.py index e723fbd..1bc4c32 100644 --- a/backend/app/services/inventory_service.py +++ b/backend/app/services/inventory_service.py @@ -22,7 +22,7 @@ SHA256 of the sorted canonical specs JSON (or 'default' when empty). import hashlib import json import uuid -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta from decimal import Decimal from sqlalchemy import select @@ -177,6 +177,10 @@ async def apply_transaction( specs = payload.specs or {} specs_hash = canonical_specs_hash(specs) tx_date = payload.transaction_date or datetime.now(timezone.utc) + # جلوگیری از ثبت تاریخ در آینده + now_utc = datetime.now(timezone.utc) + if tx_date > now_utc + timedelta(minutes=5): + raise ValidationError("امکان ثبت تاریخ یا زمان در آینده وجود ندارد", field="transaction_date") # STOCK_COUNT: apply the difference between counted and recorded quantity. if t == "STOCK_COUNT": diff --git a/frontend/src/components/JalaliDatePicker.tsx b/frontend/src/components/JalaliDatePicker.tsx index e6fc778..db87360 100644 --- a/frontend/src/components/JalaliDatePicker.tsx +++ b/frontend/src/components/JalaliDatePicker.tsx @@ -96,6 +96,51 @@ export function JalaliDatePicker({ return `${maxDateObj.getFullYear()}-${String(maxDateObj.getMonth() + 1).padStart(2, "0")}-${String(maxDateObj.getDate()).padStart(2, "0")}`; }, [maxDateObj]); + const maxJalaliParts = useMemo(() => { + if (!maxDateObj) return null; + return gregorianToJalali(maxDateObj.getFullYear(), maxDateObj.getMonth() + 1, maxDateObj.getDate()); + }, [maxDateObj]); + + const canGoNextMonth = useMemo(() => { + if (!maxJalaliParts) return true; + const [cy, cm] = curJ; + const [my, mm] = maxJalaliParts; + return cy < my || (cy === my && cm < mm); + }, [curJ, maxJalaliParts]); + + const canGoNextYear = useMemo(() => { + if (!maxJalaliParts) return true; + return curJ[0] < maxJalaliParts[0]; + }, [curJ, maxJalaliParts]); + + const clampIfFuture = (iso: string | null): string | null => { + if (!iso || !maxDateObj) return iso; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + if (d.getTime() > maxDateObj.getTime()) { + return maxDateObj.toISOString(); + } + return iso; + }; + + const getClampedTime = (h: number, m: number, jy: number, jm: number, jd: number): { h: number; m: number } => { + let validH = Math.min(23, Math.max(0, h)); + let validM = Math.min(59, Math.max(0, m)); + if (maxDateObj && maxDateIso) { + const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd); + const dayIso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`; + if (dayIso === maxDateIso) { + const maxH = maxDateObj.getHours(); + const maxM = maxDateObj.getMinutes(); + if (validH > maxH || (validH === maxH && validM > maxM)) { + validH = maxH; + validM = maxM; + } + } + } + return { h: validH, m: validM }; + }; + const [text, setText] = useState(() => { if (!value) return ""; const j = isoToJalaliStr(value); @@ -173,9 +218,15 @@ export function JalaliDatePicker({ setCurJ(([jy, jm]) => { let ny = jy; let nm = jm; - if (dir === "m-1") nm = nm === 12 ? 1 : nm + 1; + if (dir === "m-1") { + if (!canGoNextMonth) return [jy, jm, 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") { + if (!canGoNextYear) return [jy, jm, 1]; + ny += 1; + } if (dir === "y+1") ny -= 1; if (nm < 1) { nm = 12; @@ -185,6 +236,12 @@ export function JalaliDatePicker({ nm = 1; ny += 1; } + if (maxJalaliParts) { + if (ny > maxJalaliParts[0] || (ny === maxJalaliParts[0] && nm > maxJalaliParts[1])) { + ny = maxJalaliParts[0]; + nm = maxJalaliParts[1]; + } + } return [ny, nm, 1]; }); }; @@ -194,18 +251,12 @@ export function JalaliDatePicker({ const dayIso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`; if (maxDateIso && dayIso > maxDateIso) return; - let currentH = parseInt(toAsciiDigits(hourText) || "0", 10); - let currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); - let validH = Math.min(23, Math.max(0, currentH)); - let validM = Math.min(59, Math.max(0, currentM)); - if (maxDateObj && dayIso === maxDateIso) { - if (validH > maxDateObj.getHours() || (validH === maxDateObj.getHours() && validM > maxDateObj.getMinutes())) { - validH = maxDateObj.getHours(); - validM = maxDateObj.getMinutes(); - setHourText(faNum(String(validH).padStart(2, "0"))); - setMinuteText(faNum(String(validM).padStart(2, "0"))); - } - } + const currentH = parseInt(toAsciiDigits(hourText) || "0", 10); + const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); + const { h: validH, m: validM } = getClampedTime(currentH, currentM, jy, jm, jd); + setHourText(faNum(String(validH).padStart(2, "0"))); + setMinuteText(faNum(String(validM).padStart(2, "0"))); + const val = withTime ? buildIso(jy, jm, jd, validH, validM) : buildDateOnly(jy, jm, jd); onChange(val); setText( @@ -226,30 +277,28 @@ export function JalaliDatePicker({ 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; 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 { h: clampedH, m: validM } = getClampedTime(n, currentM, jy, jm, jd); + const textToShow = digits.length === 2 && n > 23 ? String(clampedH) : digits; + setHourText(faNum(textToShow)); + + onChange(buildIso(jy, jm, jd, clampedH, validM)); + const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clampedH, validM)); + setText(`${j} ${faNum(String(clampedH).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 { h: clampedH, m: validM } = getClampedTime(n, currentM, jy, jm, jd); + setHourText(faNum(String(clampedH).padStart(2, "0"))); + + onChange(buildIso(jy, jm, jd, clampedH, validM)); + const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clampedH, validM)); + setText(`${j} ${faNum(String(clampedH).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`); }; const handleMinuteChange = (raw: string) => { @@ -264,30 +313,28 @@ export function JalaliDatePicker({ 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 { h: validH, m: clampedM } = getClampedTime(currentH, n, jy, jm, jd); + const textToShow = digits.length === 2 && n > 59 ? String(clampedM) : digits; + setMinuteText(faNum(textToShow)); + + onChange(buildIso(jy, jm, jd, validH, clampedM)); + const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clampedM)); + setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clampedM).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 { h: validH, m: clampedM } = getClampedTime(currentH, n, jy, jm, jd); + setMinuteText(faNum(String(clampedM).padStart(2, "0"))); + + onChange(buildIso(jy, jm, jd, validH, clampedM)); + const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clampedM)); + setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clampedM).padStart(2, "0"))}`); }; const pickToday = () => { @@ -337,20 +384,23 @@ export function JalaliDatePicker({ } const iso = jalaliStrToIso(v); if (iso) { + const validIso = clampIfFuture(iso) || iso; if (!withTime) { const nums = String(v) .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]); - onChange(`${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`); + let dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`; + if (maxDateIso && dOnly > maxDateIso) dOnly = maxDateIso; + onChange(dOnly); } else { - onChange(iso.slice(0, 10)); + onChange(validIso.slice(0, 10)); } } else { - onChange(iso); + onChange(validIso); } - const j = isoToJalali(iso); + const j = isoToJalali(validIso); if (j) setCurJ([j[0], j[1], j[2]]); } }; @@ -362,27 +412,32 @@ export function JalaliDatePicker({ } const iso = jalaliStrToIso(text); if (iso) { + const validIso = clampIfFuture(iso) || 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")}`; + let dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`; + if (maxDateIso && dOnly > maxDateIso) dOnly = maxDateIso; onChange(dOnly); setText(isoToJalaliStr(dOnly)); } else { - onChange(iso.slice(0, 10)); - setText(isoToJalaliStr(iso)); + const dOnly = validIso.slice(0, 10); + onChange(dOnly); + setText(isoToJalaliStr(dOnly)); } } else { - onChange(iso); - const j = isoToJalaliStr(iso); - const d = new Date(iso); + onChange(validIso); + const j = isoToJalaliStr(validIso); + const d = new Date(validIso); 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)}`); + setHourText(faNum(hh)); + setMinuteText(faNum(mm)); } } } else if (value) { @@ -434,10 +489,22 @@ export function JalaliDatePicker({ {MONTHS[curJ[1] - 1]} {toFa(curJ[0])} - - diff --git a/frontend/src/lib/jalali.ts b/frontend/src/lib/jalali.ts index bc9edd8..249c496 100644 --- a/frontend/src/lib/jalali.ts +++ b/frontend/src/lib/jalali.ts @@ -115,6 +115,27 @@ export function isoToJalaliStr(iso: string | null | undefined): string { return `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`; } +// format an ISO string to "۱۴۰۳/۰۵/۱۲ ۱۴:۳۰" +export function isoToJalaliDateTime(iso: string | null | undefined): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${toFa(hh)}:${toFa(mm)}`; +} + +// format time only: "۱۴:۳۰" +export function isoToTimeStr(iso: string | null | undefined): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return `${toFa(hh)}:${toFa(mm)}`; +} + // format with month name: "۱۸ مرداد ۱۴۰۴" export function isoToJalaliLong(iso: string | null | undefined): string { if (!iso) return ""; diff --git a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx index 71468ab..488742a 100644 --- a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx +++ b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx @@ -105,7 +105,10 @@ export function ChangeInventoryPage() { const isSectionDateValid = (t: FormType): boolean => { const raw = sectionDates[t]; - return !!(raw && raw.trim() !== "" && !Number.isNaN(new Date(raw).getTime())); + if (!raw || raw.trim() === "") return false; + const d = new Date(raw); + if (Number.isNaN(d.getTime())) return false; + return d.getTime() <= Date.now() + 60000; }; const [note, setNote] = useState(""); const [deliveredTo, setDeliveredTo] = useState(""); @@ -716,8 +719,12 @@ export function ChangeInventoryPage() { }; const doSubmit = () => { - if (!isSectionDateValid(type)) { - return toast.error(`ثبت تاریخ و زمان در بخش «${meta.label}» الزامی است`); + const rawDate = sectionDates[type]; + if (!rawDate || !rawDate.trim() || Number.isNaN(new Date(rawDate).getTime())) { + return toast.error(`ثبت تاریخ و زمان در بخش «${meta.label}» الزامی است.`); + } + if (new Date(rawDate).getTime() > Date.now() + 60000) { + return toast.error("امکان ثبت تراکنش در تاریخ یا زمان آینده وجود ندارد."); } try { if (type === "PURCHASE") void submitPurchase(); diff --git a/frontend/src/pages/Inventory/InventoryPage.tsx b/frontend/src/pages/Inventory/InventoryPage.tsx index e6869a8..0ee2b89 100644 --- a/frontend/src/pages/Inventory/InventoryPage.tsx +++ b/frontend/src/pages/Inventory/InventoryPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { Link, useLocation, useSearchParams } from "react-router-dom"; -import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X, ChevronDown } from "lucide-react"; +import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X, ChevronDown, ChevronUp, Clock } from "lucide-react"; import { Header, PageLayout } from "../../components/layout/Header"; import { EmptyState } from "../../components/EmptyState"; import { useAuthStore } from "../../store/auth"; @@ -10,7 +10,7 @@ import { InventoryTopNav } from "./InventoryTopNav"; import { useInventoryData, brandName, materialName, unitName, whName } from "./useInventoryData"; import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types"; import { faNum } from "../../lib/formatters"; -import { isoToJalaliStr } from "../../lib/jalali"; +import { isoToJalaliStr, isoToJalaliDateTime, isoToTimeStr } from "../../lib/jalali"; import { cn } from "../../lib/utils"; import { JalaliDatePicker } from "../../components/JalaliDatePicker"; @@ -508,7 +508,6 @@ export function InventoryPage() { متریال - دسته‌بندی انبار مشخصه فنی تعداد @@ -660,8 +659,9 @@ function InventoryMobileCard({ — )} - - {isoToJalaliStr(g.updatedAt)} + + + {isoToJalaliDateTime(g.updatedAt)} @@ -699,13 +699,23 @@ function GroupRow({ g, isExp, isAdmin, matName, wh, projName, unit, onToggle, ti {Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"} {faNum(g.qty)} - {unit} - {isoToJalaliStr(g.updatedAt)} - 🕘 + {unit} + +
{isoToJalaliStr(g.updatedAt)}
+
+ + {isoToTimeStr(g.updatedAt)} +
+ + + + {isExp ? : } + + {isExp && ( - {timeline} + {timeline} )} @@ -737,14 +747,29 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB key={t.id} type="button" onClick={() => setSelTx(sel ? null : t.id)} - className={`flex min-w-[120px] flex-col gap-0.5 rounded-lg border p-2 text-start transition-all ${sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-accent/40"}`} + className={`flex min-w-[135px] flex-col gap-1 rounded-lg border p-2 text-start transition-all ${sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-accent/40"}`} style={{ borderTop: `3px solid ${meta.color}` }} > - {meta.icon} {meta.label} - - {faNum(Math.abs(t.quantity_number))} {unit} +
+ + {meta.icon} {meta.label} + +
+ + {t.quantity_number > 0 ? "+" : ""}{faNum(t.quantity_number)} {unit} - مانده: {faNum(bal)} {unit} +
+ مانده: {faNum(bal)} +
+
+ + {isoToJalaliDateTime(t.transaction_date)} +
+ {t.delivered_to && ( +
+ تحویل: {t.delivered_to} +
+ )} ); }); @@ -768,7 +793,11 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
{meta.label}
- تاریخ و ساعت: {isoToJalaliStr(t.transaction_date)} + زمان وقوع: {isoToJalaliDateTime(t.transaction_date)} + {t.delivered_to && تحویل به: {t.delivered_to}} + {t.created_at && ( + زمان ثبت در سیستم: {isoToJalaliDateTime(t.created_at)} + )} {t.transaction_type === "TRANSFER_OUT" || t.transaction_type === "TRANSFER_IN" ? ( <> از انبار: {t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}