// commercial-proposals.jsx — Комерційні пропозиції (Продажі). // Структурована форма → стандартний текст КП (як «Хаб Ветеран») → друк на бланку // (LetterPrintDoc). Зберігається як реєстр (commercial-proposals); при створенні // додається запис у Журнал вихідної кореспонденції (window.logOutgoing). const CP_STATUS = { draft: { label: "Чернетка", tone: "neutral" }, sent: { label: "Надіслано", tone: "blue" }, accepted: { label: "Прийнято", tone: "green" }, declined: { label: "Відхилено", tone: "red" }, }; const CP_MONTHS_WORD = ["", "один", "два", "три", "чотири", "п'ять", "шість", "сім", "вісім", "дев'ять", "десять", "одинадцять", "дванадцять"]; function cpDots(iso) { return iso ? (window.dateDots ? window.dateDots(iso) : iso) : ""; } // Клікабельний статус-тег: натискаєш — обираєш новий статус зі списку. // Меню рендериться у портал (fixed), щоб його не обрізав `overflow:hidden` // картки (.contract-card) — інакше список статусів кліпається і не клікається. function CpStatusTag({ value, editable, onChange }) { const [open, setOpen] = React.useState(false); const [pos, setPos] = React.useState(null); const btnRef = React.useRef(null); const menuRef = React.useRef(null); const st = CP_STATUS[value] || CP_STATUS.draft; const place = React.useCallback(() => { const b = btnRef.current; if (!b) return; const r = b.getBoundingClientRect(); setPos({ top: r.bottom + 4, right: Math.max(8, window.innerWidth - r.right) }); }, []); React.useEffect(() => { if (!open) return; place(); const onDoc = (e) => { if (btnRef.current && btnRef.current.contains(e.target)) return; if (menuRef.current && menuRef.current.contains(e.target)) return; setOpen(false); }; const onScroll = () => setOpen(false); document.addEventListener("mousedown", onDoc); window.addEventListener("scroll", onScroll, true); window.addEventListener("resize", onScroll); return () => { document.removeEventListener("mousedown", onDoc); window.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", onScroll); }; }, [open, place]); if (!editable) return {st.label}; const menu = open && pos ? ReactDOM.createPortal(
{Object.keys(CP_STATUS).map(k => { const o = CP_STATUS[k]; return ( ); })}
, document.body ) : null; return (
{menu}
); } // Локальне поле форми (як в edrpou-page/orders-page) — не покладаємось на глобал. function OWField({ label, children, wide }) { return ( ); } // Будує абзаци тіла КП зі структурованих полів (формулювання за зразком «Хаб Ветеран»). function buildProposalBody(p, co) { const shortName = (co && co.shortName) || "ТОВ «УКРБУДПРОЄКТ»"; const sum = Number(p.total) || 0; const withVat = p.vatMode !== "without"; const vat = withVat ? Math.round(sum / 6 * 100) / 100 : 0; // ПДВ 20% у т.ч. = сума/6 const fm = window.formatMoney || ((n) => String(n)); const words = window.moneyInWords ? window.moneyInWords(sum) : ""; const ref = (p.inNo || p.inDate) ? `У відповідь на Ваше звернення (лист${p.inDate ? " від " + cpDots(p.inDate) : ""}${p.inNo ? " № " + p.inNo : ""}) ` : ""; const mN = Number(p.termMonths) || 0; const mWord = (mN >= 1 && mN <= 12) ? CP_MONTHS_WORD[mN] : ""; const out = []; out.push(`${ref}${shortName} надає свою пропозицію на роботи з розробки проєктної документації на ${p.subject || "[опис об'єкта та робіт]"}${p.objectAddress ? ", розташованих за адресою: " + p.objectAddress : ""} (далі — Роботи) за наступними цінами:`); out.push(`Загальна вартість робіт: ${fm(sum)} грн${words ? " (" + words + ")" : ""}${withVat ? ", у тому числі ПДВ 20% — " + fm(vat) + " грн" : " (без ПДВ)"}.`); out.push(`Терміни виконання робіт: ${mN || "[__]"}${mWord ? " (" + mWord + ")" : ""} календарні місяці з моменту підписання договору.`); out.push("Терміни виконання Робіт можуть бути подовжені у разі ненадання (несвоєчасного надання) Замовником вихідних даних, необхідних для розробки проєктної документації."); if (p.expertiseSeparate) out.push("Проходження експертизи проєктної документації здійснюється та оплачується Замовником окремо і у вартість Робіт не входить."); return out; } // Зібрати об'єкт листа для LetterPrintDoc. function proposalLetter(p, co) { return { outNo: p.number || "", outDate: cpDots(p.date), inNo: p.inNo || "", inDate: cpDots(p.inDate), addressee: p.addressee || [], salutation: p.salutation || "", title: "", body: (p.body && p.body.length) ? p.body : buildProposalBody(p, co), attachments: [], }; } // Приклад «Хаб Ветеран» — як шаблон-зразок для нової КП. function cpVeteranSample() { return { inNo: "01-07/116", inDate: "2026-06-19", addressee: ["В.о. директора", "КУ «Сервісний офіс «Хаб Ветеран»", "Ірині АХРАМЄЄВІЙ"], salutation: "Шановна пані Ірино!", subject: "капітальний ремонт нежитлових приміщень першого поверху № 502 загальною площею 237,4 кв.м, для облаштування безбар'єрної адаптивної спортивної зали", objectAddress: "м. Одеса, вул. Маріупольська, 4", total: 778235.37, vatMode: "with", termMonths: 3, expertiseSeparate: true, }; } function CommercialProposalForm({ proposal, onClose, onSaved }) { const co = window.COMPANY || {}; const clients = window.DATA.CLIENTS || []; const SYS = window.SYS_DATE || "2026-06-29"; const [busy, setBusy] = React.useState(false); const [estBuilder, setEstBuilder] = React.useState(false); const [estimate, setEstimate] = React.useState(() => (proposal && proposal.estimate) || null); const [f, setF] = React.useState(() => ({ id: proposal && proposal.id || null, number: (proposal && proposal.number) || (window.nextProposalNumber ? window.nextProposalNumber() : ""), date: (proposal && proposal.date) || SYS, inNo: (proposal && proposal.inNo) || "", inDate: (proposal && proposal.inDate) || "", client: (proposal && proposal.client) || "", addresseeText: ((proposal && proposal.addressee) || []).join("\n"), salutation: (proposal && proposal.salutation) || "", subject: (proposal && proposal.subject) || "", objectAddress: (proposal && proposal.objectAddress) || "", total: (proposal && proposal.total) || "", vatMode: (proposal && proposal.vatMode) || "with", termMonths: (proposal && proposal.termMonths) || 3, expertiseSeparate: proposal ? !!proposal.expertiseSeparate : true, status: (proposal && proposal.status) || "draft", })); const set = (k, v) => setF(p => ({ ...p, [k]: v })); React.useEffect(() => { const onKey = (e) => e.key === "Escape" && onClose(); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); const applySample = () => setF(p => ({ ...p, ...cpVeteranSample(), addresseeText: cpVeteranSample().addressee.join("\n") })); const pickClient = (id) => { const c = clients.find(x => x.id === id); setF(p => ({ ...p, client: id, addresseeText: p.addresseeText || (c ? c.name : "") })); }; const addressee = f.addresseeText.split("\n").map(s => s.trim()).filter(Boolean); const valid = f.number.trim() && Number(f.total) > 0 && f.subject.trim() && addressee.length > 0; const toRecord = () => { const rec = { id: f.id || undefined, number: f.number.trim(), date: f.date, inNo: f.inNo.trim(), inDate: f.inDate || null, client: f.client || null, addressee, salutation: f.salutation.trim(), subject: f.subject.trim(), objectAddress: f.objectAddress.trim(), total: Number(f.total) || 0, vatMode: f.vatMode, termMonths: Number(f.termMonths) || null, expertiseSeparate: f.expertiseSeparate, status: f.status, estimate: estimate || null, }; rec.body = buildProposalBody(rec, co); // снапшот тексту для друку/реоупену return rec; }; const save = async () => { if (!valid || busy) return; setBusy(true); const rec = toRecord(); try { const saved = await window.API.save("commercial-proposals", f.id || null, rec); const isNew = !f.id; // Лог у журнал вихідної кореспонденції — лише для нових КП. if (isNew && window.logOutgoing) { const clientName = (clients.find(x => x.id === f.client) || {}).short; await window.logOutgoing({ docNo: rec.number, docDate: rec.date, counterparty: addressee.join(", ") || clientName || "", summary: "Комерційна пропозиція" + (rec.subject ? " — " + rec.subject : ""), regIndex: rec.number, }); } onSaved && onSaved(saved || rec); onClose(); } catch (e) { console.error("[ERP] save proposal failed:", e); alert("Не вдалося зберегти комерційну пропозицію на сервері."); } finally { setBusy(false); } }; return (
{f.id ? "Редагувати комерційну пропозицію" : "Нова комерційна пропозиція"}
№ {f.number || "—"} · {cpDots(f.date)}
{!f.id && (
Заповніть поля — текст пропозиції збудується автоматично. Можна підставити зразок.
)}