// cash-salary-debt.jsx — Борг готівкою: нарахування і видача ЗП «у конверті» по кожній людині, // щоб бачити загальну заборгованість компанії. Чутливо — лише при увімкненому показі готівки. const scdRound2 = (n) => Math.round((Number(n) || 0) * 100) / 100; const scdParseAmt = (s) => parseFloat(String(s).replace(/\s/g, "").replace(",", ".")) || 0; const SCD_MONTHS_UA = ["січень","лютий","березень","квітень","травень","червень","липень","серпень","вересень","жовтень","листопад","грудень"]; const scdPeriodLabel = (id) => { if (!id) return "—"; const [y, m] = id.split("-").map(Number); return m >= 1 && m <= 12 ? `${SCD_MONTHS_UA[m - 1]} ${y}` : id; }; const scdRnd = () => Math.random().toString(36).slice(2, 10); function AccrueForm({ team, defaultPeriod, onSave, onCancel }) { const [employee, setEmployee] = React.useState(team[0] ? team[0].id : ""); const [date, setDate] = React.useState(window.SYS_DATE); const [amount, setAmount] = React.useState(""); const [note, setNote] = React.useState(""); const [busy, setBusy] = React.useState(false); const num = scdParseAmt(amount); const canSave = num > 0 && employee; const save = async () => { if (!canSave || busy) return; setBusy(true); const ok = await onSave({ employee, date, period: date.slice(0, 7), amount: scdRound2(num), note: note.trim() }); setBusy(false); if (ok !== false) onCancel(); }; return (
Ручне нарахування боргу (напр., заднім числом за минулі періоди — щоб відобразити реальну картину).
setDate(e.target.value)} />
setAmount(e.target.value)} inputMode="decimal" placeholder="напр. 15000" autoFocus />
setNote(e.target.value)} placeholder={`напр. борг за ${scdPeriodLabel(defaultPeriod)}`} />
); } function PayoutForm({ team, presetEmployee, onSave, onCancel }) { const [employee, setEmployee] = React.useState(presetEmployee || (team[0] ? team[0].id : "")); const [date, setDate] = React.useState(window.SYS_DATE); const [amount, setAmount] = React.useState(""); const [note, setNote] = React.useState(""); const [busy, setBusy] = React.useState(false); const num = scdParseAmt(amount); const canSave = num > 0 && employee; const save = async () => { if (!canSave || busy) return; setBusy(true); const ok = await onSave({ employee, date, amount: scdRound2(num), note: note.trim() }); setBusy(false); if (ok !== false) onCancel(); }; return (
Видача готівкою зменшує борг перед працівником і одночасно списується з готівкового рахунка в «Гроші».
setDate(e.target.value)} />
setAmount(e.target.value)} inputMode="decimal" placeholder="напр. 5000" autoFocus />
setNote(e.target.value)} placeholder="напр. часткова видача" />
); } function PersonDebtDrawer({ person, entries, canEdit, onAccrue, onPayout, onDelete, onClose }) { const [adding, setAdding] = React.useState(null); // "accrual" | "payment" | null React.useEffect(() => { const onKey = (e) => e.key === "Escape" && onClose(); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); const chrono = [...entries].sort((a, b) => (a.date || "").localeCompare(b.date || "") || (a.id > b.id ? 1 : -1)); let run = 0; const withBalance = chrono.map(e => { run += e.kind === "accrual" ? e.amount : -e.amount; return { ...e, balance: run }; }); const accrued = entries.filter(e => e.kind === "accrual").reduce((s, e) => s + e.amount, 0); const paid = entries.filter(e => e.kind === "payment").reduce((s, e) => s + e.amount, 0); const debt = scdRound2(accrued - paid); const m = window.formatMoney; return ( <>
); } function CashSalaryDebtPage({ role }) { const cashVisible = window.useCashVisible(); const canSeeCash = window.canSeeCash(role); const live = !!(window.API && window.API.mode === "live"); const canEdit = role === "director" || role === "accountant"; const team = window.DATA.TEAM || []; const m = window.formatMoney; const period = window.PAYROLL_PERIOD; const [entries, setEntries] = React.useState([]); const [openPerson, setOpenPerson] = React.useState(null); const [addingAccrual, setAddingAccrual] = React.useState(false); const [addingPayout, setAddingPayout] = React.useState(false); const [busy, setBusy] = React.useState(false); const fromApi = (e) => ({ ...e }); const reload = React.useCallback(() => { if (!live) return Promise.resolve(); return window.API.list("salary-cash").then(r => setEntries((r || []).map(fromApi))).catch(() => {}); }, [live]); React.useEffect(() => { reload(); }, [reload]); if (!canSeeCash) { return (

Доступ обмежено

Готівковий контур бачать лише директор і бухгалтер.

); } if (!cashVisible) { return (

Борг готівкою

заборгованість по ЗП «у конверті» · конфіденційно

Дані приховано

Управлінський облік готівкової ЗП. Дані чутливі — увімкніть показ, щоб переглянути.

); } const byPerson = {}; team.forEach(p => { byPerson[p.id] = { person: p, accrued: 0, paid: 0, last: null, entries: [] }; }); entries.forEach(e => { if (!byPerson[e.employee]) return; // працівник видалений з команди const row = byPerson[e.employee]; row.entries.push(e); if (e.kind === "accrual") row.accrued += e.amount; else row.paid += e.amount; if (e.kind === "payment" && (!row.last || e.date > row.last)) row.last = e.date; }); const rows = Object.values(byPerson).map(r => ({ ...r, debt: scdRound2(r.accrued - r.paid) })); const totalAccrued = rows.reduce((s, r) => s + r.accrued, 0); const totalPaid = rows.reduce((s, r) => s + r.paid, 0); const totalDebt = scdRound2(totalAccrued - totalPaid); const inDebtCount = rows.filter(r => r.debt > 0).length; // — динаміка по місяцях (для всієї команди) — const byMonth = {}; entries.forEach(e => { const mo = (e.date || "").slice(0, 7); if (!mo) return; if (!byMonth[mo]) byMonth[mo] = { accrued: 0, paid: 0 }; if (e.kind === "accrual") byMonth[mo].accrued += e.amount; else byMonth[mo].paid += e.amount; }); const months = Object.keys(byMonth).sort(); let cum = 0; const monthRows = months.map(mo => { cum += byMonth[mo].accrued - byMonth[mo].paid; return { mo, ...byMonth[mo], cum: scdRound2(cum) }; }).reverse(); const alreadyAccruedThisPeriod = new Set(entries.filter(e => e.kind === "accrual" && e.period === period.id).map(e => e.employee)); const accrueCurrentPeriod = async () => { const todo = team.filter(p => (p.cashSalary || 0) > 0 && !alreadyAccruedThisPeriod.has(p.id)); if (!todo.length) { alert("Усі нарахування за цей період уже внесені."); return; } if (!confirm(`Нарахувати готівкову ЗП за ${period.label} для ${todo.length} ${window.plural ? window.plural(todo.length, "особи", "осіб", "осіб") : "осіб"}?`)) return; setBusy(true); try { for (const p of todo) { await window.API.post("/salary-cash", { employee: p.id, date: window.SYS_DATE, period: period.id, kind: "accrual", amount: p.cashSalary, note: `Нараховано за ${period.label}` }); } await reload(); } catch (e) { alert("Помилка нарахування: " + (e.message || e)); } setBusy(false); }; const saveAccrual = async (data) => { try { await window.API.post("/salary-cash", { ...data, kind: "accrual" }); await reload(); return true; } catch (e) { alert("Не вдалося зберегти нарахування: " + (e.message || e)); return false; } }; const savePayout = async (data) => { const id = "sce-" + scdRnd(); const ref = "zpc:" + id; const person = team.find(p => p.id === data.employee); try { await window.API.post("/salary-cash", { id, ...data, kind: "payment", cashOpRef: ref }); await window.API.post("/cash-ops", { date: data.date, type: "out", category: "salary", amount: data.amount, account: null, counterparty: person ? person.name : data.employee, note: data.note || `Видача ЗП готівкою${person ? " · " + person.name : ""}`, ref }); await reload(); return true; } catch (e) { alert("Не вдалося провести видачу: " + (e.message || e)); return false; } }; const deleteEntry = async (entry) => { if (!confirm("Видалити запис? Разом з ним видалиться повʼязана касова операція (якщо є).")) return; try { await window.API.del("/salary-cash/" + entry.id); if (entry.kind === "payment" && entry.cashOpRef) { const ops = await window.API.list("cash-ops", { ref: entry.cashOpRef }).catch(() => []); await Promise.all((ops || []).map(o => window.API.del("/cash-ops/" + o.id))); } await reload(); } catch (e) { alert("Не вдалося видалити: " + (e.message || e)); } }; return (

Борг готівкою

заборгованість по ЗП «у конверті» · по кожній людині конфіденційно
Як це працює. «Нараховано» — скільки готівкою належить видати (за поточний період — автоматично з розрахунку ЗП, за минулі періоди — вносите вручну). «Видано» — реальні видачі, які ви фіксуєте самі. Різниця — борг перед працівником.
Загальний борг
{m(totalDebt)}
{inDebtCount} {window.plural ? window.plural(inDebtCount, "людина", "людини", "людей") : "людей"} з боргом
Нараховано всього
{m(totalAccrued)}
Видано всього
{m(totalPaid)}
Поточний період
{period.label}
{canEdit && live && (
)} {addingAccrual &&
setAddingAccrual(false)} />
} {addingPayout &&
setAddingPayout(false)} />
} {rows.map(r => ( ))}
Працівник Нараховано Видано Борг Остання видача
{r.person.initials}
{r.person.name}
{r.person.role}
{m(r.accrued)} {m(r.paid)} 0 ? "var(--late)" : "var(--ink-3)"}}>{m(r.debt)} ₴ {r.last ? window.formatDate(r.last) : "—"}
Усього {m(totalAccrued)} {m(totalPaid)} {m(totalDebt)} ₴
{monthRows.length > 0 && (

Динаміка по місяцях

по всій команді · наростаючим підсумком
{monthRows.map(r => ( ))}
МісяцьНарахованоВиданоБорг на кінець місяця
{scdPeriodLabel(r.mo)} {m(r.accrued)} {m(r.paid)} 0 ? "var(--late)" : "var(--ink-3)"}}>{m(r.cum)} ₴
)} {openPerson && byPerson[openPerson] && ( setOpenPerson(null)} /> )}
); } window.CashSalaryDebtPage = CashSalaryDebtPage;