diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 365a067..fc08b31 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 => { + 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(`/inventory/project-transfers${qs}`); + }, + get: async (id: string, projectId?: string | null): Promise => + getData(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`), + create: async (body: ProjectTransferCreateIn, projectId?: string | null): Promise => + postData(projectId ? `/inventory/project-transfers?project_id=${projectId}` : `/inventory/project-transfers`, body), + update: async (id: string, patch: ProjectTransferUpdateIn, projectId?: string | null): Promise => + patchData(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 => + getData(projectId ? `/pm/transfers/on-the-way?project_id=${projectId}` : `/pm/transfers/on-the-way`), + receive: async (id: string, body: ProjectTransferReceiveIn, projectId?: string | null): Promise => + postData(projectId ? `/inventory/project-transfers/${id}/receive?project_id=${projectId}` : `/inventory/project-transfers/${id}/receive`, body), + }, }; + diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index e026a84..28fb14c 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; + 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; + 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; +} + diff --git a/frontend/src/pages/Inventory/ArrivalsPage.tsx b/frontend/src/pages/Inventory/ArrivalsPage.tsx index bddd4bb..8b4a722 100644 --- a/frontend/src/pages/Inventory/ArrivalsPage.tsx +++ b/frontend/src/pages/Inventory/ArrivalsPage.tsx @@ -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([]); + const [incomingTransfers, setIncomingTransfers] = useState([]); const [warehouses, setWarehouses] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(null); // quoteId + const [transferSaving, setTransferSaving] = useState(null); // transferId const [expanded, setExpanded] = useState>(new Set()); const [drafts, setDrafts] = useState>({}); + const [transferDrafts, setTransferDrafts] = useState>({}); 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 (
@@ -233,19 +285,171 @@ export function ArrivalsPage() { />
- 🚚 این بخش به مرحله «رسید» سفارش‌های خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و - اینجا فقط رسید و تقسیم بین انبارها ثبت می‌شود. رسید جزیی (قلمی و مقداری) مجاز است — هر سفارش را باز - کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید و آن را بین انبارهای پروژه تقسیم کنید؛ قلم‌هایی که مقداری برایشان - وارد نشود در صف «در راه» می‌مانند. مجموع تخصیص هر قلم باید دقیقاً با تعداد رسیده‌ی همان قلم برابر باشد و از - مانده‌ی سفارش بیشتر نشود. + 🚚 این بخش به مرحله «رسید» سفارش‌های خرید و «انتقال بین کارگاهی» متصل است: اقلام ارسالی از سایر پروژه‌ها به صورت ۱۰۰٪ تحویل و بین انبارهای پروژه تخصیص داده می‌شوند؛ سفارش‌های خرید نیز قابلیت رسید کامل یا جزیی دارند.
{loading ? (
در حال بارگذاری…
- ) : !orders.length ? ( + ) : !orders.length && !incomingTransfers.length ? ( ) : ( - orders.map(([quoteId, its]) => { + <> + {/* بخش انتقال‌های بین کارگاهی در راه */} + {incomingTransfers.length > 0 && ( +
+
+

🔁 انتقال‌های بین کارگاهی در راه ({faNum(incomingTransfers.length)})

+ — تحویل ۱۰۰٪ کامل مصالح ارسالی از سایر پروژه‌ها +
+ + {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 ( +
+ {/* سربرگ انتقال */} +
+
+ 🔁 انتقال از {t.from_project?.name || "پروژه مبدأ"} + در راه + {days !== null && ( + = 7 ? "offered" : "muted"}> + روز گذشته: {faNum(days)} روز + + )} +
+
+ ارسال‌کننده: {t.transferred_by || "—"} + تاریخ ارسال: {fmtDate(t.transfer_date)} + پروژه مقصد: {t.to_project?.name || "—"} +
+
+ +
+
+ {t.material?.name} + {t.brand?.name && {t.brand.name}} + + مقدار ارسالی: {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""} + + {t.specs && Object.keys(t.specs).length > 0 && ( + + مشخصات: {Object.entries(t.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")} + + )} +
+ + {t.note && ( +
+ یادداشت ارسال: {t.note} +
+ )} + +
+
+ تخصیص ۱۰۰٪ به انبار(های) پروژه (مجموع باید دقیقاً {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""} باشد): +
+ +
+ {draftAllocs.map((a) => ( +
+ { + const next = draftAllocs.map((x) => (x.key === a.key ? { ...x, qty: e.target.value } : x)); + setTransferAlloc(t.id, next); + }} + /> + + {draftAllocs.length > 1 && ( + + )} +
+ ))} + + +
+ +
+
+ {isExactMatch ? ( + + ✓ تخصیص کامل ۱۰۰٪ ({faNum(allocSum)} از {faNum(targetQty)}) آماده تحویل + + ) : ( + + اختلاف تخصیص: مجموع تخصیص ({faNum(allocSum)}) باید دقیقاً با مقدار کل ({faNum(targetQty)}) برابر باشد. + + )} + {hasEmptyWh && ( + + — لطفاً انبار مقصد را انتخاب کنید + + )} +
+ + +
+
+
+
+ ); + })} +
+ )} + + {/* بخش سفارش‌های خرید در راه */} + {orders.map(([quoteId, its]) => { const first = its[0]; const open = expanded.has(quoteId); const hasSubmittable = orderHasSubmittable(quoteId); @@ -401,7 +605,8 @@ export function ArrivalsPage() { )}
); - }) + })} + )} diff --git a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx index 8f40188..7109033 100644 --- a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx +++ b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx @@ -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("PURCHASE"); const [whId, setWhId] = useState(""); + const [toProjectId, setToProjectId] = useState(""); + const [allProjects, setAllProjects] = useState([]); + const [transfers, setTransfers] = useState([]); + const [loadingTransfers, setLoadingTransfers] = useState(false); + + // Edit modal state + const [editingTransfer, setEditingTransfer] = useState(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(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[] = rows .map((r, i): Record | 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() {
{/* انتخاب انبارها (چیپ‌های لمسی به جای سلکت خام) */} - {!isTransfer ? ( + {type === "PROJECT_TRANSFER" ? ( +
+
+ +
+ {whOpts.map((w) => ( + + ))} +
+
+
+ + +
+
+ ) : !isTransfer ? (
@@ -561,8 +759,8 @@ export function ChangeInventoryPage() {
)} - {/* فرم خرید / موجودی اولیه */} - {type === "PURCHASE" && ( + {/* فرم خرید / موجودی اولیه یا انتقال بین کارگاهی */} + {(type === "PURCHASE" || type === "PROJECT_TRANSFER") && (
@@ -637,11 +835,25 @@ export function ChangeInventoryPage() {