fns-receipt-service/public/app.js
romantarkin 87f6f35572 fix
2026-06-08 14:58:11 +05:00

305 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
? '<span class="badge ok">отправлено</span>'
: receipt.emailSent === false
? '<span class="badge bad">ошибка</span>'
: '<span class="badge warn">нет данных</span>';
const source = receipt.syncedFromFns ? "ФНС" : "локально";
const link = receipt.printLink
? `<a href="${receipt.printLink}" target="_blank" rel="noreferrer">открыть</a>`
: "";
return `
<tr>
<td>${formatDate(receipt.createdAt || receipt.operationTime)}</td>
<td>${receipt.email || '<span class="muted">неизвестно</span>'}</td>
<td>
<div>${receipt.receiptId || '<span class="muted">нет ID</span>'}</div>
<div class="muted">${(receipt.items || []).map(item => item.name || item.title).filter(Boolean).join(", ")}</div>
</td>
<td>${formatMoney(amount)} ₽</td>
<td>${emailBadge}</td>
<td><span class="badge">${source}</span></td>
<td>${link}</td>
</tr>
`;
}).join("") || '<tr><td colspan="7" class="muted">Чеков пока нет</td></tr>';
}
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 `
<div class="setting">
<label>
<span>${item.key}</span>
<span class="source">${item.source}${item.configured ? "" : " / пусто"}</span>
</label>
<input name="${item.key}" type="${type}" value="${item.value || ""}" placeholder="${placeholder}">
</div>
`;
}).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));