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));