feat(inventory): add mandatory per-section date pickers, prevent future dates, sync transfer dates, and sort by transaction_date

This commit is contained in:
2026-10-19 15:07:11 +03:30
parent 264c7cc906
commit 42fb314834
8 changed files with 457 additions and 108 deletions
+4
View File
@@ -217,6 +217,7 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -
"performed_by": tx.performed_by,
"delivered_to": tx.delivered_to,
"transaction_date": to_iso(tx.transaction_date),
"created_at": to_iso(tx.created_at) if hasattr(tx, "created_at") and tx.created_at else None,
"note": tx.note,
}
@@ -461,6 +462,9 @@ async def list_transactions(
InventoryTransaction.transaction_date
< datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1)
)
if not sort or sort == "-transaction_date":
stmt = stmt.order_by(InventoryTransaction.transaction_date.desc(), InventoryTransaction.created_at.desc())
else:
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
items, meta = await paginate(stmt, page, page_size, db)
+7 -5
View File
@@ -22,6 +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 decimal import Decimal
from sqlalchemy import select
@@ -175,6 +176,7 @@ async def apply_transaction(
t = payload.transaction_type
specs = payload.specs or {}
specs_hash = canonical_specs_hash(specs)
tx_date = payload.transaction_date or datetime.now(timezone.utc)
# STOCK_COUNT: apply the difference between counted and recorded quantity.
if t == "STOCK_COUNT":
@@ -197,7 +199,7 @@ async def apply_transaction(
recorded_qty=recorded,
physical_qty=payload.physical_qty,
performed_by=performed_by,
transaction_date=payload.transaction_date,
transaction_date=tx_date,
note=payload.note,
)
db.add(tx)
@@ -238,7 +240,7 @@ async def apply_transaction(
quantity=-quantity,
transfer_pair_id=pair_id,
performed_by=performed_by,
transaction_date=payload.transaction_date,
transaction_date=tx_date,
note=payload.note,
)
db.add(out_tx)
@@ -256,7 +258,7 @@ async def apply_transaction(
quantity=quantity,
transfer_pair_id=pair_id,
performed_by=performed_by,
transaction_date=payload.transaction_date,
transaction_date=tx_date,
note=payload.note,
)
db.add(in_tx)
@@ -291,7 +293,7 @@ async def apply_transaction(
quantity=quantity,
performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date,
transaction_date=tx_date,
note=payload.note,
)
db.add(tx)
@@ -321,7 +323,7 @@ async def apply_transaction(
quantity=-quantity,
performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date,
transaction_date=tx_date,
note=payload.note,
)
db.add(tx)
+57 -10
View File
@@ -21,6 +21,7 @@ export interface JalaliDatePickerProps {
className?: string;
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
disabled?: boolean;
maxDate?: Date | string; // حداکثر تاریخ مجاز (جلوگیری از انتخاب تاریخ/زمان آینده)
}
function todayJalali(): [number, number, number] {
@@ -76,6 +77,7 @@ export function JalaliDatePicker({
className,
withTime = false,
disabled = false,
maxDate,
}: JalaliDatePickerProps) {
const [open, setOpen] = useState(false);
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
@@ -83,6 +85,17 @@ export function JalaliDatePicker({
const [hourText, setHourText] = useState<string>(() => faNum(String(initialTime.h).padStart(2, "0")));
const [minuteText, setMinuteText] = useState<string>(() => faNum(String(initialTime.m).padStart(2, "0")));
const maxDateObj = useMemo(() => {
if (!maxDate) return null;
const d = typeof maxDate === "string" ? new Date(maxDate) : maxDate;
return Number.isNaN(d.getTime()) ? null : d;
}, [maxDate]);
const maxDateIso = useMemo(() => {
if (!maxDateObj) return null;
return `${maxDateObj.getFullYear()}-${String(maxDateObj.getMonth() + 1).padStart(2, "0")}-${String(maxDateObj.getDate()).padStart(2, "0")}`;
}, [maxDateObj]);
const [text, setText] = useState<string>(() => {
if (!value) return "";
const j = isoToJalaliStr(value);
@@ -145,15 +158,16 @@ export function JalaliDatePicker({
const dim = daysInMonth(jy, jm);
const today = new Date();
const tIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const cells: { d: number; iso: string; isToday: boolean }[] = [];
for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false });
const cells: { d: number; iso: string; isToday: boolean; disabled: boolean }[] = [];
for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false, disabled: false });
for (let d = 1; d <= dim; d++) {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, d);
const iso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
cells.push({ d, iso, isToday: iso === tIso });
const isDisabled = !!(maxDateIso && iso > maxDateIso);
cells.push({ d, iso, isToday: iso === tIso, disabled: isDisabled });
}
return cells;
}, [curJ]);
}, [curJ, maxDateIso]);
const nav = (dir: string) => {
setCurJ(([jy, jm]) => {
@@ -176,10 +190,22 @@ export function JalaliDatePicker({
};
const selectDay = (jy: number, jm: number, jd: number) => {
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH));
const validM = Math.min(59, Math.max(0, currentM));
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
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 val = withTime ? buildIso(jy, jm, jd, validH, validM) : buildDateOnly(jy, jm, jd);
onChange(val);
setText(
@@ -267,8 +293,19 @@ export function JalaliDatePicker({
const pickToday = () => {
const now = new Date();
const [jy, jm, jd] = todayJalali();
const nh = now.getHours();
const nm = now.getMinutes();
let nh = now.getHours();
let nm = now.getMinutes();
if (maxDateObj) {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
const todayIso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
if (maxDateIso && todayIso > maxDateIso) return;
if (todayIso === maxDateIso) {
if (nh > maxDateObj.getHours() || (nh === maxDateObj.getHours() && nm > maxDateObj.getMinutes())) {
nh = maxDateObj.getHours();
nm = maxDateObj.getMinutes();
}
}
}
setHourText(faNum(String(nh).padStart(2, "0")));
setMinuteText(faNum(String(nm).padStart(2, "0")));
const val = withTime
@@ -415,6 +452,16 @@ export function JalaliDatePicker({
{grid.map((cell, i) =>
cell.d === 0 ? (
<div key={i} />
) : cell.disabled ? (
<button
key={i}
type="button"
disabled
className="rounded py-0.5 text-[12px] opacity-25 text-muted cursor-not-allowed max-md:py-1.5"
title="امکان انتخاب تاریخ آینده وجود ندارد"
>
{toFa(cell.d)}
</button>
) : (
<button
key={i}
+1
View File
@@ -172,6 +172,7 @@ export interface InventoryTransaction {
performed_by?: string | null;
delivered_to?: string | null;
transaction_date: string;
created_at?: string;
note?: string | null;
material?: { id: string; name: string } | null;
brand?: { id: string; name: string } | null;
@@ -81,7 +81,32 @@ export function ChangeInventoryPage() {
const [matTxt, setMatTxt] = useState("");
const [brandTxt, setBrandTxt] = useState("");
const [qty, setQty] = useState("");
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
const [sectionDates, setSectionDates] = useState<Record<FormType, string>>(() => {
const now = new Date().toISOString();
return {
PURCHASE: now,
WITHDRAWAL: now,
PROJECT_TRANSFER: now,
RETURN: now,
TRANSFER: now,
TRANSFER_IN: now,
TRANSFER_OUT: now,
DAMAGE: now,
WASTE: now,
LOSS: now,
FOUND: now,
STOCK_COUNT: now,
};
});
const setSectionDate = (t: FormType, val: string | null) => {
setSectionDates((prev) => ({ ...prev, [t]: val ?? "" }));
};
const isSectionDateValid = (t: FormType): boolean => {
const raw = sectionDates[t];
return !!(raw && raw.trim() !== "" && !Number.isNaN(new Date(raw).getTime()));
};
const [note, setNote] = useState("");
const [deliveredTo, setDeliveredTo] = useState("");
const [rows, setRows] = useState<RowDraft[]>([]);
@@ -171,10 +196,13 @@ export function ChangeInventoryPage() {
const matByName = (name: string) => data.lookup.materials.find((m) => m.name === name) || null;
const brandIdByName = (name: string) => data.lookup.brands.find((b) => b.name === name)?.id || null;
const toServerIso = () => {
const toServerIso = (t: FormType) => {
try {
const d = new Date(dateInput);
const raw = sectionDates[t];
if (raw) {
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) return d.toISOString();
}
} catch { /* نادیده */ }
return new Date().toISOString();
};
@@ -433,13 +461,15 @@ export function ChangeInventoryPage() {
const op: Record<string, unknown> = {
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
quantity: n, unit_id: unitId, specs, note,
transaction_date: toServerIso(),
transaction_date: toServerIso("PURCHASE"),
};
await postOne(op);
toast.success("ثبت شد ✓");
notifyDataChanged("inventory");
const pIso = toServerIso("PURCHASE");
const pTime = new Date(pIso).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" });
setSessionLog((prev) => [
{ material: matName, brand: brandName, specs, qty: n, unitLabel: unitLabel, time: new Date().toTimeString().slice(0, 5), whName: whName(warehouses, whId) },
{ material: matName, brand: brandName, specs, qty: n, unitLabel: unitLabel, time: pTime, whName: whName(warehouses, whId) },
...prev,
]);
// حفظ زمینه (انبار + تاریخ) و ریست فیلدهای محتوا
@@ -498,7 +528,7 @@ export function ChangeInventoryPage() {
unit_id: w.unit?.id ?? null,
specs: w.specs || {},
note: buildReturnNote(retNote[w.id], w.id),
transaction_date: new Date().toISOString(),
transaction_date: toServerIso("RETURN"),
});
okCount++;
setRetDone((prev) => new Set(prev).add(w.id));
@@ -550,7 +580,7 @@ export function ChangeInventoryPage() {
quantity: parseNum(ptQty[r.id]),
specs: r.specs || {},
note,
transfer_date: new Date().toISOString(),
transfer_date: toServerIso("PROJECT_TRANSFER"),
}, user?.project_id);
ok++;
setPtSucceeded((prev) => new Set(prev).add(r.id));
@@ -586,7 +616,7 @@ export function ChangeInventoryPage() {
const rec = formRecords[i];
if (!rec) return null;
if (t === "STOCK_COUNT") {
return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: toServerIso() };
return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: toServerIso("STOCK_COUNT") };
}
return {
transaction_type: t,
@@ -598,7 +628,7 @@ export function ChangeInventoryPage() {
specs: rec.specs || {},
note: r.note,
delivered_to: t === "WITHDRAWAL" ? deliveredTo.trim() : undefined,
transaction_date: toServerIso(),
transaction_date: toServerIso(t),
};
})
.filter((x): x is Record<string, unknown> => x !== null);
@@ -613,7 +643,7 @@ export function ChangeInventoryPage() {
if (!(n > 0)) return null;
const rec = formRecords[i];
if (!rec) return null;
return { transaction_type: "TRANSFER_OUT", warehouse_id: fromId, dest_warehouse_id: toId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso() };
return { transaction_type: "TRANSFER_OUT", warehouse_id: fromId, dest_warehouse_id: toId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso("TRANSFER") };
})
.filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
@@ -629,7 +659,7 @@ export function ChangeInventoryPage() {
if (!item) return null;
if (n > item.remaining) return null;
const t = item.tx;
return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: new Date().toISOString() };
return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso("FOUND") };
})
.filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیف معتبری پر نشده است");
@@ -652,7 +682,7 @@ export function ChangeInventoryPage() {
brand_id: rec.brand?.id || null,
physical_qty: n,
note: r.note,
transaction_date: toServerIso(),
transaction_date: toServerIso("STOCK_COUNT"),
});
confirm.push({
material: rec.material.name,
@@ -686,6 +716,9 @@ export function ChangeInventoryPage() {
};
const doSubmit = () => {
if (!isSectionDateValid(type)) {
return toast.error(`ثبت تاریخ و زمان در بخش «${meta.label}» الزامی است`);
}
try {
if (type === "PURCHASE") void submitPurchase();
else if (type === "PROJECT_TRANSFER") void submitProjectTransferBatch();
@@ -700,6 +733,7 @@ export function ChangeInventoryPage() {
};
const canSubmit = (): boolean => {
if (!isSectionDateValid(type)) return false;
if (type === "PURCHASE") {
return !!(whId && matTxt.trim() && brandTxt.trim() && unitId && parseNum(qty) > 0);
}
@@ -945,21 +979,7 @@ export function ChangeInventoryPage() {
</div>
{type === "WITHDRAWAL" && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input
type="text"
list="dl-mat"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…"
value={matTxt}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
/>
<datalist id="dl-mat">
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="field">
<label className="text-xs font-semibold text-muted">
تحویل به (تحویل‌گیرنده) <span className="text-rose-500 font-bold">*</span>
@@ -972,10 +992,18 @@ export function ChangeInventoryPage() {
onChange={(e) => setDeliveredTo(e.target.value)}
/>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">
تاریخ و ساعت خروج کالا <span className="text-rose-500 font-bold">*</span>
</label>
<JalaliDatePicker
value={sectionDates.WITHDRAWAL}
onChange={(iso) => setSectionDate("WITHDRAWAL", iso)}
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</div>
)}
{(type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT") && (
<div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input
@@ -990,13 +1018,89 @@ export function ChangeInventoryPage() {
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist>
</div>
</div>
)}
{(type === "DAMAGE" || type === "WASTE" || type === "LOSS") && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="field">
<label className="text-xs font-semibold text-muted">
{type === "DAMAGE" ? "تاریخ و ساعت وقوع خرابی" : type === "WASTE" ? "تاریخ و ساعت ثبت ضایعات" : "تاریخ و ساعت ثبت اتلاف"} <span className="text-rose-500 font-bold">*</span>
</label>
<JalaliDatePicker
value={sectionDates[type]}
onChange={(iso) => setSectionDate(type, iso)}
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input
type="text"
list="dl-mat"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…"
value={matTxt}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
/>
<datalist id="dl-mat">
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist>
</div>
</div>
)}
{type === "STOCK_COUNT" && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="field">
<label className="text-xs font-semibold text-muted">
تاریخ و ساعت انجام انبارگردانی / شمارش <span className="text-rose-500 font-bold">*</span>
</label>
<JalaliDatePicker
value={sectionDates.STOCK_COUNT}
onChange={(iso) => setSectionDate("STOCK_COUNT", iso)}
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input
type="text"
list="dl-mat"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…"
value={matTxt}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
/>
<datalist id="dl-mat">
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist>
</div>
</div>
)}
</div>
)}
{/* فیلتر نام متریال برای انتقال داخلی و برگشت و پیدا شده */}
{/* فیلتر نام متریال و تاریخ برای انتقال داخلی */}
{isTransfer && (
<div className="field mb-3">
<div className="grid gap-3 sm:grid-cols-2 mb-3">
<div className="field">
<label className="text-xs font-semibold text-muted">
تاریخ و ساعت انتقال بین انبارها <span className="text-rose-500 font-bold">*</span>
</label>
<JalaliDatePicker
value={sectionDates.TRANSFER}
onChange={(iso) => setSectionDate("TRANSFER", iso)}
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input
type="text"
@@ -1010,6 +1114,7 @@ export function ChangeInventoryPage() {
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist>
</div>
</div>
)}
{/* ---------- موجودی اولیه: فرم ثبت پیوسته ---------- */}
@@ -1073,12 +1178,15 @@ export function ChangeInventoryPage() {
/>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">تاریخ و زمان (شمسی)</label>
<label className="text-xs font-semibold text-muted">
تاریخ و زمان (شمسی) <span className="text-rose-500 font-bold">*</span>
</label>
<JalaliDatePicker
value={dateInput}
onChange={(iso) => setDateInput(iso ?? new Date().toISOString())}
placeholder="۱۴۰۳/۰۵/۱۲"
value={sectionDates.PURCHASE}
onChange={(iso) => setSectionDate("PURCHASE", iso)}
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</div>
<div className="field md:col-span-2">
@@ -1097,9 +1205,22 @@ export function ChangeInventoryPage() {
</div>
)}
{/* ---------- انتقال بین کارگاهی: یادداشت بار + لیست اقلام ---------- */}
{/* ---------- انتقال بین کارگاهی: یادداشت بار + تاریخ + لیست اقلام ---------- */}
{type === "PROJECT_TRANSFER" && (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="field">
<label className="text-xs font-semibold text-muted">
تاریخ و ساعت انتقال بین کارگاهی <span className="text-rose-500 font-bold">*</span>
</label>
<JalaliDatePicker
value={sectionDates.PROJECT_TRANSFER}
onChange={(iso) => setSectionDate("PROJECT_TRANSFER", iso)}
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">یادداشت بار (اختیاری — برای همه اقلام)</label>
<input
@@ -1110,6 +1231,7 @@ export function ChangeInventoryPage() {
onChange={(e) => setNote(e.target.value)}
/>
</div>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input
@@ -1135,11 +1257,24 @@ export function ChangeInventoryPage() {
</div>
)}
{/* ---------- برگشت: فیلتر بازه + لیست برداشت‌ها ---------- */}
{/* ---------- برگشت: فیلتر بازه + تاریخ ثبت برگشت + لیست برداشت‌ها ---------- */}
{type === "RETURN" && (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="field">
<label className="text-xs font-semibold text-muted">
تاریخ و ساعت ثبت برگشت به انبار <span className="text-rose-500 font-bold">*</span>
</label>
<JalaliDatePicker
value={sectionDates.RETURN}
onChange={(iso) => setSectionDate("RETURN", iso)}
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-muted">بازه‌ی روز</label>
<label className="mb-1 block text-xs font-semibold text-muted">بازه‌ی روز برداشت‌ها (فیلتر جستجو)</label>
<ReturnFilterBar
preset={retPreset}
onPreset={(p) => setRetPreset(p)}
@@ -1149,6 +1284,7 @@ export function ChangeInventoryPage() {
onCustomTo={(iso) => setRetCustomTo(iso)}
/>
</div>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input
@@ -1208,6 +1344,19 @@ export function ChangeInventoryPage() {
)}
{type === "FOUND" && (
<div className="space-y-3">
<div className="field max-w-sm">
<label className="text-xs font-semibold text-muted">
تاریخ و ساعت پیدا شدن کالا <span className="text-rose-500 font-bold">*</span>
</label>
<JalaliDatePicker
value={sectionDates.FOUND}
onChange={(iso) => setSectionDate("FOUND", iso)}
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</div>
<FoundTable
items={pendingLosses}
rows={rows}
@@ -1216,6 +1365,7 @@ export function ChangeInventoryPage() {
emptyMsg={foundEmptyMsg}
loading={foundLoading && !pendingLosses.length}
/>
</div>
)}
{/* دکمه ثبت — دسکتاپ */}
@@ -1533,7 +1683,7 @@ export function ChangeInventoryPage() {
? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود."
: type === "RETURN"
? "برگشت، دفتر را تغییر نمی‌دهد؛ تراکنش جدیدی ثبت می‌شود و مانده قابل برگشت هر برداشت به‌روز می‌ماند."
: `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`}
: `تاریخ انتخابی: ${jalDateLabel(new Date(sectionDates[type] || new Date()))}`}
</div>
</PageLayout>
@@ -140,7 +140,7 @@ export function DailyControlPage() {
if (t.transaction_date > g.last) g.last = t.transaction_date;
}
});
return [...gmap.values()];
return [...gmap.values()].sort((a, b) => new Date(b.last).getTime() - new Date(a.last).getTime());
}, [withdrawals]);
const isToday = sel === todayKey();
+155 -10
View File
@@ -12,6 +12,22 @@ import type { InventoryRow, InventoryTransaction, TransactionType } from "../../
import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali";
import { cn } from "../../lib/utils";
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
const TX_TYPE_OPTIONS: { type: string; label: string }[] = [
{ type: "", label: "همه عملیات‌ها" },
{ type: "PURCHASE", label: "موجودی اولیه / خرید" },
{ type: "WITHDRAWAL", label: "خروج / مصرف" },
{ type: "PROJECT_TRANSFER", label: "انتقال بین کارگاهی" },
{ type: "RETURN", label: "برگشت به انبار" },
{ type: "TRANSFER_OUT", label: "انتقال بین انبارها (خروجی)" },
{ type: "TRANSFER_IN", label: "انتقال بین انبارها (ورودی)" },
{ type: "DAMAGE", label: "خرابی" },
{ type: "WASTE", label: "ضایعات" },
{ type: "LOSS", label: "مفقودی" },
{ type: "FOUND", label: "پیدا شده" },
{ type: "STOCK_COUNT", label: "انبارگردانی / شمارش" },
];
// رنگ‌بندی معنایی تراکنش‌ها
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
@@ -58,10 +74,20 @@ export function InventoryPage() {
const canModify = isSupervisor || isAdmin || user?.panels?.inventory === "limited";
const [params] = useSearchParams();
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
const [filters, setFilters] = useState<{
warehouse: string;
material: string;
project: string;
transactionType: string;
dateFrom: string | null;
dateTo: string | null;
}>({
warehouse: params.get("warehouse") || "",
material: "",
project: "",
transactionType: "",
dateFrom: null,
dateTo: null,
});
const [sort, setSort] = useState<"asc" | "desc" | "">("");
const [showMobileFilters, setShowMobileFilters] = useState(false);
@@ -77,14 +103,40 @@ export function InventoryPage() {
const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]);
// فیلتر تراکنش‌ها بر اساس نوع و بازه زمانی روزانه (۰۰:۰۰:۰۰ تا ۲۳:۵۹:۵۹)
const filteredTransactions = useMemo(() => {
let list = transactions;
if (filters.transactionType) {
list = list.filter((t) => t.transaction_type === filters.transactionType);
}
if (filters.dateFrom) {
const dFrom = new Date(filters.dateFrom);
const startOfDay = new Date(dFrom.getFullYear(), dFrom.getMonth(), dFrom.getDate(), 0, 0, 0, 0);
list = list.filter((t) => new Date(t.transaction_date).getTime() >= startOfDay.getTime());
}
if (filters.dateTo) {
const dTo = new Date(filters.dateTo);
const endOfDay = new Date(dTo.getFullYear(), dTo.getMonth(), dTo.getDate(), 23, 59, 59, 999);
list = list.filter((t) => new Date(t.transaction_date).getTime() <= endOfDay.getTime());
}
return list;
}, [transactions, filters.transactionType, filters.dateFrom, filters.dateTo]);
const hasTxFilter = !!(filters.transactionType || filters.dateFrom || filters.dateTo);
const groups = useMemo<Group[]>(() => {
const gmap = new Map<string, Group>();
const order: string[] = [];
// اگر فیلتر تراکنش فعال است، فقط اقلام دارای تراکنش منطبق در خروجی ظاهر می‌شوند
const activeInvIds = hasTxFilter ? new Set(filteredTransactions.map((t) => t.inventory_id)) : null;
inventory.forEach((r) => {
const whId = r.warehouse?.id;
if (!whId || !visibleIds.has(whId)) return;
if (filters.warehouse && whId !== filters.warehouse) return;
if (filters.material && !r.material.name.includes(filters.material.trim())) return;
if (activeInvIds && !activeInvIds.has(r.id)) return;
const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
let g = gmap.get(gk);
if (!g) {
@@ -104,7 +156,6 @@ export function InventoryPage() {
order.push(gk);
}
g.qty += r.quantity_number || 0;
if (r.updated_at > g.updatedAt) g.updatedAt = r.updated_at;
g.recs.push(r);
if (r.brand) {
if (!(r.brand.id in g.brands)) {
@@ -114,26 +165,61 @@ export function InventoryPage() {
g.brands[r.brand.id] += r.quantity_number || 0;
}
});
// استخراج آخرین تاریخ تراکنش واقعی کاربر برای هر گروه و انتساب به updatedAt
order.forEach((k) => {
const g = gmap.get(k);
if (!g) return;
const groupAllTxs = transactions.filter((t) => g.recs.some((r) => r.id === t.inventory_id));
if (groupAllTxs.length > 0) {
const latestTx = groupAllTxs.reduce((latest, t) => {
return new Date(t.transaction_date).getTime() > new Date(latest.transaction_date).getTime() ? t : latest;
}, groupAllTxs[0]);
g.updatedAt = latestTx.transaction_date;
}
});
const out = order.map((k) => gmap.get(k)!);
if (sort === "asc") out.sort((a, b) => a.qty - b.qty);
else if (sort === "desc") out.sort((a, b) => b.qty - a.qty);
else out.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
return out;
}, [inventory, visibleIds, filters, sort]);
}, [inventory, visibleIds, filters, sort, hasTxFilter, filteredTransactions, transactions]);
const groupTxs = (g: Group) =>
transactions
filteredTransactions
.filter((t) => g.recs.some((r) => r.id === t.inventory_id))
.slice()
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
.sort((a, b) => {
const da = new Date(a.transaction_date).getTime();
const db = new Date(b.transaction_date).getTime();
if (da !== db) return da - db;
const ca = a.created_at ? new Date(a.created_at).getTime() : 0;
const cb = b.created_at ? new Date(b.created_at).getTime() : 0;
return ca - cb;
});
const brandTxs = (g: Group, bid: string) =>
transactions
filteredTransactions
.filter((t) => t.brand?.id === bid && g.recs.some((r) => r.id === t.inventory_id))
.slice()
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
.sort((a, b) => {
const da = new Date(a.transaction_date).getTime();
const db = new Date(b.transaction_date).getTime();
if (da !== db) return da - db;
const ca = a.created_at ? new Date(a.created_at).getTime() : 0;
const cb = b.created_at ? new Date(b.created_at).getTime() : 0;
return ca - cb;
});
const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
const hasFilter = !!(
filters.warehouse ||
(isAdmin && filters.project) ||
filters.material ||
filters.transactionType ||
filters.dateFrom ||
filters.dateTo
);
if (loading) return <div className="min-h-screen" />;
if (!user) {
@@ -285,13 +371,72 @@ export function InventoryPage() {
</div>
)}
{/* نوع عملیات / بخش */}
<div>
<div className="mb-1.5 text-[11px] font-bold text-muted">نوع عملیات / بخش:</div>
<div className="flex flex-wrap gap-1.5">
{TX_TYPE_OPTIONS.map((opt) => (
<button
key={opt.type}
type="button"
onClick={() => setFilters((f) => ({ ...f, transactionType: f.transactionType === opt.type ? "" : opt.type }))}
className={cn(
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
filters.transactionType === opt.type
? "bg-accent text-white font-bold"
: "border border-line bg-bg/40 text-ink hover:bg-panel"
)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* فیلتر بازه تاریخ یا روز مشخص */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-bold text-muted">فیلتر بازه روزانه (بازه کامل ۰۰:۰۰ تا ۲۳:۵۹):</span>
<button
type="button"
onClick={() => {
const todayIso = new Date().toISOString().slice(0, 10);
setFilters((f) => ({ ...f, dateFrom: todayIso, dateTo: todayIso }));
}}
className="text-[11px] text-accent font-semibold hover:underline cursor-pointer"
>
امروز
</button>
</div>
<div className="grid gap-2 sm:grid-cols-2 max-w-md">
<div>
<label className="mb-0.5 block text-[10.5px] text-muted">از تاریخ (یا روز مشخص):</label>
<JalaliDatePicker
value={filters.dateFrom}
onChange={(val) => setFilters((f) => ({ ...f, dateFrom: val, dateTo: f.dateTo || val }))}
placeholder="از تاریخ…"
maxDate={new Date()}
/>
</div>
<div>
<label className="mb-0.5 block text-[10.5px] text-muted">تا تاریخ:</label>
<JalaliDatePicker
value={filters.dateTo}
onChange={(val) => setFilters((f) => ({ ...f, dateTo: val }))}
placeholder="تا تاریخ…"
maxDate={new Date()}
/>
</div>
</div>
</div>
{/* دکمه پاک کردن فیلترها */}
{hasFilter && (
<div className="pt-1 flex items-center justify-between">
<button
type="button"
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1"
onClick={() => setFilters({ warehouse: "", material: "", project: "" })}
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1 cursor-pointer"
onClick={() => setFilters({ warehouse: "", material: "", project: "", transactionType: "", dateFrom: null, dateTo: null })}
>
<X className="h-3 w-3" />
پاک کردن فیلترها
+1 -1
View File
@@ -687,7 +687,7 @@ ALTER TABLE projects ADD COLUMN IF NOT EXISTS actual_end DATE;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'ACTIVE';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS progress DOUBLE PRECISION DEFAULT 0.0;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
(revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm'))
-- (revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm'))
COMMIT;