import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import type { Attachment, Milestone, Quote } from "../../lib/types"; import { api, nextDashedId } from "../../lib/api"; import { useMaterials, useProjects, useQuotes, useUpdateQuote, useSuppliers } from "../../hooks"; import { activeItems, formatMaterialTitle, itemTermsheetIds, materialById, supplierById, } from "../../lib/quoteUtils"; import { faNum, fmtNum } from "../../lib/formatters"; import { groupCommas } from "../../lib/numutil"; import { Button } from "../../components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "../../components/ui/dialog"; import { MilestoneRow } from "../../components/MilestoneRow"; import { FileList } from "../../components/FileList"; import { JalaliDatePicker } from "../../components/JalaliDatePicker"; interface CardDraft { domId: string; isNew: boolean; milestones: Milestone[]; label: string | null; // null = پیش‌فرض «گزینه N» } export interface AcceptModalProps { quoteId: string | null; onClose: () => void; } // مودال پذیرش: انتخاب/ویرایش شرایط پرداخت + تخصیص قلم‌ها + فاکتورها export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { const quotes = useQuotes(); const suppliers = useSuppliers(); const materials = useMaterials(); const projects = useProjects(); const updateQuote = useUpdateQuote(); const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]); const [cards, setCards] = useState([]); const [invoices, setInvoices] = useState([]); const [proformas, setProformas] = useState([]); const [removedItems, setRemovedItems] = useState>(new Set()); const [assignments, setAssignments] = useState>({}); // تخصیص هر قلم به پروژه‌ها (نام‌ها) const [itemProjectsSel, setItemProjectsSel] = useState>({}); // تاریخ و زمان پذیرش — پیش‌فرض الان const [acceptDate, setAcceptDate] = useState(() => new Date().toISOString()); const [ready, setReady] = useState(false); // وقتی quote باز شد، state اولیه را بساز useMemo(() => { if (!q) return; const offered = q.termsheets.filter((t) => t.status === "offered"); setCards( offered.map((t) => ({ domId: t.id, isNew: false, milestones: JSON.parse(JSON.stringify(t.milestones)), label: t.label })), ); const rem = new Set(); q.line_items.forEach((li) => { if (li.rejected) rem.add(li.id); }); setRemovedItems(rem); setAssignments({}); // پروژه‌ی هر قلم — تخصیصِ تکی: از تخصیص قبلی قلم، وگرنه اولین پروژه‌ی پیشنهاد const projSel: Record = {}; q.line_items.forEach((li) => { const names = (li.projects || []).map((p) => p.name).filter(Boolean); 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); }, [q]); // پیوست‌ها مستقیماً از سرور (MinIO) خوانده می‌شوند — نه از state محلی useEffect(() => { if (!quoteId) return; let alive = true; void api.files .list(quoteId, "invoice") .then((list) => alive && setInvoices(list)) .catch(() => alive && setInvoices([])); void api.files .list(quoteId, "proforma") .then((list) => alive && setProformas(list)) .catch(() => alive && setProformas([])); return () => { alive = false; }; }, [quoteId]); if (!q || !ready) return null; const sup = supplierById(suppliers, q.supplier_id); const allItems = q.line_items || []; const active = activeItems(q); // گزینه‌های پروژه — پروژه‌های ثبت‌شده + پروژه‌های این پیشنهاد + تخصیص‌های قبلی قلم‌ها const projectOptions = (() => { const set = new Set(); projects.forEach((p) => p.name && set.add(p.name)); (q.projects || []).forEach((n) => set.add(n)); if (q.project) set.add(q.project); allItems.forEach((li) => (li.projects || []).forEach((p) => p.name && set.add(p.name))); return [...set].sort((a, b) => a.localeCompare(b, "fa")); })(); const cardLabel = (c: CardDraft, idx: number) => c.label || `گزینه ${faNum(idx + 1)}`; const updateCard = (domId: string, patch: Partial) => { setCards((cs) => cs.map((c) => (c.domId === domId ? { ...c, ...patch } : c))); }; const addNewCard = () => { setCards((cs) => [...cs, { domId: "new-ts-" + Date.now(), isNew: true, milestones: [{ when_iso: null, amount: null }], label: null }]); }; const removeCard = (domId: string) => { setCards((cs) => cs.filter((c) => c.domId !== domId)); setAssignments((a) => { const next: Record = {}; Object.entries(a).forEach(([liId, dom]) => { if (dom !== domId) next[liId] = dom; }); return next; }); }; const assign = (liId: string, domId: string) => { setAssignments((a) => ({ ...a, [liId]: domId })); }; const confirm = () => { if (!q) return; // پچ نهایی — همه‌ی تغییرات روی همین کلون اعمال می‌شود (نه روی آبجکت store) const next: Quote = JSON.parse(JSON.stringify(q)); // فقط کارت‌هایی با حداقل یک مرحله‌ی پرداخت const cardMeta = new Map(); cards.forEach((c, idx) => { const milestones = c.milestones.filter((m) => m.when_iso || m.amount != null); if (!milestones.length) return; const existing = next.termsheets.find((t) => t.id === c.domId); cardMeta.set(c.domId, { milestones, label: c.label || `گزینه ${faNum(idx + 1)}`, existing, }); }); if (!cardMeta.size) { toast.error("حداقل یک conditions با حداقل یک مرحله پرداخت لازم است."); return; } // رد کردن قلم‌ها — در داده می‌مانند تا با بازگشت وضعیت برگردند next.line_items.forEach((li) => { if (removedItems.has(li.id)) li.rejected = true; }); const active = activeItems(next); if (!active.length) { toast.error("همه‌ی قلم‌ها رد شده‌اند؛ حداقل یک قلم لازم است."); return; } // فقط termsheetهایی که حداقل یک قلم انتخاب کرده پذیرفته می‌شوند const fallbackDom = [...cardMeta.keys()][0]; const chosenDomIds = new Set(); active.forEach((li) => { const dom = assignments[li.id] || fallbackDom; if (dom && cardMeta.has(dom)) chosenDomIds.add(dom); }); const domToFinal: Record = {}; chosenDomIds.forEach((dom) => { const meta = cardMeta.get(dom)!; if (meta.existing) { meta.existing.milestones = meta.milestones; meta.existing.status = "accepted"; domToFinal[dom] = dom; } else { const newId = nextDashedId("ts", next.termsheets); next.termsheets.push({ id: newId, label: meta.label, milestones: meta.milestones, status: "accepted" }); domToFinal[dom] = newId; } }); // قلم‌ها: اتصال‌های offered حفظ می‌شوند + جفت پذیرفته‌شده ثبت می‌شود active.forEach((li) => { const dom = assignments[li.id] || fallbackDom; const finalId = domToFinal[dom]; const current = itemTermsheetIds(li).filter((tid) => next.termsheets.some((t) => t.id === tid)); if (finalId && !current.includes(finalId)) current.push(finalId); li.termsheet_ids = current.length ? current : finalId ? [finalId] : current; li.accepted_ts_id = finalId || null; }); // termsheet پذیرفته‌شده‌ای که هیچ قلمی انتخابش نکرده → برمی‌گردد به offered const usedIds = new Set(); active.forEach((li) => { if (li.accepted_ts_id) usedIds.add(li.accepted_ts_id); }); next.termsheets.forEach((t) => { 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.target_project = name ? { id: "", name } : null; // توجه: li.projects دست‌نخورده باقی می‌ماند تا پروژه‌های کاندیدای اولیه حفظ شوند if (name) projUnion.add(name); }); if (projUnion.size > 0) { next.projects = [...projUnion]; next.project = next.projects[0] || ""; } const now = new Date().toISOString(); next.accepted_date = acceptDate || now; next.status = "accepted"; next.events.push({ type: "accepted", occurred_at: now }); next.invoices = invoices; next.proformas = proformas; void updateQuote.mutateAsync({ id: q.id, patch: next }); setReady(false); onClose(); }; return ( !o && onClose()}> پذیرش پیشنهاد — {sup.name}
شرایط پرداخت را انتخاب یا ویرایش کنید. سپس هر قلم را به conditions دلخواه تخصیص دهید.
{/* کارت‌های termsheet */}
{cards.map((c, idx) => (
{cardLabel(c, idx)}
{c.milestones.map((m, i) => ( { const ms = [...c.milestones]; ms[i] = nextM; updateCard(c.domId, { milestones: ms }); }} onRemove={() => updateCard(c.domId, { milestones: c.milestones.filter((_, j) => j !== i) })} /> ))}
))}
{/* تخصیص قلم‌ها */}
تخصیص قلم‌ها به conditions:
قلمی که می‌خرید را تیک بزنید؛ قلمی که نمی‌خرید را بدون تیک رها کنید تا از معامله حذف شود.
{allItems.map((li) => { const removed = removedItems.has(li.id); const m = materialById(materials, li.material_id); return (
{ const next = new Set(removedItems); if (e.target.checked) next.delete(li.id); else next.add(li.id); setRemovedItems(next); }} /> {formatMaterialTitle(m)} {fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
); })} {active.length === 0 && (
همه‌ی قلم‌ها رد شده‌اند — حداقل یک قلم را تیک بزنید.
)}
{/* تخصیص قلم‌ها به پروژه */}
پروژه‌ی هر قلم:
برای هر متریال فقط یک پروژه انتخاب می‌شود — مثل شرایط پرداخت.
{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 ? ( ) : (
پروژه‌ای ثبت نشده است.
)}
); })}
{/* فاکتورهای اصلی */}
فاکتورهای اصلی (چند فایل):
api.files.upload(q.id, "invoice", f)} onDelete={(a) => a.id && void api.files.remove(q.id, "invoice", a.id).catch((err) => toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"), ) } urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)} />
{/* پیش‌فاکتورها */}
پیش‌فاکتورها (چند فایل):
api.files.upload(q.id, "proforma", f)} onDelete={(a) => a.id && void api.files.remove(q.id, "proforma", a.id).catch((err) => toast.error(err instanceof Error ? err.message : "حذف پیش‌فاکتور ناموفق بود"), ) } urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "proforma", a.id) : null)} label="افزودن پیش‌فاکتور" />
{/* تاریخ و زمان پذیرش */}
); }