feat(dashboard): add multi-project allocation UI in AcceptModal and merge on reopen in EditModal
- Add allocations field to LineItem and propagate in api.quotes.update - Implement allocation cards in AcceptModal Section 3 with per-row project select and sum validation - Disable previously selected projects within the same item to prevent duplicates - Merge split items client-side in EditModal when transitioning status to offered
This commit is contained in:
+18
-1
@@ -927,15 +927,30 @@ export const api = {
|
|||||||
|
|
||||||
if (becomingAccepted) {
|
if (becomingAccepted) {
|
||||||
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
|
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
|
||||||
// پروژهی واحدِ انتخابشده برای هر قلم (item_decisions.project_id)
|
// پروژهی واحدِ انتخابشده یا تخصیصهای چندپروژهای برای هر قلم
|
||||||
const targetProjByLine = new Map<string, string>();
|
const targetProjByLine = new Map<string, string>();
|
||||||
|
const allocationsByLine = new Map<string, { project_id: string; quantity: number }[]>();
|
||||||
for (const li of patch.line_items || []) {
|
for (const li of patch.line_items || []) {
|
||||||
|
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;
|
const tp = li.target_project;
|
||||||
if (tp && tp.name) {
|
if (tp && tp.name) {
|
||||||
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
|
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
|
||||||
if (pid) targetProjByLine.set(li.id, pid);
|
if (pid) targetProjByLine.set(li.id, pid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
accepted_termsheets: acceptedTs.map((t) => ({
|
accepted_termsheets: acceptedTs.map((t) => ({
|
||||||
@@ -953,11 +968,13 @@ export const api = {
|
|||||||
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
|
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
|
||||||
const chosen = !li.rejected && idx >= 0;
|
const chosen = !li.rejected && idx >= 0;
|
||||||
const chosenProjId = targetProjByLine.get(li.id);
|
const chosenProjId = targetProjByLine.get(li.id);
|
||||||
|
const lineAllocs = allocationsByLine.get(li.id);
|
||||||
return {
|
return {
|
||||||
line_item_id: li.id,
|
line_item_id: li.id,
|
||||||
status: chosen ? "chosen" : "rejected",
|
status: chosen ? "chosen" : "rejected",
|
||||||
...(chosen ? { accepted_termsheet_index: idx } : {}),
|
...(chosen ? { accepted_termsheet_index: idx } : {}),
|
||||||
...(chosen && chosenProjId ? { project_id: chosenProjId } : {}),
|
...(chosen && chosenProjId ? { project_id: chosenProjId } : {}),
|
||||||
|
...(chosen && lineAllocs && lineAllocs.length ? { allocations: lineAllocs } : {}),
|
||||||
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
|
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export interface LineItem {
|
|||||||
projects?: { id: string; name: string }[]; // پروژههای کاندیدای تخصیصیافته به این قلم (line_item_projects)
|
projects?: { id: string; name: string }[]; // پروژههای کاندیدای تخصیصیافته به این قلم (line_item_projects)
|
||||||
target_project?: { id: string; name: string } | null; // پروژهی نهایی انتخابشده در هنگام خرید (item_decisions.project_id)
|
target_project?: { id: string; name: string } | null; // پروژهی نهایی انتخابشده در هنگام خرید (item_decisions.project_id)
|
||||||
target_project_id?: string | null;
|
target_project_id?: string | null;
|
||||||
|
allocations?: { project_id?: string; project_name: string; quantity: number }[]; // تخصیص چندپروژهای در پذیرش
|
||||||
}
|
}
|
||||||
|
|
||||||
// پیشنهاد خرید (اصلیترین entity)
|
// پیشنهاد خرید (اصلیترین entity)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { MilestoneRow } from "../../components/MilestoneRow";
|
import { MilestoneRow } from "../../components/MilestoneRow";
|
||||||
import { FileList } from "../../components/FileList";
|
import { FileList } from "../../components/FileList";
|
||||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||||
|
import { Building2, Plus, Trash2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
interface CardDraft {
|
interface CardDraft {
|
||||||
domId: string;
|
domId: string;
|
||||||
@@ -51,6 +52,9 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
|
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
|
||||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||||
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
|
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
|
||||||
|
const [itemAllocations, setItemAllocations] = useState<
|
||||||
|
Record<string, { rowId: string; project_name: string; quantity: number | string }[]>
|
||||||
|
>({});
|
||||||
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
|
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
@@ -67,17 +71,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
setRemovedItems(rem);
|
setRemovedItems(rem);
|
||||||
setAssignments({});
|
setAssignments({});
|
||||||
const projSel: Record<string, string> = {};
|
const projSel: Record<string, string> = {};
|
||||||
|
const allocs: Record<string, { rowId: string; project_name: string; quantity: number | string }[]> = {};
|
||||||
q.line_items.forEach((li) => {
|
q.line_items.forEach((li) => {
|
||||||
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
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
|
? li.target_project.name
|
||||||
: names.length
|
: names.length
|
||||||
? names[0]
|
? names[0]
|
||||||
: q.projects && q.projects.length
|
: q.projects && q.projects.length
|
||||||
? q.projects[0]
|
? q.projects[0]
|
||||||
: q.project || "";
|
: 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);
|
setItemProjectsSel(projSel);
|
||||||
|
setItemAllocations(allocs);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}, [q]);
|
}, [q]);
|
||||||
|
|
||||||
@@ -137,6 +159,44 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
setAssignments((a) => ({ ...a, [liId]: domId }));
|
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 = () => {
|
const confirm = () => {
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
|
|
||||||
@@ -206,13 +266,64 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
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) => {
|
next.line_items.forEach((li) => {
|
||||||
if (li.rejected) return;
|
if (li.rejected) return;
|
||||||
|
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];
|
const name = itemProjectsSel[li.id];
|
||||||
if (name === undefined) return;
|
if (name !== undefined) {
|
||||||
li.target_project = name ? { id: "", name } : null;
|
li.target_project = name ? { id: "", name } : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -343,53 +454,173 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* بخش ۳: تخصیص اقلام به پروژه */}
|
{/* بخش ۳: تخصیص اقلام به پروژهها */}
|
||||||
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
||||||
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژهها</h3>
|
<div className="flex items-center justify-between mb-1">
|
||||||
<div className="mb-4 text-[12px] text-muted">
|
<h3 className="text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژهها</h3>
|
||||||
برای هر متریال خریداری شده، میتوانید پروژه مربوط به آن را مشخص کنید.
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="mb-4 text-[12px] text-muted">
|
||||||
|
مشخص کنید چه مقداری از هر قلم به کدام پروژه اختصاص داده میشود. در صورت خرید یک کالا برای چند پروژه، میتوانید آن را به چند سطر تفکیک کنید.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projectOptions.length === 0 ? (
|
||||||
|
<div className="text-[12px] text-amber-700 bg-amber-50 border border-amber-200 p-3 rounded-md text-center">
|
||||||
|
⚠️ هیچ پروژهای در سیستم ثبت نشده است. لطفاً ابتدا از بخش پروژهها، حداقل یک پروژه ایجاد کنید.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
{allItems
|
{allItems
|
||||||
.filter((li) => !removedItems.has(li.id))
|
.filter((li) => !removedItems.has(li.id))
|
||||||
.map((li) => {
|
.map((li) => {
|
||||||
const m = materialById(materials, li.material_id);
|
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 (
|
return (
|
||||||
<div key={li.id} className="rounded-lg border border-line p-3 flex flex-col justify-between hover:border-primary/30 transition-colors">
|
<div
|
||||||
<div className="mb-3 flex items-center justify-between">
|
key={li.id}
|
||||||
<span className="text-[13px] font-semibold text-slate-800 truncate">
|
className="rounded-xl border border-line bg-slate-50/50 p-4 transition-all hover:border-primary/30"
|
||||||
|
>
|
||||||
|
{/* هدر کارت قلم */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 mb-3 pb-2.5 border-b border-line/60">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<span className="text-[13.5px] font-bold text-slate-800">
|
||||||
{formatMaterialTitle(m)}
|
{formatMaterialTitle(m)}
|
||||||
</span>
|
</span>
|
||||||
<span className="ms-2 text-[11px] text-muted bg-slate-100 px-2 py-0.5 rounded-full whitespace-nowrap">
|
|
||||||
{fmtNum(li.quantity)} {li.unit}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{projectOptions.length ? (
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-medium text-slate-600 bg-white border border-line px-2.5 py-0.5 rounded-full">
|
||||||
|
کل: {fmtNum(targetQty)} {li.unit}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{isComplete ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 rounded-full">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
تخصیص کامل ({fmtNum(allocSum)} {li.unit})
|
||||||
|
</span>
|
||||||
|
) : isOver ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-red-700 bg-red-50 border border-red-200 px-2.5 py-0.5 rounded-full">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
مازاد تخصیص: {fmtNum(Math.abs(diff))} {li.unit}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-amber-700 bg-amber-50 border border-amber-200 px-2.5 py-0.5 rounded-full">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
باقیمانده: {fmtNum(diff)} {li.unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیفهای تخصیص */}
|
||||||
|
<div className="flex flex-col gap-2.5 mb-3">
|
||||||
|
{allocs.map((alloc, idx) => {
|
||||||
|
const otherUsedProjects = new Set(
|
||||||
|
allocs
|
||||||
|
.filter((a) => a.rowId !== alloc.rowId)
|
||||||
|
.map((a) => a.project_name.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={alloc.rowId}
|
||||||
|
className="flex flex-wrap sm:flex-nowrap items-center gap-2 bg-white p-2.5 rounded-lg border border-line"
|
||||||
|
>
|
||||||
|
<span className="text-[11px] text-muted w-5 text-center shrink-0 font-mono">
|
||||||
|
{faNum(idx + 1)}.
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* انتخاب پروژه (الزام ۳: عدم امکان انتخاب پروژه تکراری) */}
|
||||||
|
<div className="flex-1 min-w-[180px]">
|
||||||
<select
|
<select
|
||||||
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||||
value={itemProjectsSel[li.id] ?? ""}
|
value={alloc.project_name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
|
updateAllocationRow(li.id, alloc.rowId, {
|
||||||
|
project_name: e.target.value,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(itemProjectsSel[li.id] ? [] : [{ name: "" }]).map((_, i) => (
|
<option value="">انتخاب پروژه...</option>
|
||||||
<option key={i} value="">
|
{projectOptions.map((p) => {
|
||||||
انتخاب پروژه...
|
const isUsed = otherUsedProjects.has(p);
|
||||||
|
return (
|
||||||
|
<option key={p} value={p} disabled={isUsed}>
|
||||||
|
{p} {isUsed ? "(در ردیف دیگر انتخاب شده)" : ""}
|
||||||
</option>
|
</option>
|
||||||
))}
|
);
|
||||||
{projectOptions.map((n) => (
|
})}
|
||||||
<option key={n} value={n}>
|
|
||||||
{n}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
) : (
|
</div>
|
||||||
<div className="text-[12px] text-muted bg-slate-50 p-2 rounded-md text-center">پروژهای در سیستم ثبت نشده است.</div>
|
|
||||||
)}
|
{/* مقدار تخصیص و برچسب واحد */}
|
||||||
|
<div className="relative flex items-center w-36 shrink-0">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
min="0"
|
||||||
|
placeholder="مقدار"
|
||||||
|
className="w-full h-9 rounded-md border border-line bg-panel pe-10 ps-2.5 py-1 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-left"
|
||||||
|
dir="ltr"
|
||||||
|
value={alloc.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAllocationRow(li.id, alloc.rowId, {
|
||||||
|
quantity: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="absolute end-2 text-[11px] text-muted pointer-events-none select-none">
|
||||||
|
{li.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* دکمه حذف ردیف */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={allocs.length <= 1}
|
||||||
|
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
|
||||||
|
className="h-9 w-9 flex items-center justify-center rounded-md border border-line text-slate-400 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors disabled:opacity-30 disabled:pointer-events-none shrink-0"
|
||||||
|
title="حذف این ردیف تخصیص"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* فوتر کارت قلم: دکمه افزودن ردیف جدید */}
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addAllocationRow(li.id, targetQty)}
|
||||||
|
disabled={
|
||||||
|
allocs.length >= projectOptions.length && projectOptions.length > 0
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] font-medium text-primary hover:text-primary/80 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>افزودن ردیف تخصیص (پروژه دیگر)</span>
|
||||||
|
</button>
|
||||||
|
<span className="text-[11px] text-muted">
|
||||||
|
مجموع: {fmtNum(allocSum)} از {fmtNum(targetQty)} {li.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* بخش ۴: فاکتورها، پیشفاکتورها و تاریخ */}
|
{/* بخش ۴: فاکتورها، پیشفاکتورها و تاریخ */}
|
||||||
|
|||||||
@@ -384,7 +384,26 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
li.accepted_ts_id = null;
|
li.accepted_ts_id = null;
|
||||||
li.target_project = null;
|
li.target_project = null;
|
||||||
li.target_project_id = null;
|
li.target_project_id = null;
|
||||||
|
li.allocations = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ادغام سطرهای تفکیکشده حاصل از تخصیص چندپروژهای (الزام ۲)
|
||||||
|
const mergedMap = new Map<string, LineItem>();
|
||||||
|
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") {
|
} else if (next.status === "accepted") {
|
||||||
next.arrived_date = null;
|
next.arrived_date = null;
|
||||||
next.line_items.forEach((li) => {
|
next.line_items.forEach((li) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user