// api-client.jsx — шар даних: під'єднання прототипу до реального бекенду (FastAPI). // // LIVE-режим: бере дані з /api/* (той самий origin), з JWT-авторизацією і ролями. // DEMO-режим: якщо API недосяжний (пісочниця / file:// / немає бекенду) — тихо // відкочується на вбудовані seed-дані (window.DATA.* з data.jsx), щоб прототип // лишався придатним для ітерацій дизайну. Перемикання — автоматичне. // // Контракт: відповіді API мають ІДЕНТИЧНУ форму до масивів у data.jsx // (camelCase-поля, рядкові slug-id, гроші числом, дати "YYYY-MM-DD"), // тому рендер-код не змінюється — змінюється лише джерело даних. (function () { const LS_TOKEN = "erp_token"; const LS_BASE = "erp_api_base"; // override для локалки, напр. http://localhost:8000/api function resolveBase() { try { const ov = localStorage.getItem(LS_BASE); if (ov) return ov.replace(/\/+$/, ""); } catch (e) {} if (location.protocol === "file:") return null; const o = location.origin; if (!o || o === "null") return null; return o + "/api"; } const API = { base: resolveBase(), mode: "demo", // 'live' | 'demo' user: null, token: null, needAuth: false, }; let _resolveReady; API.ready = new Promise(r => { _resolveReady = r; }); try { API.token = localStorage.getItem(LS_TOKEN) || null; } catch (e) {} API.setToken = (t) => { API.token = t || null; try { t ? localStorage.setItem(LS_TOKEN, t) : localStorage.removeItem(LS_TOKEN); } catch (e) {} }; function ApiError(message, status, data) { this.message = message; this.status = status; this.data = data; this.isApiError = true; } ApiError.prototype = Object.create(Error.prototype); API.ApiError = ApiError; // --- низькорівневий fetch із Bearer і обробкою 401/403 --- async function apiFetch(path, opts) { opts = opts || {}; if (!API.base) throw new ApiError("no-api-base", 0); const headers = Object.assign({}, opts.headers || {}); const isForm = opts.body instanceof FormData; if (opts.body && !isForm) headers["Content-Type"] = "application/json"; if (API.token) headers["Authorization"] = "Bearer " + API.token; let res; try { res = await fetch(API.base + path, Object.assign({}, opts, { headers })); } catch (e) { throw new ApiError("network", 0, e); } if (res.status === 401) { API.setToken(null); API.user = null; window.dispatchEvent(new Event("erp-unauth")); throw new ApiError("unauthorized", 401); } if (res.status === 403) throw new ApiError("forbidden", 403); if (res.status === 204) return null; const ct = res.headers.get("content-type") || ""; if (!res.ok) { let data = null; try { data = ct.includes("json") ? await res.json() : await res.text(); } catch (e) {} throw new ApiError((data && data.detail) || ("http-" + res.status), res.status, data); } if (!ct.includes("application/json")) return res.blob(); return res.json(); } API.get = (p) => apiFetch(p); API.post = (p, body) => apiFetch(p, { method: "POST", body: body instanceof FormData ? body : JSON.stringify(body || {}) }); API.patch = (p, body) => apiFetch(p, { method: "PATCH", body: JSON.stringify(body || {}) }); API.del = (p) => apiFetch(p, { method: "DELETE" }); // повертає items[] з {items,total,limit,offset} або сирий масив API.list = async (resource, params) => { const qs = params ? "?" + new URLSearchParams(params).toString() : ""; const r = await API.get("/" + resource + qs); if (r && Array.isArray(r.items)) return r.items; return Array.isArray(r) ? r : []; }; // --- авторизація --- API.login = async (email, password) => { const r = await API.post("/auth/login", { email, password }); API.setToken(r.token); API.user = r.user; return r.user; }; API.fetchMe = async () => { const u = await API.get("/auth/me"); API.user = u; return u; }; API.logout = () => { API.setToken(null); API.user = null; try { location.reload(); } catch (e) {} }; // Первинне встановлення пароля при примусовій зміні (перший вхід) — лише новий пароль. API.setPassword = async (newPassword) => { const r = await API.post("/auth/set-initial-password", { newPassword }); if (API.user) API.user.mustChangePassword = false; return r; }; // Самозміна пароля (поточний → новий). Знімає прапорець mustChangePassword. API.changePassword = async (currentPassword, newPassword) => { const r = await API.post("/auth/change-password", { currentPassword, newPassword }); if (API.user) API.user.mustChangePassword = false; return r; }; // --- завантаження файлів (скани, акти) --- API.uploadFile = (file, meta) => { const fd = new FormData(); fd.append("file", file); if (meta) Object.keys(meta).forEach(k => meta[k] != null && fd.append(k, meta[k])); return API.post("/files", fd); }; // ============ BOOTSTRAP ============ // resource → ключ у window.DATA (slug-id зберігаються як є) const DATA_COLLECTIONS = { objects: "OBJECTS", tasks: "TASKS", "task-templates": "TASK_TEMPLATES", employees: "TEAM", vacations: "VACATIONS", documents: "DOCUMENTS", trainings: "TRAININGS", "active-onboardings": "ACTIVE_ONBOARDINGS", "one-on-ones": "ONE_ON_ONES", assets: "ASSETS", "inventory-sessions": "INVENTORY_SESSIONS", clients: "CLIENTS", contracts: "CONTRACTS", invoices: "INVOICES", suppliers: "SUPPLIERS", "sub-payments": "SUB_PAYMENTS", leads: "LEADS", expenses: "EXPENSES", "cash-accounts": "CASH_ACCOUNTS", "cash-ops": "CASH_OPS", "advance-reports": "ADVANCE_REPORTS", // Проєктна діяльність ГО — у LIVE тягнемо з бекенду (порожньо, поки не введуть реальні) projects: "PROJECTS", donors: "DONORS", "ngo-partners": "NGO_PARTNERS", }; function done(mode) { API.mode = mode; _resolveReady(mode); return mode; } function goDemo(reason) { if (reason) console.warn("[ERP] DEMO-режим:", reason); if (!API.user) API.user = { id: "ok", name: "Олена Кравченко", email: "o.kravchenko@ukrbudproiekt.ua", role: "director", canSeeSalary: true, canSeeCash: true, }; API.needAuth = false; return done("demo"); } // Публічний health-чек: чи бекенд узагалі є. /api/meta вимагає авторизації, // тому для детекту LIVE використовуємо саме /api/health (без токена). async function probeBackend() { try { const res = await fetch(API.base + "/health"); return { present: res.ok }; } catch (e) { return { present: false }; } } API.bootstrap = async function () { if (!API.base) return goDemo("немає origin (пісочниця/file://)"); const probe = await probeBackend(); if (!probe.present) return goDemo("бекенд недосяжний на " + API.base + " (/health)"); // Бекенд є → LIVE. Порядок: health → /auth/me (або вхід) → /meta + колекції API.mode = "live"; if (API.token) { try { await API.fetchMe(); } catch (e) { if (e.status === 401) API.setToken(null); } } if (!API.token || !API.user) { API.needAuth = true; return done("live"); } try { await API.loadData(null); API.needAuth = false; return done("live"); } catch (e) { return goDemo("не вдалося завантажити дані: " + (e.message || e)); } }; // Викликається після успішного логіну API.afterLogin = async function () { await API.loadData(null); API.needAuth = false; API.mode = "live"; }; // Тягне мету + усі колекції фази, кладе у window.DATA.*, перецілює геттери і обчислення API.loadData = async function (metaPreloaded) { window.DATA = window.DATA || {}; const meta = metaPreloaded || await API.get("/meta"); if (meta) applyMeta(meta); // довідники категорій (top-level, не під DATA) const [expCats, assetCats] = await Promise.all([ API.list("expense-categories").catch(() => null), API.list("asset-categories").catch(() => null), ]); if (expCats) window.EXPENSE_CATEGORIES = expCats; if (assetCats) window.ASSET_CATEGORIES = assetCats; // основні колекції — паралельно, нестачу ігноруємо (можуть бути приховані за роллю) const entries = Object.entries(DATA_COLLECTIONS); const results = await Promise.allSettled(entries.map(([res]) => API.list(res))); results.forEach((r, i) => { if (r.status === "fulfilled") window.DATA[entries[i][1]] = r.value; }); // обчислювані набори — кеш для синхронного рендеру (мають форму прототипу) if (API.user && API.user.canSeeSalary) { const period = meta && meta.payrollPeriod ? meta.payrollPeriod : null; try { const reg = await API.list("payroll" + (period ? "?period=" + period : "")); window.__payrollCache = indexBy(reg, r => (r.person && r.person.id) || r.employeeId || r.id); } catch (e) {} try { const yr = (meta && meta.sysDate ? meta.sysDate.slice(0, 4) : "2026"); window.__plCache = { [yr]: await API.get("/reports/pl?year=" + yr) }; } catch (e) {} } rebindGettersAndCompute(); }; function indexBy(arr, keyFn) { const m = {}; (arr || []).forEach(x => { const k = keyFn(x); if (k != null) m[k] = x; }); return m; } // Перенести значення /meta у глобали, які очікує UI (тільки наявні ключі) function applyMeta(meta) { if (meta.sysDate) { window.SYS_DATE = meta.sysDate; if (window.TODAY) window.TODAY = Object.assign({}, window.TODAY, { day: String(parseInt(meta.sysDate.slice(8, 10), 10)) }); } if (meta.currentWeek) window.CURRENT_WEEK = meta.currentWeek; if (meta.company) window.COMPANY = meta.company; if (meta.fxRates) window.FX_RATES = meta.fxRates; if (meta.taxRates) window.TAX_RATES = meta.taxRates; if (meta.overheadRates) window.OVERHEAD_RATES = meta.overheadRates; if (meta.permissionsCatalog) window.PERMISSIONS_CATALOG = meta.permissionsCatalog; const L = meta.labels || {}; if (L.employment) window.EMPLOYMENT_LABELS = L.employment; if (L.salaryModel) window.SALARY_MODEL_LABELS = L.salaryModel; if (L.assetStatus) window.ASSET_STATUS = L.assetStatus; if (L.assetLocations) window.ASSET_LOCATIONS = L.assetLocations; if (L.reconcileResult)window.RECONCILE_RESULT = L.reconcileResult; if (L.contractStatus) window.CONTRACT_STATUS = L.contractStatus; } // Геттери в data.jsx/warehouse-data.jsx замкнені на локальні seed-масиви — // після підміни window.DATA.* їх треба перецілити на свіжі дані. // Обчислення — перевага серверним полям (asset.depreciation, session.stats, кеш ЗП/P&L). function rebindGettersAndCompute() { window.getTeam = (id) => (window.DATA.TEAM || []).find(x => x.id === id); window.getObject = (id) => (window.DATA.OBJECTS || []).find(x => x.id === id); window.getClient = (id) => (window.DATA.CLIENTS || []).find(x => x.id === id); window.getContract= (id) => (window.DATA.CONTRACTS || []).find(x => x.id === id); window.getAsset = (id) => (window.DATA.ASSETS || []).find(x => x.id === id); window.getInventorySession = (id) => (window.DATA.INVENTORY_SESSIONS || []).find(x => x.id === id); window.getCategory = (id) => (window.EXPENSE_CATEGORIES || []).find(c => c.id === id); window.getAssetCategory = (id) => (window.ASSET_CATEGORIES || []).find(c => c.id === id) || (window.ASSET_CATEGORIES || [])[0]; // амортизація: сервер кладе у asset.depreciation const _dep = window.computeDepreciation; window.computeDepreciation = (a) => (a && a.depreciation) ? a.depreciation : (_dep ? _dep(a) : null); // зведення інвентаризації: сервер кладе у session.stats const _stats = window.inventoryStats; window.inventoryStats = (s) => (s && s.stats) ? s.stats : (_stats ? _stats(s) : null); // ЗП: перевага серверному кешу /payroll const _pay = window.computePayroll; window.computePayroll = (id, h, p) => { if (window.__payrollCache && window.__payrollCache[id]) return window.__payrollCache[id]; return _pay ? _pay(id, h, p) : null; }; // P&L: перевага серверному /reports/pl const _pl = window.computeAnnualPL; window.computeAnnualPL = (year) => { const y = year || "2026"; if (window.__plCache && window.__plCache[y]) return window.__plCache[y]; return _pl ? _pl(y) : null; }; // готівковий контур: видимість за роллю з сервера window.canSeeCash = (role) => API.user ? !!API.user.canSeeCash : (role === "director" || role === "accountant"); // наступний інв.№ для прев'ю (реальний номер генерує сервер при POST) window.nextInvNo = (catId) => { const cat = window.getAssetCategory(catId); const nums = (window.DATA.ASSETS || []).filter(a => a.cat === catId) .map(a => parseInt(String(a.invNo || "").split("-").pop(), 10)).filter(n => !isNaN(n)); const next = (nums.length ? Math.max.apply(null, nums) : 0) + 1; return "УБП-" + (cat ? cat.prefix : "??") + "-" + String(next).padStart(4, "0"); }; } // Зручний хелпер для майбутніх мутацій: зберегти й оновити локальний кеш API.save = async (resource, id, patch) => { return id ? API.patch("/" + resource + "/" + id, patch) : API.post("/" + resource, patch); }; window.API = API; })();