// purchases-page.jsx — Закупівлі: вхідні рахунки постачальників (до сплати) // + прибуткові накладні (надходження). ОЗ-позиції створюють картки в «Майні». window.DATA.PURCHASE_INVOICES = window.DATA.PURCHASE_INVOICES || []; window.DATA.GOODS_RECEIPTS = window.DATA.GOODS_RECEIPTS || []; // Сплачена закупівля → разовий видаток у «Витратах» (id детермінований, без дублів). window.syncPurchaseExpense = async function (inv) { if (!inv || inv.status !== "paid") return; const live = window.API && window.API.mode === "live" && window.API.save; const expId = "e-pur-" + inv.id; const supName = ((window.DATA.SUPPLIERS || []).find(s => s.id === inv.supplier) || {}).name || "Постачальник"; const patch = { cat: "equipment", title: supName + (inv.number ? " · " + inv.number : ""), supplier: supName, amount: inv.amount || 0, monthlyAmount: 0, periodicity: "once", paid: true, paidDate: inv.paidDate || window.SYS_DATE, note: "Із «Закупівель»", receiptUrl: inv.receiptUrl || null, vatRate: inv.vatMode === "without" ? 0 : 20, taxInvoice: false, }; const existing = (window.DATA.EXPENSES || []).find(e => e.id === expId); if (existing) { Object.assign(existing, patch); if (live) { try { await window.API.save("expenses", expId, patch); } catch (e) { console.error("[ERP] sync purchase expense:", e); } } } else { const exp = { id: expId, ...patch, _added: true }; (window.DATA.EXPENSES || (window.DATA.EXPENSES = [])).push(exp); if (live) { try { await window.API.save("expenses", null, exp); } catch (e) { console.error("[ERP] create purchase expense:", e); } } } }; // ── Редактор позицій (рядки) ── function PurchaseItems({ items, setItems, withKind }) { const assetCats = window.ASSET_CATEGORIES || []; const setRow = (i, k, v) => setItems(items.map((r, j) => { if (j !== i) return r; const r2 = { ...r, [k]: v }; r2.sum = Math.round((Number(r2.qty) || 0) * (Number(r2.price) || 0) * 100) / 100; return r2; })); const addRow = () => setItems([...items, { name: "", qty: 1, unit: "шт", price: "", sum: 0, ...(withKind ? { kind: "material", assetCat: "oz" } : {}) }]); const rmRow = (i) => setItems(items.filter((_, j) => j !== i)); return (
{items.map((r, i) => (
setRow(i, "name", e.target.value)} placeholder="Найменування" /> setRow(i, "qty", e.target.value)} placeholder="к-сть" title="Кількість" /> setRow(i, "unit", e.target.value)} placeholder="од." title="Одиниця" /> setRow(i, "price", e.target.value)} placeholder="ціна ₴" title="Ціна за одиницю" /> {window.formatMoney(r.sum || 0)} ₴ {withKind && ( )} {withKind && r.kind === "asset" && ( )}
))}
); } function itemsTotal(items) { return (items || []).reduce((s, r) => s + (Number(r.sum) || 0), 0); } // ── Новий / редагування рахунку постачальника ── function NewPurchaseInvoiceModal({ onClose, onSaved, editing }) { const SYS = window.SYS_DATE || "2026-05-29"; const suppliers = window.DATA.SUPPLIERS || []; const addDays = (iso, n) => { try { const d = new Date(iso + "T00:00:00"); d.setDate(d.getDate() + n); return d.toISOString().slice(0, 10); } catch (_) { return iso; } }; const isEdit = !!editing; const [f, setF] = React.useState(isEdit ? { supplier: editing.supplier || "", number: editing.number || "", date: editing.date || SYS, dueDate: editing.dueDate || SYS, vatMode: editing.vatMode || "with", status: editing.status || "to_pay", note: editing.note || "", receiptUrl: editing.receiptUrl || "", } : { supplier: "", number: "", date: SYS, dueDate: addDays(SYS, 7), vatMode: "with", status: "to_pay", note: "", receiptUrl: "", }); const [items, setItems] = React.useState(isEdit && Array.isArray(editing.items) ? editing.items.map(x => ({ ...x })) : [{ name: "", qty: 1, unit: "шт", price: "", sum: 0 }]); const [busy, setBusy] = React.useState(false); const set = (k, v) => setF(p => ({ ...p, [k]: v })); React.useEffect(() => { const onKey = (e) => e.key === "Escape" && onClose(); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); const amount = itemsTotal(items); const vat = f.vatMode === "with" ? Math.round(amount * 20 / 120 * 100) / 100 : 0; const valid = f.supplier && amount > 0 && items.some(r => r.name.trim()); const save = async () => { if (!valid || busy) return; const live = window.API && window.API.mode === "live" && window.API.save; const cleanItems = items.filter(r => r.name.trim()).map(r => ({ name: r.name.trim(), qty: Number(r.qty) || 0, unit: r.unit || "шт", price: Number(r.price) || 0, sum: Number(r.sum) || 0 })); const patch = { supplier: f.supplier, number: f.number.trim(), date: f.date, dueDate: f.dueDate || f.date, items: cleanItems, amount, vatMode: f.vatMode, vat, status: f.status, paidDate: f.status === "paid" ? (isEdit && editing.paidDate ? editing.paidDate : SYS) : null, note: f.note.trim() || null, receiptUrl: f.receiptUrl.trim() || null, }; if (isEdit) { Object.assign(editing, patch); } else { patch.id = "pin-" + Date.now(); patch.received = false; window.DATA.PURCHASE_INVOICES.push(patch); } if (live) { setBusy(true); try { await window.API.save("purchase-invoices", isEdit ? editing.id : null, patch); } catch (e) { console.error("[ERP] purchase invoice save:", e); alert("Не вдалося зберегти рахунок на сервері."); } } await window.syncPurchaseExpense(isEdit ? editing : patch); // якщо сплачено → у «Витрати» onSaved && onSaved(); onClose(); }; return (
e.stopPropagation()} onSubmit={e => { e.preventDefault(); save(); }}>
{isEdit ? "Редагувати рахунок постачальника" : "Рахунок постачальника (до сплати)"}
set("number", e.target.value)} placeholder="напр. РФ-1024" />
set("date", e.target.value)} />
set("dueDate", e.target.value)} />
Разом: {window.formatMoney(amount)} ₴
{f.vatMode === "with" &&
у т.ч. ПДВ {window.formatMoney(vat)} ₴
}
set("receiptUrl", e.target.value)} placeholder="https://… (скан рахунку)" /> set("note", e.target.value)} placeholder="необов'язково" /> {!valid &&
Оберіть постачальника й додайте хоча б одну позицію з ціною.
}
); } // ── Нове надходження (прибуткова накладна) ── function NewGoodsReceiptModal({ onClose, onSaved, fromInvoice }) { const SYS = window.SYS_DATE || "2026-05-29"; const suppliers = window.DATA.SUPPLIERS || []; const invoices = window.DATA.PURCHASE_INVOICES || []; const [f, setF] = React.useState({ supplier: fromInvoice ? fromInvoice.supplier : "", invoice: fromInvoice ? fromInvoice.id : "", number: "", date: SYS, note: "", receiptUrl: "", }); const [items, setItems] = React.useState( fromInvoice && Array.isArray(fromInvoice.items) ? fromInvoice.items.map(x => ({ ...x, kind: "asset", assetCat: "oz" })) : [{ name: "", qty: 1, unit: "шт", price: "", sum: 0, kind: "asset", assetCat: "oz" }] ); const [busy, setBusy] = React.useState(false); const set = (k, v) => setF(p => ({ ...p, [k]: v })); React.useEffect(() => { const onKey = (e) => e.key === "Escape" && onClose(); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); const supplierInvoices = invoices.filter(i => !f.supplier || i.supplier === f.supplier); const pickInvoice = (id) => { const inv = invoices.find(x => x.id === id); if (inv && Array.isArray(inv.items)) setItems(inv.items.map(x => ({ ...x, kind: "asset", assetCat: "oz" }))); setF(p => ({ ...p, invoice: id, supplier: inv ? inv.supplier : p.supplier })); }; const amount = itemsTotal(items); const valid = f.supplier && amount > 0 && items.some(r => r.name.trim()); const save = async () => { if (!valid || busy) return; const live = window.API && window.API.mode === "live" && window.API.save; if (live) setBusy(true); try { const cleanItems = []; for (const r of items.filter(x => x.name.trim())) { const it = { name: r.name.trim(), qty: Number(r.qty) || 0, unit: r.unit || "шт", price: Number(r.price) || 0, sum: Number(r.sum) || 0, kind: r.kind || "material" }; // ОЗ-позиція → створюємо картку в «Майні» if (it.kind === "asset") { const asset = { id: "as-" + Date.now() + "-" + cleanItems.length, cat: r.assetCat || "oz", name: it.name, cost: it.sum, bought: f.date, status: "active", lifeMonths: null, subscription: false, }; window.DATA.ASSETS && window.DATA.ASSETS.push(asset); if (live) { try { const saved = await window.API.post("/assets", asset); if (saved && saved.id) { it.assetId = saved.id; if (saved.invNo) it.assetInvNo = saved.invNo; } } catch (e) { console.error("[ERP] create asset from receipt:", e); } } } cleanItems.push(it); } const rec = { id: "grn-" + Date.now(), supplier: f.supplier, invoice: f.invoice || null, number: f.number.trim(), date: f.date, items: cleanItems, amount, note: f.note.trim() || null, receiptUrl: f.receiptUrl.trim() || null, }; window.DATA.GOODS_RECEIPTS.push(rec); if (live) await window.API.save("goods-receipts", null, rec); // позначити рахунок оформленим if (f.invoice) { const inv = invoices.find(x => x.id === f.invoice); if (inv) { inv.received = true; if (live) await window.API.save("purchase-invoices", inv.id, { received: true }); } } } catch (e) { console.error("[ERP] goods receipt save:", e); alert("Не вдалося зберегти надходження на сервері."); } onSaved && onSaved(); onClose(); }; return (
e.stopPropagation()} onSubmit={e => { e.preventDefault(); save(); }}>
Надходження (прибуткова накладна)
set("number", e.target.value)} placeholder="напр. ВН-558" />
set("date", e.target.value)} />
Разом: {window.formatMoney(amount)} ₴
set("receiptUrl", e.target.value)} placeholder="https://… (скан накладної від постачальника)" /> set("note", e.target.value)} placeholder="необов'язково" /> {!valid &&
Оберіть постачальника й додайте позиції.
}
); } // ── Сторінка «Закупівлі» ── function PurchasesPage({ role }) { const invoices = window.DATA.PURCHASE_INVOICES || []; const receipts = window.DATA.GOODS_RECEIPTS || []; const today = window.SYS_DATE || "2026-05-29"; const [tab, setTab] = React.useState("invoices"); const [openInv, setOpenInv] = React.useState(false); // false | "new" | invoice const [openRec, setOpenRec] = React.useState(false); // false | "new" | {fromInvoice} const [, tick] = React.useState(0); const canEdit = role === "director" || role === "accountant"; const supName = (id) => (window.DATA.SUPPLIERS.find(s => s.id === id) || {}).name || "—"; // — Привʼязка оплачених рахунків до «Грошей» (видаток з обраного рахунку, 1 раз) — const live = !!(window.API && window.API.mode === "live"); const [accounts, setAccounts] = React.useState([]); const [cashOps, setCashOps] = React.useState([]); const [payAcct, setPayAcct] = React.useState("cash"); const [busyPay, setBusyPay] = React.useState(false); const reloadOps = React.useCallback(() => { if (!live) return Promise.resolve(); return window.API.list("cash-ops").then(r => setCashOps(r || [])).catch(() => {}); }, [live]); React.useEffect(() => { if (!live) return; window.API.list("cash-accounts").then(r => { setAccounts(r || []); const tov = (r || []).find(a => a.entity === "tov"); if (tov) setPayAcct(tov.id); }).catch(() => {}); reloadOps(); }, [live, reloadOps]); const moneyAccounts = [{ id: null, label: "Готівка", entity: "cash" }, ...accounts]; const acctVal = (id) => id === null ? "cash" : id; const acctToId = (v) => v === "cash" ? null : v; const postedRefs = new Set(cashOps.map(o => o.ref).filter(Boolean)); const isPosted = (inv) => postedRefs.has("pi:" + inv.id); const postToMoney = async (inv) => { if (!live) { alert("Доступно лише в робочому режимі (з бекендом)."); return; } if (isPosted(inv)) { alert("Цей рахунок уже проведено в гроші."); return; } const acctId = acctToId(payAcct); const acctLabel = (moneyAccounts.find(a => (a.id || null) === (acctId || null)) || {}).label || "рахунок"; if (!confirm(`Провести оплату ${window.formatMoney(inv.amount)} ₴ постачальнику «${supName(inv.supplier)}» як видаток з «${acctLabel}»?`)) return; setBusyPay(true); try { await window.API.post("/cash-ops", { date: inv.paidDate || today, type: "out", category: "supplier", amount: inv.amount, counterparty: supName(inv.supplier), note: "Закупівля № " + (inv.number || ""), account: acctId, ref: "pi:" + inv.id }); await reloadOps(); alert("Проведено в гроші — баланс рахунку зменшено."); } catch (e) { alert("Не вдалося провести: " + (e.message || e)); } setBusyPay(false); }; const toPay = invoices.filter(i => i.status !== "paid"); const overdue = toPay.filter(i => i.dueDate && i.dueDate < today); const toPaySum = toPay.reduce((s, i) => s + (i.amount || 0), 0); const paidSum = invoices.filter(i => i.status === "paid").reduce((s, i) => s + (i.amount || 0), 0); const markPaid = async (inv) => { if (!confirm(`Позначити рахунок ${inv.number || ""} як сплачений?`)) return; inv.status = "paid"; inv.paidDate = today; tick(x => x + 1); if (window.API && window.API.mode === "live" && window.API.save) { try { await window.API.save("purchase-invoices", inv.id, { status: "paid", paidDate: today }); } catch (e) { console.error(e); alert("Не вдалося зберегти на сервері."); } } await window.syncPurchaseExpense(inv); // → у «Витрати» }; const delInv = async (inv) => { if (!confirm(`Видалити рахунок ${inv.number || ""}? Дію не скасувати.`)) return; const a = window.DATA.PURCHASE_INVOICES; const i = a.indexOf(inv); if (i >= 0) a.splice(i, 1); tick(x => x + 1); if (window.API && window.API.mode === "live" && window.API.del) { try { await window.API.del("/purchase-invoices/" + inv.id); } catch (e) { console.error(e); } } }; const delRec = async (r) => { if (!confirm(`Видалити надходження ${r.number || ""}?`)) return; const a = window.DATA.GOODS_RECEIPTS; const i = a.indexOf(r); if (i >= 0) a.splice(i, 1); tick(x => x + 1); if (window.API && window.API.mode === "live" && window.API.del) { try { await window.API.del("/goods-receipts/" + r.id); } catch (e) { console.error(e); } } }; return (

