import { useEffect, useMemo, useState } from "react"; import { Trash2, AlertCircle } 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, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils"; import { STATUS_LABEL, statusOptionsFor } from "../../lib/constants"; import { faNum } from "../../lib/formatters"; import { normalize } from "../../lib/normalize"; import { specsKey } from "../../lib/quoteUtils"; 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 { MilestoneRow } from "../../components/MilestoneRow"; import { Button } from "../../components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "../../components/ui/dialog"; interface EditItemRow { key: string; origLi?: LineItem; projects?: { id: string; name: string }[]; 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; // "" یا "new-ts-..." برای کارت جدید 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 [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); // حفظ تمام پروژه‌های کاندیدا: اتحاد پروژه‌های سطح پیشنهاد و پروژه‌های کاندیدای هر قلم const candidateSet = new Set(); (q.projects || []).forEach((p) => p && candidateSet.add(p)); if (q.project) candidateSet.add(q.project); q.line_items.forEach((li) => { (li.projects || []).forEach((p) => { const n = typeof p === "string" ? p : p.name; if (n) candidateSet.add(n); }); }); setSelectedProjects([...candidateSet]); setNotes(q.notes || ""); setItemRows( q.line_items.map((li) => { const m = materialById(materials, li.material_id); return { key: li.id, origLi: li, projects: li.projects && li.projects.length ? [...li.projects] : [], 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]); if (!q || !ready) return null; const tsLabel = (card: EditTsCard, idx: number) => { const orig = q.termsheets.find((t) => t.id === card.domId); return orig?.label || `گزینه ${faNum(idx + 1)}`; }; const patchItem = (key: string, patch: Partial) => { setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r))); }; const addItemRow = () => { setItemRows((rows) => [ ...rows, { key: "new-" + Math.random().toString(36).slice(2, 9), cat: "", brand: "", specs: [], qty: null, unit: "", price: null, total: null, tsDomIds: [], 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-" + Math.random().toString(36).slice(2, 9), domId: "", milestones: [{ when_iso: null, amount: null }] }, ]); }; const removeTsCard = (key: string) => { setTsCards((cards) => cards.filter((c) => c.key !== 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; // termsheetها 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; 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 = materialsCopy.find( (m) => normalize(m.name) === normalize(cat) && normalize(m.brand) === normalize(brand) && specsKey(m.specs) === specsKey(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 || "", category: cat, 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.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; } const candProjs = row.projects && row.projects.length ? row.projects : row.origLi?.projects && row.origLi.projects.length ? row.origLi.projects : selectedProjects.map((pname) => ({ id: "", name: pname })); newItems.push({ id: row.origLi ? row.origLi.id : row.key.startsWith("new-") ? row.key : "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: candProjs, 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) => referencedTs.has(t.id)); 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; }); } 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()}> ویرایش پیشنهاد {hasReceipts && (
این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.
)} {/* اطلاعات بالایی — تک‌ستونه در موبایل، ۲ ستونه در دسکتاپ */}
setSupplierName(it.label)} items={suppliers.map((s) => ({ id: s.id, name: s.name }))} allowAdd addSuffix="به عنوان تأمین‌کنندهٔ جدید" placeholder="نام تأمین‌کننده" onInputChange={(v) => setSupplierName(v)} openOnFocus={false} />