const state = { token: localStorage.getItem("fnsAdminToken") || "", tokenExpiresAt: Number(localStorage.getItem("fnsAdminTokenExpiresAt") || 0), receipts: [], errors: [], config: [] }; const $ = selector => document.querySelector(selector); const $$ = selector => [...document.querySelectorAll(selector)]; function headers() { const result = { "Content-Type": "application/json" }; if (state.token) { result.Authorization = `Bearer ${state.token}`; } return result; } async function api(path, options = {}) { const response = await fetch(path, { ...options, headers: { ...headers(), ...(options.headers || {}) } }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.error || data.technicalError?.message || `HTTP ${response.status}`); } return data; } function saveToken(auth) { state.token = auth.token; state.tokenExpiresAt = auth.expiresAt; localStorage.setItem("fnsAdminToken", state.token); localStorage.setItem("fnsAdminTokenExpiresAt", String(state.tokenExpiresAt)); } function clearToken() { state.token = ""; state.tokenExpiresAt = 0; localStorage.removeItem("fnsAdminToken"); localStorage.removeItem("fnsAdminTokenExpiresAt"); } function tokenLooksFresh() { return Boolean(state.token && state.tokenExpiresAt * 1000 > Date.now() + 30000); } async function login(password) { const auth = await api("/admin/api/login", { method: "POST", body: JSON.stringify({ password }) }); saveToken(auth); return auth; } function toast(message) { const el = $("#toast"); el.textContent = message; el.classList.remove("hidden"); clearTimeout(toast.timer); toast.timer = setTimeout(() => el.classList.add("hidden"), 4200); } function formatDate(value) { if (!value) return "-"; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString("ru-RU"); } function formatMoney(value) { return (Number(value) || 0).toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function receiptText(receipt) { const itemText = (receipt.items || []) .map(item => `${item.id || ""} ${item.name || item.title || ""}`) .join(" "); return `${receipt.email || ""} ${receipt.receiptId || ""} ${itemText}`.toLowerCase(); } function renderReceipts() { const query = $("#receipt-search").value.trim().toLowerCase(); const filtered = state.receipts.filter(receipt => receiptText(receipt).includes(query)); const total = filtered.reduce((sum, receipt) => sum + (Number(receipt.amount) || Number(receipt.totalAmount) || 0), 0); $("#metric-count").textContent = filtered.length; $("#metric-total").textContent = formatMoney(total); $("#metric-errors").textContent = state.errors.length; $("#receipts-body").innerHTML = filtered.map(receipt => { const amount = receipt.amount || receipt.totalAmount || 0; const emailBadge = receipt.emailSent === true ? 'отправлено' : receipt.emailSent === false ? 'ошибка' : 'нет данных'; const source = receipt.syncedFromFns ? "ФНС" : "локально"; const link = receipt.printLink ? `открыть` : ""; return ` ${formatDate(receipt.createdAt || receipt.operationTime)} ${receipt.email || 'неизвестно'}
${receipt.receiptId || 'нет ID'}
${(receipt.items || []).map(item => item.name || item.title).filter(Boolean).join(", ")}
${formatMoney(amount)} ₽ ${emailBadge} ${source} ${link} `; }).join("") || 'Чеков пока нет'; } function renderSettings() { $("#settings-list").innerHTML = state.config.map(item => { const type = item.secret ? "password" : item.key.includes("PORT") || item.key.includes("TIMEOUT") ? "number" : "text"; const placeholder = item.secret && item.configured ? "сохранено, введите новое значение для замены" : ""; return `
`; }).join(""); } async function loadReceipts() { const data = await api("/admin/api/receipts"); state.receipts = data.receipts || []; state.errors = data.errors || []; renderReceipts(); } async function loadConfig() { const data = await api("/admin/api/config"); state.config = data.config || []; renderSettings(); } async function bootstrap() { if (!tokenLooksFresh()) { clearToken(); return; } await api("/admin/api/session", { method: "POST", body: "{}" }); $("#login-form").classList.add("hidden"); $("#session-actions").classList.remove("hidden"); await Promise.all([loadReceipts(), loadConfig()]); } function addItemRow(item = {}) { const template = $("#item-row-template").content.cloneNode(true); const row = template.querySelector(".item-row"); row.querySelector('[name="id"]').value = item.id || ""; row.querySelector('[name="name"]').value = item.name || ""; row.querySelector('[name="price"]').value = item.price || ""; row.querySelector('[name="quantity"]').value = item.quantity || 1; row.querySelector(".remove-item").addEventListener("click", () => { row.remove(); updateCreateTotal(); }); row.addEventListener("input", updateCreateTotal); $("#items-list").append(row); updateCreateTotal(); } function currentItems() { return $$("#items-list .item-row").map(row => ({ id: row.querySelector('[name="id"]').value.trim(), name: row.querySelector('[name="name"]').value.trim(), price: Number(row.querySelector('[name="price"]').value), quantity: Number(row.querySelector('[name="quantity"]').value) || 1 })).filter(item => item.name && item.price > 0); } function updateCreateTotal() { const total = currentItems().reduce((sum, item) => sum + item.price * item.quantity, 0); $("#create-total").textContent = formatMoney(total); } function showView(name) { $$(".tab").forEach(tab => tab.classList.toggle("active", tab.dataset.view === name)); $$(".view").forEach(view => view.classList.toggle("active", view.id === `view-${name}`)); } function bindEvents() { $$(".tab").forEach(tab => tab.addEventListener("click", () => showView(tab.dataset.view))); $("#login-form").addEventListener("submit", async event => { event.preventDefault(); try { await login($("#admin-password").value); $("#admin-password").value = ""; await bootstrap(); toast("Вход выполнен"); } catch (err) { toast(err.message); } }); $("#logout-button").addEventListener("click", () => { clearToken(); location.reload(); }); $("#receipt-search").addEventListener("input", renderReceipts); $("#reload-receipts").addEventListener("click", () => loadReceipts().then(() => toast("Чеки обновлены")).catch(err => toast(err.message))); $("#sync-fns").addEventListener("click", async () => { try { const result = await api("/admin/api/receipts/sync", { method: "POST", body: "{}" }); await loadReceipts(); toast(`Синхронизировано из ФНС: ${result.synced}`); } catch (err) { toast(err.message); } }); $("#add-item").addEventListener("click", () => addItemRow()); $("#create-form").addEventListener("submit", async event => { event.preventDefault(); const email = new FormData(event.currentTarget).get("email"); const items = currentItems(); if (!items.length) { toast("Добавьте хотя бы одну позицию с ценой"); return; } try { const result = await api("/api/v1/create-receipt", { method: "POST", body: JSON.stringify({ email, items }) }); await loadReceipts(); toast(result.emailSent ? "Чек создан и отправлен" : "Чек создан, но письмо не ушло"); } catch (err) { toast(err.message); } }); $("#settings-form").addEventListener("submit", async event => { event.preventDefault(); const values = Object.fromEntries(new FormData(event.currentTarget).entries()); const nextPassword = values.API_PASS; const rotatesJwtSecret = Boolean(values.JWT_SECRET); try { const data = await api("/admin/api/config", { method: "PUT", body: JSON.stringify({ values }) }); if (nextPassword) { await login(nextPassword); } else if (rotatesJwtSecret) { clearToken(); $("#login-form").classList.remove("hidden"); $("#session-actions").classList.add("hidden"); } state.config = data.config || []; renderSettings(); toast(rotatesJwtSecret && !nextPassword ? "Настройки сохранены, войдите заново" : "Настройки сохранены"); } catch (err) { toast(err.message); } }); $("#check-health").addEventListener("click", async () => { $("#health-output").textContent = JSON.stringify(await fetch("/health/deep").then(r => r.json()), null, 2); }); $("#check-smtp").addEventListener("click", async () => { $("#health-output").textContent = JSON.stringify(await fetch("/health/smtp").then(r => r.json()), null, 2); }); $("#check-fns-user").addEventListener("click", async () => { try { $("#health-output").textContent = JSON.stringify(await api("/admin/api/fns-user"), null, 2); } catch (err) { $("#health-output").textContent = err.message; } }); } bindEvents(); addItemRow(); bootstrap().catch(err => toast(err.message));