// finance-pages.jsx — Фінанси: Клієнти · Договори · Рахунки і дебіторка // ============ КЛІЄНТИ ============ function ClientsPage({ role }) { window.useCounterparties && window.useCounterparties(); const clients = window.DATA.CLIENTS; const contracts = window.DATA.CONTRACTS; const objects = window.DATA.OBJECTS; const invoices = window.DATA.INVOICES; const [openId, setOpenId] = React.useState(null); const [wiz, setWiz] = React.useState(false); const canEdit = role === "director" || role === "accountant"; // Підрахунок метрик по клієнту const stats = (clientId) => { const myContracts = contracts.filter(c => c.client === clientId); const totalContracts = myContracts.reduce((s, c) => s + c.total, 0); const myInvoices = invoices.filter(i => i.client === clientId); const paid = myInvoices.filter(i => i.status === "paid").reduce((s, i) => s + i.amount, 0); const owed = myInvoices.filter(i => i.status === "overdue" || i.status === "issued").reduce((s, i) => s + i.amount, 0); const overdue = myInvoices.filter(i => i.status === "overdue").reduce((s, i) => s + i.amount, 0); const objs = myContracts.map(c => c.obj).filter(Boolean); return { totalContracts, paid, owed, overdue, objCount: objs.length, contractCount: myContracts.length }; }; const totals = clients.reduce((acc, c) => { const s = stats(c.id); return { contracts: acc.contracts + s.contractCount, total: acc.total + s.totalContracts, paid: acc.paid + s.paid, owed: acc.owed + s.owed, overdue: acc.overdue + s.overdue, }; }, { contracts: 0, total: 0, paid: 0, owed: 0, overdue: 0 }); return (

Замовники

