// invoice-template.jsx — друкована форма «Рахунок на оплату» (A4) // Відкривається з Рахунки/дебіторка. Продавець = COMPANY (реально), // дані рахунку підставляються з обраного інвойсу; відсутні поля — плейсхолдери. // ПДВ 20% нараховується ЗВЕРХУ на суму без ПДВ. // ── Сума прописом (українською) ─────────────────────────── (function () { const ONES = { m: ["", "один", "два", "три", "чотири", "п’ять", "шість", "сім", "вісім", "дев’ять"], f: ["", "одна", "дві", "три", "чотири", "п’ять", "шість", "сім", "вісім", "дев’ять"], }; const TEENS = ["десять", "одинадцять", "дванадцять", "тринадцять", "чотирнадцять", "п’ятнадцять", "шістнадцять", "сімнадцять", "вісімнадцять", "дев’ятнадцять"]; const TENS = ["", "", "двадцять", "тридцять", "сорок", "п’ятдесят", "шістдесят", "сімдесят", "вісімдесят", "дев’яносто"]; const HUNDREDS = ["", "сто", "двісті", "триста", "чотириста", "п’ятсот", "шістсот", "сімсот", "вісімсот", "дев’ятсот"]; function triad(num, gender) { const out = []; const h = Math.floor(num / 100); const t = Math.floor((num % 100) / 10); const u = num % 10; if (h) out.push(HUNDREDS[h]); if (t === 1) { out.push(TEENS[u]); } else { if (t) out.push(TENS[t]); if (u) out.push(ONES[gender][u]); } return out.join(" "); } // форма слова за українськими правилами множини function plural(n, one, few, many) { const m10 = n % 10, m100 = n % 100; if (m10 === 1 && m100 !== 11) return one; if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few; return many; } const SCALES = [ { g: "m", f: ["мільярд", "мільярди", "мільярдів"] }, { g: "m", f: ["мільйон", "мільйони", "мільйонів"] }, { g: "f", f: ["тисяча", "тисячі", "тисяч"] }, ]; function intToWords(n, lastGender) { if (n === 0) return "нуль"; const triads = []; let x = n; while (x > 0) { triads.unshift(x % 1000); x = Math.floor(x / 1000); } const total = triads.length; const parts = []; triads.forEach((tri, i) => { const fromEnd = total - 1 - i; // 0 = одиниці if (tri === 0) return; if (fromEnd === 0) { parts.push(triad(tri, lastGender)); } else { const scale = SCALES[SCALES.length - fromEnd]; if (!scale) return; parts.push(triad(tri, scale.g)); parts.push(plural(tri, scale.f[0], scale.f[1], scale.f[2])); } }); return parts.filter(Boolean).join(" "); } // moneyInWords(1234.56) → "Одна тисяча двісті тридцять чотири гривні 56 копійок" window.moneyInWords = function (amount) { const grn = Math.floor(amount); const kop = Math.round((amount - grn) * 100); let words = intToWords(grn, "f"); words = words.charAt(0).toUpperCase() + words.slice(1); const grnWord = plural(grn, "гривня", "гривні", "гривень"); const kopWord = plural(kop, "копійка", "копійки", "копійок"); return `${words} ${grnWord} ${String(kop).padStart(2, "0")} ${kopWord}`; }; })(); // ── Допоміжне форматування ──────────────────────────────── function invFmt(n) { return Number(n).toLocaleString("uk-UA", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } const INV_MONTHS = ["січня", "лютого", "березня", "квітня", "травня", "червня", "липня", "серпня", "вересня", "жовтня", "листопада", "грудня"]; function invDateUA(iso) { if (!iso) return null; const d = new Date(iso); if (isNaN(d)) return null; return `«${String(d.getDate()).padStart(2, "0")}» ${INV_MONTHS[d.getMonth()]} ${d.getFullYear()} р.`; } // плейсхолдер для відсутніх даних function Ph({ children }) { return {children}; } // ── Друкована форма ─────────────────────────────────────── function InvoicePrintDoc({ invoice, onClose }) { React.useEffect(() => { const onKey = (e) => e.key === "Escape" && onClose(); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); const isFop = !!(invoice && invoice.execEntity === "fop"); const co = isFop ? (window.COMPANY_FOP || window.COMPANY || {}) : (window.COMPANY || {}); let client = invoice ? window.getClient(invoice.client) : null; const obj = invoice ? window.getObject(invoice.obj) : null; const contract = invoice ? (window.DATA.CONTRACTS || []).find(c => c.id === invoice.contract) : null; // Проєкт ГО: контрагент — партнер-ГО if (!client && invoice && invoice.partnerId && window.getNgoPartner) { const ng = window.getNgoPartner(invoice.partnerId); if (ng) client = { name: ng.name, edrpou: ng.edrpou, address: ng.city ? `м. ${ng.city}` : null, contact: ng.contact }; } // ПДВ 20% ЗВЕРХУ (крім ФОП-неплатника) const noVat = !!(invoice && invoice.noVat); const VAT = noVat ? 0 : 0.20; const hasAmount = invoice && typeof invoice.amount === "number"; const base = hasAmount ? invoice.amount : 0; const vat = Math.round(base * VAT * 100) / 100; const total = Math.round((base + vat) * 100) / 100; // рядок номенклатури з даних інвойсу const lineName = (invoice && (invoice.title || contract)) ? `Проєктні роботи${contract ? ` за договором № ${contract.number}` : ""}${invoice.title ? ` — ${invoice.title}` : ""}` : null; const number = invoice ? invoice.number : null; const issue = invoice ? invoice.issueDate : null; const doPrint = () => window.print(); return ( <>
Рахунок на оплату{number ? ` № ${number}` : ""}
{/* Логотип компанії */}
{co.shortName}
{co.phone} · {co.email || "office@ukrbudproiekt.ua"}
{/* Банківські реквізити одержувача коштів (стандартна шапка) */}
Постачальник
{co.fullName || co.shortName}
Код ЄДРПОУ
{co.edrpou}
Банк
{co.bank}
ІПН платника ПДВ
{co.vatId || уточнити}
Рахунок (IBAN)
{co.iban}
Адреса
{co.address}
{/* Шапка → заголовок */}

