fix(inventory): fix table column shift, show transaction date/time on dashboard cards, and enforce strict multi-layer future date prevention

This commit is contained in:
2026-10-19 23:48:42 +03:30
parent 42fb314834
commit 31300728b6
5 changed files with 203 additions and 75 deletions
+5 -1
View File
@@ -22,7 +22,7 @@ SHA256 of the sorted canonical specs JSON (or 'default' when empty).
import hashlib import hashlib
import json import json
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
from decimal import Decimal from decimal import Decimal
from sqlalchemy import select from sqlalchemy import select
@@ -177,6 +177,10 @@ async def apply_transaction(
specs = payload.specs or {} specs = payload.specs or {}
specs_hash = canonical_specs_hash(specs) specs_hash = canonical_specs_hash(specs)
tx_date = payload.transaction_date or datetime.now(timezone.utc) 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. # STOCK_COUNT: apply the difference between counted and recorded quantity.
if t == "STOCK_COUNT": if t == "STOCK_COUNT":
+123 -56
View File
@@ -96,6 +96,51 @@ export function JalaliDatePicker({
return `${maxDateObj.getFullYear()}-${String(maxDateObj.getMonth() + 1).padStart(2, "0")}-${String(maxDateObj.getDate()).padStart(2, "0")}`; return `${maxDateObj.getFullYear()}-${String(maxDateObj.getMonth() + 1).padStart(2, "0")}-${String(maxDateObj.getDate()).padStart(2, "0")}`;
}, [maxDateObj]); }, [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<string>(() => { const [text, setText] = useState<string>(() => {
if (!value) return ""; if (!value) return "";
const j = isoToJalaliStr(value); const j = isoToJalaliStr(value);
@@ -173,9 +218,15 @@ export function JalaliDatePicker({
setCurJ(([jy, jm]) => { setCurJ(([jy, jm]) => {
let ny = jy; let ny = jy;
let nm = jm; 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 === "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 (dir === "y+1") ny -= 1;
if (nm < 1) { if (nm < 1) {
nm = 12; nm = 12;
@@ -185,6 +236,12 @@ export function JalaliDatePicker({
nm = 1; nm = 1;
ny += 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]; return [ny, nm, 1];
}); });
}; };
@@ -194,18 +251,12 @@ export function JalaliDatePicker({
const dayIso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`; const dayIso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
if (maxDateIso && dayIso > maxDateIso) return; if (maxDateIso && dayIso > maxDateIso) return;
let currentH = parseInt(toAsciiDigits(hourText) || "0", 10); const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
let currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
let validH = Math.min(23, Math.max(0, currentH)); const { h: validH, m: validM } = getClampedTime(currentH, currentM, jy, jm, jd);
let validM = Math.min(59, Math.max(0, currentM)); setHourText(faNum(String(validH).padStart(2, "0")));
if (maxDateObj && dayIso === maxDateIso) { setMinuteText(faNum(String(validM).padStart(2, "0")));
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 val = withTime ? buildIso(jy, jm, jd, validH, validM) : buildDateOnly(jy, jm, jd); const val = withTime ? buildIso(jy, jm, jd, validH, validM) : buildDateOnly(jy, jm, jd);
onChange(val); onChange(val);
setText( setText(
@@ -226,30 +277,28 @@ export function JalaliDatePicker({
return; return;
} }
const n = parseInt(digits, 10); 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 [jy, jm, jd] = curJ;
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const validM = Math.min(59, Math.max(0, currentM)); const { h: clampedH, m: validM } = getClampedTime(n, currentM, jy, jm, jd);
onChange(buildIso(jy, jm, jd, clamped, validM)); const textToShow = digits.length === 2 && n > 23 ? String(clampedH) : digits;
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM)); setHourText(faNum(textToShow));
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).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 handleHourBlur = () => { const handleHourBlur = () => {
const digits = toAsciiDigits(hourText).replace(/\D/g, ""); const digits = toAsciiDigits(hourText).replace(/\D/g, "");
const n = digits ? parseInt(digits, 10) : 0; 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 [jy, jm, jd] = curJ;
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const validM = Math.min(59, Math.max(0, currentM)); const { h: clampedH, m: validM } = getClampedTime(n, currentM, jy, jm, jd);
onChange(buildIso(jy, jm, jd, clamped, validM)); setHourText(faNum(String(clampedH).padStart(2, "0")));
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"))}`); 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) => { const handleMinuteChange = (raw: string) => {
@@ -264,30 +313,28 @@ export function JalaliDatePicker({
return; return;
} }
const n = parseInt(digits, 10); 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 [jy, jm, jd] = curJ;
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10); const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH)); const { h: validH, m: clampedM } = getClampedTime(currentH, n, jy, jm, jd);
onChange(buildIso(jy, jm, jd, validH, clamped)); const textToShow = digits.length === 2 && n > 59 ? String(clampedM) : digits;
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped)); setMinuteText(faNum(textToShow));
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).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 handleMinuteBlur = () => { const handleMinuteBlur = () => {
const digits = toAsciiDigits(minuteText).replace(/\D/g, ""); const digits = toAsciiDigits(minuteText).replace(/\D/g, "");
const n = digits ? parseInt(digits, 10) : 0; 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 [jy, jm, jd] = curJ;
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10); const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH)); const { h: validH, m: clampedM } = getClampedTime(currentH, n, jy, jm, jd);
onChange(buildIso(jy, jm, jd, validH, clamped)); setMinuteText(faNum(String(clampedM).padStart(2, "0")));
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"))}`); 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 = () => { const pickToday = () => {
@@ -337,20 +384,23 @@ export function JalaliDatePicker({
} }
const iso = jalaliStrToIso(v); const iso = jalaliStrToIso(v);
if (iso) { if (iso) {
const validIso = clampIfFuture(iso) || iso;
if (!withTime) { if (!withTime) {
const nums = String(v) const nums = String(v)
.split(/[/\-.]/) .split(/[/\-.]/)
.map((x) => parseInt(toAsciiDigits(x), 10)); .map((x) => parseInt(toAsciiDigits(x), 10));
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) { if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]); 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 { } else {
onChange(iso.slice(0, 10)); onChange(validIso.slice(0, 10));
} }
} else { } else {
onChange(iso); onChange(validIso);
} }
const j = isoToJalali(iso); const j = isoToJalali(validIso);
if (j) setCurJ([j[0], j[1], j[2]]); if (j) setCurJ([j[0], j[1], j[2]]);
} }
}; };
@@ -362,27 +412,32 @@ export function JalaliDatePicker({
} }
const iso = jalaliStrToIso(text); const iso = jalaliStrToIso(text);
if (iso) { if (iso) {
const validIso = clampIfFuture(iso) || iso;
if (!withTime) { if (!withTime) {
const nums = String(text) const nums = String(text)
.split(/[/\-.]/) .split(/[/\-.]/)
.map((x) => parseInt(toAsciiDigits(x), 10)); .map((x) => parseInt(toAsciiDigits(x), 10));
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) { if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]); 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); onChange(dOnly);
setText(isoToJalaliStr(dOnly)); setText(isoToJalaliStr(dOnly));
} else { } else {
onChange(iso.slice(0, 10)); const dOnly = validIso.slice(0, 10);
setText(isoToJalaliStr(iso)); onChange(dOnly);
setText(isoToJalaliStr(dOnly));
} }
} else { } else {
onChange(iso); onChange(validIso);
const j = isoToJalaliStr(iso); const j = isoToJalaliStr(validIso);
const d = new Date(iso); const d = new Date(validIso);
if (!Number.isNaN(d.getTime())) { if (!Number.isNaN(d.getTime())) {
const hh = String(d.getHours()).padStart(2, "0"); const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0");
setText(`${j} ${faNum(hh + ":" + mm)}`); setText(`${j} ${faNum(hh + ":" + mm)}`);
setHourText(faNum(hh));
setMinuteText(faNum(mm));
} }
} }
} else if (value) { } else if (value) {
@@ -434,10 +489,22 @@ export function JalaliDatePicker({
<span className="text-[13px] font-bold"> <span className="text-[13px] font-bold">
{MONTHS[curJ[1] - 1]} {toFa(curJ[0])} {MONTHS[curJ[1] - 1]} {toFa(curJ[0])}
</span> </span>
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m-1")}> <button
type="button"
disabled={!canGoNextMonth}
className={cn("rounded px-1.5 text-sm", canGoNextMonth ? "cursor-pointer hover:bg-slate-bg" : "opacity-25 cursor-not-allowed text-muted")}
onClick={() => canGoNextMonth && nav("m-1")}
title={canGoNextMonth ? "ماه بعد" : "امکان انتخاب تاریخ آینده وجود ندارد"}
>
› ›
</button> </button>
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y-1")}> <button
type="button"
disabled={!canGoNextYear}
className={cn("rounded px-1.5 text-sm", canGoNextYear ? "cursor-pointer hover:bg-slate-bg" : "opacity-25 cursor-not-allowed text-muted")}
onClick={() => canGoNextYear && nav("y-1")}
title={canGoNextYear ? "سال بعد" : "امکان انتخاب تاریخ آینده وجود ندارد"}
>
» »
</button> </button>
</div> </div>
+21
View File
@@ -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"))}`; 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: "۱۸ مرداد ۱۴۰۴" // format with month name: "۱۸ مرداد ۱۴۰۴"
export function isoToJalaliLong(iso: string | null | undefined): string { export function isoToJalaliLong(iso: string | null | undefined): string {
if (!iso) return ""; if (!iso) return "";
@@ -105,7 +105,10 @@ export function ChangeInventoryPage() {
const isSectionDateValid = (t: FormType): boolean => { const isSectionDateValid = (t: FormType): boolean => {
const raw = sectionDates[t]; 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 [note, setNote] = useState("");
const [deliveredTo, setDeliveredTo] = useState(""); const [deliveredTo, setDeliveredTo] = useState("");
@@ -716,8 +719,12 @@ export function ChangeInventoryPage() {
}; };
const doSubmit = () => { const doSubmit = () => {
if (!isSectionDateValid(type)) { const rawDate = sectionDates[type];
return toast.error(`ثبت تاریخ و زمان در بخش «${meta.label}» الزامی است`); 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 { try {
if (type === "PURCHASE") void submitPurchase(); if (type === "PURCHASE") void submitPurchase();
+44 -15
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link, useLocation, useSearchParams } from "react-router-dom"; 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 { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState"; import { EmptyState } from "../../components/EmptyState";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
@@ -10,7 +10,7 @@ import { InventoryTopNav } from "./InventoryTopNav";
import { useInventoryData, brandName, materialName, unitName, whName } from "./useInventoryData"; import { useInventoryData, brandName, materialName, unitName, whName } from "./useInventoryData";
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types"; import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali"; import { isoToJalaliStr, isoToJalaliDateTime, isoToTimeStr } from "../../lib/jalali";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { JalaliDatePicker } from "../../components/JalaliDatePicker"; import { JalaliDatePicker } from "../../components/JalaliDatePicker";
@@ -508,7 +508,6 @@ export function InventoryPage() {
<tr className="border-b border-line bg-bg/40 text-right text-muted"> <tr className="border-b border-line bg-bg/40 text-right text-muted">
<th className="w-8 py-2.5" /> <th className="w-8 py-2.5" />
<th className="py-2.5 pe-3 font-semibold">متریال</th> <th className="py-2.5 pe-3 font-semibold">متریال</th>
<th className="py-2.5 pe-3 font-semibold">دسته‌بندی</th>
<th className="py-2.5 pe-3 font-semibold">انبار</th> <th className="py-2.5 pe-3 font-semibold">انبار</th>
<th className="py-2.5 pe-3 font-semibold">مشخصه فنی</th> <th className="py-2.5 pe-3 font-semibold">مشخصه فنی</th>
<th className="py-2.5 pe-3 font-semibold">تعداد</th> <th className="py-2.5 pe-3 font-semibold">تعداد</th>
@@ -660,8 +659,9 @@ function InventoryMobileCard({
<span className="text-muted/60">—</span> <span className="text-muted/60">—</span>
)} )}
</div> </div>
<span className="shrink-0 tabular-nums"> <span className="shrink-0 tabular-nums flex items-center gap-1">
{isoToJalaliStr(g.updatedAt)} <Clock className="w-3 h-3 text-muted/70" />
<span>{isoToJalaliDateTime(g.updatedAt)}</span>
</span> </span>
</div> </div>
</div> </div>
@@ -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(" · ") || "—"} {Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}
</td> </td>
<td className="py-2.5 pe-3 font-extrabold text-ink tabular-nums">{faNum(g.qty)}</td> <td className="py-2.5 pe-3 font-extrabold text-ink tabular-nums">{faNum(g.qty)}</td>
<td className="py-2.5 pe-3">{unit}</td> <td className="py-2.5 pe-3 font-medium text-ink/80">{unit}</td>
<td className="py-2.5 pe-3 text-muted tabular-nums">{isoToJalaliStr(g.updatedAt)}</td> <td className="py-2.5 pe-3 text-muted tabular-nums">
<td className="py-2.5 text-muted">🕘</td> <div className="font-semibold text-ink">{isoToJalaliStr(g.updatedAt)}</div>
<div className="text-[10.5px] text-muted flex items-center gap-1 mt-0.5">
<Clock className="w-3 h-3 text-muted/70 shrink-0" />
<span>{isoToTimeStr(g.updatedAt)}</span>
</div>
</td>
<td className="py-2.5 text-center text-muted">
<span className="p-1 inline-flex items-center justify-center rounded hover:text-accent">
{isExp ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</span>
</td>
</tr> </tr>
{isExp && ( {isExp && (
<tr className="border-b border-line/40 bg-accent-bg/10"> <tr className="border-b border-line/40 bg-accent-bg/10">
<td colSpan={9} className="p-3.5">{timeline}</td> <td colSpan={8} className="p-3.5">{timeline}</td>
</tr> </tr>
)} )}
</> </>
@@ -737,14 +747,29 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
key={t.id} key={t.id}
type="button" type="button"
onClick={() => setSelTx(sel ? null : t.id)} 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}` }} style={{ borderTop: `3px solid ${meta.color}` }}
> >
<span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span> <div className="flex items-center justify-between gap-1">
<span className="text-[12px] font-bold tabular-nums" style={{ color: meta.color }}> <span className="text-[11px] font-bold" style={{ color: meta.color }}>
{faNum(Math.abs(t.quantity_number))} {unit} {meta.icon} {meta.label}
</span>
</div>
<span className="text-[12.5px] font-extrabold tabular-nums" style={{ color: meta.color }}>
{t.quantity_number > 0 ? "+" : ""}{faNum(t.quantity_number)} {unit}
</span> </span>
<span className="text-[10.5px] text-muted">مانده: <b className="tabular-nums">{faNum(bal)}</b> {unit}</span> <div className="flex items-center justify-between gap-1 text-[10px] text-muted border-t border-line/40 pt-1">
<span>مانده: <b className="tabular-nums font-semibold">{faNum(bal)}</b></span>
</div>
<div className="flex items-center gap-1 text-[9.5px] text-muted/90 tabular-nums">
<Clock className="w-2.5 h-2.5 text-muted shrink-0" />
<span>{isoToJalaliDateTime(t.transaction_date)}</span>
</div>
{t.delivered_to && (
<div className="text-[9.5px] text-ink/80 font-medium truncate max-w-[125px]" title={t.delivered_to}>
تحویل: {t.delivered_to}
</div>
)}
</button> </button>
); );
}); });
@@ -768,7 +793,11 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
<div className="mt-2 rounded-lg border p-2.5 text-[12px] bg-panel shadow-2xs" style={{ borderTop: `3px solid ${meta.color}` }}> <div className="mt-2 rounded-lg border p-2.5 text-[12px] bg-panel shadow-2xs" style={{ borderTop: `3px solid ${meta.color}` }}>
<b style={{ color: meta.color }}>{meta.label}</b> <b style={{ color: meta.color }}>{meta.label}</b>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-muted"> <div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-muted">
<span>تاریخ و ساعت: <b className="text-ink">{isoToJalaliStr(t.transaction_date)}</b></span> <span>زمان وقوع: <b className="text-ink font-bold">{isoToJalaliDateTime(t.transaction_date)}</b></span>
{t.delivered_to && <span>تحویل به: <b className="text-ink font-bold">{t.delivered_to}</b></span>}
{t.created_at && (
<span>زمان ثبت در سیستم: <b className="text-ink/80">{isoToJalaliDateTime(t.created_at)}</b></span>
)}
{t.transaction_type === "TRANSFER_OUT" || t.transaction_type === "TRANSFER_IN" ? ( {t.transaction_type === "TRANSFER_OUT" || t.transaction_type === "TRANSFER_IN" ? (
<> <>
<span>از انبار: <b className="text-ink">{t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span> <span>از انبار: <b className="text-ink">{t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>