{clients.length} {window.plural(clients.length, "замовник", "замовники", "замовників")} {totals.contracts} {window.plural(totals.contracts, "активний договір", "активні договори", "активних договорів")} {totals.overdue > 0 && <> дебіторка {window.formatMoney(totals.overdue)} ₴ }
Сума договорів
{window.formatMoney(totals.total)}
за всіма замовниками
Отримано
{window.formatMoney(totals.paid)}
{Math.round(totals.paid / totals.total * 100)}% від договорів
Очікується
{window.formatMoney(totals.owed - totals.overdue)}
у графіку
0 ? "is-warn" : ""}`}>
Прострочено
{window.formatMoney(totals.overdue)}
{totals.overdue > 0 ? "потребує дій" : "усе вчасно"}
{canEdit && (
)} {clients.map(c => { const s = stats(c.id); return ( setOpenId(c.id)}> ); })}
Замовник ЄДРПОУ Об'єктів Договорів Сплачено Заборгованість
{c.short}
{c.contact.name} · {c.contact.role}
{c.edrpou} {s.objCount} {s.contractCount} · {window.formatMoney(s.totalContracts)} ₴ {window.formatMoney(s.paid)} ₴ {s.overdue > 0 ? {window.formatMoney(s.overdue)} ₴ : s.owed > 0 ? {window.formatMoney(s.owed)} ₴ : }
{openId && c.id === openId)} onClose={() => setOpenId(null)} />} {wiz && setWiz(false)} />}
); } function ClientDrawer({ client, onClose }) { React.useEffect(() => { const onKey = (e) => e.key === "Escape" && onClose(); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); const contracts = window.DATA.CONTRACTS.filter(c => c.client === client.id); const objects = window.DATA.OBJECTS.filter(o => contracts.some(c => c.obj === o.id)); const invoices = window.DATA.INVOICES.filter(i => i.client === client.id); const overdue = invoices.filter(i => i.status === "overdue"); const paid = invoices.filter(i => i.status === "paid").reduce((s, i) => s + i.amount, 0); const [printDraft, setPrintDraft] = React.useState(null); const [pickInvoice, setPickInvoice] = React.useState(false); return ( <>
{pickInvoice && setPickInvoice(false)} onPick={(draft) => { setPickInvoice(false); setPrintDraft(draft); }} />} {printDraft && setPrintDraft(null)} />} ); } // ============ ДОГОВОРИ ============ function ContractsPage({ role, tasks, onCreateTasks, onGoToTasks }) { window.useCounterparties && window.useCounterparties(); const contracts = window.DATA.CONTRACTS; const [openId, setOpenId] = React.useState(null); const [filter, setFilter] = React.useState("active"); const [wiz, setWiz] = React.useState(false); const [printId, setPrintId] = React.useState(null); const canEdit = role === "director" || role === "accountant"; const filtered = filter === "all" ? contracts : filter === "active" ? contracts.filter(c => c.status === "active") : filter === "projects" ? [] : contracts.filter(c => c.status === filter); const goProjects = window.DATA.PROJECTS || []; const showProjects = filter === "all" || filter === "projects"; const totalsAll = contracts.reduce((s, c) => s + c.total, 0); const paidAll = window.DATA.INVOICES.filter(i => i.status === "paid").reduce((s, i) => s + i.amount, 0); return (

Договори

{contracts.length} {window.plural(contracts.length, "договір", "договори", "договорів")} {goProjects.length > 0 && <> + {goProjects.length} {window.plural(goProjects.length, "проєкт ГО", "проєкти ГО", "проєктів ГО")} } загальна сума {window.formatMoney(totalsAll)} ₴ сплачено {Math.round(paidAll / totalsAll * 100)}%
{filtered.map(c => ( setOpenId(openId === c.id ? null : c.id)} tasks={tasks} onCreateTasks={onCreateTasks} onGoToTasks={onGoToTasks} /> ))} {showProjects && goProjects.length > 0 && ( {filter === "all" &&
Проєктна діяльність · ГО / грантові
} {goProjects.map(p => ( setOpenId(openId === p.id ? null : p.id)} /> ))}
)}
{wiz && { setWiz(false); if (rec) { if (openBlank) setPrintId(rec.id); else setOpenId(rec.id); } }} />} {printId && setPrintId(null)} />}
); } function ContractCard({ contract, isOpen, onToggle, tasks, onCreateTasks, onGoToTasks }) { const client = window.getClient(contract.client); const obj = window.getObject(contract.obj); const status = window.CONTRACT_STATUS[contract.status]; const [printDraft, setPrintDraft] = React.useState(null); const [printAct, setPrintAct] = React.useState(null); const [printContract, setPrintContract] = React.useState(false); const paid = contract.schedule.filter(s => s.paidDate).reduce((sum, s) => sum + s.amount, 0); const total = contract.total; const progressPct = Math.round(paid / total * 100); // підрахунок прострочених const today = "2026-05-26"; const overdueItems = contract.schedule.filter(s => !s.paidDate && s.dueDate < today); return (
{printContract && setPrintContract(false)} />}
№ {contract.number}
{window.formatDate(contract.date)}
{obj?.name} · {obj?.code}
{client?.short} {contract.schedule.length} {window.plural(contract.schedule.length, "етап", "етапи", "етапів")} {overdueItems.length > 0 && <> {overdueItems.length} прострочено }
{window.formatMoney(total)}
сплачено {window.formatMoney(paid)} ₴
{status.label}
{paid > 0 ? `сплачено ${progressPct}%` : "очікує першого платежу"} {window.formatMoney(paid)} / {window.formatMoney(total)} ₴
{isOpen && (
{contract.status === "supervision" && window.SupervisionBlock && ( setPrintAct(a)} /> )} setPrintDraft(d)} onPrintAct={(a) => setPrintAct(a)} />
)} {printDraft && setPrintDraft(null)} />} {printAct && setPrintAct(null)} />}
); } // ============ ДЕБІТОРКА / РАХУНКИ ============ function ReceivablesPage({ role }) { const invoices = window.DATA.INVOICES; const today = "2026-05-26"; const [printInv, setPrintInv] = React.useState(null); const [filter, setFilter] = React.useState("active"); const active = invoices.filter(i => i.status !== "paid"); const overdue = invoices.filter(i => i.status === "overdue"); const upcoming = invoices.filter(i => i.status === "issued" && window.daysBetween(today, i.dueDate) <= 30); const paid = invoices.filter(i => i.status === "paid"); const overdueSum = overdue.reduce((s, i) => s + i.amount, 0); const upcomingSum = active.filter(i => i.status === "issued").reduce((s, i) => s + i.amount, 0); const paidSum = paid.reduce((s, i) => s + i.amount, 0); const shown = filter === "overdue" ? overdue : filter === "upcoming" ? upcoming : filter === "paid" ? paid : active; // Cash flow plan по тижнях найближчого місяця const cashflow = buildCashflow(active, today); return (

Рахунки і дебіторка

{active.length} {window.plural(active.length, "відкритий", "відкриті", "відкритих")} {overdue.length > 0 && <> {overdue.length} прострочено }
0 ? "is-warn" : ""}`}>
Дебіторка
{window.formatMoney(overdueSum)}
{overdue.length} прострочених рахунків
Очікується
{window.formatMoney(upcomingSum)}
{active.length - overdue.length} рахунків у графіку
Сплачено
{window.formatMoney(paidSum)}
{paid.length} закритих рахунків
Сер. час оплати
{avgPaymentDays(paid)} дн.
від виставлення до оплати
{/* Cash flow прогноз */}

