feat(frontend): integrate inter-project material transfer into ChangeInventoryPage and ArrivalsPage

- Add PROJECT_TRANSFER tab under reductions in ChangeInventoryPage
- Render complete material spec form with destination project selector
- Show matching stock preview in source warehouse
- Add transfer history table with in-transit edit modal and cancellation dialog
- Add incoming transfer cards in ArrivalsPage with 100% full delivery allocation
This commit is contained in:
2026-09-23 22:31:10 +03:30
parent b326cf64bf
commit 856513b6d7
4 changed files with 742 additions and 21 deletions
+28
View File
@@ -30,6 +30,10 @@ import type {
Supplier,
TransactionType,
Warehouse,
ProjectTransfer,
ProjectTransferCreateIn,
ProjectTransferUpdateIn,
ProjectTransferReceiveIn,
} from "./types";
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
@@ -1746,4 +1750,28 @@ export const apiV26 = {
attributes: [],
}),
},
projectTransfers: {
list: async (projectId?: string | null, direction: "outgoing" | "incoming" | "all" = "all", status?: string): Promise<ProjectTransfer[]> => {
const q = new URLSearchParams();
if (projectId) q.set("project_id", projectId);
if (direction) q.set("direction", direction);
if (status) q.set("status", status);
const qs = q.toString() ? `?${q.toString()}` : "";
return getData<ProjectTransfer[]>(`/inventory/project-transfers${qs}`);
},
get: async (id: string, projectId?: string | null): Promise<ProjectTransfer> =>
getData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`),
create: async (body: ProjectTransferCreateIn, projectId?: string | null): Promise<ProjectTransfer> =>
postData<ProjectTransfer>(projectId ? `/inventory/project-transfers?project_id=${projectId}` : `/inventory/project-transfers`, body),
update: async (id: string, patch: ProjectTransferUpdateIn, projectId?: string | null): Promise<ProjectTransfer> =>
patchData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`, patch),
delete: async (id: string, projectId?: string | null): Promise<{ ok: boolean; message: string }> =>
deleteData(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`),
onTheWay: async (projectId?: string | null): Promise<ProjectTransfer[]> =>
getData<ProjectTransfer[]>(projectId ? `/pm/transfers/on-the-way?project_id=${projectId}` : `/pm/transfers/on-the-way`),
receive: async (id: string, body: ProjectTransferReceiveIn, projectId?: string | null): Promise<ProjectTransfer> =>
postData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}/receive?project_id=${projectId}` : `/inventory/project-transfers/${id}/receive`, body),
},
};
+49
View File
@@ -200,3 +200,52 @@ export interface ConfirmedReceipt {
quality_note: string | null;
allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[];
}
export interface ProjectTransfer {
id: string;
from_project: { id: string; name: string };
from_warehouse: { id: string; name: string };
to_project: { id: string; name: string };
to_warehouse?: { id: string; name: string } | null;
material: { id: string; name: string };
brand?: { id: string; name: string } | null;
unit: { id: number; code: string; name_fa?: string | null };
quantity: number;
specs: Record<string, string>;
specs_hash: string;
note?: string | null;
status: "in_transit" | "received" | "cancelled";
transfer_date: string;
transferred_by?: string | null;
received_at?: string | null;
received_by?: string | null;
receipt_allocations?: { warehouse_id: string; quantity: string | number }[] | null;
created_at: string;
updated_at: string;
}
export interface ProjectTransferCreateIn {
from_warehouse_id: string;
to_project_id: string;
material_id: string;
brand_id?: string | null;
unit_id: number;
quantity: number;
specs?: Record<string, string>;
note?: string | null;
transfer_date?: string | null;
}
export interface ProjectTransferUpdateIn {
to_project_id?: string;
quantity?: number;
note?: string | null;
transfer_date?: string | null;
}
export interface ProjectTransferReceiveIn {
allocations: { warehouse_id: string; quantity: number }[];
received_at?: string | null;
note?: string | null;
}
+215 -10
View File
@@ -8,7 +8,7 @@ import { useAuthStore } from "../../store/auth";
import { inventorySubheader } from "./panelMenu";
import { apiV26 } from "../../lib/api";
import { refreshProcurementStore } from "../../hooks";
import type { PendingArrival, Warehouse } from "../../lib/types";
import type { PendingArrival, Warehouse, ProjectTransfer } from "../../lib/types";
import { faNum, fmtDate } from "../../lib/formatters";
// کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید)
@@ -25,22 +25,27 @@ export function ArrivalsPage() {
const isPM = user?.role === "pm";
const [items, setItems] = useState<PendingArrival[]>([]);
const [incomingTransfers, setIncomingTransfers] = useState<ProjectTransfer[]>([]);
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null); // quoteId
const [transferSaving, setTransferSaving] = useState<string | null>(null); // transferId
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [drafts, setDrafts] = useState<Record<string, ItemDraft>>({});
const [transferDrafts, setTransferDrafts] = useState<Record<string, AllocRow[]>>({});
const load = useCallback(async () => {
try {
const activeProject = isPM ? user?.project_id || undefined : undefined;
const [arr, whs] = await Promise.all([
const [arr, whs, incoming] = await Promise.all([
// پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM را بررسی می‌کند
apiV26.pm.onTheWay(activeProject),
apiV26.warehouses.list(activeProject),
apiV26.projectTransfers.onTheWay(activeProject),
]);
setItems(arr);
setWarehouses(whs);
setIncomingTransfers(incoming);
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
} finally {
@@ -212,6 +217,53 @@ export function ArrivalsPage() {
}
};
// ---------- transfer helpers ----------
const transferDraftOf = (t: ProjectTransfer): AllocRow[] => {
if (transferDrafts[t.id] && transferDrafts[t.id].length > 0) {
return transferDrafts[t.id];
}
const defaultWh = warehouses.find((w) => String(w.project_id) === String(t.to_project.id))?.id || warehouses[0]?.id || "";
return [{ key: 1, qty: String(t.quantity), whId: defaultWh }];
};
const setTransferAlloc = (transferId: string, rows: AllocRow[]) => {
setTransferDrafts((prev) => ({ ...prev, [transferId]: rows }));
};
const handleReceiveTransfer = async (transfer: ProjectTransfer, allocs: AllocRow[]) => {
const activeProject = isPM ? user?.project_id || undefined : undefined;
const allocSum = round4(allocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0));
const targetQty = parseFloat(String(transfer.quantity));
if (allocSum !== targetQty) {
toast.error(`مجموع تخصیص انبارها (${faNum(allocSum)}) باید دقیقاً برابر کل بار ارسالی (${faNum(targetQty)}) باشد.`);
return;
}
if (allocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId)) {
toast.error("لطفاً برای تمامی ردیف‌های دارای مقدار، انبار مقصد را انتخاب کنید.");
return;
}
setTransferSaving(transfer.id);
try {
const payloadAllocs = allocs
.filter((a) => (parseFloat(a.qty) || 0) > 0 && a.whId)
.map((a) => ({ warehouse_id: a.whId, quantity: parseFloat(a.qty) }));
await apiV26.projectTransfers.receive(transfer.id, {
allocations: payloadAllocs,
}, activeProject);
toast.success("تحویل انتقال بین کارگاهی ۱۰۰٪ تأیید شد و به انبار پروژه وارد گردید ✓");
await load();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در تأیید تحویل انتقال");
} finally {
setTransferSaving(null);
}
};
if (!isAdmin && !isPM) {
return (
<div className="min-h-screen">
@@ -233,19 +285,171 @@ export function ArrivalsPage() {
/>
<PageLayout className="flex flex-col gap-4">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
🚚 این بخش به مرحله «رسید» سفارش‌های خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و
اینجا فقط رسید و تقسیم بین انبارها ثبت می‌شود. <b>رسید جزیی (قلمی و مقداری) مجاز است</b> — هر سفارش را باز
کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید و آن را بین انبارهای پروژه تقسیم کنید؛ قلم‌هایی که مقداری برایشان
وارد نشود در صف «در راه» می‌مانند. مجموع تخصیص هر قلم باید دقیقاً با تعداد رسیده‌ی همان قلم برابر باشد و از
مانده‌ی سفارش بیشتر نشود.
🚚 این بخش به مرحله «رسید» سفارش‌های خرید و «انتقال بین کارگاهی» متصل است: اقلام ارسالی از سایر پروژه‌ها به صورت ۱۰۰٪ تحویل و بین انبارهای پروژه تخصیص داده می‌شوند؛ سفارش‌های خرید نیز قابلیت رسید کامل یا جزیی دارند.
</div>
{loading ? (
<div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری…</div>
) : !orders.length ? (
) : !orders.length && !incomingTransfers.length ? (
<EmptyState emoji="🚚" text="کالای در راهی برای دریافت وجود ندارد." />
) : (
orders.map(([quoteId, its]) => {
<>
{/* بخش انتقال‌های بین کارگاهی در راه */}
{incomingTransfers.length > 0 && (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<h3 className="text-[14px] font-bold text-foreground">🔁 انتقال‌های بین کارگاهی در راه ({faNum(incomingTransfers.length)})</h3>
<span className="text-[11.5px] text-muted">— تحویل ۱۰۰٪ کامل مصالح ارسالی از سایر پروژه‌ها</span>
</div>
{incomingTransfers.map((t) => {
const draftAllocs = transferDraftOf(t);
const allocSum = round4(draftAllocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0));
const targetQty = parseFloat(String(t.quantity));
const isExactMatch = allocSum === targetQty;
const hasEmptyWh = draftAllocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId);
const isValid = isExactMatch && !hasEmptyWh && allocSum > 0;
const days = daysElapsed(t.transfer_date);
const projectWhs = warehouses.filter((w) => String(w.project_id) === String(t.to_project.id));
const destWhOptions = projectWhs.length > 0 ? projectWhs : warehouses;
return (
<div key={t.id} className="overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
{/* سربرگ انتقال */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-line bg-panel px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[14px] font-bold text-foreground">🔁 انتقال از {t.from_project?.name || "پروژه مبدأ"}</span>
<Badge variant="offered">در راه</Badge>
{days !== null && (
<Badge variant={days >= 7 ? "offered" : "muted"}>
روز گذشته: {faNum(days)} روز
</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-3 text-[11.5px] text-muted">
<span>ارسال‌کننده: <b className="text-foreground">{t.transferred_by || "—"}</b></span>
<span>تاریخ ارسال: <b className="text-foreground">{fmtDate(t.transfer_date)}</b></span>
<span>پروژه مقصد: <b className="text-foreground">{t.to_project?.name || "—"}</b></span>
</div>
</div>
<div className="px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[13px] font-bold">{t.material?.name}</span>
{t.brand?.name && <Badge variant="outline">{t.brand.name}</Badge>}
<Badge variant="outline">
مقدار ارسالی: {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""}
</Badge>
{t.specs && Object.keys(t.specs).length > 0 && (
<span className="text-[11.5px] text-muted">
مشخصات: {Object.entries(t.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")}
</span>
)}
</div>
{t.note && (
<div className="mt-1 text-[11.5px] text-muted">
یادداشت ارسال: <span className="text-foreground">{t.note}</span>
</div>
)}
<div className="mt-3 rounded-lg border border-line/70 bg-bg/50 p-3">
<div className="text-[12px] font-semibold text-muted mb-2">
تخصیص ۱۰۰٪ به انبار(های) پروژه (مجموع باید دقیقاً {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""} باشد):
</div>
<div className="flex flex-col gap-2">
{draftAllocs.map((a) => (
<div key={a.key} className="flex flex-wrap items-center gap-2">
<input
type="number"
inputMode="decimal"
className="h-8 w-28 rounded-lg border border-line bg-panel px-2 text-sm ltr"
placeholder="تعداد"
value={a.qty}
onChange={(e) => {
const next = draftAllocs.map((x) => (x.key === a.key ? { ...x, qty: e.target.value } : x));
setTransferAlloc(t.id, next);
}}
/>
<select
className="h-8 min-w-[200px] flex-1 rounded-lg border border-line bg-panel px-2 text-xs"
value={a.whId}
onChange={(e) => {
const next = draftAllocs.map((x) => (x.key === a.key ? { ...x, whId: e.target.value } : x));
setTransferAlloc(t.id, next);
}}
>
<option value="">— انتخاب انبار پروژه مقصد —</option>
{destWhOptions.map((w) => (
<option key={w.id} value={w.id}>{w.name}</option>
))}
</select>
{draftAllocs.length > 1 && (
<button
type="button"
className="h-8 px-2 text-muted hover:text-red transition-colors"
onClick={() => {
const next = draftAllocs.filter((x) => x.key !== a.key);
setTransferAlloc(t.id, next);
}}
title="حذف این ردیف تخصیص"
>
✕
</button>
)}
</div>
))}
<button
type="button"
className="w-fit rounded-lg border border-dashed border-accent/50 px-3 py-1 text-[11.5px] text-accent mt-1 hover:bg-accent-bg/40 transition-colors"
onClick={() => {
const next = [...draftAllocs, { key: Date.now() + Math.random(), qty: "", whId: destWhOptions[0]?.id || "" }];
setTransferAlloc(t.id, next);
}}
>
+ افزودن انبار دیگر برای تقسیم بار
</button>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-line/60 pt-2 text-[12px]">
<div>
{isExactMatch ? (
<span className="font-semibold text-emerald-600">
✓ تخصیص کامل ۱۰۰٪ ({faNum(allocSum)} از {faNum(targetQty)}) آماده تحویل
</span>
) : (
<span className="font-bold text-rose-600">
اختلاف تخصیص: مجموع تخصیص ({faNum(allocSum)}) باید دقیقاً با مقدار کل ({faNum(targetQty)}) برابر باشد.
</span>
)}
{hasEmptyWh && (
<span className="ms-2 font-bold text-rose-600">
— لطفاً انبار مقصد را انتخاب کنید
</span>
)}
</div>
<Button
size="sm"
variant="green"
disabled={!isValid || transferSaving === t.id}
onClick={() => void handleReceiveTransfer(t, draftAllocs)}
>
{transferSaving === t.id ? "در حال ثبت تحویل…" : "✓ تأیید تحویل ۱۰۰٪ و ورود به انبار"}
</Button>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
{/* بخش سفارش‌های خرید در راه */}
{orders.map(([quoteId, its]) => {
const first = its[0];
const open = expanded.has(quoteId);
const hasSubmittable = orderHasSubmittable(quoteId);
@@ -401,7 +605,8 @@ export function ArrivalsPage() {
)}
</div>
);
})
})}
</>
)}
</PageLayout>
</div>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
@@ -6,21 +6,22 @@ import { Combobox } from "../../components/Combobox";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { useAuthStore } from "../../store/auth";
import { inventorySubheader } from "./panelMenu";
import { apiV26 } from "../../lib/api";
import { api, apiV26 } from "../../lib/api";
import { notifyDataChanged } from "../../lib/crossTab";
import { useInventoryData, unitName, whName } from "./useInventoryData";
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { uniqueUnits } from "../../lib/units";
import type { InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters";
import type { InventoryTransaction, TransactionType, Project, ProjectTransfer } from "../../lib/types";
import { faNum, fmtDate } from "../../lib/formatters";
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
import { cn } from "../../lib/utils";
type FormType = TransactionType | "TRANSFER";
type FormType = TransactionType | "TRANSFER" | "PROJECT_TRANSFER";
const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [
{ t: "PURCHASE", label: "موجودی اولیه", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت موجودی اولیه — متریال/برند/واحد را از فهرست انتخاب کنید؛ اگر نامی در فهرست نبود، همان نام تایپ‌شده هنگام ثبت به‌صورت خودکار در کاتالوگ سراسری ساخته می‌شود. ثبت حداقل یک مشخصه فنی الزامی است." },
{ t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدل‌ها (برند و مشخصه متفاوت) نمایش داده می‌شوند. برداشت‌ها در «کنترل روزانه» امروز دیده می‌شوند." },
{ t: "PROJECT_TRANSFER", label: "انتقال بین کارگاهی", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انتقال متریال از انبار این پروژه به یک پروژه دیگر — پس از ثبت، موجودی در انبار مبدأ کسر شده و کالا به عنوان بار در راه برای پروژه مقصد ارسال می‌شود تا مدیر پروژه مقصد آن را تحویل بگیرد." },
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شده‌اند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
{ t: "TRANSFER", label: "انتقال", dir: "جابه‌جایی", cls: "mv", color: "#2563eb", desc: "جابه‌جایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." },
{ t: "DAMAGE", label: "ضایعات", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت ضایعات خرید (مثلاً لوله‌های سوراخ) — انبار و متریال را انتخاب و تعداد ضایعات هر مدل را وارد کنید." },
@@ -53,6 +54,22 @@ export function ChangeInventoryPage() {
const [type, setType] = useState<FormType>("PURCHASE");
const [whId, setWhId] = useState("");
const [toProjectId, setToProjectId] = useState("");
const [allProjects, setAllProjects] = useState<Project[]>([]);
const [transfers, setTransfers] = useState<ProjectTransfer[]>([]);
const [loadingTransfers, setLoadingTransfers] = useState(false);
// Edit modal state
const [editingTransfer, setEditingTransfer] = useState<ProjectTransfer | null>(null);
const [editQty, setEditQty] = useState("");
const [editToProjectId, setEditToProjectId] = useState("");
const [editNote, setEditNote] = useState("");
const [editBusy, setEditBusy] = useState(false);
// Delete modal state
const [deleteConfirmTransfer, setDeleteConfirmTransfer] = useState<ProjectTransfer | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [fromId, setFromId] = useState("");
const [toId, setToId] = useState("");
const [matTxt, setMatTxt] = useState("");
@@ -194,6 +211,83 @@ export function ChangeInventoryPage() {
}
};
const loadTransfers = useCallback(async () => {
if (!user?.project_id) return;
setLoadingTransfers(true);
try {
const list = await apiV26.projectTransfers.list(user.project_id, "outgoing");
setTransfers(list);
} catch (err) {
console.error("Failed to load project transfers:", err);
} finally {
setLoadingTransfers(false);
}
}, [user?.project_id]);
useEffect(() => {
api.projects.list().then(setAllProjects).catch(() => {});
}, []);
useEffect(() => {
if (type === "PROJECT_TRANSFER") {
void loadTransfers();
}
}, [type, loadTransfers]);
const matchedStock = useMemo(() => {
if (type !== "PROJECT_TRANSFER" || !whId || !matTxt.trim()) return null;
const mt = matTxt.trim();
const bt = brandTxt.trim();
return data.inventory.filter((r) => {
if (r.warehouse?.id !== whId) return false;
if (r.material.name !== mt) return false;
if (bt && r.brand?.name !== bt) return false;
return true;
});
}, [type, whId, matTxt, brandTxt, data.inventory]);
const handleSaveEdit = async () => {
if (!editingTransfer) return;
const n = parseNum(editQty);
if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد");
if (!editToProjectId) return toast.error("پروژه مقصد را انتخاب کنید");
setEditBusy(true);
try {
await apiV26.projectTransfers.update(editingTransfer.id, {
quantity: n,
to_project_id: editToProjectId,
note: editNote,
}, user?.project_id);
toast.success("انتقال با موفقیت ویرایش شد ✓");
notifyDataChanged("inventory");
setEditingTransfer(null);
await data.refresh();
await loadTransfers();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ویرایش انتقال");
} finally {
setEditBusy(false);
}
};
const handleConfirmDelete = async () => {
if (!deleteConfirmTransfer) return;
setDeleteBusy(true);
try {
await apiV26.projectTransfers.delete(deleteConfirmTransfer.id, user?.project_id);
toast.success("انتقال لغو شد و موجودی به انبار مبدأ بازگردانده شد ✓");
notifyDataChanged("inventory");
setDeleteConfirmTransfer(null);
await data.refresh();
await loadTransfers();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در لغو انتقال");
} finally {
setDeleteBusy(false);
}
};
const submitPurchase = async () => {
const matName = matTxt.trim();
const brandName = brandTxt.trim();
@@ -234,6 +328,66 @@ export function ChangeInventoryPage() {
}
};
const submitProjectTransfer = async () => {
const matName = matTxt.trim();
const brandName = brandTxt.trim();
const unitName = unitTxt.trim();
const n = parseNum(qty);
if (!whId) return toast.error("انبار مبدأ را انتخاب کنید");
if (!toProjectId) return toast.error("پروژه مقصد را انتخاب کنید");
if (!matName) return toast.error("نام متریال را وارد کنید");
if (!brandName) return toast.error("نام برند را وارد کنید");
if (!unitName) return toast.error("نام واحد را وارد کنید");
if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد");
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید");
setBusy(true);
try {
let uid = unitIdByName(unitName);
if (!uid) {
const created = await apiV26.catalog.createUnit(unitName);
uid = created.id;
}
let bid = brandIdByName(brandName);
if (!bid) {
const created = await apiV26.catalog.createBrand(brandName);
bid = created.id;
}
let m = matByName(matName);
if (!m) {
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
m = { id: created.id, name: created.name, canonical_unit: null };
}
await apiV26.projectTransfers.create({
from_warehouse_id: whId,
to_project_id: toProjectId,
material_id: m.id,
brand_id: bid,
unit_id: uid,
quantity: n,
specs: specsFromChips(),
note,
transfer_date: toServerIso(),
}, user?.project_id);
toast.success("انتقال بین کارگاهی ثبت شد و از انبار مبدأ کسر گردید ✓");
notifyDataChanged("inventory");
setQty("");
setSpecsChips([]);
setNote("");
setMatTxt("");
setBrandTxt("");
setUnitTxt("");
setToProjectId("");
await data.refresh();
await loadTransfers();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ثبت انتقال بین کارگاهی");
} finally {
setBusy(false);
}
};
const submitList = async (t: TransactionType) => {
const ops: Record<string, unknown>[] = rows
.map((r, i): Record<string, unknown> | null => {
@@ -356,6 +510,7 @@ export function ChangeInventoryPage() {
const doSubmit = () => {
try {
if (type === "PURCHASE") void submitPurchase();
else if (type === "PROJECT_TRANSFER") void submitProjectTransfer();
else if (type === "RETURN") void submitReturn();
else if (type === "TRANSFER") void submitTransfer();
else if (type === "FOUND") void submitFound();
@@ -370,6 +525,9 @@ export function ChangeInventoryPage() {
if (type === "PURCHASE") {
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
}
if (type === "PROJECT_TRANSFER") {
return !!(whId && toProjectId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
}
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") {
@@ -463,7 +621,47 @@ export function ChangeInventoryPage() {
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4 w-full shadow-2xs">
{/* انتخاب انبارها (چیپ‌های لمسی به جای سلکت خام) */}
{!isTransfer ? (
{type === "PROJECT_TRANSFER" ? (
<div className="mb-3 grid gap-3 md:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-muted">از (انبار مبدأ این پروژه) *</label>
<div className="flex flex-wrap gap-1.5 mt-1">
{whOpts.map((w) => (
<button
key={w.id}
type="button"
onClick={() => { setWhId(w.id); }}
className={cn(
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
whId === w.id
? "bg-rose-600 text-white font-bold shadow-xs"
: "border border-line bg-bg/50 text-ink hover:bg-panel"
)}
>
{w.name}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-muted">به (پروژه مقصد) *</label>
<select
className="mt-1 h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm font-medium"
value={toProjectId}
onChange={(e) => setToProjectId(e.target.value)}
>
<option value="">— انتخاب پروژه مقصد —</option>
{allProjects
.filter((p) => p.id !== user?.project_id)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
</div>
) : !isTransfer ? (
<div className="mb-3 flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-muted">انبار *</label>
@@ -561,8 +759,8 @@ export function ChangeInventoryPage() {
</div>
)}
{/* فرم خرید / موجودی اولیه */}
{type === "PURCHASE" && (
{/* فرم خرید / موجودی اولیه یا انتقال بین کارگاهی */}
{(type === "PURCHASE" || type === "PROJECT_TRANSFER") && (
<div className="grid gap-3 md:grid-cols-2">
<div className="field md:col-span-2">
<label className="text-xs font-semibold text-muted">متریال *</label>
@@ -637,11 +835,25 @@ export function ChangeInventoryPage() {
<label className="text-xs font-semibold text-muted">یادداشت</label>
<textarea
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-xs sm:text-sm"
placeholder="شماره فاکتور، تأمین‌کننده، توضیحات…"
placeholder={type === "PROJECT_TRANSFER" ? "توضیحات بار ارسالی، شماره بارنامه، راننده…" : "شماره فاکتور، تأمین‌کننده، توضیحات…"}
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</div>
{type === "PROJECT_TRANSFER" && matchedStock && matchedStock.length > 0 && (
<div className="field md:col-span-2 rounded-lg border border-line/70 bg-bg/50 p-3 text-xs">
<div className="font-semibold text-muted mb-1.5">موجودی‌های فعلی این متریال در انبار مبدأ ({whName(warehouses, whId)}):</div>
<div className="flex flex-wrap gap-2">
{matchedStock.map((s) => (
<div key={s.id} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[11.5px] shadow-2xs">
<span className="font-medium">{s.brand?.name || "بدون برند"}</span>
<span className="text-muted"> — {Object.entries(s.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "بدون مشخصه"} — </span>
<b className="text-foreground">{faNum(s.quantity_number)} {unitName(data.lookup.units, s.unit?.id)}</b>
</div>
))}
</div>
</div>
)}
</div>
)}
@@ -702,11 +914,234 @@ export function ChangeInventoryPage() {
className="w-full sm:w-auto rounded-lg px-5 py-2.5 text-xs sm:text-[13px] font-bold text-white disabled:opacity-40 shadow-xs transition-transform active:scale-95"
style={{ background: meta.color }}
>
✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`}
✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "انتقال بین کارگاهی" ? "ثبت انتقال بین کارگاهی و کسر از انبار" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`}
</button>
</div>
</div>
{/* بخش سوابق انتقال‌های بین کارگاهی این پروژه */}
{type === "PROJECT_TRANSFER" && (
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4 w-full shadow-2xs">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-line pb-3">
<div>
<h3 className="text-sm font-bold text-foreground">📋 سوابق انتقال‌های بین کارگاهی این پروژه</h3>
<p className="mt-0.5 text-[11.5px] text-muted">
مصالح ارسالی به سایر پروژه‌ها؛ تا زمان تأیید تحویل در مقصد می‌توانید آن‌ها را ویرایش یا لغو کنید.
</p>
</div>
<button
type="button"
onClick={() => void loadTransfers()}
className="rounded-lg border border-line px-3 py-1.5 text-xs font-medium hover:bg-bg transition-colors"
>
🔄 به‌روزرسانی لیست
</button>
</div>
{loadingTransfers ? (
<div className="py-8 text-center text-xs text-muted">در حال بارگذاری سوابق…</div>
) : !transfers.length ? (
<div className="py-8 text-center text-xs text-muted">هیچ انتقال بین کارگاهی از این پروژه ثبت نشده است.</div>
) : (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-right text-xs min-w-[650px]">
<thead>
<tr className="border-b border-line text-muted">
<th className="py-2.5 pe-3">تاریخ انتقال</th>
<th className="py-2.5 pe-3">متریال / برند</th>
<th className="py-2.5 pe-3">مشخصات فنی</th>
<th className="py-2.5 pe-3">مقدار</th>
<th className="py-2.5 pe-3">مبدأ → مقصد</th>
<th className="py-2.5 pe-3">وضعیت</th>
<th className="py-2.5 text-center">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-line/50">
{transfers.map((t) => {
const isInTransit = t.status === "in_transit";
return (
<tr key={t.id} className="hover:bg-bg/40">
<td className="py-3 pe-3 text-muted">{fmtDate(t.transfer_date)}</td>
<td className="py-3 pe-3 font-semibold">
<div>{t.material?.name}</div>
<div className="text-[11px] text-muted font-normal">{t.brand?.name || "بدون برند"}</div>
</td>
<td className="py-3 pe-3 text-muted text-[11.5px]">
{t.specs && Object.keys(t.specs).length > 0
? Object.entries(t.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")
: "—"}
</td>
<td className="py-3 pe-3 font-bold text-foreground">
{faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code}
</td>
<td className="py-3 pe-3">
<div className="text-[11px] text-muted">از: {t.from_warehouse?.name}</div>
<div className="text-[12px] font-medium text-foreground">به: {t.to_project?.name}</div>
</td>
<td className="py-3 pe-3">
{isInTransit ? (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2.5 py-0.5 text-[11px] font-bold text-amber-700 border border-amber-500/20">
🟡 در انتظار تأیید مقصد
</span>
) : (
<div className="flex flex-col gap-0.5">
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-[11px] font-bold text-emerald-700 border border-emerald-500/20">
🟢 تحویل‌شده در مقصد
</span>
{t.to_warehouse?.name && (
<span className="text-[10.5px] text-muted">انبار: {t.to_warehouse.name}</span>
)}
</div>
)}
</td>
<td className="py-3 text-center">
{isInTransit ? (
<div className="flex items-center justify-center gap-1.5">
<button
type="button"
onClick={() => {
setEditingTransfer(t);
setEditQty(String(t.quantity));
setEditToProjectId(t.to_project?.id || "");
setEditNote(t.note || "");
}}
className="rounded-lg border border-line px-2.5 py-1 text-[11.5px] font-medium hover:bg-bg transition-colors"
title="ویرایش انتقال"
>
✏️ ویرایش
</button>
<button
type="button"
onClick={() => setDeleteConfirmTransfer(t)}
className="rounded-lg border border-rose-200 bg-rose-50 px-2.5 py-1 text-[11.5px] font-medium text-rose-700 hover:bg-rose-100 transition-colors"
title="لغو انتقال"
>
✕ لغو
</button>
</div>
) : (
<span className="inline-flex items-center gap-1 text-[11px] text-muted font-medium">
🔒 قفل
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
{/* مودال ویرایش انتقال */}
{editingTransfer && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !editBusy && setEditingTransfer(null)}>
<div className="w-full max-w-md rounded-xl border border-line bg-panel p-4 sm:p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="mb-2 text-[14px] font-bold">✏️ ویرایش انتقال بین کارگاهی</h3>
<p className="mb-3 text-[11.5px] text-muted">
تغییر تعداد در انبار مبدأ تعدیل می‌گردد. پس از تحویل در مقصد، ویرایش غیرفعال خواهد شد.
</p>
<div className="flex flex-col gap-3">
<div className="rounded-lg border border-line bg-bg/50 p-2.5 text-xs">
<div>متریال: <b>{editingTransfer.material?.name}</b></div>
<div className="text-muted mt-0.5">انبار مبدأ: {editingTransfer.from_warehouse?.name}</div>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">پروژه مقصد *</label>
<select
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm font-medium"
value={editToProjectId}
onChange={(e) => setEditToProjectId(e.target.value)}
>
{allProjects
.filter((p) => p.id !== user?.project_id)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">
تعداد ({editingTransfer.unit?.name_fa || editingTransfer.unit?.code}) *
</label>
<input
inputMode="decimal"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm ltr"
value={editQty}
onChange={(e) => setEditQty(e.target.value)}
/>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">یادداشت</label>
<textarea
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-xs sm:text-sm"
value={editNote}
onChange={(e) => setEditNote(e.target.value)}
/>
</div>
</div>
<div className="mt-4 flex items-center justify-end gap-2 border-t border-line pt-3">
<button
type="button"
className="rounded-lg border border-line px-4 py-2 text-[12.5px]"
onClick={() => setEditingTransfer(null)}
disabled={editBusy}
>
انصراف
</button>
<button
type="button"
className="rounded-lg bg-blue-600 px-4 py-2 text-[12.5px] font-bold text-white hover:bg-blue-700 disabled:opacity-50"
onClick={() => void handleSaveEdit()}
disabled={editBusy}
>
{editBusy ? "در حال ذخیره…" : "ذخیره تغییرات"}
</button>
</div>
</div>
</div>
)}
{/* دیالوگ تأیید لغو انتقال */}
{deleteConfirmTransfer && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !deleteBusy && setDeleteConfirmTransfer(null)}>
<div className="w-full max-w-sm rounded-xl border border-line bg-panel p-4 sm:p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="mb-2 text-[14px] font-bold text-rose-600">⚠️ لغو انتقال بین کارگاهی</h3>
<p className="mb-3 text-[12px] leading-6 text-foreground">
آیا از لغو این انتقال اطمینان دارید؟ با لغو انتقال، مقدار <b>{faNum(deleteConfirmTransfer.quantity)} {deleteConfirmTransfer.unit?.name_fa || deleteConfirmTransfer.unit?.code}</b> از متریال <b>{deleteConfirmTransfer.material?.name}</b> فوراً به انبار مبدأ ({deleteConfirmTransfer.from_warehouse?.name}) بازگردانده خواهد شد.
</p>
<div className="mt-4 flex items-center justify-end gap-2 border-t border-line pt-3">
<button
type="button"
className="rounded-lg border border-line px-4 py-2 text-[12.5px]"
onClick={() => setDeleteConfirmTransfer(null)}
disabled={deleteBusy}
>
انصراف
</button>
<button
type="button"
className="rounded-lg bg-rose-600 px-4 py-2 text-[12.5px] font-bold text-white hover:bg-rose-700 disabled:opacity-50"
onClick={() => void handleConfirmDelete()}
disabled={deleteBusy}
>
{deleteBusy ? "در حال لغو…" : "بله، لغو و بازگشت موجودی"}
</button>
</div>
</div>
</div>
)}
{/* مودال تأیید شمارش موجودی */}
{countConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !countBusy && setCountConfirm(null)}>
@@ -771,7 +1206,11 @@ export function ChangeInventoryPage() {
)}
<div className="text-[11px] text-muted">
{isTransfer ? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود." : `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`}
{type === "PROJECT_TRANSFER"
? "انتقال بین کارگاهی: موجودی فوراً از انبار مبدأ کسر شده و پس از تأیید تحویل توسط مدیر پروژه مقصد، وارد انبار مقصد می‌شود."
: isTransfer
? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود."
: `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`}
</div>
</PageLayout>
</div>