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; } async function downloadFile(path) { const response = await fetch(path, { headers: headers() }); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error || `HTTP ${response.status}`); } const blob = await response.blob(); const disposition = response.headers.get("Content-Disposition") || ""; const match = /filename="?([^"]+)"?/i.exec(disposition); const filename = match?.[1] || "receipts.csv"; const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.append(link); link.click(); link.remove(); URL.revokeObjectURL(url); } 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 isAppleOrAndroidDevice() { const userAgent = navigator.userAgent || ""; const platform = navigator.platform || ""; const isTouchMac = platform === "MacIntel" && navigator.maxTouchPoints > 1; return /Android|iPhone|iPad|iPod/i.test(userAgent) || isTouchMac; } function shouldHideDustMarket(value) { const normalized = String(value || "").trim().replace(/\s+/g, " ").toLowerCase(); return isAppleOrAndroidDevice() && normalized === "рынок пыли"; } 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 normalizeClientType(value) { return value === "legal" ? "legal" : "individual"; } function taxRateForClientType(clientType) { return normalizeClientType(clientType) === "legal" ? 0.06 : 0.04; } function receiptGross(receipt) { return Number(receipt.grossAmount ?? receipt.amount ?? receipt.totalAmount ?? 0) || 0; } function receiptTaxRate(receipt) { return Number(receipt.taxRate ?? taxRateForClientType(receipt.clientType)); } function receiptTax(receipt) { return Number(receipt.taxAmount ?? (receiptGross(receipt) * receiptTaxRate(receipt))) || 0; } function receiptNet(receipt) { return Number(receipt.netAmount ?? (receiptGross(receipt) - receiptTax(receipt))) || 0; } function receiptText(receipt) { const itemText = (receipt.items || []) .map(item => `${item.id || ""} ${item.name || item.title || ""}`) .join(" "); return `${receipt.email || ""} ${receipt.receiptId || ""} ${itemText}`.toLowerCase(); } function receiptTime(receipt) { return new Date(receipt.createdAt || receipt.operationTime || receipt.raw?.operationTime || "").getTime(); } function receiptInSelectedPeriod(receipt) { const from = $("#receipt-date-from").value; const to = $("#receipt-date-to").value; const time = receiptTime(receipt); if ((from || to) && Number.isNaN(time)) return false; if (from && time < new Date(`${from}T00:00:00.000Z`).getTime()) return false; if (to && time > new Date(`${to}T23:59:59.999Z`).getTime()) return false; return true; } function renderReceipts() { const query = $("#receipt-search").value.trim().toLowerCase(); const filtered = state.receipts.filter(receipt => receiptText(receipt).includes(query) && receiptInSelectedPeriod(receipt) ); const activeReceipts = filtered.filter(receipt => receipt.status !== "cancelled"); const grossTotal = activeReceipts.reduce((sum, receipt) => sum + receiptGross(receipt), 0); const taxTotal = activeReceipts.reduce((sum, receipt) => sum + receiptTax(receipt), 0); const netTotal = activeReceipts.reduce((sum, receipt) => sum + receiptNet(receipt), 0); $("#metric-count").textContent = activeReceipts.length; $("#metric-gross").textContent = formatMoney(grossTotal); $("#metric-tax").textContent = formatMoney(taxTotal); $("#metric-net").textContent = formatMoney(netTotal); $("#metric-errors").textContent = state.errors.length; $("#receipts-body").innerHTML = filtered.map(receipt => { const grossAmount = receiptGross(receipt); const taxAmount = receiptTax(receipt); const netAmount = receiptNet(receipt); const emailBadge = receipt.emailSent === true ? 'отправлено' : receipt.emailSent === false ? 'ошибка' : 'нет данных'; const source = receipt.syncedFromFns ? "ФНС" : "локально"; const statusBadge = receipt.status === "cancelled" ? 'аннулирован' : 'действует'; const clientTypeLabel = normalizeClientType(receipt.clientType) === "legal" ? "юр, 6%" : "физ, 4%"; const link = receipt.printLink ? `открыть` : ""; return ` ${formatDate(receipt.createdAt || receipt.operationTime)} ${receipt.email || 'неизвестно'}
${receipt.receiptId || 'нет ID'}
${clientTypeLabel}
${(receipt.items || []).map(item => item.name || item.title).filter(Boolean).join(", ")}
${formatMoney(grossAmount)} ₽ ${formatMoney(taxAmount)} ₽ ${formatMoney(netAmount)} ₽ ${statusBadge} ${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 ? "сохранено, введите новое значение для замены" : ""; const value = shouldHideDustMarket(item.value) ? "" : item.value; 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); const tax = total * taxRateForClientType($("#client-type").value); const net = total - tax; $("#create-total").textContent = formatMoney(total); $("#create-tax").textContent = formatMoney(tax); $("#create-net").textContent = formatMoney(net); } 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); $("#receipt-date-from").addEventListener("input", renderReceipts); $("#receipt-date-to").addEventListener("input", renderReceipts); $("#reload-receipts").addEventListener("click", () => loadReceipts().then(() => toast("Чеки обновлены")).catch(err => toast(err.message))); $("#export-receipts").addEventListener("click", async () => { try { const params = new URLSearchParams(); const search = $("#receipt-search").value.trim(); const dateFrom = $("#receipt-date-from").value; const dateTo = $("#receipt-date-to").value; if (search) params.set("search", search); if (dateFrom) params.set("dateFrom", dateFrom); if (dateTo) params.set("dateTo", dateTo); await downloadFile(`/admin/api/receipts/export?${params.toString()}`); toast("Экспорт скачан"); } catch (err) { toast(err.message); } }); $("#sync-fns").addEventListener("click", async () => { try { const month = $("#sync-month").value; const result = await api("/admin/api/receipts/sync", { method: "POST", body: JSON.stringify({ month }) }); await loadReceipts(); toast(`ФНС ${result.month}: действует ${result.activeSynced}, аннулировано ${result.cancelledSynced}`); } catch (err) { toast(err.message); } }); $("#add-item").addEventListener("click", () => addItemRow()); $("#client-type").addEventListener("change", updateCreateTotal); $("#create-form").addEventListener("submit", async event => { event.preventDefault(); const email = new FormData(event.currentTarget).get("email"); const clientType = new FormData(event.currentTarget).get("clientType"); const items = currentItems(); if (!items.length) { toast("Добавьте хотя бы одну позицию с ценой"); return; } try { const result = await api("/api/v1/create-receipt", { method: "POST", body: JSON.stringify({ email, clientType, 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(); $("#sync-month").value = new Date().toISOString().slice(0, 7); bootstrap().catch(err => toast(err.message));