Закупівлі

вхідні рахунки постачальників і надходження (прибуткові накладні)
0 ? "is-warn" : ""}`}>
До сплати
{window.formatMoney(toPaySum)}
{toPay.length} рахунків
0 ? "is-warn" : ""}`}>
Прострочено
0 ? "var(--late)" : "var(--ink)" }}>{overdue.length}
{window.formatMoney(overdue.reduce((s, i) => s + (i.amount || 0), 0))} ₴
Сплачено
{window.formatMoney(paidSum)}
за весь час
Надходжень
{receipts.length}
прибуткових накладних
{canEdit && tab === "invoices" && ( )} {canEdit && tab === "invoices" && } {canEdit && tab === "receipts" && }
{tab === "invoices" && ( {invoices.map(inv => { const isOver = inv.status !== "paid" && inv.dueDate && inv.dueDate < today; return ( ); })} {invoices.length === 0 && }
№ / постачальникПозиціїДатаСплатити доСумаСтатус
{supName(inv.supplier)}
№ {inv.number || "—"}{inv.received ? " · оприбутковано" : ""}
{(inv.items || []).length} поз. · {(inv.items || []).slice(0, 2).map(x => x.name).join(", ")}{(inv.items || []).length > 2 ? "…" : ""} {window.formatDate(inv.date)} {window.formatDate(inv.dueDate)}{isOver &&
прострочено
}
{window.formatMoney(inv.amount)} ₴ {inv.status === "paid" ? сплачено : до сплати} {inv.receiptUrl && /^https?:\/\//i.test(inv.receiptUrl) && }
{canEdit && !inv.received && } {canEdit && inv.status !== "paid" && } {canEdit && inv.status === "paid" && (isPosted(inv) ? у грошах ✓ : )} {canEdit && } {canEdit && }
Рахунків постачальників ще немає.
)} {tab === "receipts" && ( {receipts.map(r => ( ))} {receipts.length === 0 && }
№ / постачальникПозиціїДатаСумаНакладна
{supName(r.supplier)}
№ {r.number || "—"}
{(r.items || []).length} поз.{(r.items || []).some(x => x.kind === "asset") ? " · є ОЗ → Майно" : ""} {window.formatDate(r.date)} {window.formatMoney(r.amount)} ₴ {r.receiptUrl && /^https?:\/\//i.test(r.receiptUrl) ? 📎 накладна : }
{canEdit && }
Надходжень ще немає.
)} {openInv && setOpenInv(false)} onSaved={() => tick(x => x + 1)} />} {openRec && setOpenRec(false)} onSaved={() => tick(x => x + 1)} />}
); } window.PurchasesPage = PurchasesPage;