Merge prod-v8 (multi-project allocation, inter-project transfers) into the all-branches integration

Conflicts (all keep-both):
- init_db.py: v7 added ensure_bot_drafts_table, v8 added
  ensure_project_transfers_table at the same anchor — both functions and
  both startup calls kept
- main.py: same, both ensure calls kept
- ChangeInventoryPage.canSubmit: v9's WITHDRAWAL branch and v8's
  PROJECT_TRANSFER branch — both validation branches kept
This commit is contained in:
MMD
2026-10-01 15:05:09 +03:30
18 changed files with 2454 additions and 102 deletions
+219 -12
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,24 +25,31 @@ 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);
setItems(Array.isArray(arr) ? arr : []);
setWarehouses(Array.isArray(whs) ? whs : []);
setIncomingTransfers(Array.isArray(incoming) ? incoming : []);
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
setItems([]);
setIncomingTransfers([]);
} finally {
setLoading(false);
}
@@ -212,6 +219,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 +287,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 +607,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("");
@@ -206,6 +223,82 @@ 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(Array.isArray(list) ? list : []);
} catch (err) {
console.error("Failed to load project transfers:", err);
setTransfers([]);
} finally {
setLoadingTransfers(false);
}
}, [user?.project_id]);
useEffect(() => {
api.projects
.list()
.then((res) => setAllProjects(Array.isArray(res) ? res : []))
.catch(() => setAllProjects([]));
}, []);
useEffect(() => {
if (type === "PROJECT_TRANSFER") {
void loadTransfers();
}
}, [type, loadTransfers]);
const whStock = useMemo(() => {
if (!whId) return [];
return (data.inventory || []).filter((r) => r.warehouse?.id === whId && r.quantity_number > 0);
}, [data.inventory, whId]);
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();
@@ -246,6 +339,82 @@ 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 (!unitName) return toast.error("نام واحد را وارد کنید");
if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد");
setBusy(true);
try {
// Find matching inventory in the selected source warehouse
const invInWh = (data.inventory || []).filter(
(r) => r.warehouse?.id === whId && r.material.name.trim() === matName
);
const matchedInv = invInWh.find(
(r) => (brandName ? r.brand?.name === brandName : !r.brand)
) || invInWh[0];
let uid = matchedInv?.unit?.id ?? unitIdByName(unitName);
if (!uid) {
const created = await apiV26.catalog.createUnit(unitName);
uid = created.id;
}
let bid: string | null = matchedInv?.brand?.id ?? (brandName ? brandIdByName(brandName) : null);
if (brandName && !bid) {
const created = await apiV26.catalog.createBrand(brandName);
bid = created.id;
}
let mid = matchedInv?.material.id;
if (!mid) {
let m = matByName(matName);
if (!m) {
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
mid = created.id;
} else {
mid = m.id;
}
}
const finalSpecs = matchedInv ? (matchedInv.specs || {}) : specsFromChips();
await apiV26.projectTransfers.create({
from_warehouse_id: whId,
to_project_id: toProjectId,
material_id: mid,
brand_id: bid || null,
unit_id: uid,
quantity: n,
specs: finalSpecs,
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) => {
if (t === "WITHDRAWAL" && !deliveredTo.trim()) {
return toast.error("نام تحویل‌گیرنده اقلام الزامی است");
@@ -382,6 +551,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();
@@ -399,6 +569,10 @@ export function ChangeInventoryPage() {
if (type === "WITHDRAWAL") {
return !!(whId && deliveredTo.trim() && rows.some((r) => parseNum(r.qty) > 0));
}
if (type === "PROJECT_TRANSFER") {
return !!(whId && toProjectId && matTxt.trim() && unitTxt.trim() && parseNum(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") {
@@ -492,7 +666,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>
@@ -619,9 +833,72 @@ export function ChangeInventoryPage() {
</div>
)}
{/* فرم خرید / موجودی اولیه */}
{type === "PURCHASE" && (
{/* فرم خرید / موجودی اولیه یا انتقال بین کارگاهی */}
{(type === "PURCHASE" || type === "PROJECT_TRANSFER") && (
<div className="grid gap-3 md:grid-cols-2">
{/* چیپ‌های انتخاب سریع از موجودی‌های انبار مبدأ */}
{type === "PROJECT_TRANSFER" && whId && (
<div className="field md:col-span-2 rounded-xl border border-rose-500/20 bg-rose-500/5 p-3.5">
<div className="flex items-center justify-between gap-2 mb-2">
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
<span>📦</span>
<span>موجودی‌های موجود در {whName(warehouses, whId)} — برای پر کردن خودکار فرم کلیک کنید:</span>
</span>
<span className="text-[11px] text-muted">{faNum(whStock.length)} قلم کالا</span>
</div>
{whStock.length === 0 ? (
<div className="text-xs text-muted py-1">هیچ کالایی با موجودی مثبت در این انبار یافت نشد.</div>
) : (
<div className="flex flex-wrap gap-2">
{whStock.map((r) => {
const isSelected = matTxt === r.material.name && (brandTxt === (r.brand?.name || "") || (!brandTxt && !r.brand));
const specsSummary = Object.entries(r.specs || {})
.map(([k, v]) => (v ? `${v}` : k.replace(/^spec_/, "")))
.filter(Boolean)
.join(" · ");
return (
<button
key={r.id}
type="button"
onClick={() => {
setMatTxt(r.material.name);
setBrandTxt(r.brand?.name || "");
setUnitTxt(unitName(data.lookup.units, r.unit?.id) || "");
const chips = Object.entries(r.specs || {})
.map(([k, v]) => (v ? `${v}` : k.replace(/^spec_/, "")))
.filter(Boolean);
setSpecsChips(chips);
}}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs text-start transition-all shadow-2xs",
isSelected
? "border-rose-500 bg-rose-600 text-white font-bold ring-2 ring-rose-500/30"
: "border-line bg-panel hover:border-ink/40 text-ink"
)}
>
<span className="font-bold">{r.material.name}</span>
<span className={isSelected ? "text-white/80" : "text-muted font-normal"}>
({r.brand?.name || "بدون برند"})
</span>
{specsSummary && (
<span className={isSelected ? "text-white/90" : "text-muted text-[11px]"}>
[{specsSummary}]
</span>
)}
<span className={cn(
"rounded-md px-1.5 py-0.5 text-[11.5px] font-black",
isSelected ? "bg-white/20 text-white" : "bg-bg text-foreground"
)}>
{faNum(r.quantity_number)} {unitName(data.lookup.units, r.unit?.id)}
</span>
</button>
);
})}
</div>
)}
</div>
)}
<div className="field md:col-span-2">
<label className="text-xs font-semibold text-muted">متریال *</label>
<Combobox
@@ -640,13 +917,15 @@ export function ChangeInventoryPage() {
)}
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">برند *</label>
<label className="text-xs font-semibold text-muted">
برند {type === "PROJECT_TRANSFER" ? "(اختیاری)" : "*"}
</label>
<Combobox
value={brandTxt}
items={data.lookup.brands.map((b) => b.name)}
allowAdd
addSuffix="به عنوان برند جدید"
placeholder="نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"
placeholder={type === "PROJECT_TRANSFER" ? "نام برند (اختیاری — برای اقلام بدون برند خالی بگذارید)" : "نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"}
onInputChange={setBrandTxt}
onChange={(it) => setBrandTxt(it.label)}
/>
@@ -683,11 +962,13 @@ export function ChangeInventoryPage() {
/>
</div>
<div className="field md:col-span-2">
<label className="text-xs font-semibold text-muted">مشخصه‌های فنی *</label>
<label className="text-xs font-semibold text-muted">
مشخصه‌های فنی {type === "PROJECT_TRANSFER" ? "(اختیاری)" : "*"}
</label>
<MultiSelectCombobox
values={specsChips}
onChange={setSpecsChips}
placeholder="نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)"
placeholder={type === "PROJECT_TRANSFER" ? "مشخصات فنی (اختیاری — مثلاً مسی، سایز و…)" : "نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)"}
className="w-full"
/>
</div>
@@ -695,7 +976,7 @@ 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)}
/>
@@ -762,11 +1043,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)}>
@@ -831,7 +1335,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>