fns-receipt-service/public/app.js
romantarkin 2dd70399fb fix
2026-06-13 17:07:49 +05:00

426 lines
15 KiB
JavaScript
Raw Permalink 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;
}
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
? '<span class="badge ok">отправлено</span>'
: receipt.emailSent === false
? '<span class="badge bad">ошибка</span>'
: '<span class="badge warn">нет данных</span>';
const source = receipt.syncedFromFns ? "ФНС" : "локально";
const statusBadge = receipt.status === "cancelled"
? '<span class="badge bad">аннулирован</span>'
: '<span class="badge ok">действует</span>';
const clientTypeLabel = normalizeClientType(receipt.clientType) === "legal" ? "юр, 6%" : "физ, 4%";
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">${clientTypeLabel}</div>
<div class="muted">${(receipt.items || []).map(item => item.name || item.title).filter(Boolean).join(", ")}</div>
</td>
<td>${formatMoney(grossAmount)} ₽</td>
<td>${formatMoney(taxAmount)} ₽</td>
<td>${formatMoney(netAmount)} ₽</td>
<td>${statusBadge}</td>
<td>${emailBadge}</td>
<td><span class="badge">${source}</span></td>
<td>${link}</td>
</tr>
`;
}).join("") || '<tr><td colspan="10" 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 ? "сохранено, введите новое значение для замены" : "";
const value = shouldHideDustMarket(item.value) ? "" : item.value;
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="${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);
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));