6 Commits
Author SHA1 Message Date
whqreza d85ede7d11 feat(inventory): display all warehouse items on selection and track withdrawal recipient
- Show all available inventory items immediately when selecting a warehouse in withdrawal form
- Add vertical scrolling container with max height to prevent layout overflow for large item lists
- Add Material column to the inventory rows table for clear item identification
- Add required 'delivered_to' recipient field in withdrawal form with frontend validation
- Track delivered_to in database, models, schemas, and API
- Display recipient badge on withdrawal cards in daily control page
- Group daily control withdrawals by inventory ID and recipient
2026-09-28 22:54:02 +03:30
ZCode 253c922280 revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm')
Their three commits (730d0bd, 5a9b543, 387edfb) reached production by push and
auto-deployed. 387edfb expects a users.username column that the live database does
not have, so POST /api/v1/auth/login was returning HTTP 500. Their schema changes
never applied either (projects still has only id, name, description, start_date,
pm_user_id, created_at), so nothing depends on the reverted code.

This commit sets the tree back to exactly a02b3ea — verified with git diff against
it — while keeping history linear: the work itself is preserved on 'pm' at 387edfb.
2026-09-26 17:41:07 +00:00
Milad 387edfb1ab feat(org,rbac): dedicate organization panel, enforce role permissions, subheader info, and fix supervisor project sync 2026-09-26 18:51:37 +03:30
Milad 5a9b543040 feat(rbac): enforce procurement write restrictions and support supervisor role in SSO 2026-09-26 12:32:01 +03:30
Milad 730d0bd496 feat: PM panel under ERP shell + project-supervisor role
- /panel/pm: the PM app (served on the same origin under /pm) is embedded as
  a full-height iframe; supervisors are funneled there from every route,
  ERP MobileTabBar hidden on that route
- Panels menu: «مدیریت پروژه (PM)» is an internal route (admin/pm/supervisor
  only — procurement managers do not see it); PM link is no longer external
- New system role 'supervisor' (user_role enum): label in users router,
  allowed in UserRoleUpdate schema, deny-by-default on all ERP routers;
  admin panel can grant/revoke it alongside procurement manager
- Removed the obsolete VITE_PM_URL build plumbing (same-origin now)
2026-09-26 01:39:48 +03:30
MMD a02b3ea7cc fix(edit-modal): restore the proforma upload section
The proforma FileList existed in prod_v5 (a181a87) but was dropped when the
prod_v2 EditModal redesign (de177f3) was merged — that branch predates the
feature and its version of the file was taken wholesale to keep the new
tabbed layout, silently discarding the proforma block.

