// cashflow-page.jsx — Cash flow: баланс, надходження, виплати, прогноз function CashflowPage({ role }) { const accounts = window.DATA.CASH_ACCOUNTS; const ops = window.DATA.CASH_OPS; const invoices = window.DATA.INVOICES; const expenses = window.DATA.EXPENSES; const today = "2026-05-26"; const [horizon, setHorizon] = React.useState(12); // тижнів вперед // ===== Поточні баланси ===== const usdRate = 41.5; const totalUah = accounts.reduce((s, a) => s + (a.currency === "UAH" ? a.balance : a.balance * usdRate), 0); const mainBalance = accounts.find(a => a.primary)?.balance || 0; // ===== Прогноз: побудова тижневих кошиків ===== const weeks = buildWeeks(today, horizon); const monthlyOutflow = expenses.reduce((s, e) => s + (e.monthlyAmount || 0), 0); const monthlySalary = window.DATA.TEAM.reduce((s, p) => { const pay = window.computePayroll(p.id, p.partRate ? 88 : 176, 176); return s + (pay?.net || 0); }, 0); // Надходження: усі неоплачені інвойси з dueDate у горизонті const upcomingIn = invoices.filter(i => i.status !== "paid" && i.dueDate >= today); // Виплати: щомісячні витрати (розкидані по тижнях рівномірно) + ЗП в кінці кожного місяця // Для прогнозу спрощуємо: за тиждень = monthlyOutflow / 4.33 для нормальних витрат const weeklyExpense = monthlyOutflow / 4.33; // Заповнюємо тижні let runningBalance = totalUah; weeks.forEach(w => { // надходження upcomingIn.forEach(inv => { if (inv.dueDate >= w.start && inv.dueDate <= w.end) { w.inflow += inv.amount; w.inflowItems.push({ kind: "invoice", inv }); } }); // витрати: розкидаємо щомісячні w.outflow += weeklyExpense; // ЗП в останньому тижні кожного місяця (приблизно) const monthEnd = new Date(w.end); const nextDay = new Date(monthEnd); nextDay.setDate(nextDay.getDate() + 1); if (nextDay.getMonth() !== monthEnd.getMonth()) { w.outflow += monthlySalary; w.outflowItems.push({ kind: "salary", amount: monthlySalary }); } // Великі субпідряд/експертиза витрати з nextDate в цьому тижні expenses.forEach(e => { if (e.periodicity === "once" && !e.paid && e.nextDate >= w.start && e.nextDate <= w.end) { w.outflow += e.amount; w.outflowItems.push({ kind: "expense", e }); } }); w.net = w.inflow - w.outflow; runningBalance += w.net; w.balance = runningBalance; if (runningBalance < 0) w.hasGap = true; }); const minBalance = Math.min(...weeks.map(w => w.balance), totalUah); const gapWeeks = weeks.filter(w => w.hasGap); const totalInflow = weeks.reduce((s, w) => s + w.inflow, 0); const totalOutflow = weeks.reduce((s, w) => s + w.outflow, 0); return (

Cash flow