Прогноз надходжень

найближчі 12 тижнів
{cashflow.map((w, idx) => (
{w.sum > 0 ? Math.round(w.sum / 1000) + "k" : ""}
0 ? Math.max(8, w.sum / cashflow.reduce((m, x) => Math.max(m, x.sum), 1) * 100) + "%" : "0%"}}>
{w.label}
))}
{shown.map(inv => { const client = window.getClient(inv.client); const obj = window.getObject(inv.obj); const daysUntil = window.daysBetween(today, inv.dueDate); return ( ); })} {shown.length === 0 && ( )}
№ рахунку Замовник / об'єкт Етап Виставлено Термін Сума Статус
№ {inv.number}
{client?.short}
{obj?.code} · {obj?.name}
{inv.title} {window.formatDate(inv.issueDate)}
{window.formatDate(inv.dueDate)}
{inv.status === "issued" && daysUntil >= 0 && daysUntil <= 7 &&
через {daysUntil} {window.plural(daysUntil, "день", "дні", "днів")}
} {inv.status === "overdue" &&
+{inv.lateDays} {window.plural(inv.lateDays, "день", "дні", "днів")}
} {inv.status === "paid" && inv.paidDate &&
сплачено {window.formatDate(inv.paidDate)}
}
{window.formatMoney(inv.amount)} ₴ {inv.status === "paid" && сплачено} {inv.status === "overdue" && прострочено} {inv.status === "issued" && виставлено}
Немає рахунків у цій категорії.
{printInv && setPrintInv(null)} />}
); } // helpers function avgPaymentDays(paid) { const days = paid.map(i => window.daysBetween(i.issueDate, i.paidDate)); if (days.length === 0) return 0; return Math.round(days.reduce((s,d) => s + d, 0) / days.length); } function buildCashflow(active, today) { // 12 тижнів з сьогодні const weeks = []; const todayD = new Date(today); for (let i = -2; i < 10; i++) { const start = new Date(todayD); start.setDate(start.getDate() + i * 7); weeks.push({ start: start.toISOString().slice(0, 10), end: new Date(start.getTime() + 6 * 86400000).toISOString().slice(0, 10), label: i === 0 ? "цей" : i === 1 ? "наст." : `${start.getDate()}.${String(start.getMonth()+1).padStart(2,'0')}`, sum: 0, overdue: false }); } active.forEach(inv => { weeks.forEach(w => { if (inv.dueDate >= w.start && inv.dueDate <= w.end) { w.sum += inv.amount; if (inv.status === "overdue") w.overdue = true; } }); }); return weeks; } window.ClientsPage = ClientsPage; window.ContractsPage = ContractsPage; window.ReceivablesPage = ReceivablesPage;