feat(inventory): add mandatory per-section date pickers, prevent future dates, sync transfer dates, and sort by transaction_date
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user