305 lines
10 KiB
JavaScript
305 lines
10 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;
|
||
}
|
||
|
||
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));
|