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"; import { Building2, Plus, Trash2, CheckCircle2, AlertCircle } from "lucide-react"; // تبدیل عدد به حروف فارسی جهت نمایش زیر باکس مقدار const ONES = ["", "یک", "دو", "سه", "چهار", "پنج", "شش", "هفت", "هشت", "نه"]; const TEENS = [ "ده", "یازده", "دوازده", "سیزده", "چهارده", "پانزده", "شانزده", "هفده", "هجده", "نوزده", ]; const TENS = [ "", "", "بیست", "سی", "چهل", "پنجاه", "شصت", "هفتاد", "هشتاد", "نود", ]; const HUNDREDS = [ "", "صد", "دویست", "سیصد", "چهارصد", "پانصد", "ششصد", "هفتصد", "هشتصد", "نهصد", ]; const SCALES = ["", "هزار", "میلیون", "میلیارد", "تریلیون"]; const DECIMAL_SCALES: Record = { 1: "دهم", 2: "صدم", 3: "هزارم", 4: "ده‌هزارم", }; function convertThreeDigits(n: number): string { if (n === 0) return ""; const parts: string[] = []; const h = Math.floor(n / 100); const rem = n % 100; if (h > 0) parts.push(HUNDREDS[h]); if (rem >= 10 && rem <= 19) { parts.push(TEENS[rem - 10]); } else { const t = Math.floor(rem / 10); const u = rem % 10; if (t > 0) parts.push(TENS[t]); if (u > 0) parts.push(ONES[u]); } return parts.join(" و "); } function toEnglishDigits(str: string): string { return str .replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 1776)) .replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 1632)); } function numberToPersianWords(val: number | string | null | undefined): string { if (val == null || val === "") return ""; const numStr = toEnglishDigits(String(val).trim()); const num = parseFloat(numStr); if (isNaN(num)) return ""; if (num === 0) return "صفر"; const isNeg = num < 0; const parts = Math.abs(num).toString().split("."); const intStr = parts[0]; const decStr = parts[1]; let intNum = parseInt(intStr, 10); let intResult = ""; if (intNum === 0) { intResult = "صفر"; } else { const chunks: number[] = []; while (intNum > 0) { chunks.push(intNum % 1000); intNum = Math.floor(intNum / 1000); } const chunkWords: string[] = []; for (let i = chunks.length - 1; i >= 0; i--) { const c = chunks[i]; if (c > 0) { const w = convertThreeDigits(c); const s = SCALES[i]; chunkWords.push(s ? `${w} ${s}` : w); } } intResult = chunkWords.join(" و "); } if (decStr && parseInt(decStr, 10) > 0) { const cleanDecStr = decStr.slice(0, 4); const decNum = parseInt(cleanDecStr, 10); const decWord = numberToPersianWords(decNum); const scale = DECIMAL_SCALES[cleanDecStr.length]; if (scale) { if (intNum === 0) { return `${isNeg ? "منفی " : ""}${decWord} ${scale}`; } return `${isNeg ? "منفی " : ""}${intResult} و ${decWord} ${scale}`; } return `${isNeg ? "منفی " : ""}${intResult} ممیز ${decWord}`; } return isNeg ? `منفی ${intResult}` : intResult; } interface CardDraft { domId: string; isNew: boolean; milestones: Milestone[]; label: string | null; } 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 [itemAllocations, setItemAllocations] = useState< Record >({}); const [acceptDate, setAcceptDate] = useState(() => new Date().toISOString()); const [ready, setReady] = useState(false); 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 = {}; const allocs: Record = {}; q.line_items.forEach((li) => { const names = (li.projects || []).map((p) => p.name).filter(Boolean); 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]); 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 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; 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("حداقل یک شرایط پرداخت با حداقل یک مرحله پرداخت لازم است."); return; } next.line_items.forEach((li) => { if (removedItems.has(li.id)) li.rejected = true; }); const active = activeItems(next); if (!active.length) { toast.error("همه‌ی قلم‌ها رد شده‌اند؛ حداقل یک قلم لازم است."); return; } 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; } }); 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; }); 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"; }); 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 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(); 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}

شرایط پرداخت را ویرایش کنید. سپس هر قلم را به بخش و پروژه دلخواه تخصیص دهید.

{/* بدنه اسکرول‌پذیر */}
{/* بخش ۱: تنظیم شرایط پرداخت */}

۱. تنظیم شرایط پرداخت

{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) })} /> ))}
))}
{/* بخش ۲: تخصیص اقلام */}

۲. تخصیص اقلام به شرایط پرداخت

قلمی که می‌خرید را تیک بزنید؛ قلمی که نمی‌خرید را بدون تیک رها کنید تا از معامله حذف شود.
{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 && (
⚠️ همه‌ی قلم‌ها رد شده‌اند — حداقل یک قلم را تیک بزنید.
)}
{/* بخش ۳: تخصیص اقلام به پروژه‌ها */}

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

مشخص کنید چه مقداری از هر قلم به کدام پروژه اختصاص داده می‌شود. در صورت خرید یک کالا برای چند پروژه، می‌توانید آن را به چند سطر تفکیک کنید.
{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) => { const otherUsedProjects = new Set( allocs .filter((a) => a.rowId !== alloc.rowId) .map((a) => a.project_name.trim()) .filter(Boolean) ); const words = numberToPersianWords(alloc.quantity); return (
{/* انتخاب پروژه و دکمه حذف موبایل */}
{/* دکمه حذف موبایل */}
{/* فیلد مقدار بدون اسپینر + واحد ثابت + متن حروفی */}
updateAllocationRow(li.id, alloc.rowId, { quantity: e.target.value, }) } />
{li.unit}
{words ? (
{words} {li.unit}
) : null}
{/* دکمه حذف دسکتاپ — تراز شده با لبه بالای دراپ‌داون و اینپوت */}
); })}
{/* فوتر کارت قلم */}
مجموع: {fmtNum(allocSum)} از {fmtNum(targetQty)} {li.unit}
); })}
)}
{/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */}

فاکتورهای اصلی

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 : "حذف فاکتور ناموفق بود"), ) } onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)} loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)} />

پیش‌فاکتورها

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 : "حذف پیش‌فاکتور ناموفق بود"), ) } onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)} loadPreview={(a) => api.files.previewUrl(q.id, "proforma", a.id!)} label="افزودن پیش‌فاکتور" />
{/* فوتر ثابت */}
); }