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 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":
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user