Author SHA1 Message Date
whqreza da883a28c0 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
2026-09-23 22:02:49 +03:30
whqreza b6a1ce2ae7 feat(procurement): support multi-project line item split allocation and merge on reopen
- Add ProjectAllocationIn and allocations field to ItemDecisionIn schema
- Implement line item split allocation across projects in accept_quote
- Replicate all ItemTermsheet options and LineItemSpec records to split rows
- Ensure QuoteProjects and LineItemProjects relations are updated
- Add validation against duplicate project selection and decimal sum mismatches
- Implement _merge_split_line_items_on_reopen to consolidate rows when reverting status to offered
2026-09-23 22:02:24 +03:30
whqreza 64d80e15f8 feat(seed): include ali.mohaghegh admin user in seed plan 2026-09-23 22:02:16 +03:30
7 changed files with 526 additions and 54 deletions
+6
View File
@@ -135,6 +135,11 @@ class AcceptTermsheetIn(APIModel):
schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
class ProjectAllocationIn(APIModel):
project_id: UUID
quantity: Decimal = Field(gt=0, description="Allocated quantity for this project")
class ItemDecisionIn(APIModel):
line_item_id: UUID
status: Literal["chosen", "rejected"]
@@ -142,6 +147,7 @@ class ItemDecisionIn(APIModel):
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
adjusted_notes: str | None = None
project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)")
allocations: list[ProjectAllocationIn] = Field(default_factory=list, description="Multi-project split allocations for this line item")
class ItemProjectsIn(APIModel):
+197
View File
@@ -7,6 +7,7 @@ same transaction.
import asyncio
import logging
from datetime import date, datetime, timezone
from decimal import Decimal
from uuid import UUID
from sqlalchemy import delete, func, select, update
@@ -18,6 +19,7 @@ from app.models.procurement import (
ItemTermsheet,
LineItem,
LineItemProjects,
LineItemSpec,
Project,
Quote,
QuoteEvent,
@@ -418,6 +420,78 @@ async def replace_quote_children(
async def _ensure_quote_project(db: AsyncSession, quote_id: UUID, project_id: UUID) -> None:
"""Ensure (quote_id, project_id) exists in quote_projects junction."""
exists = await db.scalar(
select(QuoteProjects).where(
QuoteProjects.quote_id == quote_id,
QuoteProjects.project_id == project_id,
)
)
if not exists:
db.add(QuoteProjects(quote_id=quote_id, project_id=project_id))
async def _merge_split_line_items_on_reopen(db: AsyncSession, quote_id: UUID) -> None:
"""Consolidate split line items when reverting a quote back to offered.
Line items sharing the exact same material, brand_material, unit, notes,
and identical specs are merged into a single consolidated row whose quantity
is the sum of all split rows. Secondary split rows are safely deleted.
"""
line_items = (
await db.execute(
select(LineItem).where(LineItem.quote_id == quote_id).order_by(LineItem.created_at.asc(), LineItem.id.asc())
)
).scalars().all()
if len(line_items) <= 1:
return
# Load all specs for these line items to compare canonical signatures
specs_rows = (
await db.execute(
select(LineItemSpec).where(LineItemSpec.line_item_id.in_([li.id for li in line_items]))
)
).scalars().all()
specs_by_li: dict[UUID, list[tuple]] = {}
for sp in specs_rows:
sig = (
str(sp.attribute_definition_id),
str(sp.value_number) if sp.value_number is not None else "",
(sp.value_string or "").strip(),
str(sp.value_boolean) if sp.value_boolean is not None else "",
str(sp.value_date) if sp.value_date is not None else "",
)
specs_by_li.setdefault(sp.line_item_id, []).append(sig)
# Group line items by identity
groups: dict[tuple, list[LineItem]] = {}
for li in line_items:
specs_sig = tuple(sorted(specs_by_li.get(li.id, [])))
key = (
li.material_id,
li.brand_material_id,
li.unit_id,
(li.notes or "").strip(),
specs_sig,
)
groups.setdefault(key, []).append(li)
for key, items in groups.items():
if len(items) > 1:
primary = items[0]
total_qty = sum(Decimal(str(it.quantity)) for it in items)
primary.quantity = total_qty
secondary_ids = [it.id for it in items[1:]]
await db.execute(delete(ItemDecision).where(ItemDecision.line_item_id.in_(secondary_ids)))
await db.execute(delete(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(secondary_ids)))
await db.execute(delete(LineItemProjects).where(LineItemProjects.line_item_id.in_(secondary_ids)))
await db.execute(delete(LineItemSpec).where(LineItemSpec.line_item_id.in_(secondary_ids)))
await db.execute(delete(LineItem).where(LineItem.id.in_(secondary_ids)))
await db.flush()
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
"""Backward transitions only; reverts bookkeeping when going back to offered."""
current = quote.status
@@ -454,6 +528,8 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
)
.execution_options(synchronize_session=False)
)
# Requirement 2: Merge split line items back into single rows
await _merge_split_line_items_on_reopen(db, quote.id)
# LineItemProjects (candidate projects) are preserved untouched!
elif new_status == "accepted": # arrived -> accepted
quote.arrived_date = None
@@ -557,6 +633,125 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
"برای قلم انتخاب‌شده adjusted_unit_price الزامی است", field="item_decisions"
)
termsheet_id = ts_ids_by_index[index]
li_row = await db.scalar(
select(LineItem).where(LineItem.id == decision_in.line_item_id).with_for_update()
)
if li_row is None:
raise NotFoundError("خط آیتم یافت نشد", field="item_decisions.line_item_id")
allocations = decision_in.allocations or []
if allocations:
# Requirement 3: Validate no duplicate projects within allocations of this item
alloc_pids = [a.project_id for a in allocations]
if len(alloc_pids) != len(set(alloc_pids)):
raise ValidationError(
"پروژه تکراری برای یک قلم کالا در ردیف‌های تخصیص انتخاب شده است",
field="item_decisions.allocations",
)
# Requirement 5: Decimal precision check for sum of allocation quantities
alloc_sum = sum(Decimal(str(a.quantity)) for a in allocations)
target_qty = Decimal(str(li_row.quantity))
if alloc_sum != target_qty:
raise ValidationError(
f"مجموع مقادیر تخصیص‌یافته ({alloc_sum}) با مقدار کل قلم ({target_qty}) برابر نیست",
field="item_decisions.allocations",
)
# Validate all projects exist
for a in allocations:
if await db.get(Project, a.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="item_decisions.allocations.project_id")
if len(allocations) >= 2:
# 1. Update the original line item to the first allocation quantity
first_alloc = allocations[0]
li_row.quantity = first_alloc.quantity
decision_in.project_id = first_alloc.project_id
# Query all existing item_termsheets and line_item_specs for copying
orig_termsheets = (
await db.execute(
select(ItemTermsheet).where(ItemTermsheet.line_item_id == li_row.id)
)
).scalars().all()
orig_specs = (
await db.execute(
select(LineItemSpec).where(LineItemSpec.line_item_id == li_row.id)
)
).scalars().all()
# 2. For each subsequent allocation, create a new LineItem and copy specs + ALL termsheets
for split_alloc in allocations[1:]:
new_li = LineItem(
quote_id=quote.id,
material_id=li_row.material_id,
brand_material_id=li_row.brand_material_id,
unit_id=li_row.unit_id,
quantity=split_alloc.quantity,
notes=li_row.notes,
)
db.add(new_li)
await db.flush()
# Copy LineItemSpec
for sp in orig_specs:
db.add(
LineItemSpec(
line_item_id=new_li.id,
attribute_definition_id=sp.attribute_definition_id,
value_number=sp.value_number,
value_string=sp.value_string,
value_boolean=sp.value_boolean,
value_date=sp.value_date,
unit_id_at_time=sp.unit_id_at_time,
)
)
# Requirement 1: Copy ALL payment options (ItemTermsheet)
chosen_new_it: ItemTermsheet | None = None
for it_orig in orig_termsheets:
price = (
decision_in.adjusted_unit_price
if (it_orig.termsheet_id == termsheet_id and decision_in.adjusted_unit_price is not None)
else it_orig.unit_price
)
copied_it = ItemTermsheet(
line_item_id=new_li.id,
termsheet_id=it_orig.termsheet_id,
unit_price=price,
min_order_qty=it_orig.min_order_qty,
)
db.add(copied_it)
if it_orig.termsheet_id == termsheet_id:
chosen_new_it = copied_it
if chosen_new_it is None:
chosen_new_it = ItemTermsheet(
line_item_id=new_li.id,
termsheet_id=termsheet_id,
unit_price=decision_in.adjusted_unit_price,
)
db.add(chosen_new_it)
await db.flush()
# Create ItemDecision for the split line item
new_decision = ItemDecision(
line_item_id=new_li.id,
status="chosen",
chosen_item_termsheet_id=chosen_new_it.id,
adjusted_unit_price=decision_in.adjusted_unit_price,
adjusted_notes=decision_in.adjusted_notes,
project_id=split_alloc.project_id,
decided_at=utcnow(),
)
db.add(new_decision)
# Requirement 4: sync QuoteProjects & LineItemProjects
await _ensure_quote_project(db, quote.id, split_alloc.project_id)
db.add(LineItemProjects(line_item_id=new_li.id, project_id=split_alloc.project_id))
elif len(allocations) == 1:
decision_in.project_id = allocations[0].project_id
it = await db.scalar(
select(ItemTermsheet).where(
ItemTermsheet.line_item_id == decision_in.line_item_id,
@@ -610,6 +805,8 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
if decision.project_id is not None:
if await db.get(Project, decision.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")
# Requirement 4: sync QuoteProjects
await _ensure_quote_project(db, quote.id, decision.project_id)
chosen_ts_ids.add(termsheet_id)
else: # rejected
if decision is None:
+1
View File
@@ -128,6 +128,7 @@ async def get_category(db, slug: str) -> MaterialCategory:
USER_PLAN = [
# (casdoor username, first, last, role)
("ali.mohaghegh", "علی", "محقق", "admin"),
("razaei", "علی", "رضایی", "admin"),
("mousavi", "حسین", "موسوی", "procurement_manager"),
("mohaghegh", "محمد", "محقق", "pm"),
+22 -5
View File
@@ -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<string, string>();
const allocationsByLine = new Map<string, { project_id: string; quantity: number }[]>();
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 } : {}),
};
}),
+1
View File
@@ -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)
+280 -49
View File
@@ -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<Set<string>>(new Set());
const [assignments, setAssignments] = 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 [ready, setReady] = useState(false);
@@ -67,17 +71,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
setRemovedItems(rem);
setAssignments({});
const projSel: Record<string, string> = {};
const allocs: Record<string, { rowId: string; project_name: string; quantity: number | string }[]> = {};
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) {
</div>
</section>
{/* بخش ۳: تخصیص اقلام به پروژه */}
{/* بخش ۳: تخصیص اقلام به پروژه‌ها */}
<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">
<h3 className="text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژه‌ها</h3>
</div>
<div className="mb-4 text-[12px] text-muted">
برای هر متریال خریداری شده، می‌توانید پروژه مربوط به آن را مشخص کنید.
مشخص کنید چه مقداری از هر قلم به کدام پروژه اختصاص داده می‌شود. در صورت خرید یک کالا برای چند پروژه، می‌توانید آن را به چند سطر تفکیک کنید.
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{allItems
.filter((li) => !removedItems.has(li.id))
.map((li) => {
const m = materialById(materials, li.material_id);
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 className="mb-3 flex items-center justify-between">
<span className="text-[13px] font-semibold text-slate-800 truncate">
{formatMaterialTitle(m)}
</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>
{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
.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 (
<div
key={li.id}
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)}
</span>
</div>
<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
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={alloc.project_name}
onChange={(e) =>
updateAllocationRow(li.id, alloc.rowId, {
project_name: e.target.value,
})
}
>
<option value="">انتخاب پروژه...</option>
{projectOptions.map((p) => {
const isUsed = otherUsedProjects.has(p);
return (
<option key={p} value={p} disabled={isUsed}>
{p} {isUsed ? "(در ردیف دیگر انتخاب شده)" : ""}
</option>
);
})}
</select>
</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 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>
{projectOptions.length ? (
<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"
value={itemProjectsSel[li.id] ?? ""}
onChange={(e) =>
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
}
>
{(itemProjectsSel[li.id] ? [] : [{ name: "" }]).map((_, i) => (
<option key={i} value="">
انتخاب پروژه...
</option>
))}
{projectOptions.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
) : (
<div className="text-[12px] text-muted bg-slate-50 p-2 rounded-md text-center">پروژه‌ای در سیستم ثبت نشده است.</div>
)}
</div>
);
})}
</div>
);
})}
</div>
)}
</section>
{/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */}
@@ -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<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") {
next.arrived_date = null;
next.line_items.forEach((li) => {