diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6e5f6ca..365a067 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -927,13 +927,28 @@ export const api = { if (becomingAccepted) { const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted"); - // پروژه‌ی واحدِ انتخاب‌شده برای هر قلم (item_decisions.project_id) + // پروژه‌ی واحدِ انتخاب‌شده یا تخصیص‌های چندپروژه‌ای برای هر قلم const targetProjByLine = new Map(); + const allocationsByLine = new Map(); for (const li of patch.line_items || []) { - const tp = li.target_project; - if (tp && tp.name) { - const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name); - if (pid) targetProjByLine.set(li.id, pid); + if (li.allocations && li.allocations.length > 0) { + const resolvedAllocs: { project_id: string; quantity: number }[] = []; + for (const a of li.allocations) { + const pid = looksLikeUuid(a.project_id) ? a.project_id! : await projectIdByNameOrCreate(a.project_name); + if (pid && a.quantity > 0) { + resolvedAllocs.push({ project_id: pid, quantity: Number(a.quantity) }); + } + } + if (resolvedAllocs.length) { + allocationsByLine.set(li.id, resolvedAllocs); + targetProjByLine.set(li.id, resolvedAllocs[0].project_id); + } + } else { + const tp = li.target_project; + if (tp && tp.name) { + const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name); + if (pid) targetProjByLine.set(li.id, pid); + } } } @@ -953,11 +968,13 @@ export const api = { const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1; const chosen = !li.rejected && idx >= 0; const chosenProjId = targetProjByLine.get(li.id); + const lineAllocs = allocationsByLine.get(li.id); return { line_item_id: li.id, status: chosen ? "chosen" : "rejected", ...(chosen ? { accepted_termsheet_index: idx } : {}), ...(chosen && chosenProjId ? { project_id: chosenProjId } : {}), + ...(chosen && lineAllocs && lineAllocs.length ? { allocations: lineAllocs } : {}), ...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}), }; }), diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index dad95b0..e026a84 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -73,6 +73,7 @@ export interface LineItem { projects?: { id: string; name: string }[]; // پروژه‌های کاندیدای تخصیص‌یافته به این قلم (line_item_projects) target_project?: { id: string; name: string } | null; // پروژه‌ی نهایی انتخاب‌شده در هنگام خرید (item_decisions.project_id) target_project_id?: string | null; + allocations?: { project_id?: string; project_name: string; quantity: number }[]; // تخصیص چندپروژه‌ای در پذیرش } // پیشنهاد خرید (اصلی‌ترین entity) diff --git a/frontend/src/pages/Dashboard/AcceptModal.tsx b/frontend/src/pages/Dashboard/AcceptModal.tsx index df384e0..6d499d1 100644 --- a/frontend/src/pages/Dashboard/AcceptModal.tsx +++ b/frontend/src/pages/Dashboard/AcceptModal.tsx @@ -23,6 +23,7 @@ import { import { MilestoneRow } from "../../components/MilestoneRow"; import { FileList } from "../../components/FileList"; import { JalaliDatePicker } from "../../components/JalaliDatePicker"; +import { Building2, Plus, Trash2, CheckCircle2, AlertCircle } from "lucide-react"; interface CardDraft { domId: string; @@ -51,6 +52,9 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { const [removedItems, setRemovedItems] = useState>(new Set()); const [assignments, setAssignments] = useState>({}); const [itemProjectsSel, setItemProjectsSel] = useState>({}); + const [itemAllocations, setItemAllocations] = useState< + Record + >({}); const [acceptDate, setAcceptDate] = useState(() => new Date().toISOString()); const [ready, setReady] = useState(false); @@ -67,17 +71,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { setRemovedItems(rem); setAssignments({}); const projSel: Record = {}; + const allocs: Record = {}; q.line_items.forEach((li) => { const names = (li.projects || []).map((p) => p.name).filter(Boolean); - projSel[li.id] = li.target_project?.name + const defaultProj = li.target_project?.name ? li.target_project.name : names.length ? names[0] : q.projects && q.projects.length ? q.projects[0] : q.project || ""; + projSel[li.id] = defaultProj; + if (li.allocations && li.allocations.length) { + allocs[li.id] = li.allocations.map((a, idx) => ({ + rowId: `alloc-${li.id}-${idx}`, + project_name: a.project_name, + quantity: a.quantity, + })); + } else { + allocs[li.id] = [ + { + rowId: `alloc-${li.id}-0`, + project_name: defaultProj, + quantity: li.quantity ?? 1, + }, + ]; + } }); setItemProjectsSel(projSel); + setItemAllocations(allocs); setReady(true); }, [q]); @@ -137,6 +159,44 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { setAssignments((a) => ({ ...a, [liId]: domId })); }; + const addAllocationRow = (liId: string, totalQty: number) => { + setItemAllocations((prev) => { + const currentList = prev[liId] || []; + const currentSum = currentList.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0); + const remaining = Math.max(0, Math.round((totalQty - currentSum) * 10000) / 10000); + const usedProjects = new Set(currentList.map((a) => a.project_name).filter(Boolean)); + const firstAvailable = projectOptions.find((p) => !usedProjects.has(p)) || ""; + const newRow = { + rowId: `alloc-${liId}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + project_name: firstAvailable, + quantity: remaining > 0 ? remaining : "", + }; + return { ...prev, [liId]: [...currentList, newRow] }; + }); + }; + + const removeAllocationRow = (liId: string, rowId: string) => { + setItemAllocations((prev) => { + const currentList = prev[liId] || []; + if (currentList.length <= 1) return prev; + return { ...prev, [liId]: currentList.filter((a) => a.rowId !== rowId) }; + }); + }; + + const updateAllocationRow = ( + liId: string, + rowId: string, + patch: Partial<{ project_name: string; quantity: number | string }>, + ) => { + setItemAllocations((prev) => { + const currentList = prev[liId] || []; + return { + ...prev, + [liId]: currentList.map((a) => (a.rowId === rowId ? { ...a, ...patch } : a)), + }; + }); + }; + const confirm = () => { if (!q) return; @@ -206,13 +266,64 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered"; }); - // پروژه‌ی هر قلم (تکی هدف خرید) — فقط target_project تنظیم می‌شود؛ - // li.projects و next.projects دست‌نخورده باقی می‌مانند تا پروژه‌های کاندیدای اولیه حفظ شوند + // اعتبارسنجی تخصیص‌های چندپروژه‌ای به ازای هر قلم (الزامات ۳ و ۵) + for (const li of active) { + const allocs = itemAllocations[li.id] || []; + const m = materialById(materials, li.material_id); + const title = formatMaterialTitle(m); + const targetQty = Number(li.quantity ?? 0); + + if (!allocs.length) { + toast.error(`برای قلم «${title}» حداقل یک ردیف تخصیص به پروژه لازم است.`); + return; + } + + for (const a of allocs) { + if (!a.project_name.trim()) { + toast.error(`برای قلم «${title}»، لطفاً پروژهٔ تمام ردیف‌های تخصیص را مشخص کنید.`); + return; + } + const qVal = parseFloat(String(a.quantity)); + if (isNaN(qVal) || qVal <= 0) { + toast.error(`برای قلم «${title}»، مقدار تخصیص باید عددی بزرگ‌تر از صفر باشد.`); + return; + } + } + + // الزام ۳: جلوگیری از انتخاب پروژه تکراری برای یک قلم + const pNames = allocs.map((a) => a.project_name.trim()); + if (new Set(pNames).size !== pNames.length) { + toast.error(`برای قلم «${title}»، پروژهٔ تکراری در ردیف‌های تخصیص انتخاب شده است.`); + return; + } + + // الزام ۵: مقایسه با دقت اعشاری بالا + const sumQty = allocs.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0); + const diff = Math.abs(sumQty - targetQty); + if (diff > 0.0001) { + toast.error( + `مجموع مقادیر تخصیص‌یافته برای «${title}» (${fmtNum(sumQty)} ${li.unit}) با مقدار کل قلم (${fmtNum(targetQty)} ${li.unit}) برابر نیست.` + ); + return; + } + } + + // انتساب تخصیص‌های چندپروژه‌ای به اقلام next.line_items.forEach((li) => { if (li.rejected) return; - const name = itemProjectsSel[li.id]; - if (name === undefined) return; - li.target_project = name ? { id: "", name } : null; + const allocs = itemAllocations[li.id]; + if (allocs && allocs.length) { + li.allocations = allocs.map((a) => ({ + project_name: a.project_name.trim(), + quantity: parseFloat(String(a.quantity)) || 0, + })); + li.target_project = { id: "", name: allocs[0].project_name.trim() }; + } else { + const name = itemProjectsSel[li.id]; + if (name !== undefined) { + li.target_project = name ? { id: "", name } : null; + } + } }); const now = new Date().toISOString(); @@ -343,53 +454,173 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { - {/* بخش ۳: تخصیص اقلام به پروژه */} + {/* بخش ۳: تخصیص اقلام به پروژه‌ها */}
-

