diff --git a/backend/app/services/expand_service.py b/backend/app/services/expand_service.py index 506d03c..4ae44f0 100644 --- a/backend/app/services/expand_service.py +++ b/backend/app/services/expand_service.py @@ -77,13 +77,14 @@ def project_brief(project: Project | None) -> dict | None: return {"id": str(project.id), "name": project.name} -def decision_brief(d: ItemDecision) -> dict: +def decision_brief(d: ItemDecision, project: Project | None = None) -> dict: """Shape used inside expanded line items (P0-14).""" return { "id": str(d.id), "status": d.status, "chosen_item_termsheet_id": str(d.chosen_item_termsheet_id) if d.chosen_item_termsheet_id else None, "project_id": str(d.project_id) if d.project_id else None, # v2.6: the single purchased project + "project": project_brief(project) if project is not None else None, "adjusted_unit_price": float(d.adjusted_unit_price) if d.adjusted_unit_price is not None else None, "decided_at": to_iso(d.decided_at), } @@ -313,6 +314,7 @@ class _LineItemData: self.item_termsheets_by_line: dict[uuid.UUID, list[ItemTermsheet]] = {} self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {} self.projects_by_line: dict[uuid.UUID, list[Project]] = {} + self.projects_by_id: dict[uuid.UUID, Project] = {} async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], expand: set[str]) -> _LineItemData: @@ -375,6 +377,13 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp if "decision" in expand or "receipt" in expand: decisions = (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all() data.decisions = {d.line_item_id: d for d in decisions} + dec_proj_ids = {d.project_id for d in decisions if d.project_id is not None} + missing_pids = dec_proj_ids - set(proj_map.keys()) + if missing_pids: + extra_projs = (await db.execute(select(Project).where(Project.id.in_(missing_pids)))).scalars().all() + for p in extra_projs: + proj_map[p.id] = p + data.projects_by_id = proj_map dec_ids = [d.id for d in decisions] if dec_ids: receipts = (await db.execute(select(ItemReceipt).where(ItemReceipt.item_decision_id.in_(dec_ids)))).scalars().all() @@ -469,8 +478,10 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc data.specs_by_line.get(li.id, []), data.attr_defs, data.units, include_data_type=False ) decision = data.decisions.get(li.id) + dec_proj = data.projects_by_id.get(decision.project_id) if decision and decision.project_id else None + d["target_project"] = project_brief(dec_proj) if dec_proj is not None else None if "decision" in expand: - d["decision"] = decision_brief(decision) if decision is not None else None + d["decision"] = decision_brief(decision, dec_proj) if decision is not None else None # chosen_item_termsheet_id یک id از جدول item_termsheets است؛ برای مقایسه با # termsheet_ids در جلوی برنامه، id ترم‌شیت واقعی هم ارسال می‌شود. if decision is not None and decision.chosen_item_termsheet_id is not None: diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index e7fa272..6c41c34 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -279,13 +279,23 @@ async def update_quote(db: AsyncSession, quote_id: UUID, payload: QuoteUpdate, a return quote +async def quote_has_receipts(db: AsyncSession, quote_id: UUID) -> bool: + """Returns True if any warehouse receipt has been posted for the quote's items.""" + count = await db.scalar( + select(func.count(ItemReceipt.id)) + .join(ItemDecision, ItemReceipt.item_decision_id == ItemDecision.id) + .join(LineItem, ItemDecision.line_item_id == LineItem.id) + .where(LineItem.quote_id == quote_id, ItemReceipt.received_qty > 0) + ) + return (count or 0) > 0 + + async def replace_quote_children( db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None ) -> Quote: """Full replacement of a quote's line items + termsheets (edit modal). - Only allowed while the quote is still offered — acceptance data - (decisions/receipts) would otherwise be destroyed. + Only allowed while the quote is still offered and has no warehouse receipts. """ async with db.begin(): quote = await get_quote_or_404(db, quote_id) @@ -294,6 +304,11 @@ async def replace_quote_children( "برای تغییر اقلام یا شرایط پرداخت، ابتدا وضعیت را به «پیشنهاد شده» برگردانید", field="status", ) + if await quote_has_receipts(db, quote.id): + raise ConflictError( + "این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.", + field="status", + ) # children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps await db.execute( delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False) @@ -312,6 +327,12 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s if new_status not in BACKWARD_TRANSITIONS.get(current, set()): raise ConflictError("انتقال وضعیت مجاز نیست — فقط انتقال‌های عقب‌گرد پذیرفته می‌شود", field="status") + if await quote_has_receipts(db, quote.id): + raise ConflictError( + "این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.", + field="status", + ) + if new_status == "offered": # Reopen: clear lifecycle dates, demote accepted termsheets, reset decisions. quote.accepted_date = None @@ -332,10 +353,11 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s chosen_item_termsheet_id=None, adjusted_unit_price=None, adjusted_notes=None, + project_id=None, ) .execution_options(synchronize_session=False) ) - # item_receipts are deliberately NOT deleted (contract P0-17). + # LineItemProjects (candidate projects) are preserved untouched! elif new_status == "accepted": # arrived -> accepted quote.arrived_date = None @@ -692,7 +714,8 @@ async def expire_quote(db: AsyncSession, quote_id: UUID, actor: str | None = Non async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict: - async with db.begin(): + tx = db.begin_nested() if db.in_transaction() else db.begin() + async with tx: quote = await get_quote_or_404(db, quote_id) if quote.status in ("accepted", "arrived"): raise ConflictError("پیشنهادهای پذیرفته‌شده یا رسیده قابل حذف نیستند") @@ -712,6 +735,17 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict: termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all() events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all() + # clean up attachments in MinIO before cascade + from app.models.files import QuoteAttachment + from app.services import storage + + attachments = (await db.execute(select(QuoteAttachment).where(QuoteAttachment.quote_id == quote.id))).scalars().all() + for att in attachments: + try: + await storage.remove_object(att.object_key) + except Exception: + logger.warning("Failed to remove MinIO object %s for attachment %s", att.object_key, att.id) + await db.delete(quote) return { "id": str(quote.id), @@ -722,6 +756,7 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict: "events": len(events), "decisions": len(decisions), "receipts": len(receipts), + "attachments": len(attachments), }, } diff --git a/frontend/src/components/JalaliDatePicker.tsx b/frontend/src/components/JalaliDatePicker.tsx index 97d66d1..2d4ab5c 100644 --- a/frontend/src/components/JalaliDatePicker.tsx +++ b/frontend/src/components/JalaliDatePicker.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { daysInMonth, gregorianToJalali, @@ -14,11 +14,12 @@ import { cn } from "../lib/utils"; import { useClickOutside } from "./useClickOutside"; export interface JalaliDatePickerProps { - value: string | null; // ISO date (با time اختیاری) - onChange: (iso: string | null) => void; + value: string | null; // ISO date (یا YYYY-MM-DD با time اختیاری) + onChange: (val: string | null) => void; placeholder?: string; className?: string; withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید) + disabled?: boolean; } function todayJalali(): [number, number, number] { @@ -28,6 +29,13 @@ function todayJalali(): [number, number, number] { function isoToJalali(iso: string | null): [number, number, number] | null { if (!iso) return null; + const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/); + if (m) { + const gy = parseInt(m[1], 10); + const gm = parseInt(m[2], 10); + const gd = parseInt(m[3], 10); + return gregorianToJalali(gy, gm, gd); + } const d = new Date(iso); if (Number.isNaN(d.getTime())) return null; return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()); @@ -39,6 +47,12 @@ function buildIso(jy: number, jm: number, jd: number, h: number, m: number): str return new Date(gy, gm - 1, gd, h, m).toISOString(); } +// ساخت تاریخ استاندارد YYYY-MM-DD بدون مؤلفه زمان +function buildDateOnly(jy: number, jm: number, jd: number): string { + const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd); + return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`; +} + // وقتی هنوز زمان مشخصی ست نشده، ساعت/دقیقه‌ی فعلی سیستم پیش‌فرض است // (نه ۰۰:۰۰) — دکمه‌ی «امروز» و انتخاب روز، همین زمان را نگه می‌دارند. function nowTime(): { h: number; m: number } { @@ -60,6 +74,7 @@ export function JalaliDatePicker({ placeholder, className, withTime = false, + disabled = false, }: JalaliDatePickerProps) { const [open, setOpen] = useState(false); const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali()); @@ -74,11 +89,35 @@ export function JalaliDatePicker({ return `${j} ${faNum(hh + ":" + mm)}`; }); + useEffect(() => { + if (!value) { + setText(""); + return; + } + const j = isoToJalaliStr(value); + if (!withTime) { + setText(j); + } else { + const d = new Date(value); + if (Number.isNaN(d.getTime())) { + setText(j); + } else { + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + setText(`${j} ${faNum(hh + ":" + mm)}`); + } + } + const jParts = isoToJalali(value); + if (jParts) setCurJ(jParts); + }, [value, withTime]); + const ref = useClickOutside(() => setOpen(false)); // تاریخ انتخاب‌شده به صورت محلی (نه UTC) برای مقایسه با سلول‌های تقویم function selectedLocalDate(): string | null { if (!value) return null; + const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/); + if (m) return `${m[1]}-${m[2]}-${m[3]}`; const d = new Date(value); if (Number.isNaN(d.getTime())) return null; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; @@ -124,9 +163,11 @@ export function JalaliDatePicker({ }; const selectDay = (jy: number, jm: number, jd: number) => { - const isoWithTime = buildIso(jy, jm, jd, time.h, time.m); - onChange(isoWithTime); - setText(`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(time.h).padStart(2, "0") + ":" + String(time.m).padStart(2, "0")) : ""}`); + const val = withTime ? buildIso(jy, jm, jd, time.h, time.m) : buildDateOnly(jy, jm, jd); + onChange(val); + setText( + `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(time.h).padStart(2, "0") + ":" + String(time.m).padStart(2, "0")) : ""}` + ); setOpen(false); }; @@ -145,11 +186,10 @@ export function JalaliDatePicker({ const pickToday = () => { const now = new Date(); const [jy, jm, jd] = todayJalali(); - // بدون withTime فقط تاریخ امروز ثبت می‌شود — ساعت همیشه نیمه‌شب - const isoWithTime = withTime + const val = withTime ? buildIso(jy, jm, jd, now.getHours(), now.getMinutes()) - : buildIso(jy, jm, jd, 0, 0); - onChange(isoWithTime); + : buildDateOnly(jy, jm, jd); + onChange(val); setText( withTime ? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(now.getHours()).padStart(2, "0") + ":" + String(now.getMinutes()).padStart(2, "0"))}` @@ -168,7 +208,19 @@ export function JalaliDatePicker({ setText(v); const iso = jalaliStrToIso(v); if (iso) { - onChange(iso); + if (!withTime) { + const nums = String(v) + .split(/[/\-.]/) + .map((x) => parseInt(String(x).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10)); + if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) { + const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]); + onChange(`${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`); + } else { + onChange(iso.slice(0, 10)); + } + } else { + onChange(iso); + } const j = isoToJalali(iso); if (j) setCurJ([j[0], j[1], j[2]]); } @@ -178,15 +230,16 @@ export function JalaliDatePicker({
setOpen(true)} - onClick={() => setOpen(true)} + onFocus={() => !disabled && setOpen(true)} + onClick={() => !disabled && setOpen(true)} onChange={(e) => onInputChange(e.target.value)} /> - {open && ( + {open && !disabled && (
void; onRemove: () => void; + disabled?: boolean; } // یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف -export function MilestoneRow({ milestone, onChange, onRemove }: MilestoneRowProps) { +export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) { return (
onChange({ ...milestone, when_iso: iso })} placeholder="تاریخ" className="w-36 shrink-0" + disabled={disabled} />
onChange({ ...milestone, amount })} placeholder="مبلغ (تومان)" showWords + disabled={disabled} />
- + {!disabled && ( + + )}
); } diff --git a/frontend/src/components/MultiSelectCombobox.tsx b/frontend/src/components/MultiSelectCombobox.tsx index 4855743..f6b44a8 100644 --- a/frontend/src/components/MultiSelectCombobox.tsx +++ b/frontend/src/components/MultiSelectCombobox.tsx @@ -12,6 +12,7 @@ export interface MultiSelectComboboxProps { className?: string; inputClassName?: string; allowCreate?: boolean; // اجازه‌ی تایپ مقدار جدید (پیش‌فرض true) + disabled?: boolean; } // چند-انتخابی خلاق (creatable multi-select) با چیپ‌ها: @@ -27,6 +28,7 @@ export function MultiSelectCombobox({ className, inputClassName, allowCreate = true, + disabled = false, }: MultiSelectComboboxProps) { const [text, setText] = useState(""); const [open, setOpen] = useState(false); @@ -105,46 +107,56 @@ export function MultiSelectCombobox({ return (
inputRef.current?.focus()} + className={cn( + "flex min-h-9 w-full flex-wrap items-center gap-1 rounded-lg border border-line bg-panel px-2 py-1 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/40", + disabled && "cursor-not-allowed opacity-60 bg-canvas/40", + )} + onClick={() => !disabled && inputRef.current?.focus()} > {values.map((v) => ( {v} - + {!disabled && ( + + )} ))} { + if (disabled) return; setText(e.target.value); setHighlight(-1); setOpen(true); }} - onFocus={() => setOpen(true)} - onKeyDown={onKeyDown} + onFocus={() => !disabled && setOpen(true)} + onKeyDown={(e) => { + if (disabled) return; + onKeyDown(e); + }} />
- {open && ( + {open && !disabled && (
{listCount === 0 && (
diff --git a/frontend/src/components/NumberInput.tsx b/frontend/src/components/NumberInput.tsx index 3629c88..9187767 100644 --- a/frontend/src/components/NumberInput.tsx +++ b/frontend/src/components/NumberInput.tsx @@ -10,6 +10,7 @@ export interface NumberInputProps { showWords?: boolean; // نمایش معادل فارسی زیر input inputMode?: "numeric" | "decimal" | undefined; maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد) + disabled?: boolean; } // ورودی عددی با فرمت‌دهی خودکار (groupCommas) و نمایش کلمات فارسی @@ -21,6 +22,7 @@ export function NumberInput({ showWords = false, inputMode = "numeric", maxDecimals, + disabled = false, }: NumberInputProps) { // فرمت با رعایت حداکثر اعشار (گرد کردن) const formatValue = (n: number): string => { @@ -62,7 +64,8 @@ export function NumberInput({
({ id: p.id, name: p.name })), + target_project: targetProj ? { id: targetProj.id, name: targetProj.name } : null, + target_project_id: raw.decision?.project_id || targetProj?.id || null, }; } @@ -662,10 +668,10 @@ function adaptAttachment(raw: RawAttachment): Attachment { function toDateOnly(v: string | null | undefined): string | null { if (!v) return null; - const m = String(v).match(/^(\d{4}-\d{2}-\d{2})/); - if (m) return m[1]; + const str = String(v).trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str; // datetime کامل → تاریخ به وقت محلی (نه UTC که یک روز عقب می‌افتد) - const d = new Date(v); + const d = new Date(str); if (Number.isNaN(d.getTime())) return null; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } @@ -855,23 +861,17 @@ export const api = { if (becomingAccepted) { const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted"); - // تخصیص پروژه به هر قلم — نام‌ها به id واقعی ترجمه می‌شوند - const itemProjects: { line_item_id: string; project_ids: string[] }[] = []; - const allProjectIds: string[] = []; - const seenPids = new Set(); + // پروژه‌ی واحدِ انتخاب‌شده برای هر قلم (item_decisions.project_id) + const targetProjByLine = new Map(); for (const li of patch.line_items || []) { - const ids: string[] = []; - for (const p of li.projects || []) { - const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name); - if (!pid) continue; - ids.push(pid); - if (!seenPids.has(pid)) { - seenPids.add(pid); - allProjectIds.push(pid); - } + 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); } - itemProjects.push({ line_item_id: li.id, project_ids: ids }); } + const allProjectIds = [...new Set([...targetProjByLine.values()])]; + const payload = { accepted_termsheets: acceptedTs.map((t) => ({ id: looksLikeUuid(t.id) ? t.id : undefined, @@ -887,11 +887,7 @@ export const api = { item_decisions: (patch.line_items || []).map((li) => { const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1; const chosen = !li.rejected && idx >= 0; - // پروژه‌ی واحدِ انتخاب‌شده برای این قلم — مستقیم داخل decision می‌رود تا - // «کالاهای در راه» همان مدیر پروژه را (با item_decisions.project_id) - // نشان دهد؛ قبلاً فقط item_projects جداگانه می‌رفت و decision بدون - // پروژه می‌ماند (قلم‌ها در پنل هیچ مدیر پروژه‌ای دیده نمی‌شدند) - const chosenProjId = itemProjects.find((p) => p.line_item_id === li.id)?.project_ids[0]; + const chosenProjId = targetProjByLine.get(li.id); return { line_item_id: li.id, status: chosen ? "chosen" : "rejected", @@ -900,7 +896,7 @@ export const api = { ...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}), }; }), - item_projects: itemProjects, + item_projects: [], // کاندیداهای قبلی در line_item_projects کاملاً حفظ می‌شوند }; await patchData(`/quotes/${id}/accept`, payload); // پروژه‌های سطح پیشنهاد با اتحاد پروژه‌های اقلام همگام می‌شوند diff --git a/frontend/src/lib/quoteUtils.ts b/frontend/src/lib/quoteUtils.ts index 647f184..455e351 100644 --- a/frontend/src/lib/quoteUtils.ts +++ b/frontend/src/lib/quoteUtils.ts @@ -40,20 +40,58 @@ export function specsLabel(specs: string[]): string { return specs.join(" · "); } -// نام نمایشی متریال — نام سراسری واقعی (materials.name)، نه دسته. -// برند و مشخصه فقط وقتی به نام اضافه می‌شوند که داخلش نیامده باشند (بدون تکرار). -export function materialLabel(m: Pick): string { - const name = (m.name || "").trim(); - const specs = (m.specs || []).filter(Boolean); - if (!name && !m.brand && !specs.length) return m.category === "؟" ? "؟" : "کالا"; - if (!name) { - return [m.brand, ...specs].filter(Boolean).join(" ") || "کالا"; +/** + * عنوان تمیز و بدون تکرار کالا: + * ترکیب هوشمندانه‌ی نام اصلی (materials.name)، برند، و مشخصه‌ها بدون تکرار کلمات مشترک. + */ +export function formatMaterialTitle( + m: Pick | null | undefined, + extraSpecs?: string[], +): string { + if (!m) return "کالا"; + const rawName = (m.name || "").trim(); + const rawBrand = (m.brand || "").trim(); + const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])] + .map((s) => (s || "").trim()) + .filter(Boolean); + + const normName = normalize(rawName); + const normBrand = normalize(rawBrand); + + if (!rawName) { + if (rawBrand && rawSpecs.length) return `${rawBrand} · ${rawSpecs.join(" · ")}`; + if (rawBrand) return rawBrand; + if (rawSpecs.length) return rawSpecs.join(" · "); + return m.category && m.category !== "؟" ? m.category : "کالا"; } - const parts = [name]; - if (m.brand && !name.includes(m.brand)) parts.push(m.brand); - const missing = specs.filter((s) => !name.includes(s)); - if (missing.length) parts.push(`(${missing.join("، ")})`); - return parts.join(" "); + + let title = rawName; + if (normBrand && !normName.includes(normBrand)) { + title = `${rawBrand} ${title}`; + } + + const normTitle = normalize(title); + const distinctSpecs: string[] = []; + const seenNormSpecs = new Set(); + + for (const s of rawSpecs) { + const ns = normalize(s); + if (!ns || seenNormSpecs.has(ns)) continue; + seenNormSpecs.add(ns); + + // اگر مشخصه قبلاً داخل عنوان یا نام کالا آمده باشد، تکرار نمی‌کنیم + if (normTitle.includes(ns)) continue; + distinctSpecs.push(s); + } + + if (distinctSpecs.length > 0) { + return `${title} · ${distinctSpecs.join(" · ")}`; + } + return title; +} + +export function materialLabel(m: Pick | null | undefined): string { + return formatMaterialTitle(m); } // آرایه‌ی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید) @@ -104,8 +142,21 @@ export function lineItemTotal(li: LineItem): number | null { return null; } -// پروژه‌های یک قلم — فقط پروژه‌های تخصیص‌یافته به همین قلم (بدون fallback به سطح پیشنهاد) -export function itemProjects(_q: Quote, li: LineItem): string[] { +// پروژه‌های یک قلم: +// - در وضعیت offered: تمام پروژه‌های کاندیدا (line_item_projects) +// - در وضعیت accepted یا arrived: فقط تک پروژه‌ی انتخاب‌شده در خرید (item_decisions.project_id) +export function itemProjects(q: Quote, li: LineItem): string[] { + if (q.status === "accepted" || q.status === "arrived") { + if (li.target_project?.name) { + return [li.target_project.name]; + } + // اگر target_project هنوز آبجکت نام نداشت، بررسی نام در صورت تک‌پروژه بودن + if (li.projects && li.projects.length === 1) { + const p = li.projects[0]; + return [typeof p === "string" ? p : p.name]; + } + return []; + } return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)); } @@ -163,8 +214,24 @@ export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: stri return []; } -// ستون پروژه +// ستون پروژه: +// در وضعیت‌های پذیرفته‌شده یا رسیده، فقط پروژه‌های هدف خرید اقلام نشان داده می‌شوند export function projectCell(q: Quote): string { + if (q.status === "accepted" || q.status === "arrived") { + const targets = (q.line_items || []) + .filter((li) => !li.rejected) + .map((li) => + li.target_project?.name || + (li.projects && li.projects.length === 1 + ? typeof li.projects[0] === "string" + ? li.projects[0] + : li.projects[0].name + : null), + ) + .filter(Boolean) as string[]; + const unique = [...new Set(targets)]; + if (unique.length) return unique.join("، "); + } if (q.projects && q.projects.length) return q.projects.join("، "); return q.project || "—"; } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 18534f6..49a2a38 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -71,7 +71,9 @@ export interface LineItem { arrived_date: string | null; // تاریخ آخرین رسید received_qty?: number | null; // مجموع رسیدهای ثبت‌شده (رسید جزیی) — null = هنوز داده‌ای نیست received_warehouses?: { id: string; name: string; quantity: number }[] | null; // انبارهای مقصد رسیدها - projects?: { id: string; name: string }[]; // پروژه‌های تخصیص‌یافته به این قلم + projects?: { id: string; name: string }[]; // پروژه‌های کاندیدای تخصیص‌یافته به این قلم (line_item_projects) + target_project?: { id: string; name: string } | null; // پروژه‌ی نهایی انتخاب‌شده در هنگام خرید (item_decisions.project_id) + target_project_id?: string | null; } // پیشنهاد خرید (اصلی‌ترین entity) diff --git a/frontend/src/pages/Dashboard/AcceptModal.tsx b/frontend/src/pages/Dashboard/AcceptModal.tsx index 88af99e..d9dccca 100644 --- a/frontend/src/pages/Dashboard/AcceptModal.tsx +++ b/frontend/src/pages/Dashboard/AcceptModal.tsx @@ -5,11 +5,9 @@ import { api, nextDashedId } from "../../lib/api"; import { useMaterials, useProjects, useQuotes, useUpdateQuote, useSuppliers } from "../../hooks"; import { activeItems, + formatMaterialTitle, itemTermsheetIds, materialById, - materialLabel, - materialSpecs, - specsLabel, supplierById, } from "../../lib/quoteUtils"; import { faNum, fmtNum } from "../../lib/formatters"; @@ -76,11 +74,13 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { const projSel: Record = {}; q.line_items.forEach((li) => { const names = (li.projects || []).map((p) => p.name).filter(Boolean); - projSel[li.id] = names.length - ? names[0] - : q.projects && q.projects.length - ? q.projects[0] - : q.project || ""; + projSel[li.id] = li.target_project?.name + ? li.target_project.name + : names.length + ? names[0] + : q.projects && q.projects.length + ? q.projects[0] + : q.project || ""; }); setItemProjectsSel(projSel); setReady(true); @@ -219,17 +219,20 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered"; }); - // پروژه‌ی هر قلم (تکی) + همگام‌سازی پروژه‌های سطح پیشنهاد + // پروژه‌ی هر قلم (تکی هدف خرید) + همگام‌سازی پروژه‌های سطح پیشنهاد const projUnion = new Set(); next.line_items.forEach((li) => { if (li.rejected) return; const name = itemProjectsSel[li.id]; if (name === undefined) return; - li.projects = name ? [{ id: "", name }] : []; + li.target_project = name ? { id: "", name } : null; + // توجه: li.projects دست‌نخورده باقی می‌ماند تا پروژه‌های کاندیدای اولیه حفظ شوند if (name) projUnion.add(name); }); - next.projects = [...projUnion]; - next.project = next.projects[0] || ""; + if (projUnion.size > 0) { + next.projects = [...projUnion]; + next.project = next.projects[0] || ""; + } const now = new Date().toISOString(); next.accepted_date = acceptDate || now; @@ -298,7 +301,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { {allItems.map((li) => { const removed = removedItems.has(li.id); const m = materialById(materials, li.material_id); - const specText = specsLabel(materialSpecs(m)); return (
- {materialLabel(m)} - {specText ? ` · ${specText}` : ""} + {formatMaterialTitle(m)} {fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)} @@ -351,14 +352,12 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { .filter((li) => !removedItems.has(li.id)) .map((li) => { const m = materialById(materials, li.material_id); - const specText = specsLabel(materialSpecs(m)); return (
- {materialLabel(m)} + {formatMaterialTitle(m)} - {specText ? · {specText} : ""} {fmtNum(li.quantity)} {li.unit} diff --git a/frontend/src/pages/Dashboard/ArriveModal.tsx b/frontend/src/pages/Dashboard/ArriveModal.tsx index e48a3c3..db9760e 100644 --- a/frontend/src/pages/Dashboard/ArriveModal.tsx +++ b/frontend/src/pages/Dashboard/ArriveModal.tsx @@ -3,7 +3,7 @@ import { toast } from "sonner"; import { Package } from "lucide-react"; import type { Quote } from "../../lib/types"; import { useMaterials, useQuotes, useSuppliers, useUpdateQuote } from "../../hooks"; -import { activeItems, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils"; +import { activeItems, formatMaterialTitle, materialById, supplierById } from "../../lib/quoteUtils"; import { fmtNum } from "../../lib/formatters"; import { groupCommas } from "../../lib/numutil"; import { Button } from "../../components/ui/button"; @@ -86,7 +86,6 @@ export function ArriveModal({ quoteId, onClose }: ArriveModalProps) {
{items.map((li) => { const m = materialById(materials, li.material_id); - const specText = materialSpecs(m).join(" · "); return (