Restores the state, the load effect, the saved patch field and the upload
UI (on the تاریخ tab, next to the invoice card), using the current
authenticated download/preview helpers rather than the removed urlOf.
2026-09-23 22:01:14 +03:30
11 changed files with 165 additions and 50 deletions
+4
View File
@@ -247,6 +247,7 @@ _V26_DDL = [
physical_qty NUMERIC(18,6),
transfer_pair_id UUID,
performed_by TEXT,
delivered_to TEXT,
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
@@ -258,6 +259,9 @@ _V26_DDL = [
"""
CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date)
""",
"""
ALTER TABLE inventory_transactions ADD COLUMN IF NOT EXISTS delivered_to TEXT
""",
# suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it.
"""
ALTER TABLE suppliers DROP COLUMN IF EXISTS categories
+1
View File
@@ -156,6 +156,7 @@ class InventoryTransaction(Base):
# NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row.
transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder)
delivered_to: Mapped[str | None] = mapped_column(Text)
transaction_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+1
View File
@@ -197,6 +197,7 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -
"physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None,
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
"performed_by": tx.performed_by,
"delivered_to": tx.delivered_to,
"transaction_date": to_iso(tx.transaction_date),
"note": tx.note,
}
+1
View File
@@ -64,6 +64,7 @@ class InventoryTransactionCreate(APIModel):
unit_id: int | None = None
specs: dict | None = None
note: str | None = None
delivered_to: str | None = None
transaction_date: datetime | None = None
@model_validator(mode="after")
@@ -290,6 +290,7 @@ async def apply_transaction(
transaction_type=t,
quantity=quantity,
performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date,
note=payload.note,
)
@@ -319,6 +320,7 @@ async def apply_transaction(
transaction_type=t,
quantity=-quantity,
performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date,
note=payload.note,
)
+1 -1
View File
@@ -1678,7 +1678,7 @@ export const apiV26 = {
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
material_id: string; brand_id?: string | null; quantity?: number | null;
physical_qty?: number | null; unit_id?: number | null; specs?: Record<string, string> | null;
note?: string | null; transaction_date?: string | null;
note?: string | null; delivered_to?: string | null; transaction_date?: string | null;
}): Promise<InventoryTransaction[]> => {
return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
},
+1
View File
@@ -163,6 +163,7 @@ export interface InventoryTransaction {
physical_qty?: number | null;
transfer_pair_id?: string | null;
performed_by?: string | null;
delivered_to?: string | null;
transaction_date: string;
note?: string | null;
material?: { id: string; name: string } | null;
@@ -86,6 +86,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
const [notes, setNotes] = useState("");
const [invoices, setInvoices] = useState<Attachment[]>([]);
const [proformas, setProformas] = useState<Attachment[]>([]);
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
const [quoteDate, setQuoteDate] = useState<string | null>(null);
@@ -189,6 +190,10 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
.list(quoteId, "invoice")
.then((list) => alive && setInvoices(list))
.catch(() => alive && setInvoices([]));
void api.files
.list(quoteId, "proforma")
.then((list) => alive && setProformas(list))
.catch(() => alive && setProformas([]));
return () => {
alive = false;
};
@@ -314,6 +319,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
next.projects = [...selectedProjects];
next.notes = notes.trim();
next.invoices = invoices;
next.proformas = proformas;
const tsIdMap: Record<string, string> = {};
const newTermsheets: Termsheet[] = [];
@@ -865,6 +871,25 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
/>
</div>
{/* پیش‌فاکتورها — در همه‌ی مراحل چرخه‌ی پیشنهاد قابل افزودن/ویرایش است */}
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
<div className="text-xs font-bold text-gray-900">پیش‌فاکتورها</div>
<FileList
files={proformas}
onChange={setProformas}
upload={(f) => api.files.upload(q.id, "proforma", f)}
onDelete={(a) =>
a.id &&
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
toast.error(err instanceof Error ? err.message : "حذف پیش‌فاکتور ناموفق بود"),
)
}
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
loadPreview={(a) => api.files.previewUrl(q.id, "proforma", a.id!)}
label="افزودن پیش‌فاکتور"
/>
</div>
</div>
)}
</div>
@@ -62,6 +62,7 @@ export function ChangeInventoryPage() {
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
const [specsChips, setSpecsChips] = useState<string[]>([]);
const [note, setNote] = useState("");
const [deliveredTo, setDeliveredTo] = useState("");
const [rows, setRows] = useState<RowDraft[]>([]);
const [busy, setBusy] = useState(false);
@@ -86,10 +87,20 @@ export function ChangeInventoryPage() {
);
const formRecords = useMemo(() => {
const txt = matTxt.trim();
const txt = matTxt.trim().toLowerCase();
const wid = type === "TRANSFER" ? fromId : whId;
return data.inventory.filter((r) => {
if (r.warehouse?.id !== wid) return false;
if (type === "WITHDRAWAL") {
if (r.quantity_number <= 0) return false;
if (txt) {
const mMatch = r.material.name.toLowerCase().includes(txt);
const bMatch = r.brand?.name?.toLowerCase().includes(txt) ?? false;
const sMatch = Object.values(r.specs || {}).some((v) => String(v).toLowerCase().includes(txt));
if (!mMatch && !bMatch && !sMatch) return false;
}
return true;
}
if (txt && !r.material.name.includes(txt)) return false;
return true;
});
@@ -186,6 +197,7 @@ export function ChangeInventoryPage() {
setQty("");
setSpecsChips([]);
setNote("");
setDeliveredTo("");
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ثبت");
@@ -235,6 +247,9 @@ export function ChangeInventoryPage() {
};
const submitList = async (t: TransactionType) => {
if (t === "WITHDRAWAL" && !deliveredTo.trim()) {
return toast.error("نام تحویل‌گیرنده اقلام الزامی است");
}
const ops: Record<string, unknown>[] = rows
.map((r, i): Record<string, unknown> | null => {
const n = parseNum(r.qty);
@@ -244,7 +259,18 @@ export function ChangeInventoryPage() {
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: t, warehouse_id: whId, 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: t,
warehouse_id: whId,
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,
delivered_to: t === "WITHDRAWAL" ? deliveredTo.trim() : undefined,
transaction_date: toServerIso(),
};
})
.filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
@@ -370,6 +396,9 @@ export function ChangeInventoryPage() {
if (type === "PURCHASE") {
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
}
if (type === "WITHDRAWAL") {
return !!(whId && deliveredTo.trim() && rows.some((r) => parseNum(r.qty) > 0));
}
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "FOUND") {
@@ -486,7 +515,36 @@ export function ChangeInventoryPage() {
</div>
</div>
{(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
{type === "WITHDRAWAL" && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input
list="dl-mat"
className="h-9 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="field">
<label className="text-xs font-semibold text-muted">
تحویل به (تحویل‌گیرنده) <span className="text-rose-500 font-bold">*</span>
</label>
<input
className="h-9 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>
)}
{(type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
<div className="field">
<label className="text-xs font-semibold text-muted">{type === "FOUND" ? "متریال (خالی = همه مفقودی‌ها)" : "متریال *"}</label>
<input
@@ -653,10 +711,11 @@ export function ChangeInventoryPage() {
setRows={setRows}
records={formRecords}
unitNameFn={(id) => unitName(data.lookup.units, id)}
showMaterial={type === "WITHDRAWAL" || !matTxt.trim()}
emptyMsg={
!whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید."
: !matTxt.trim() ? "نام متریال را تایپ کنید تا همه مدل‌های آن (با برند و مشخصه متفاوت) نمایش داده شود."
: !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا «موجودی اولیه» ثبت کنید."
: (type !== "WITHDRAWAL" && !matTxt.trim()) ? "نام متریال را تایپ کنید تا همه مدل‌های آن (با برند و مشخصه متفاوت) نمایش داده شود."
: !formRecords.length ? (matTxt.trim() ? "برای این عبارت در انبار انتخابی رکوردی یافت نشد." : "هیچ کالای دارای موجودی در این انبار یافت نشد.")
: ""
}
/>
@@ -673,6 +732,7 @@ export function ChangeInventoryPage() {
setRows={setRows}
records={formRecords}
unitNameFn={(id) => unitName(data.lookup.units, id)}
showMaterial={!matTxt.trim()}
emptyMsg={
!fromId || !toId ? "برای نمایش موجودی‌ها، انبار مبدأ، مقصد و نام متریال را وارد کنید."
: !matTxt.trim() ? "نام متریال را تایپ کنید…"
@@ -778,57 +838,69 @@ export function ChangeInventoryPage() {
);
}
function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg, showMaterial = true }: {
isCount: boolean;
rows: RowDraft[];
setRows: (r: RowDraft[]) => void;
records: { id: string; brand: { id: string; name: string } | null; unit: { id: number; code: string; name_fa: string | null } | null; quantity_number: number; specs: Record<string, string> }[];
records: { id: string; material?: { id: string; name: string }; brand: { id: string; name: string } | null; unit: { id: number; code: string; name_fa: string | null } | null; quantity_number: number; specs: Record<string, string> }[];
unitNameFn: (id: number | null | undefined) => string;
emptyMsg: string;
showMaterial?: boolean;
}) {
if (emptyMsg) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
return (
<div className="overflow-x-auto">
<table className="w-full text-[12.5px] min-w-[500px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">مشخصه‌ها</th>
<th className="py-2 pe-3">برند</th>
<th className="py-2 pe-3">واحد</th>
<th className="py-2 pe-3">موجودی فعلی</th>
<th className="py-2 pe-3">{isCount ? "شمارش‌شده" : "تعداد"}</th>
<th className="py-2">یادداشت</th>
</tr>
</thead>
<tbody>
{records.map((r, i) => (
<tr key={r.id} className="border-b border-line/50">
<td className="py-2 pe-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-2 pe-3">{r.brand?.name || "—"}</td>
<td className="py-2 pe-3">{unitNameFn(r.unit?.id)}</td>
<td className="py-2 pe-3 font-bold">{faNum(r.quantity_number)}</td>
<td className="py-2 pe-3">
<input
inputMode="decimal"
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
placeholder={isCount ? "شمارش‌شده" : "۰"}
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
</td>
<td className="py-2">
<input
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="توضیح (اختیاری)"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</td>
<div>
<div className="overflow-x-auto max-h-[420px] overflow-y-auto border border-line rounded-lg">
<table className="w-full text-[12.5px] min-w-[560px]">
<thead className="sticky top-0 bg-panel z-10 shadow-2xs border-b border-line">
<tr className="text-right text-muted">
{showMaterial && <th className="py-2.5 px-3">متریال</th>}
<th className="py-2.5 px-3">مشخصه‌ها</th>
<th className="py-2.5 px-3">برند</th>
<th className="py-2.5 px-3">واحد</th>
<th className="py-2.5 px-3">موجودی فعلی</th>
<th className="py-2.5 px-3">{isCount ? "شمارش‌شده" : "تعداد"}</th>
<th className="py-2.5 px-3">یادداشت</th>
</tr>
))}
</tbody>
</table>
<div className="mt-2 text-[11px] text-muted">ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</div>
</thead>
<tbody className="divide-y divide-line/40">
{records.map((r, i) => (
<tr key={r.id} className="hover:bg-bg/40 transition-colors">
{showMaterial && (
<td className="py-2 px-3 font-bold text-ink">
{r.material?.name || "—"}
</td>
)}
<td className="py-2 px-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-2 px-3">{r.brand?.name || "—"}</td>
<td className="py-2 px-3">{unitNameFn(r.unit?.id)}</td>
<td className="py-2 px-3 font-bold text-ink">{faNum(r.quantity_number)}</td>
<td className="py-2 px-3">
<input
inputMode="decimal"
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr focus:border-accent"
placeholder={isCount ? "شمارش‌شده" : "۰"}
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
</td>
<td className="py-2 px-3">
<input
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
placeholder="توضیح (اختیاری)"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
<span>ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</span>
<span>{faNum(records.length)} قلم کالا</span>
</div>
</div>
);
}
@@ -129,9 +129,10 @@ export function DailyControlPage() {
const wGroups = useMemo(() => {
const gmap = new Map<string, { recId: string; q: number; last: string; tx: InventoryTransaction }>();
withdrawals.forEach((t) => {
const g = gmap.get(t.inventory_id);
const gKey = `${t.inventory_id}__${t.delivered_to || ""}`;
const g = gmap.get(gKey);
if (!g) {
gmap.set(t.inventory_id, { recId: t.inventory_id, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
gmap.set(gKey, { recId: gKey, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
} else {
g.q += Math.abs(t.quantity_number || 0);
if (t.transaction_date > g.last) g.last = t.transaction_date;
@@ -324,6 +325,12 @@ export function DailyControlPage() {
whName(data.warehouses, g.tx.warehouse_id)
].filter(Boolean).join(" · ")}
</span>
{g.tx.delivered_to && (
<span className="inline-flex items-center gap-1 mt-1.5 text-[11px] font-semibold text-blue-700 bg-blue-50/80 border border-blue-200/60 rounded px-1.5 py-0.5 w-fit">
<span className="text-slate-500 font-normal">تحویل به:</span>
<span>{g.tx.delivered_to}</span>
</span>
)}
</span>
<span className="text-xs font-bold text-rose-600 tabular-nums">
{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
+1
View File
@@ -455,6 +455,7 @@ CREATE TABLE inventory_transactions (
physical_qty NUMERIC(18,6),
transfer_pair_id UUID,
performed_by TEXT,
delivered_to TEXT,
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()