۳. تخصیص اقلام به پروژه‌ها

+
+

۳. تخصیص اقلام به پروژه‌ها

+
- برای هر متریال خریداری شده، می‌توانید پروژه مربوط به آن را مشخص کنید. + مشخص کنید چه مقداری از هر قلم به کدام پروژه اختصاص داده می‌شود. در صورت خرید یک کالا برای چند پروژه، می‌توانید آن را به چند سطر تفکیک کنید.
-
- {allItems - .filter((li) => !removedItems.has(li.id)) - .map((li) => { - const m = materialById(materials, li.material_id); - return ( -
-
- - {formatMaterialTitle(m)} - - - {fmtNum(li.quantity)} {li.unit} - + + {projectOptions.length === 0 ? ( +
+ ⚠️ هیچ پروژه‌ای در سیستم ثبت نشده است. لطفاً ابتدا از بخش پروژه‌ها، حداقل یک پروژه ایجاد کنید. +
+ ) : ( +
+ {allItems + .filter((li) => !removedItems.has(li.id)) + .map((li) => { + const m = materialById(materials, li.material_id); + const targetQty = Number(li.quantity ?? 0); + const allocs = itemAllocations[li.id] || []; + const allocSum = allocs.reduce( + (sum, a) => sum + (parseFloat(String(a.quantity)) || 0), + 0 + ); + const diff = Math.round((targetQty - allocSum) * 10000) / 10000; + const isComplete = Math.abs(diff) < 0.0001; + const isOver = diff < -0.0001; + + return ( +
+ {/* هدر کارت قلم */} +
+
+ + + {formatMaterialTitle(m)} + +
+ +
+ + کل: {fmtNum(targetQty)} {li.unit} + + + {isComplete ? ( + + + تخصیص کامل ({fmtNum(allocSum)} {li.unit}) + + ) : isOver ? ( + + + مازاد تخصیص: {fmtNum(Math.abs(diff))} {li.unit} + + ) : ( + + + باقیمانده: {fmtNum(diff)} {li.unit} + + )} +
+
+ + {/* ردیف‌های تخصیص */} +
+ {allocs.map((alloc, idx) => { + const otherUsedProjects = new Set( + allocs + .filter((a) => a.rowId !== alloc.rowId) + .map((a) => a.project_name.trim()) + .filter(Boolean) + ); + + return ( +
+ + {faNum(idx + 1)}. + + + {/* انتخاب پروژه (الزام ۳: عدم امکان انتخاب پروژه تکراری) */} +
+ +
+ + {/* مقدار تخصیص و برچسب واحد */} +
+ + updateAllocationRow(li.id, alloc.rowId, { + quantity: e.target.value, + }) + } + /> + + {li.unit} + +
+ + {/* دکمه حذف ردیف */} + +
+ ); + })} +
+ + {/* فوتر کارت قلم: دکمه افزودن ردیف جدید */} +
+ + + مجموع: {fmtNum(allocSum)} از {fmtNum(targetQty)} {li.unit} + +
- {projectOptions.length ? ( - - ) : ( -
پروژه‌ای در سیستم ثبت نشده است.
- )} -
- ); - })} -
+ ); + })} +
+ )}
{/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */} diff --git a/frontend/src/pages/Dashboard/EditModal.tsx b/frontend/src/pages/Dashboard/EditModal.tsx index 04686b1..3c2ee5f 100644 --- a/frontend/src/pages/Dashboard/EditModal.tsx +++ b/frontend/src/pages/Dashboard/EditModal.tsx @@ -384,7 +384,26 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { li.accepted_ts_id = null; li.target_project = null; li.target_project_id = null; + li.allocations = undefined; }); + + // ادغام سطرهای تفکیک‌شده حاصل از تخصیص چندپروژه‌ای (الزام ۲) + const mergedMap = new Map(); + for (const li of next.line_items) { + const key = `${li.material_id}__${li.unit || ""}__${li.unit_price ?? ""}`; + const existing = mergedMap.get(key); + if (existing) { + const q1 = Number(existing.quantity ?? 0); + const q2 = Number(li.quantity ?? 0); + existing.quantity = Math.round((q1 + q2) * 10000) / 10000; + if (existing.unit_price != null && existing.quantity != null) { + existing.total_price = existing.unit_price * existing.quantity; + } + } else { + mergedMap.set(key, { ...li }); + } + } + next.line_items = Array.from(mergedMap.values()); } else if (next.status === "accepted") { next.arrived_date = null; next.line_items.forEach((li) => {