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:
@@ -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":
|
||||
|
||||
@@ -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<string>(() => {
|
||||
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({
|
||||
<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
|
||||
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 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>
|
||||
</div>
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
<tr className="border-b border-line bg-bg/40 text-right text-muted">
|
||||
<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>
|
||||
@@ -660,8 +659,9 @@ function InventoryMobileCard({
|
||||
<span className="text-muted/60">—</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{isoToJalaliStr(g.updatedAt)}
|
||||
<span className="shrink-0 tabular-nums flex items-center gap-1">
|
||||
<Clock className="w-3 h-3 text-muted/70" />
|
||||
<span>{isoToJalaliDateTime(g.updatedAt)}</span>
|
||||
</span>
|
||||
</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(" · ") || "—"}
|
||||
</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 text-muted tabular-nums">{isoToJalaliStr(g.updatedAt)}</td>
|
||||
<td className="py-2.5 text-muted">🕘</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">
|
||||
<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>
|
||||
{isExp && (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
@@ -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}` }}
|
||||
>
|
||||
<span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span>
|
||||
<span className="text-[12px] font-bold tabular-nums" style={{ color: meta.color }}>
|
||||
{faNum(Math.abs(t.quantity_number))} {unit}
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[11px] font-bold" style={{ color: meta.color }}>
|
||||
{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 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>
|
||||
);
|
||||
});
|
||||
@@ -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}` }}>
|
||||
<b style={{ color: meta.color }}>{meta.label}</b>
|
||||
<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" ? (
|
||||
<>
|
||||
<span>از انبار: <b className="text-ink">{t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
|
||||
|
||||
Reference in New Issue
Block a user