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
+5 -1
View File
@@ -217,6 +217,7 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -
"performed_by": tx.performed_by, "performed_by": tx.performed_by,
"delivered_to": tx.delivered_to, "delivered_to": tx.delivered_to,
"transaction_date": to_iso(tx.transaction_date), "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, "note": tx.note,
} }
@@ -461,7 +462,10 @@ async def list_transactions(
InventoryTransaction.transaction_date InventoryTransaction.transaction_date
< datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1) < datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1)
) )
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date") 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) items, meta = await paginate(stmt, page, page_size, db)
# Enrich: warehouse name + material/brand/specs/unit for the UI rows. # Enrich: warehouse name + material/brand/specs/unit for the UI rows.
+7 -5
View File
@@ -22,6 +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 decimal import Decimal from decimal import Decimal
from sqlalchemy import select from sqlalchemy import select
@@ -175,6 +176,7 @@ async def apply_transaction(
t = payload.transaction_type t = payload.transaction_type
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)
# 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":
@@ -197,7 +199,7 @@ async def apply_transaction(
recorded_qty=recorded, recorded_qty=recorded,
physical_qty=payload.physical_qty, physical_qty=payload.physical_qty,
performed_by=performed_by, performed_by=performed_by,
transaction_date=payload.transaction_date, transaction_date=tx_date,
note=payload.note, note=payload.note,
) )
db.add(tx) db.add(tx)
@@ -238,7 +240,7 @@ async def apply_transaction(
quantity=-quantity, quantity=-quantity,
transfer_pair_id=pair_id, transfer_pair_id=pair_id,
performed_by=performed_by, performed_by=performed_by,
transaction_date=payload.transaction_date, transaction_date=tx_date,
note=payload.note, note=payload.note,
) )
db.add(out_tx) db.add(out_tx)
@@ -256,7 +258,7 @@ async def apply_transaction(
quantity=quantity, quantity=quantity,
transfer_pair_id=pair_id, transfer_pair_id=pair_id,
performed_by=performed_by, performed_by=performed_by,
transaction_date=payload.transaction_date, transaction_date=tx_date,
note=payload.note, note=payload.note,
) )
db.add(in_tx) db.add(in_tx)
@@ -291,7 +293,7 @@ async def apply_transaction(
quantity=quantity, quantity=quantity,
performed_by=performed_by, performed_by=performed_by,
delivered_to=payload.delivered_to, delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date, transaction_date=tx_date,
note=payload.note, note=payload.note,
) )
db.add(tx) db.add(tx)
@@ -321,7 +323,7 @@ async def apply_transaction(
quantity=-quantity, quantity=-quantity,
performed_by=performed_by, performed_by=performed_by,
delivered_to=payload.delivered_to, delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date, transaction_date=tx_date,
note=payload.note, note=payload.note,
) )
db.add(tx) db.add(tx)
+57 -10
View File
@@ -21,6 +21,7 @@ export interface JalaliDatePickerProps {
className?: string; className?: string;
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید) withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
disabled?: boolean; disabled?: boolean;
maxDate?: Date | string; // حداکثر تاریخ مجاز (جلوگیری از انتخاب تاریخ/زمان آینده)
} }
function todayJalali(): [number, number, number] { function todayJalali(): [number, number, number] {
@@ -76,6 +77,7 @@ export function JalaliDatePicker({
className, className,
withTime = false, withTime = false,
disabled = false, disabled = false,
maxDate,
}: JalaliDatePickerProps) { }: JalaliDatePickerProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali()); 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 [hourText, setHourText] = useState<string>(() => faNum(String(initialTime.h).padStart(2, "0")));
const [minuteText, setMinuteText] = useState<string>(() => faNum(String(initialTime.m).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>(() => { const [text, setText] = useState<string>(() => {
if (!value) return ""; if (!value) return "";
const j = isoToJalaliStr(value); const j = isoToJalaliStr(value);
@@ -145,15 +158,16 @@ export function JalaliDatePicker({
const dim = daysInMonth(jy, jm); const dim = daysInMonth(jy, jm);
const today = new Date(); const today = new Date();
const tIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; 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 }[] = []; const cells: { d: number; iso: string; isToday: boolean; disabled: boolean }[] = [];
for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false }); for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false, disabled: false });
for (let d = 1; d <= dim; d++) { for (let d = 1; d <= dim; d++) {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, d); const [gy, gm, gd] = jalaliToGregorian(jy, jm, d);
const iso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`; 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; return cells;
}, [curJ]); }, [curJ, maxDateIso]);
const nav = (dir: string) => { const nav = (dir: string) => {
setCurJ(([jy, jm]) => { setCurJ(([jy, jm]) => {
@@ -176,10 +190,22 @@ export function JalaliDatePicker({
}; };
const selectDay = (jy: number, jm: number, jd: number) => { const selectDay = (jy: number, jm: number, jd: number) => {
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10); const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); const dayIso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
const validH = Math.min(23, Math.max(0, currentH)); if (maxDateIso && dayIso > maxDateIso) return;
const validM = Math.min(59, Math.max(0, currentM));
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); const val = withTime ? buildIso(jy, jm, jd, validH, validM) : buildDateOnly(jy, jm, jd);
onChange(val); onChange(val);
setText( setText(
@@ -267,8 +293,19 @@ export function JalaliDatePicker({
const pickToday = () => { const pickToday = () => {
const now = new Date(); const now = new Date();
const [jy, jm, jd] = todayJalali(); const [jy, jm, jd] = todayJalali();
const nh = now.getHours(); let nh = now.getHours();
const nm = now.getMinutes(); 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"))); setHourText(faNum(String(nh).padStart(2, "0")));
setMinuteText(faNum(String(nm).padStart(2, "0"))); setMinuteText(faNum(String(nm).padStart(2, "0")));
const val = withTime const val = withTime
@@ -415,6 +452,16 @@ export function JalaliDatePicker({
{grid.map((cell, i) => {grid.map((cell, i) =>
cell.d === 0 ? ( cell.d === 0 ? (
<div key={i} /> <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 <button
key={i} key={i}
+1
View File
@@ -172,6 +172,7 @@ export interface InventoryTransaction {
performed_by?: string | null; performed_by?: string | null;
delivered_to?: string | null; delivered_to?: string | null;
transaction_date: string; transaction_date: string;
created_at?: string;
note?: string | null; note?: string | null;
material?: { id: string; name: string } | null; material?: { id: string; name: string } | null;
brand?: { id: string; name: string } | null; brand?: { id: string; name: string } | null;
@@ -81,7 +81,32 @@ export function ChangeInventoryPage() {
const [matTxt, setMatTxt] = useState(""); const [matTxt, setMatTxt] = useState("");
const [brandTxt, setBrandTxt] = useState(""); const [brandTxt, setBrandTxt] = useState("");
const [qty, setQty] = 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 [note, setNote] = useState("");
const [deliveredTo, setDeliveredTo] = useState(""); const [deliveredTo, setDeliveredTo] = useState("");
const [rows, setRows] = useState<RowDraft[]>([]); 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 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 brandIdByName = (name: string) => data.lookup.brands.find((b) => b.name === name)?.id || null;
const toServerIso = () => { const toServerIso = (t: FormType) => {
try { try {
const d = new Date(dateInput); const raw = sectionDates[t];
if (!Number.isNaN(d.getTime())) return d.toISOString(); if (raw) {
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) return d.toISOString();
}
} catch { /* نادیده */ } } catch { /* نادیده */ }
return new Date().toISOString(); return new Date().toISOString();
}; };
@@ -433,13 +461,15 @@ export function ChangeInventoryPage() {
const op: Record<string, unknown> = { const op: Record<string, unknown> = {
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid, transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
quantity: n, unit_id: unitId, specs, note, quantity: n, unit_id: unitId, specs, note,
transaction_date: toServerIso(), transaction_date: toServerIso("PURCHASE"),
}; };
await postOne(op); await postOne(op);
toast.success("ثبت شد ✓"); toast.success("ثبت شد ✓");
notifyDataChanged("inventory"); notifyDataChanged("inventory");
const pIso = toServerIso("PURCHASE");
const pTime = new Date(pIso).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" });
setSessionLog((prev) => [ 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, ...prev,
]); ]);
// حفظ زمینه (انبار + تاریخ) و ریست فیلدهای محتوا // حفظ زمینه (انبار + تاریخ) و ریست فیلدهای محتوا
@@ -498,7 +528,7 @@ export function ChangeInventoryPage() {
unit_id: w.unit?.id ?? null, unit_id: w.unit?.id ?? null,
specs: w.specs || {}, specs: w.specs || {},
note: buildReturnNote(retNote[w.id], w.id), note: buildReturnNote(retNote[w.id], w.id),
transaction_date: new Date().toISOString(), transaction_date: toServerIso("RETURN"),
}); });
okCount++; okCount++;
setRetDone((prev) => new Set(prev).add(w.id)); setRetDone((prev) => new Set(prev).add(w.id));
@@ -550,7 +580,7 @@ export function ChangeInventoryPage() {
quantity: parseNum(ptQty[r.id]), quantity: parseNum(ptQty[r.id]),
specs: r.specs || {}, specs: r.specs || {},
note, note,
transfer_date: new Date().toISOString(), transfer_date: toServerIso("PROJECT_TRANSFER"),
}, user?.project_id); }, user?.project_id);
ok++; ok++;
setPtSucceeded((prev) => new Set(prev).add(r.id)); setPtSucceeded((prev) => new Set(prev).add(r.id));
@@ -586,7 +616,7 @@ export function ChangeInventoryPage() {
const rec = formRecords[i]; const rec = formRecords[i];
if (!rec) return null; if (!rec) return null;
if (t === "STOCK_COUNT") { 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 { return {
transaction_type: t, transaction_type: t,
@@ -598,7 +628,7 @@ export function ChangeInventoryPage() {
specs: rec.specs || {}, specs: rec.specs || {},
note: r.note, note: r.note,
delivered_to: t === "WITHDRAWAL" ? deliveredTo.trim() : undefined, delivered_to: t === "WITHDRAWAL" ? deliveredTo.trim() : undefined,
transaction_date: toServerIso(), transaction_date: toServerIso(t),
}; };
}) })
.filter((x): x is Record<string, unknown> => x !== null); .filter((x): x is Record<string, unknown> => x !== null);
@@ -613,7 +643,7 @@ export function ChangeInventoryPage() {
if (!(n > 0)) return null; if (!(n > 0)) return null;
const rec = formRecords[i]; const rec = formRecords[i];
if (!rec) return null; 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); .filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است"); if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
@@ -629,7 +659,7 @@ export function ChangeInventoryPage() {
if (!item) return null; if (!item) return null;
if (n > item.remaining) return null; if (n > item.remaining) return null;
const t = item.tx; 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); .filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیف معتبری پر نشده است"); if (!ops.length) return toast.error("هیچ ردیف معتبری پر نشده است");
@@ -652,7 +682,7 @@ export function ChangeInventoryPage() {
brand_id: rec.brand?.id || null, brand_id: rec.brand?.id || null,
physical_qty: n, physical_qty: n,
note: r.note, note: r.note,
transaction_date: toServerIso(), transaction_date: toServerIso("STOCK_COUNT"),
}); });
confirm.push({ confirm.push({
material: rec.material.name, material: rec.material.name,
@@ -686,6 +716,9 @@ export function ChangeInventoryPage() {
}; };
const doSubmit = () => { const doSubmit = () => {
if (!isSectionDateValid(type)) {
return toast.error(`ثبت تاریخ و زمان در بخش «${meta.label}» الزامی است`);
}
try { try {
if (type === "PURCHASE") void submitPurchase(); if (type === "PURCHASE") void submitPurchase();
else if (type === "PROJECT_TRANSFER") void submitProjectTransferBatch(); else if (type === "PROJECT_TRANSFER") void submitProjectTransferBatch();
@@ -700,6 +733,7 @@ export function ChangeInventoryPage() {
}; };
const canSubmit = (): boolean => { const canSubmit = (): boolean => {
if (!isSectionDateValid(type)) return false;
if (type === "PURCHASE") { if (type === "PURCHASE") {
return !!(whId && matTxt.trim() && brandTxt.trim() && unitId && parseNum(qty) > 0); return !!(whId && matTxt.trim() && brandTxt.trim() && unitId && parseNum(qty) > 0);
} }
@@ -945,7 +979,31 @@ export function ChangeInventoryPage() {
</div> </div>
{type === "WITHDRAWAL" && ( {type === "WITHDRAWAL" && (
<div className="grid gap-3 sm:grid-cols-2"> <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>
</label>
<input
type="text"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="نام فرد، اکیپ یا پیمانکار تحویل‌گیرنده اقلام…"
value={deliveredTo}
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>
<div className="field"> <div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label> <label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input <input
@@ -960,55 +1018,102 @@ export function ChangeInventoryPage() {
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)} {data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist> </datalist>
</div> </div>
</div>
)}
{(type === "DAMAGE" || type === "WASTE" || type === "LOSS") && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="field"> <div className="field">
<label className="text-xs font-semibold text-muted"> <label className="text-xs font-semibold text-muted">
تحویل به (تحویل‌گیرنده) <span className="text-rose-500 font-bold">*</span> {type === "DAMAGE" ? "تاریخ و ساعت وقوع خرابی" : type === "WASTE" ? "تاریخ و ساعت ثبت ضایعات" : "تاریخ و ساعت ثبت اتلاف"} <span className="text-rose-500 font-bold">*</span>
</label> </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 <input
type="text" type="text"
list="dl-mat"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm" className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="نام فرد، اکیپ یا پیمانکار تحویل‌گیرنده اقلام…" placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…"
value={deliveredTo} value={matTxt}
onChange={(e) => setDeliveredTo(e.target.value)} 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> </div>
)} )}
{(type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT") && ( {type === "STOCK_COUNT" && (
<div className="field"> <div className="grid gap-3 sm:grid-cols-2">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label> <div className="field">
<input <label className="text-xs font-semibold text-muted">
type="text" تاریخ و ساعت انجام انبارگردانی / شمارش <span className="text-rose-500 font-bold">*</span>
list="dl-mat" </label>
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm" <JalaliDatePicker
placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…" value={sectionDates.STOCK_COUNT}
value={matTxt} onChange={(iso) => setSectionDate("STOCK_COUNT", iso)}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }} placeholder="۱۴۰۳/۰۵/۱۲ --:--"
/> withTime
<datalist id="dl-mat"> maxDate={new Date()}
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)} />
</datalist> </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>
)} )}
</div> </div>
)} )}
{/* فیلتر نام متریال برای انتقال داخلی و برگشت و پیدا شده */} {/* فیلتر نام متریال و تاریخ برای انتقال داخلی */}
{isTransfer && ( {isTransfer && (
<div className="field mb-3"> <div className="grid gap-3 sm:grid-cols-2 mb-3">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label> <div className="field">
<input <label className="text-xs font-semibold text-muted">
type="text" تاریخ و ساعت انتقال بین انبارها <span className="text-rose-500 font-bold">*</span>
list="dl-mat2" </label>
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm" <JalaliDatePicker
placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…" value={sectionDates.TRANSFER}
value={matTxt} onChange={(iso) => setSectionDate("TRANSFER", iso)}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }} placeholder="۱۴۰۳/۰۵/۱۲ --:--"
/> withTime
<datalist id="dl-mat2"> maxDate={new Date()}
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)} />
</datalist> </div>
<div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input
type="text"
list="dl-mat2"
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-mat2">
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist>
</div>
</div> </div>
)} )}
@@ -1073,12 +1178,15 @@ export function ChangeInventoryPage() {
/> />
</div> </div>
<div className="field"> <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 <JalaliDatePicker
value={dateInput} value={sectionDates.PURCHASE}
onChange={(iso) => setDateInput(iso ?? new Date().toISOString())} onChange={(iso) => setSectionDate("PURCHASE", iso)}
placeholder="۱۴۰۳/۰۵/۱۲" placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime withTime
maxDate={new Date()}
/> />
</div> </div>
<div className="field md:col-span-2"> <div className="field md:col-span-2">
@@ -1097,18 +1205,32 @@ export function ChangeInventoryPage() {
</div> </div>
)} )}
{/* ---------- انتقال بین کارگاهی: یادداشت بار + لیست اقلام ---------- */} {/* ---------- انتقال بین کارگاهی: یادداشت بار + تاریخ + لیست اقلام ---------- */}
{type === "PROJECT_TRANSFER" && ( {type === "PROJECT_TRANSFER" && (
<div className="space-y-3"> <div className="space-y-3">
<div className="field"> <div className="grid gap-3 sm:grid-cols-2">
<label className="text-xs font-semibold text-muted">یادداشت بار (اختیاری — برای همه اقلام)</label> <div className="field">
<input <label className="text-xs font-semibold text-muted">
type="text" تاریخ و ساعت انتقال بین کارگاهی <span className="text-rose-500 font-bold">*</span>
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm" </label>
placeholder="توضیحات بار ارسالی، شماره بارنامه، راننده…" <JalaliDatePicker
value={note} value={sectionDates.PROJECT_TRANSFER}
onChange={(e) => setNote(e.target.value)} 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
type="text"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="توضیحات بار ارسالی، شماره بارنامه، راننده…"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</div>
</div> </div>
<div className="field"> <div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label> <label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
@@ -1135,19 +1257,33 @@ export function ChangeInventoryPage() {
</div> </div>
)} )}
{/* ---------- برگشت: فیلتر بازه + لیست برداشت‌ها ---------- */} {/* ---------- برگشت: فیلتر بازه + تاریخ ثبت برگشت + لیست برداشت‌ها ---------- */}
{type === "RETURN" && ( {type === "RETURN" && (
<div className="space-y-3"> <div className="space-y-3">
<div> <div className="grid gap-3 sm:grid-cols-2">
<label className="mb-1 block text-xs font-semibold text-muted">بازه‌ی روز</label> <div className="field">
<ReturnFilterBar <label className="text-xs font-semibold text-muted">
preset={retPreset} تاریخ و ساعت ثبت برگشت به انبار <span className="text-rose-500 font-bold">*</span>
onPreset={(p) => setRetPreset(p)} </label>
customFrom={retCustomFrom} <JalaliDatePicker
customTo={retCustomTo} value={sectionDates.RETURN}
onCustomFrom={(iso) => setRetCustomFrom(iso)} onChange={(iso) => setSectionDate("RETURN", iso)}
onCustomTo={(iso) => setRetCustomTo(iso)} placeholder="۱۴۰۳/۰۵/۱۲ --:--"
/> withTime
maxDate={new Date()}
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-muted">بازه‌ی روز برداشت‌ها (فیلتر جستجو)</label>
<ReturnFilterBar
preset={retPreset}
onPreset={(p) => setRetPreset(p)}
customFrom={retCustomFrom}
customTo={retCustomTo}
onCustomFrom={(iso) => setRetCustomFrom(iso)}
onCustomTo={(iso) => setRetCustomTo(iso)}
/>
</div>
</div> </div>
<div className="field"> <div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label> <label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
@@ -1208,14 +1344,28 @@ export function ChangeInventoryPage() {
)} )}
{type === "FOUND" && ( {type === "FOUND" && (
<FoundTable <div className="space-y-3">
items={pendingLosses} <div className="field max-w-sm">
rows={rows} <label className="text-xs font-semibold text-muted">
setRows={setRows} تاریخ و ساعت پیدا شدن کالا <span className="text-rose-500 font-bold">*</span>
unitNameFn={(id) => unitName(data.lookup.units, id)} </label>
emptyMsg={foundEmptyMsg} <JalaliDatePicker
loading={foundLoading && !pendingLosses.length} value={sectionDates.FOUND}
/> onChange={(iso) => setSectionDate("FOUND", iso)}
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</div>
<FoundTable
items={pendingLosses}
rows={rows}
setRows={setRows}
unitNameFn={(id) => unitName(data.lookup.units, id)}
emptyMsg={foundEmptyMsg}
loading={foundLoading && !pendingLosses.length}
/>
</div>
)} )}
{/* دکمه ثبت — دسکتاپ */} {/* دکمه ثبت — دسکتاپ */}
@@ -1533,7 +1683,7 @@ export function ChangeInventoryPage() {
? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود." ? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود."
: type === "RETURN" : type === "RETURN"
? "برگشت، دفتر را تغییر نمی‌دهد؛ تراکنش جدیدی ثبت می‌شود و مانده قابل برگشت هر برداشت به‌روز می‌ماند." ? "برگشت، دفتر را تغییر نمی‌دهد؛ تراکنش جدیدی ثبت می‌شود و مانده قابل برگشت هر برداشت به‌روز می‌ماند."
: `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`} : `تاریخ انتخابی: ${jalDateLabel(new Date(sectionDates[type] || new Date()))}`}
</div> </div>
</PageLayout> </PageLayout>
@@ -140,7 +140,7 @@ export function DailyControlPage() {
if (t.transaction_date > g.last) g.last = t.transaction_date; 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]); }, [withdrawals]);
const isToday = sel === todayKey(); const isToday = sel === todayKey();
+155 -10
View File
@@ -12,6 +12,22 @@ import type { InventoryRow, InventoryTransaction, TransactionType } from "../../
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali"; import { isoToJalaliStr } from "../../lib/jalali";
import { cn } from "../../lib/utils"; 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 }> = { 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 canModify = isSupervisor || isAdmin || user?.panels?.inventory === "limited";
const [params] = useSearchParams(); 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") || "", warehouse: params.get("warehouse") || "",
material: "", material: "",
project: "", project: "",
transactionType: "",
dateFrom: null,
dateTo: null,
}); });
const [sort, setSort] = useState<"asc" | "desc" | "">(""); const [sort, setSort] = useState<"asc" | "desc" | "">("");
const [showMobileFilters, setShowMobileFilters] = useState(false); const [showMobileFilters, setShowMobileFilters] = useState(false);
@@ -77,14 +103,40 @@ export function InventoryPage() {
const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]); 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 groups = useMemo<Group[]>(() => {
const gmap = new Map<string, Group>(); const gmap = new Map<string, Group>();
const order: string[] = []; const order: string[] = [];
// اگر فیلتر تراکنش فعال است، فقط اقلام دارای تراکنش منطبق در خروجی ظاهر می‌شوند
const activeInvIds = hasTxFilter ? new Set(filteredTransactions.map((t) => t.inventory_id)) : null;
inventory.forEach((r) => { inventory.forEach((r) => {
const whId = r.warehouse?.id; const whId = r.warehouse?.id;
if (!whId || !visibleIds.has(whId)) return; if (!whId || !visibleIds.has(whId)) return;
if (filters.warehouse && whId !== filters.warehouse) return; if (filters.warehouse && whId !== filters.warehouse) return;
if (filters.material && !r.material.name.includes(filters.material.trim())) 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)}`; const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
let g = gmap.get(gk); let g = gmap.get(gk);
if (!g) { if (!g) {
@@ -104,7 +156,6 @@ export function InventoryPage() {
order.push(gk); order.push(gk);
} }
g.qty += r.quantity_number || 0; g.qty += r.quantity_number || 0;
if (r.updated_at > g.updatedAt) g.updatedAt = r.updated_at;
g.recs.push(r); g.recs.push(r);
if (r.brand) { if (r.brand) {
if (!(r.brand.id in g.brands)) { if (!(r.brand.id in g.brands)) {
@@ -114,26 +165,61 @@ export function InventoryPage() {
g.brands[r.brand.id] += r.quantity_number || 0; 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)!); const out = order.map((k) => gmap.get(k)!);
if (sort === "asc") out.sort((a, b) => a.qty - b.qty); if (sort === "asc") out.sort((a, b) => a.qty - b.qty);
else if (sort === "desc") out.sort((a, b) => b.qty - a.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()); else out.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
return out; return out;
}, [inventory, visibleIds, filters, sort]); }, [inventory, visibleIds, filters, sort, hasTxFilter, filteredTransactions, transactions]);
const groupTxs = (g: Group) => const groupTxs = (g: Group) =>
transactions filteredTransactions
.filter((t) => g.recs.some((r) => r.id === t.inventory_id)) .filter((t) => g.recs.some((r) => r.id === t.inventory_id))
.slice() .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) => const brandTxs = (g: Group, bid: string) =>
transactions filteredTransactions
.filter((t) => t.brand?.id === bid && g.recs.some((r) => r.id === t.inventory_id)) .filter((t) => t.brand?.id === bid && g.recs.some((r) => r.id === t.inventory_id))
.slice() .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 (loading) return <div className="min-h-screen" />;
if (!user) { if (!user) {
@@ -285,13 +371,72 @@ export function InventoryPage() {
</div> </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 && ( {hasFilter && (
<div className="pt-1 flex items-center justify-between"> <div className="pt-1 flex items-center justify-between">
<button <button
type="button" type="button"
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1" className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1 cursor-pointer"
onClick={() => setFilters({ warehouse: "", material: "", project: "" })} onClick={() => setFilters({ warehouse: "", material: "", project: "", transactionType: "", dateFrom: null, dateTo: null })}
> >
<X className="h-3 w-3" /> <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 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 progress DOUBLE PRECISION DEFAULT 0.0;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now(); 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; COMMIT;