прогноз надходжень і витрат горизонт {horizon} тижнів {gapWeeks.length > 0 && <> можливий касовий розрив у {gapWeeks.length} {window.plural(gapWeeks.length, "тижні", "тижнях", "тижнях")} }
{/* Загальний баланс */}
Загальний баланс
{window.formatMoney(Math.round(totalUah))}
{accounts.length} рахунків · сьогодні
Очікувані надходження
+{window.formatMoney(totalInflow)}
{upcomingIn.length} рахунків · {horizon} тижнів
Заплановані виплати
−{window.formatMoney(Math.round(totalOutflow))}
ЗП · оренда · ПЗ · субпідряд
0 ? "is-warn" : ""}`}>
Мінімум балансу
{window.formatMoney(Math.round(minBalance))}
{gapWeeks.length > 0 ? "перевір прогноз!" : "усе в нормі"}
{/* Графік прогнозу */}

Прогноз балансу

по тижнях
{/* Рахунки */}

Рахунки

{accounts.length}
{accounts.map(a => (
{a.label}
{a.bank}
{a.primary && основний}
{a.currency === "USD" ? "$" : ""}{window.formatMoney(a.balance)} {a.currency === "USD" ? "USD" : "₴"}
{a.currency === "USD" && (
≈ {window.formatMoney(Math.round(a.balance * usdRate))} ₴
)} {a.iban &&
{a.iban}
}
))}
{/* Тижнева таблиця прогнозу */}

Деталізація по тижнях

{horizon} тижнів
{weeks.map((w, idx) => ( ))}
Тиждень Надходження Виплати Чистий рух Баланс на кінець Деталі
{w.label}
{w.range}
0 ? "var(--c-green-deep)" : "var(--ink-4)"}}> {w.inflow > 0 ? "+" + window.formatMoney(w.inflow) + " ₴" : "—"} −{window.formatMoney(Math.round(w.outflow))} ₴ = 0 ? "var(--c-green-deep)" : "var(--late)"}}> {w.net >= 0 ? "+" : ""}{window.formatMoney(Math.round(w.net))} ₴ {window.formatMoney(Math.round(w.balance))} ₴ {w.inflowItems.length + w.outflowItems.length > 0 ? : тільки регулярні }
{/* Останні операції */}

Останні операції

{ops.length} останніх рухів по рахунках
{ops.map(o => { const acc = accounts.find(a => a.id === o.account); return ( ); })}
Дата Опис Рахунок Сума
{window.formatDate(o.date)}
{o.type === "in" ? "↓" : "↑"}
{o.note}
{acc?.label} 0 ? "var(--c-green-deep)" : "var(--ink)"}}> {o.amount > 0 ? "+" : ""}{window.formatMoney(o.amount)} ₴
); } // ============ ГРАФІК ============ function CashflowChart({ weeks, initialBalance }) { const allBalances = [initialBalance, ...weeks.map(w => w.balance)]; const maxBal = Math.max(...allBalances); const minBal = Math.min(...allBalances, 0); const range = maxBal - minBal || 1; // bars: positive = inflow up, negative = outflow down const maxBar = Math.max(...weeks.map(w => Math.max(w.inflow, w.outflow)), 1); return (
{/* Bars: in/out per week */}
{weeks.map((w, idx) => (
{w.inflow > 0 && (
)} {w.outflow > 0 && (
)}
))}
{/* Balance line */} { const x = i * 60 + 30; const y = 200 - ((w.balance - minBal) / range) * 180 - 10; return `${x},${y}`; }).join(" ")} fill="none" stroke="var(--c-green-deep)" strokeWidth="2.5" strokeLinejoin="round" strokeLinecap="round" /> {weeks.map((w, i) => { const x = i * 60 + 30; const y = 200 - ((w.balance - minBal) / range) * 180 - 10; return ( ); })} {/* Week labels */}
{weeks.map((w, idx) => (
{w.label}
{Math.round(w.balance / 1000)}k
))}
); } // ============ helpers ============ function buildWeeks(todayIso, count) { const result = []; const today = new Date(todayIso); for (let i = 0; i < count; i++) { const start = new Date(today); start.setDate(start.getDate() + i * 7); const end = new Date(start); end.setDate(end.getDate() + 6); result.push({ start: start.toISOString().slice(0, 10), end: end.toISOString().slice(0, 10), label: i === 0 ? "Цей тиждень" : i === 1 ? "Наст. тиждень" : `${start.getDate()}.${pad2(start.getMonth()+1)}`, range: `${start.getDate()}.${pad2(start.getMonth()+1)}–${end.getDate()}.${pad2(end.getMonth()+1)}`, inflow: 0, outflow: 0, net: 0, balance: 0, inflowItems: [], outflowItems: [], hasGap: false, }); } return result; } function pad2(n) { return String(n).padStart(2, "0"); } window.CashflowPage = CashflowPage;