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:
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user