// 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 (
{ setOpen(false); if (k !== value) onChange(k); }}
>
{o.label}
);
})}
,
document.body
) : null;
return (
setOpen(o => !o)}
>
{st.label} ▾
{menu}
);
}
// Локальне поле форми (як в edrpou-page/orders-page) — не покладаємось на глобал.
function OWField({ label, children, wide }) {
return (
{label}
{children}
);
}
// Будує абзаци тіла КП зі структурованих полів (формулювання за зразком «Хаб Ветеран»).
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 && (
Заповніть поля — текст пропозиції збудується автоматично. Можна підставити зразок.
Підставити зразок «Хаб Ветеран»
)}
pickClient(e.target.value)}>
— не обрано —
{clients.map(c => {c.short} · {c.edrpou} )}
set("salutation", e.target.value)} placeholder="напр. Шановна пані Ірино!" />
set("number", e.target.value)} />
set("date", e.target.value)} />
set("inNo", e.target.value)} placeholder="напр. 01-07/116" />
set("inDate", e.target.value)} />
set("objectAddress", e.target.value)} placeholder="напр. м. Одеса, вул. …" />
set("total", e.target.value)} placeholder="778235.37" disabled={!!estimate} title={estimate ? "Вартість береться з кошторису" : ""} />
set("vatMode", e.target.value)}>
У т.ч. ПДВ 20%
Без ПДВ
set("termMonths", e.target.value)} />
set("status", e.target.value)}>
{Object.keys(CP_STATUS).map(k => {CP_STATUS[k].label} )}
set("expertiseSeparate", e.target.checked)} /> Експертиза оплачується Замовником окремо (не входить у вартість)
setEstBuilder(true)}>
{estimate ? "Редагувати кошторис" : "Скласти кошторис"}
{estimate
?
✓ форми 1-П/2-П/3-П · разом {window.formatMoney(window.estimateTotals ? window.estimateTotals(estimate).grand : (estimate.total || 0))} ₴
: необов'язково — якщо додати, вартість КП рахується з кошторису }
{estimate && { setEstimate(null); }}>✕ прибрати }
{f.id ? "Зміни збережуться у реєстрі." : "Створиться запис у реєстрі + у Журналі вихідної кореспонденції."}
Скасувати
{busy ? "Збереження…" : (f.id ? "Зберегти" : "Створити")}
{estBuilder && window.EstimateBuilder && (
x.id === f.client) || {}).name || addressee[0] || "",
proposalNumber: f.number, proposalDate: cpDots(f.date),
}}
onClose={() => setEstBuilder(false)}
onSave={(est, grand) => { setEstimate(est); setF(p => ({ ...p, total: grand })); }}
/>
)}
);
}
function CommercialProposalsPage({ role }) {
const co = window.COMPANY || {};
const [rows, setRows] = React.useState([]);
const [filter, setFilter] = React.useState("all");
const [form, setForm] = React.useState(null); // null | {} | proposal
const [printP, setPrintP] = React.useState(null);
const [printEst, setPrintEst] = React.useState(null);
const [contractCP, setContractCP] = React.useState(null); // КП, з якої створюємо договір
const canEdit = role === "director" || role === "accountant" || role === "sales" || role === "deputy";
const reload = React.useCallback(() => {
if (!(window.API && window.API.list)) return;
window.API.list("commercial-proposals", {})
.then(r => setRows((r || []).slice().sort((a, b) => (b.date || "").localeCompare(a.date || ""))))
.catch(() => {});
}, []);
React.useEffect(() => { reload(); }, [reload]);
const shown = filter === "all" ? rows : rows.filter(p => p.status === filter);
const totalSum = rows.reduce((s, p) => s + (p.total || 0), 0);
// Розподіл кількості і сум по статусах.
const breakdown = React.useMemo(() => {
const acc = {};
Object.keys(CP_STATUS).forEach(k => { acc[k] = { count: 0, sum: 0 }; });
rows.forEach(p => { const k = CP_STATUS[p.status] ? p.status : "draft"; acc[k].count++; acc[k].sum += (p.total || 0); });
return acc;
}, [rows]);
const changeStatus = (p, next) => {
// Оптимістично оновлюємо локально, потім патчимо на сервері.
setRows(rs => rs.map(x => x.id === p.id ? { ...x, status: next } : x));
if (window.API && window.API.patch) {
window.API.patch("/commercial-proposals/" + p.id, { status: next }).catch(reload);
}
};
const remove = (id) => {
if (!window.confirm("Видалити комерційну пропозицію?")) return;
window.API.del("/commercial-proposals/" + id).then(reload).catch(() => {});
};
const duplicate = (p) => {
const SYS = window.SYS_DATE || "2026-06-29";
setForm({ ...p, id: undefined, number: (window.nextProposalNumber ? window.nextProposalNumber() : ""), date: SYS, status: "draft" });
};
return (
Комерційні пропозиції
{rows.length} {window.plural(rows.length, "пропозиція", "пропозиції", "пропозицій")}
на суму {window.formatMoney(totalSum)} ₴
setFilter("all")}>Усі
{Object.keys(CP_STATUS).map(k => (
setFilter(k)}>{CP_STATUS[k].label}
))}
{canEdit &&
setForm({})}> Нова пропозиція }
{Object.keys(CP_STATUS).map(k => {
const o = CP_STATUS[k], b = breakdown[k] || { count: 0, sum: 0 };
return (
setFilter(f => f === k ? "all" : k)}
>
{o.label}
{window.formatMoney(b.sum)} ₴
{b.count} {window.plural(b.count, "пропозиція", "пропозиції", "пропозицій")}
);
})}
{shown.length === 0 &&
Пропозицій ще немає. Натисніть «Нова пропозиція».
}
{shown.map(p => {
return (
№ {p.number}
{cpDots(p.date)}
{(p.addressee || [])[0] || "Адресат"} · {(p.addressee || [])[1] || ""}
{p.subject}
{window.formatMoney(p.total)} ₴
{p.vatMode === "without" ? "без ПДВ" : "у т.ч. ПДВ"}
changeStatus(p, next)} />
setPrintP(p)}> Друк
{p.estimate && setPrintEst(p)}> Кошторис }
{canEdit && window.ContractWizard && setContractCP(p)}> Договір }
{canEdit && setForm(p)}>✎ }
{canEdit && duplicate(p)}>⧉ }
{canEdit && remove(p.id)}>✕ }
);
})}
{contractCP && window.ContractWizard && (
{
const cp = contractCP;
setContractCP(null);
if (rec) {
// КП реалізовано договором → статус «Прийнято» + перехід у реєстр договорів.
if (cp.status !== "accepted") changeStatus(cp, "accepted");
if (window.__setPage) window.__setPage("contracts");
}
}}
/>
)}
{form && setForm(null)} onSaved={reload} />}
{printP && window.LetterPrintDoc && setPrintP(null)} />}
{printEst && window.EstimatePrintDoc && (
setPrintEst(null)} />
)}
);
}
window.CommercialProposalsPage = CommercialProposalsPage;
window.buildProposalBody = buildProposalBody;