import { useEffect, useMemo, useState } from "react"; import { Trash2, AlertCircle, Plus, Package, CreditCard, Calendar } from "lucide-react"; import { toast } from "sonner"; import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types"; import { api, nextDashedId, nextId } from "../../lib/api"; import { cn } from "../../lib/utils"; import { useMaterials, useProjects, useQuotes, useSuppliers, useUpdateQuote, useDeleteQuote, useAddSupplier, useAddMaterial, } from "../../hooks"; import { itemTermsheetIds, matchMaterial, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils"; import { STATUS_LABEL, statusOptionsFor } from "../../lib/constants"; import { faNum } from "../../lib/formatters"; import { normalize } from "../../lib/normalize"; import { Combobox } from "../../components/Combobox"; import { JalaliDatePicker } from "../../components/JalaliDatePicker"; import { NumberInput } from "../../components/NumberInput"; import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; import { FileList } from "../../components/FileList"; import { TermsheetCard } from "../../components/TermsheetCard"; import { Button } from "../../components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "../../components/ui/dialog"; interface EditItemRow { key: string; origLi?: LineItem; cat: string; brand: string; specs: string[]; qty: number | null; unit: string; price: number | null; total: number | null; tsDomIds: string[]; acceptedTsId: string | null; rejected: boolean; restore: boolean; } interface EditTsCard { key: string; domId: string; milestones: Milestone[]; } export interface EditModalProps { quoteId: string | null; onClose: () => void; } export function EditModal({ quoteId, onClose }: EditModalProps) { const quotes = useQuotes(); const suppliers = useSuppliers(); const materials = useMaterials(); const projects = useProjects(); const updateQuote = useUpdateQuote(); const deleteQuote = useDeleteQuote(); const addSupplier = useAddSupplier(); const addMaterial = useAddMaterial(); const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]); const hasReceipts = useMemo(() => { if (!q) return false; if (q.status === "arrived") return true; return (q.line_items || []).some( (li) => (li.received_qty != null && li.received_qty > 0) || !!li.arrived_date, ); }, [q]); const [ready, setReady] = useState(false); const [activeTab, setActiveTab] = useState<"items" | "terms" | "dates">("items"); const [supplierName, setSupplierName] = useState(""); const [selectedProjects, setSelectedProjects] = useState([]); const [notes, setNotes] = useState(""); const [invoices, setInvoices] = useState([]); const [proformas, setProformas] = useState([]); const [itemRows, setItemRows] = useState([]); const [tsCards, setTsCards] = useState([]); const [quoteDate, setQuoteDate] = useState(null); const [expiryDate, setExpiryDate] = useState(null); const [acceptedDate, setAcceptedDate] = useState(null); const [arrivedDate, setArrivedDate] = useState(null); const [status, setStatus] = useState("offered"); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [deleting, setDeleting] = useState(false); const handleDelete = async () => { if (!q) return; setDeleting(true); try { await deleteQuote.mutateAsync(q.id); toast.success("پیشنهاد خرید با موفقیت حذف شد"); setShowDeleteConfirm(false); onClose(); } catch (err) { toast.error(err instanceof Error ? err.message : "حذف پیشنهاد ناموفق بود"); } finally { setDeleting(false); } }; const [globalSpecs, setGlobalSpecs] = useState([]); useEffect(() => { let alive = true; void api .specsList() .then((list) => alive && setGlobalSpecs(list)) .catch(() => {}); return () => { alive = false; }; }, []); const specOptions = useMemo(() => { const set = new Set(); globalSpecs.forEach((s) => s && set.add(s)); materials.forEach((m) => (m.specs || []).forEach((s) => s && set.add(s))); itemRows.forEach((r) => r.specs.forEach((s) => s && set.add(s))); return [...set].sort((a, b) => a.localeCompare(b, "fa")); }, [materials, itemRows, globalSpecs]); const projectOptions = useMemo(() => { const set = new Set(); projects.forEach((p) => p.name && set.add(p.name)); if (q) { (q.projects || []).forEach((n) => set.add(n)); if (q.project) set.add(q.project); } return [...set].sort((a, b) => a.localeCompare(b, "fa")); }, [projects, q]); useMemo(() => { if (!q) return; const sup = supplierById(suppliers, q.supplier_id); setSupplierName(sup.name); setSelectedProjects(q.projects && q.projects.length ? [...q.projects] : q.project ? [q.project] : []); setNotes(q.notes || ""); setItemRows( q.line_items.map((li) => { const m = materialById(materials, li.material_id); return { key: li.id, origLi: li, cat: m.name || "", brand: m.brand || "", specs: materialSpecs(m), qty: li.quantity, unit: li.unit || "", price: li.unit_price, total: li.total_price, tsDomIds: itemTermsheetIds(li).filter((tid) => q.termsheets.some((t) => t.id === tid)), acceptedTsId: li.accepted_ts_id, rejected: !!li.rejected, restore: true, }; }), ); setTsCards( q.termsheets.map((t) => ({ key: t.id, domId: t.id, milestones: t.milestones.map((m) => ({ ...m })), })), ); setQuoteDate(q.quote_date || null); setExpiryDate(q.expiry_date || null); setAcceptedDate(q.accepted_date || null); setArrivedDate(q.arrived_date || null); setStatus(q.status); setReady(true); }, [q, suppliers, materials]); 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]); const itemOptions = useMemo(() => { return itemRows .filter((r) => !r.rejected || r.restore) .map((r, idx) => ({ tempId: r.key, label: `${faNum(idx + 1)}. ${r.cat || "قلم جدید"}${r.brand ? ` • ${r.brand}` : ""}`, })); }, [itemRows]); const getCoveredIds = (card: EditTsCard) => { const cardId = card.domId || card.key; const explicit = itemRows .filter((r) => (!r.rejected || r.restore) && r.tsDomIds.includes(cardId)) .map((r) => r.key); if (!explicit.length && tsCards[0]?.key === card.key) { return itemRows.filter((r) => !r.rejected || r.restore).map((r) => r.key); } return explicit; }; const handleCoverageChange = (cardKey: string, newCoveredKeys: string[]) => { const card = tsCards.find((c) => c.key === cardKey); const cardId = card?.domId || cardKey; setItemRows((rows) => rows.map((row) => { const shouldHave = newCoveredKeys.includes(row.key); const exists = row.tsDomIds.includes(cardId); if (shouldHave === exists) return row; const nextTsDomIds = shouldHave ? [...row.tsDomIds, cardId] : row.tsDomIds.filter((id) => id !== cardId); return { ...row, tsDomIds: nextTsDomIds }; }), ); }; if (!q || !ready) return null; const patchItem = (key: string, patch: Partial) => { setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r))); }; const addItemRow = () => { const newKey = "new-" + Math.random().toString(36).slice(2, 9); const defaultTsId = tsCards[0] ? tsCards[0].domId || tsCards[0].key : ""; setItemRows((rows) => [ ...rows, { key: newKey, cat: "", brand: "", specs: [], qty: null, unit: "", price: null, total: null, tsDomIds: defaultTsId ? [defaultTsId] : [], acceptedTsId: null, rejected: false, restore: true, }, ]); }; const updateTs = (key: string, patch: Partial) => { setTsCards((cards) => cards.map((c) => (c.key === key ? { ...c, ...patch } : c))); }; const addTsCard = () => { setTsCards((cards) => [ ...cards, { key: "new-ts-" + Date.now() + Math.random().toString(36).slice(2, 6), domId: "", milestones: [{ when_iso: null, amount: null }], }, ]); }; const removeTsCard = (key: string) => { if (tsCards.length <= 1) { toast.error("حداقل یک روش شرایط پرداخت باید وجود داشته باشد."); return; } setTsCards((cards) => cards.filter((c) => c.key !== key)); const card = tsCards.find((c) => c.key === key); const cardId = card?.domId || key; setItemRows((rows) => rows.map((r) => ({ ...r, tsDomIds: r.tsDomIds.filter((id) => id !== cardId && id !== key), })), ); }; const confirm = async () => { if (hasReceipts && status !== q.status) { toast.error("این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است."); return; } const next: Quote = JSON.parse(JSON.stringify(q)); if (supplierName.trim()) { const norm = normalize(supplierName); const exist = suppliers.find((s) => normalize(s.name) === norm); if (exist) { next.supplier_id = exist.id; } else { const newSup = { id: nextId("s", suppliers), name: supplierName.trim(), location: "", phone: "", contact: "", topic: "", categories: [] }; const created = await addSupplier.mutateAsync(newSup); next.supplier_id = created.id; } } next.project = selectedProjects[0] || ""; next.projects = [...selectedProjects]; next.notes = notes.trim(); next.invoices = invoices; next.proformas = proformas; const tsIdMap: Record = {}; const newTermsheets: Termsheet[] = []; tsCards.forEach((card, i) => { const domId = card.domId || "new-ts-" + i; const realId = domId && !domId.startsWith("new-ts-") ? domId : nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets); tsIdMap[domId] = realId; tsIdMap[card.key] = realId; const orig = next.termsheets.find((t) => t.id === realId); const label = orig?.label || `گزینه ${faNum(i + 1)}`; const st = orig?.status || "offered"; newTermsheets.push({ id: realId, label, milestones: card.milestones, status: st }); }); next.termsheets = newTermsheets; const newItems: LineItem[] = []; const materialsCopy = [...materials]; for (const row of itemRows) { const cat = row.cat.trim(); const brand = row.brand.trim(); const specs = row.specs; const unit = row.unit.trim(); const qty = row.qty; const price = row.price; const total = row.total; if (!cat && !brand && qty == null && price == null) continue; let material = matchMaterial(materialsCopy, cat, brand, specs); let materialId: string; if (material) { materialId = material.id; } else { const nm = { id: nextId("m", materialsCopy), name: (cat || brand || specs.join(" ") || "").trim() || "کالای جدید", topic: materialById(materials, row.origLi?.material_id || "").topic || "", brand, specs, base_unit: unit, }; materialsCopy.push(nm); const created = await addMaterial.mutateAsync(nm); materialId = created.id; } const wasRejected = row.rejected; const rejected = wasRejected && !row.restore; const termsheet_ids: string[] = []; let acceptedTsId = row.acceptedTsId; if (!rejected) { row.tsDomIds.forEach((domId) => { const realId = tsIdMap[domId] || domId; if (realId && !termsheet_ids.includes(realId)) termsheet_ids.push(realId); }); if (!termsheet_ids.length && next.termsheets.length) { termsheet_ids.push(next.termsheets[0].id); } if (wasRejected) { const acc = next.termsheets.find((t) => t.status === "accepted"); if (acc) { termsheet_ids.length = 0; termsheet_ids.push(acc.id); } acceptedTsId = acc ? acc.id : null; } } else { acceptedTsId = null; } newItems.push({ id: "li-" + (newItems.length + 1), material_id: materialId, quantity: qty, unit, unit_price: price, total_price: total, termsheet_ids, accepted_ts_id: acceptedTsId, rejected, arrived_date: row.origLi?.arrived_date ?? null, projects: row.origLi?.projects ?? [], target_project: status === "offered" ? null : (row.origLi?.target_project ?? null), target_project_id: status === "offered" ? null : (row.origLi?.target_project_id ?? null), }); } next.line_items = newItems.length ? newItems : next.line_items; const referencedTs = new Set(); next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid))); next.termsheets = next.termsheets.filter((t, idx) => referencedTs.has(t.id) || idx === 0); next.line_items.forEach((li) => { if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) { li.accepted_ts_id = null; } }); next.quote_date = quoteDate || q.quote_date; next.expiry_date = expiryDate ? (expiryDate.length > 10 ? expiryDate.slice(0, 10) : expiryDate) : ""; next.accepted_date = acceptedDate; next.arrived_date = arrivedDate; next.status = status as Quote["status"]; if (next.status === "offered") { next.accepted_date = null; next.arrived_date = null; next.termsheets.forEach((t) => { t.status = "offered"; }); next.line_items.forEach((li) => { li.rejected = false; li.arrived_date = null; li.accepted_ts_id = null; li.target_project = null; li.target_project_id = null; li.allocations = undefined; }); // ادغام سطرهای تفکیک‌شده حاصل از تخصیص چندپروژه‌ای (الزام ۲) const mergedMap = new Map(); 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) => { li.arrived_date = null; }); } try { await updateQuote.mutateAsync({ id: q.id, patch: next }); } catch (err) { toast.error(err instanceof Error ? err.message : "ذخیره‌ی تغییرات ناموفق بود"); return; } setReady(false); onClose(); }; return ( <> !o && onClose()}> {/* هدر */} ویرایش پیشنهاد خرید
{supplierName ? `تأمین‌کننده: ${supplierName}` : "تنظیم اقلام، مبالغ و شرایط پرداخت"}
{hasReceipts && (
این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.
)} {/* تب‌بار */}
{/* محتوا */}
{/* تب ۱: اقلام */} {activeTab === "items" && (
setSupplierName(it.label)} items={suppliers.map((s) => ({ id: s.id, name: s.name }))} allowAdd addSuffix="به عنوان تأمین‌کنندهٔ جدید" placeholder="نام تأمین‌کننده" onInputChange={(v) => setSupplierName(v)} />