// 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}` : ""}
Закрити
Друк / Зберегти PDF
{/* Логотип компанії */}
{co.phone} · {co.email || "office@ukrbudproiekt.ua"}
{/* Банківські реквізити одержувача коштів (стандартна шапка) */}
Постачальник
{co.fullName || co.shortName}
{/* Шапка → заголовок */}
Рахунок на оплату № {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__ р. }
{/* Таблиця номенклатури */}
№
Найменування робіт / послуг
Кіл-сть
Од.
Ціна без ПДВ, ₴
Сума без ПДВ, ₴
1
{lineName || Найменування робіт / послуг за договором }
1
посл.
{hasAmount ? invFmt(base) : 0,00 }
{hasAmount ? invFmt(base) : 0,00 }
{/* порожні рядки бланку */}
{!hasAmount && [2, 3].map(n => (
{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 (
pickContract(c)}>
{obj?.code || "—"}
{st.label}
{obj?.name || "Без об'єкта"}
{client?.short || "—"}
№ {c.number}
{allPaid
? усі платежі графіку закриті
: <>{next?.stage} {fmt(next?.amount || 0)} ₴ >}
);
})}
{selected ? (
<>
Платіж графіку
{sched.map((s, i) => {
const tone = milestoneTone(s);
const disabled = !!s.paidDate;
return (
setMilestoneIdx(i)} />
{s.stage}
{s.pct}%
термін {(() => { const d = new Date(s.dueDate); return isNaN(d) ? s.dueDate : `${String(d.getDate()).padStart(2,"0")}.${String(d.getMonth()+1).padStart(2,"0")}.${String(d.getFullYear()).slice(2)}`; })()}
{fmt(s.amount)} ₴
{tone.label}
);
})}
Сума (без ПДВ)
{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;