Рахунок на оплату № {number || ______} від {invDateUA(issue) || «__» ________ 20__ р.}

{/* Сторони */}
Постачальник (продавець):
{co.fullName || co.shortName}
Код ЄДРПОУ: {co.edrpou} · ІПН: {co.vatId || уточнити}
{co.address}
{co.bank}
{co.iban}
Одержувач (платник):
{client ? client.name : Назва платника}
Код ЄДРПОУ: {client?.edrpou || ________} · ІПН: уточнити у платника
{client?.address || Адреса платника}
{obj &&
Об'єкт: {obj.code} · {obj.name}
}
Підстава:{" "} {contract ? <>Договір № {contract.number}{contract.date ? ` від ${invDateUA(contract.date) || contract.date}` : ""}{obj ? `, ${obj.name}` : ""} : Договір № ____ від «__» ________ 20__ р.}
{/* Таблиця номенклатури */} {/* порожні рядки бланку */} {!hasAmount && [2, 3].map(n => ( ))}
Найменування робіт / послуг Кіл-сть Од. Ціна без ПДВ, ₴ Сума без ПДВ, ₴
1 {lineName || Найменування робіт / послуг за договором} 1 посл. {hasAmount ? invFmt(base) : 0,00} {hasAmount ? invFmt(base) : 0,00}
{n}
{/* Підсумки */}
Разом без ПДВ: {hasAmount ? invFmt(base) : 0,00} ₴
ПДВ{noVat ? "" : " 20%"}: {noVat ? "без ПДВ" : {hasAmount ? invFmt(vat) : 0,00} ₴}
Всього до сплати: {hasAmount ? invFmt(total) : 0,00} ₴
Усього найменувань 1, на суму{" "} {hasAmount ? invFmt(total) : "0,00"} ₴
{hasAmount ? window.moneyInWords(total) : Сума прописом}
Рахунок дійсний для оплати протягом 5 (п'яти) банківських днів. У призначенні платежу зазначайте: «Оплата за рахунком № {number || "____"} {contract ? `, договір № ${contract.number}` : (invoice && invoice.contractNumber ? `, договір № ${invoice.contractNumber}` : "")}{noVat ? " (без ПДВ)" : ", у т.ч. ПДВ 20%"}».
{/* Підпис + М.П. */}
Керівник
{co.director || Прізвище, ініціали}
М.П.
); } function InvoiceStyles() { return ( ); } window.InvoicePrintDoc = InvoicePrintDoc; // невелика іконка принтера для кнопки в таблиці function InvPrintIcon() { return ( ); } window.InvPrintIcon = InvPrintIcon; // Синтез чернетки рахунку з договору (наступна несплачена віха графіку). // Повертає об'єкт, сумісний з InvoicePrintDoc. window.draftInvoiceForContract = function (contract, milestoneIdx) { if (!contract) return null; const today = window.TODAY_ISO || "2026-05-26"; const sched = contract.schedule || []; let idx = (typeof milestoneIdx === "number" && milestoneIdx >= 0) ? milestoneIdx : sched.findIndex(s => !s.paidDate); if (idx < 0) idx = sched.length ? sched.length - 1 : -1; const next = idx >= 0 ? sched[idx] : {}; const base = (contract.number || "").split("/")[0] || contract.number || ""; const proj = contract.__project || null; const execEntity = proj ? proj.execEntity : (contract.execEntity || null); return { id: "draft-" + (contract.id || (proj && proj.id) || "x"), number: base ? `${base}/${String(idx >= 0 ? idx + 1 : 1).padStart(2, "0")}` : null, contract: contract.id, client: contract.client, obj: contract.obj, title: next.stage || "", amount: next.amount || 0, issueDate: today, dueDate: next.dueDate || today, status: "draft", draft: true, execEntity, noVat: execEntity === "fop", projectName: proj ? proj.name : (contract.projectName || null), projectCode: proj ? proj.code : (contract.projectCode || null), partnerId: proj ? proj.partner : (contract.partnerId || null), contractNumber: contract.number || null, }; }; // ───────────────────────────────────────────────────────────── // Пікер «по якому договору / об'єкту виставити рахунок». // Показує всі договори (об'єкт + замовник + наступна несплачена віха графіку), // дозволяє обрати конкретну віху, далі відкриває друковану форму. // ───────────────────────────────────────────────────────────── function InvoiceContractPicker({ onClose, onPick, contracts }) { const { useState, useEffect } = React; const list = contracts || window.DATA.CONTRACTS || []; const today = window.TODAY_ISO || "2026-05-26"; // за замовчуванням — перший активний договір const firstActive = list.find(c => c.status === "active") || list[0]; const [selId, setSelId] = useState(firstActive ? firstActive.id : null); const nextUnpaidIdx = (c) => { const i = (c.schedule || []).findIndex(s => !s.paidDate); return i < 0 ? Math.max(0, (c.schedule || []).length - 1) : i; }; const [milestoneIdx, setMilestoneIdx] = useState(firstActive ? nextUnpaidIdx(firstActive) : 0); useEffect(() => { const onKey = (e) => e.key === "Escape" && onClose(); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); const pickContract = (c) => { setSelId(c.id); setMilestoneIdx(nextUnpaidIdx(c)); }; const selected = list.find(c => c.id === selId) || null; const sched = selected ? (selected.schedule || []) : []; const fmt = (n) => (window.formatMoney ? window.formatMoney(n) : n); const milestoneTone = (s) => { if (s.paidDate) return { label: "сплачено", cls: "tone-neutral" }; if (s.dueDate && s.dueDate < today) return { label: "прострочено", cls: "tone-warn" }; return { label: "до сплати", cls: "tone-live" }; }; const submit = () => { if (!selected) return; onPick(window.draftInvoiceForContract(selected, milestoneIdx)); }; return ( <>
Виставити рахунок
Оберіть договір / об'єкт і платіж графіку
Договір / об'єкт
{list.map(c => { const obj = window.getObject(c.obj); const client = window.getClient(c.client); const st = window.CONTRACT_STATUS[c.status] || {}; const ni = nextUnpaidIdx(c); const next = c.schedule ? c.schedule[ni] : null; const allPaid = (c.schedule || []).every(s => s.paidDate); return ( ); })}
{selected ? ( <>
Платіж графіку
{sched.map((s, i) => { const tone = milestoneTone(s); const disabled = !!s.paidDate; return ( ); })}
Сума (без ПДВ) {fmt(sched[milestoneIdx]?.amount || 0)} ₴
ПДВ 20% зверху {fmt(Math.round((sched[milestoneIdx]?.amount || 0) * 0.20))} ₴
До сплати {fmt(Math.round((sched[milestoneIdx]?.amount || 0) * 1.20))} ₴
) : (
Немає договорів для виставлення рахунку.
)}
); } window.InvoiceContractPicker = InvoiceContractPicker;