413 lines
14 KiB
JavaScript
413 lines
14 KiB
JavaScript
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 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 ? "сохранено, введите новое значение для замены" : "";
|
||
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);
|
||
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));
|