const tg = window.Telegram?.WebApp;
tg?.ready();
tg?.expand();
const textNodes = new WeakMap();
const attrOriginals = new WeakMap();
const i18n = {
en: {
"Гараж": "Garage",
"Автомобиль": "Vehicle",
"Не выбран": "Not selected",
"Добавь авто или выбери из списка": "Add a vehicle or choose one",
"Расходы": "Expenses",
"топливо, сервис и ремонты": "fuel, service and repairs",
"Средний расход": "Average consumption",
"л/100 км по полным данным": "L/100 km from complete data",
"Автомобили": "Vehicles",
"Профиль учета": "Tracking profile",
"Старт": "Start",
"Добавь авто и первую запись, чтобы видеть точные отчеты": "Add a vehicle and first entry to see accurate reports",
"Отчет": "Report",
"Стоимость владения": "Ownership cost",
"Весь срок": "All time",
"Выбери марку": "Choose make",
"Выбери модель": "Choose model",
"Сначала марка": "Choose make first",
"Топливо": "Fuel",
"Эффективность": "Efficiency",
"Месяц": "Month",
"День": "Day",
"Квартал": "Quarter",
"Год": "Year",
"Свой период": "Custom period",
"Заправка": "Fuel",
"Сервис": "Service",
"Скан чека": "Receipt scan",
"30 сек": "30 sec",
"ТО / ремонт": "Maintenance / repair",
"Динамика расходов": "Expense trend",
"Структура": "Breakdown",
"Дата": "Date",
"Одометр, км": "Odometer, km",
"Литры": "Liters",
"Цена за литр": "Price per liter",
"АЗС": "Fuel station",
"Не выбрано": "Not selected",
"Полный бак": "Full tank",
"Сохранить заправку": "Save fuel entry",
"Тип": "Type",
"Обслуживание": "Maintenance",
"Ремонт": "Repair",
"Жидкости": "Fluids",
"Шины": "Tires",
"Осмотр": "Inspection",
"Страховка": "Insurance",
"Налог": "Tax",
"Другое": "Other",
"Что сделано": "Work done",
"Масло": "Oil",
"Стоимость": "Cost",
"Исполнитель": "Vendor",
"Сохранить запись": "Save entry",
"Меню": "Menu",
"Добавить автомобиль": "Add vehicle",
"Локаль и валюта": "Language and currency",
"Уведомления": "Notifications",
"Сканировать чек": "Scan receipt",
"Настройки": "Settings",
"Язык": "Language",
"Валюта": "Currency",
"Сохранить настройки": "Save settings",
"Напомним о ТО, страховке и регулярном внесении пробега.": "We'll remind you about maintenance, insurance and regular odometer updates.",
"Включить уведомления": "Enable notifications",
"Фото или файл чека": "Receipt photo or file",
"Сфотографировать": "Take photo",
"Выбрать файл": "Choose file",
"Файл не выбран": "No file selected",
"Распознать": "Recognize",
"После распознавания поля заправки заполнятся автоматически.": "After recognition, fuel fields will be filled automatically.",
"Новое авто": "New vehicle",
"Название авто": "Vehicle name",
"Марка": "Make",
"Модель": "Model",
"Добавить авто": "Add vehicle",
"За весь срок": "All time",
"За месяц": "This month",
"За день": "Per day",
"За квартал": "Quarter",
"За год": "Year",
"За период": "Period",
"На 100 км": "Per 100 km",
"На 1 км": "Per 1 km",
"записей": "entries",
"среднее в периоде": "average in period",
"нет данных": "no data",
"Выбери автомобиль": "Choose a vehicle",
"Выбери автомобиль для статистики": "Choose a vehicle for stats",
"Добавь первый автомобиль": "Add your first vehicle",
"Без деталей": "No details",
"Профиль точный": "Accurate profile",
"Хороший учет": "Good tracking",
"Набираем данные": "Collecting data",
"Отчеты уже достаточно надежны для решений по расходам": "Reports are reliable enough for expense decisions",
"Чем регулярнее записи, тем точнее расход, цена километра и напоминания": "More regular entries make consumption, cost per km and reminders more accurate",
"Итого": "Total",
"Стоимость 1 км": "Cost per km",
"Пробег": "Mileage",
"Записей": "Entries",
"Потрачено": "Spent",
"Литров": "Liters",
"Средняя заправка": "Average refill",
"Расход": "Consumption",
"Главная категория": "Top category",
"Макс. категория": "Max category",
"Прогноз сегодня": "Today forecast",
"+30 дней": "+30 days",
"Лучший рост точности даст привычка заносить одометр при каждой заправке и сервисе.": "Accuracy improves most when odometer is entered at every fuel and service record.",
"Нет записей за выбранный период": "No entries for selected period",
"Добавь заправку или сервисную запись": "Add fuel or service entry",
"Нет расходов": "No expenses",
"топливо": "fuel",
"Уведомления включены": "Notifications enabled",
"Уведомления запрещены в настройках браузера": "Notifications are blocked in browser settings",
"Браузер не поддерживает уведомления": "Browser does not support notifications",
"PWA установлена и работает офлайн после первого открытия.": "PWA is installed and works offline after first open.",
"Напоминания готовы": "Reminders are ready",
"Мы напомним о ТО, страховке и обновлении пробега.": "We'll remind you about maintenance, insurance and mileage updates.",
"Напоминаний на ближайшее время нет": "No reminders due soon",
"Готов к работе": "Ready",
"Обновляю данные...": "Refreshing data...",
"Сохраняю...": "Saving...",
"Сохранено": "Saved",
"Распознаю чек...": "Recognizing receipt...",
"Выбери файл чека": "Choose receipt file",
"Проверь распознанные значения": "Check recognized values",
"Ошибка": "Error",
"Прогноз цены": "Price forecast",
"Текущая цена": "Current price",
"Средняя цена": "Average price",
},
ko: {
"Гараж": "차고",
"Автомобиль": "차량",
"Не выбран": "선택 안 됨",
"Добавь авто или выбери из списка": "차량을 추가하거나 목록에서 선택하세요",
"Расходы": "지출",
"топливо, сервис и ремонты": "연료, 정비, 수리",
"Средний расход": "평균 연비",
"л/100 км по полным данным": "완전한 데이터 기준 L/100km",
"Автомобили": "차량",
"Профиль учета": "기록 프로필",
"Старт": "시작",
"Отчет": "리포트",
"Стоимость владения": "소유 비용",
"Весь срок": "전체",
"Выбери марку": "브랜드 선택",
"Выбери модель": "모델 선택",
"Сначала марка": "브랜드를 먼저 선택하세요",
"Топливо": "연료",
"Эффективность": "효율",
"Месяц": "월",
"День": "일",
"Квартал": "분기",
"Год": "년",
"Свой период": "직접 선택",
"Заправка": "주유",
"Сервис": "정비",
"Скан чека": "영수증 스캔",
"30 сек": "30초",
"ТО / ремонт": "정비 / 수리",
"Динамика расходов": "지출 추이",
"Структура": "구성",
"Дата": "날짜",
"Одометр, км": "주행거리, km",
"Литры": "리터",
"Цена за литр": "리터당 가격",
"АЗС": "주유소",
"Не выбрано": "선택 안 됨",
"Полный бак": "가득 주유",
"Сохранить заправку": "주유 저장",
"Тип": "유형",
"Обслуживание": "정비",
"Ремонт": "수리",
"Жидкости": "오일/액체",
"Шины": "타이어",
"Осмотр": "점검",
"Страховка": "보험",
"Налог": "세금",
"Другое": "기타",
"Что сделано": "작업 내용",
"Масло": "오일",
"Стоимость": "비용",
"Исполнитель": "업체",
"Сохранить запись": "기록 저장",
"Меню": "메뉴",
"Добавить автомобиль": "차량 추가",
"Локаль и валюта": "언어와 통화",
"Уведомления": "알림",
"Сканировать чек": "영수증 스캔",
"Настройки": "설정",
"Язык": "언어",
"Валюта": "통화",
"Сохранить настройки": "설정 저장",
"Напомним о ТО, страховке и регулярном внесении пробега.": "정비, 보험, 주행거리 입력을 알려드릴게요.",
"Включить уведомления": "알림 켜기",
"Фото или файл чека": "영수증 사진 또는 파일",
"Сфотографировать": "사진 촬영",
"Выбрать файл": "파일 선택",
"Файл не выбран": "선택된 파일 없음",
"Распознать": "인식",
"После распознавания поля заправки заполнятся автоматически.": "인식 후 주유 입력란이 자동으로 채워집니다.",
"Новое авто": "새 차량",
"Название авто": "차량 이름",
"Марка": "브랜드",
"Модель": "모델",
"Добавить авто": "차량 추가",
"За весь срок": "전체",
"За месяц": "월",
"За день": "일 평균",
"За квартал": "분기",
"За год": "년",
"За период": "기간",
"На 100 км": "100km당",
"На 1 км": "1km당",
"записей": "개 기록",
"среднее в периоде": "기간 평균",
"нет данных": "데이터 없음",
"Выбери автомобиль": "차량을 선택하세요",
"Выбери автомобиль для статистики": "통계를 볼 차량을 선택하세요",
"Добавь первый автомобиль": "첫 차량을 추가하세요",
"Без деталей": "상세 정보 없음",
"Профиль точный": "정확한 프로필",
"Хороший учет": "좋은 기록",
"Набираем данные": "데이터 수집 중",
"Итого": "합계",
"Стоимость 1 км": "1km 비용",
"Пробег": "주행거리",
"Записей": "기록",
"Потрачено": "지출",
"Литров": "리터",
"Средняя заправка": "평균 주유",
"Расход": "연비",
"Главная категория": "주요 카테고리",
"Макс. категория": "최대 카테고리",
"Прогноз сегодня": "오늘 예측",
"+30 дней": "+30일",
"Нет записей за выбранный период": "선택한 기간에 기록이 없습니다",
"Добавь заправку или сервисную запись": "주유 또는 정비 기록을 추가하세요",
"Нет расходов": "지출 없음",
"топливо": "연료",
"Уведомления включены": "알림이 켜졌습니다",
"Уведомления запрещены в настройках браузера": "브라우저 설정에서 알림이 차단되었습니다",
"Браузер не поддерживает уведомления": "브라우저가 알림을 지원하지 않습니다",
"PWA установлена и работает офлайн после первого открытия.": "PWA는 첫 실행 후 오프라인에서도 작동합니다.",
"Напоминания готовы": "알림 준비 완료",
"Мы напомним о ТО, страховке и обновлении пробега.": "정비, 보험, 주행거리 업데이트를 알려드릴게요.",
"Напоминаний на ближайшее время нет": "다가오는 알림이 없습니다",
"Готов к работе": "준비 완료",
"Обновляю данные...": "데이터 새로고침 중...",
"Сохраняю...": "저장 중...",
"Сохранено": "저장됨",
"Распознаю чек...": "영수증 인식 중...",
"Выбери файл чека": "영수증 파일을 선택하세요",
"Проверь распознанные значения": "인식된 값을 확인하세요",
"Ошибка": "오류",
"Прогноз цены": "가격 예측",
"Текущая цена": "현재 가격",
"Средняя цена": "평균 가격",
},
};
function t(text) {
return i18n[state.user?.locale]?.[text] || text;
}
function applyTranslations(root = document.body) {
document.documentElement.lang = state.user?.locale || "ru";
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent || ["SCRIPT", "STYLE"].includes(parent.tagName)) return NodeFilter.FILTER_REJECT;
return node.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
},
});
while (walker.nextNode()) {
const node = walker.currentNode;
if (!textNodes.has(node)) textNodes.set(node, node.nodeValue.trim());
const original = textNodes.get(node);
node.nodeValue = node.nodeValue.replace(node.nodeValue.trim(), t(original));
}
root.querySelectorAll?.("[placeholder], [aria-label], [title]").forEach((element) => {
["placeholder", "aria-label", "title"].forEach((attr) => {
const value = element.getAttribute(attr);
if (!value) return;
let originals = attrOriginals.get(element);
if (!originals) {
originals = {};
attrOriginals.set(element, originals);
}
originals[attr] ||= value;
element.setAttribute(attr, t(originals[attr]));
});
});
}
const state = {
user: null,
authConfig: null,
cars: [],
catalog: [],
selectedCarId: null,
latestFuel: [],
latestService: [],
latestExpenses: [],
latestStats: null,
allStats: null,
analytics: null,
serviceCenters: [],
publicServiceCenters: [],
appointments: [],
maintenanceRecommendations: [],
stoCalendar: [],
confirmations: null,
connectedServices: [],
adminPendingServices: [],
vehicleScore: null,
vehicleTimeline: [],
achievements: [],
receiptFile: null,
serviceWorkerRegistration: null,
period: {
preset: "month",
dateFrom: null,
dateTo: null,
},
};
async function initPwa() {
if (!("serviceWorker" in navigator)) return;
try {
state.serviceWorkerRegistration = await navigator.serviceWorker.register("/sw.js");
} catch (error) {
console.warn("Service worker registration failed", error);
}
}
function updateNotificationStatus(message) {
const node = document.querySelector("#notificationStatus");
if (node) node.textContent = t(message);
}
async function enableNotifications() {
if (!("Notification" in window)) {
updateNotificationStatus("Браузер не поддерживает уведомления");
return;
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
updateNotificationStatus("Уведомления запрещены в настройках браузера");
return;
}
const registration = state.serviceWorkerRegistration || (await navigator.serviceWorker?.ready);
if (registration?.pushManager && window.APP_VAPID_PUBLIC_KEY) {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(window.APP_VAPID_PUBLIC_KEY),
});
localStorage.setItem("driversPushSubscription", JSON.stringify(subscription));
await savePushSubscription(subscription);
}
await showDueReminders(registration);
if (registration?.showNotification) {
await registration.showNotification(t("Напоминания готовы"), {
body: t("Мы напомним о ТО, страховке и обновлении пробега."),
icon: "/static/icon.svg",
badge: "/static/icon.svg",
tag: "drivers-bot-ready",
});
}
updateNotificationStatus("Уведомления включены");
}
async function showDueReminders(registration) {
if (!state.user) return;
const reminders = await api(`/users/${state.user.id}/reminders`);
if (!reminders.length) {
updateNotificationStatus("Напоминаний на ближайшее время нет");
return;
}
updateNotificationStatus(`Есть напоминаний: ${reminders.length}`);
if (Notification.permission !== "granted" || !registration?.showNotification) return;
const item = reminders[0];
const body = item.due_odometer
? `${item.car_name}: ${item.title}, срок ${item.due_odometer} км`
: `${item.car_name}: ${item.title}, срок ${item.due_date}`;
await registration.showNotification(t("Напоминания готовы"), {
body,
icon: "/static/icon.svg",
badge: "/static/icon.svg",
tag: `drivers-reminder-${item.id}`,
data: "/",
});
}
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
function today() {
return new Date().toISOString().slice(0, 10);
}
function formData(form) {
return Object.fromEntries(new FormData(form).entries());
}
async function api(path, options = {}) {
const headers = { "Content-Type": "application/json", ...authHeaders(options.headers || {}) };
if (options.body instanceof FormData) delete headers["Content-Type"];
const response = await fetch(`/api${path}`, {
headers,
...options,
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || response.statusText);
}
if (response.status === 204) return null;
return response.json();
}
function authHeaders(extra = {}) {
const headers = { ...extra };
if (tg?.initData) headers["X-Telegram-Init-Data"] = tg.initData;
if (!tg?.initData && state.authConfig?.allow_dev_auth) {
headers["X-Dev-Telegram-Id"] = localStorage.getItem("driversDevTelegramId") || "1";
}
return headers;
}
async function loadAuthConfig() {
state.authConfig = await api("/users/auth/config");
window.APP_VAPID_PUBLIC_KEY = state.authConfig.vapid_public_key || "";
}
function setStatus(message = "Готов к работе") {
const node = document.querySelector("#statusBar");
if (node) node.textContent = t(message);
}
function toast(message, tone = "success") {
const node = document.querySelector("#toast");
if (!node) return;
node.textContent = t(message);
node.className = `toast ${tone}`;
window.clearTimeout(toast.timer);
toast.timer = window.setTimeout(() => node.classList.add("hidden"), 2600);
}
function haptic(type = "light") {
try {
if (type === "error") tg?.HapticFeedback?.notificationOccurred("error");
else if (type === "success") tg?.HapticFeedback?.notificationOccurred("success");
else tg?.HapticFeedback?.impactOccurred(type);
} catch (_) {
// Telegram haptics are best-effort and absent in regular browsers.
}
}
function setButtonBusy(button, busy, label = "Сохраняю...") {
if (!button) return;
if (button.tagName !== "BUTTON") {
button.disabled = busy;
button.classList.toggle("is-busy", busy);
return;
}
if (busy) {
button.dataset.label = button.textContent;
button.disabled = true;
button.classList.add("is-busy");
button.innerHTML = `${t(label)}`;
} else {
button.disabled = false;
button.classList.remove("is-busy");
button.textContent = button.dataset.label || button.textContent;
delete button.dataset.label;
}
}
async function runAction(button, statusMessage, callback) {
haptic();
setStatus(statusMessage);
setButtonBusy(button, true, statusMessage);
try {
const result = await callback();
setStatus("Готов к работе");
return result;
} catch (error) {
console.error(error);
setStatus("Ошибка");
toast(error.message || "Ошибка", "error");
haptic("error");
throw error;
} finally {
setButtonBusy(button, false);
}
}
async function ensureUser() {
if (tg?.initData) {
state.user = await api("/users/webapp-auth", {
method: "POST",
body: JSON.stringify({ init_data: tg.initData }),
});
hideAuthOverlay();
updateRoleVisibility();
return;
}
if (state.authConfig?.allow_dev_auth) {
const devId = localStorage.getItem("driversDevTelegramId") || "1";
localStorage.setItem("driversDevTelegramId", devId);
state.user = await api("/users/me");
hideAuthOverlay();
updateRoleVisibility();
return;
}
await showTelegramLogin();
throw new Error("Требуется вход через Telegram");
}
function hideAuthOverlay() {
document.querySelector("#authOverlay")?.classList.add("hidden");
document.body.classList.remove("auth-required");
}
function updateRoleVisibility() {
const isAdmin = ["admin", "verifier", "moderator"].includes(state.user?.platform_role);
document.querySelectorAll(".admin-only").forEach((node) => node.classList.toggle("hidden", !isAdmin));
}
function showTelegramOpenHint() {
const overlay = document.querySelector("#authOverlay");
const slot = document.querySelector("#telegramLoginSlot");
const link = document.querySelector("#telegramLoginLink");
const message = document.querySelector("#authMessage");
const note = document.querySelector("#authNote");
overlay?.classList.remove("hidden");
document.body.classList.add("auth-required");
const botUsername = state.authConfig?.bot_username;
if (message) {
message.textContent = botUsername
? "Откройте CarPass через Telegram. Бот привяжет гараж к вашему аккаунту и покажет кнопку Mini App."
: "Это приложение открывается через Telegram-бота. Настройте BOT_USERNAME на сервере.";
}
if (slot && !slot.dataset.ready) slot.textContent = "";
if (note) {
note.textContent = isMobileBrowser()
? "После перехода нажмите Start, затем кнопку «Открыть CarPass» под сообщением бота."
: "На компьютере можно войти кнопкой Telegram ниже или открыть бота.";
}
if (!botUsername) {
return;
}
if (link) {
link.href = telegramBotUrl(botUsername);
link.target = isMobileBrowser() ? "_self" : "_blank";
link.classList.remove("hidden");
}
}
async function showTelegramLogin() {
showTelegramOpenHint();
const slot = document.querySelector("#telegramLoginSlot");
if (!slot || slot.dataset.ready) return;
const botUsername = state.authConfig?.bot_username;
if (!botUsername) return;
if (isMobileBrowser()) {
slot.innerHTML = `В мобильном браузере авторизация проходит через Telegram-бота.`;
slot.dataset.ready = "true";
return;
}
window.onTelegramAuth = async (user) => {
state.user = await api("/users/telegram-login", {
method: "POST",
body: JSON.stringify(user),
});
localStorage.setItem("driversUser", JSON.stringify(state.user));
hideAuthOverlay();
updateRoleVisibility();
await loadCars();
};
const script = document.createElement("script");
script.async = true;
script.src = "https://telegram.org/js/telegram-widget.js?22";
script.setAttribute("data-telegram-login", botUsername);
script.setAttribute("data-size", "large");
script.setAttribute("data-radius", "8");
script.setAttribute("data-request-access", "write");
script.setAttribute("data-onauth", "onTelegramAuth(user)");
script.addEventListener("error", () => {
slot.textContent = "Кнопка Telegram не загрузилась. Используй вход ниже.";
});
slot.dataset.ready = "true";
slot.appendChild(script);
}
function isMobileBrowser() {
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) || window.matchMedia("(max-width: 640px)").matches;
}
function telegramBotUrl(botUsername) {
return `https://t.me/${botUsername}?start=garage`;
}
async function savePushSubscription(subscription) {
if (!state.user || !subscription) return;
await api(`/users/${state.user.id}/push-subscriptions`, {
method: "POST",
body: JSON.stringify({
...subscription.toJSON(),
user_agent: navigator.userAgent,
}),
});
}
function money(value) {
const currency = state.user?.currency || "RUB";
return Number(value || 0).toLocaleString(
{ ru: "ru-RU", en: "en-US", ko: "ko-KR" }[state.user?.locale] || "ru-RU",
{ style: "currency", currency, maximumFractionDigits: currency === "KRW" ? 0 : 2 },
);
}
function selectedCar() {
return state.cars.find((car) => car.id === state.selectedCarId) || null;
}
function numberOrNull(value) {
return value === "" || value == null ? null : Number(value);
}
function shiftMonths(base, count) {
const copy = new Date(base);
copy.setMonth(copy.getMonth() + count);
return copy;
}
function dateValue(date) {
return date.toISOString().slice(0, 10);
}
function applyPeriodPreset(preset = "month") {
document.querySelector("#periodPreset").value = preset;
const now = new Date();
const to = dateValue(now);
let fromDate = new Date(now.getFullYear(), now.getMonth(), 1);
if (preset === "all") fromDate = new Date(2000, 0, 1);
if (preset === "7d") fromDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6);
if (preset === "30d" || preset === "month") fromDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29);
if (preset === "3m" || preset === "quarter") fromDate = shiftMonths(now, -3);
if (preset === "6m") fromDate = shiftMonths(now, -6);
if (preset === "12m" || preset === "year") fromDate = shiftMonths(now, -12);
if (preset === "day") fromDate = now;
if (preset !== "custom") {
document.querySelector("#periodFrom").value = dateValue(fromDate);
document.querySelector("#periodTo").value = to;
}
state.period = {
preset,
dateFrom: document.querySelector("#periodFrom").value,
dateTo: document.querySelector("#periodTo").value,
};
}
function periodQuery() {
const params = new URLSearchParams();
if (state.period.dateFrom) params.set("date_from", state.period.dateFrom);
if (state.period.dateTo) params.set("date_to", state.period.dateTo);
const query = params.toString();
return query ? `?${query}` : "";
}
function allPeriodQuery() {
return "?date_from=2000-01-01&date_to=2100-01-01";
}
async function loadCatalog() {
state.catalog = await api("/catalog/makes");
}
function initCarCatalog() {
const makeSelect = document.querySelector("#makeSelect");
const modelSelect = document.querySelector("#modelSelect");
const trimSelect = document.querySelector("#trimSelect");
const fuelTypeSelect = document.querySelector("#fuelTypeSelect");
const preview = document.querySelector("#catalogPreview");
const makes = [...state.catalog].sort((a, b) => a.name.localeCompare(b.name, "ru"));
makeSelect.innerHTML = `` + makes
.map((make) => ``)
.join("");
function selectedModel() {
const make = state.catalog.find((item) => item.name === makeSelect.value);
return make?.models.find((model) => model.name === modelSelect.value) || null;
}
function syncPreview() {
const model = selectedModel();
const trim = model?.trims?.find((item) => item.name === trimSelect.value);
if (!model) {
preview.innerHTML = `${t("Выбери модель")}Покажем кузов, топливо, привод и годы выпуска.`;
return;
}
const chips = [
trim?.body_type,
trim?.fuel_type,
trim?.transmission,
trim?.drive_type,
trim?.year_from && trim?.year_to ? `${trim.year_from}-${trim.year_to}` : null,
].filter(Boolean);
preview.innerHTML = `
${makeSelect.value} ${model.name}${trim ? ` · ${trim.name}` : ""}
${chips.length ? chips.join(" · ") : "Базовые параметры можно уточнить позже"}
`;
if (trim?.fuel_type && !fuelTypeSelect.value) fuelTypeSelect.value = trim.fuel_type;
}
function syncTrims() {
const model = selectedModel();
const trims = model?.trims || [];
trimSelect.disabled = !trims.length;
trimSelect.innerHTML = trims.length
? `` + trims.map((trim) => ``).join("")
: ``;
syncPreview();
}
function syncModels() {
const make = makeSelect.value;
const models = state.catalog.find((item) => item.name === make)?.models || [];
modelSelect.disabled = !models.length;
modelSelect.innerHTML = models.length
? `` + models.map((model) => ``).join("")
: ``;
syncTrims();
}
makeSelect.addEventListener("change", syncModels);
modelSelect.addEventListener("change", syncTrims);
trimSelect.addEventListener("change", syncPreview);
syncModels();
}
function resetCarCatalog() {
document.querySelector("#makeSelect").value = "";
const modelSelect = document.querySelector("#modelSelect");
const trimSelect = document.querySelector("#trimSelect");
modelSelect.disabled = true;
modelSelect.innerHTML = ``;
trimSelect.disabled = true;
trimSelect.innerHTML = ``;
document.querySelector("#catalogPreview").innerHTML =
`${t("Выбери модель")}Покажем кузов, топливо, привод и годы выпуска.`;
}
function updateHero(stats) {
const car = selectedCar();
document.querySelector("#selectedCarTitle").textContent = stats ? money(stats.cost_per_month || stats.total_cost || 0) : t("Не выбран");
document.querySelector("#selectedCarMeta").textContent = car
? [car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей")
: t("Добавь авто или выбери из списка");
document.querySelector("#summaryTotal").textContent = stats?.cost_per_km ? money(stats.cost_per_km) : "-";
document.querySelector("#summaryConsumption").textContent = stats ? money(stats.forecast_next_month || 0) : "-";
}
function formatFuelPrice(value) {
if (!value) return "-";
return money(value).replace(/\s?₽|RUB/i, "").trim();
}
function renderCars() {
const root = document.querySelector("#cars");
const drawerRoot = document.querySelector("#drawerCars");
if (!state.cars.length) {
root.innerHTML = `
${t("Добавь первый автомобиль")}
`;
if (drawerRoot) drawerRoot.innerHTML = root.innerHTML;
updateHero(null);
return;
}
const markup = state.cars
.map(
(car) => `
`,
)
.join("");
root.innerHTML = markup;
if (drawerRoot) drawerRoot.innerHTML = markup;
document.querySelectorAll("[data-car]").forEach((button) => {
button.addEventListener("click", () => selectCar(Number(button.dataset.car)));
});
}
function setInputValue(form, name, value) {
if (!form?.elements[name]) return;
const input = form.elements[name];
if (input.type === "checkbox") {
input.checked = Boolean(value);
return;
}
input.value = value ?? "";
}
function csvList(value) {
return value ? value.split(",").map((item) => item.trim()).filter(Boolean) : null;
}
function fillCarProfileForm() {
const form = document.querySelector("#carProfileForm");
const hint = document.querySelector("#carProfileHint");
const car = selectedCar();
form.querySelectorAll("input, select, button").forEach((node) => {
node.disabled = !car;
});
if (!car) {
form.reset();
hint.textContent = t("Выбери автомобиль, чтобы настроить жидкости, расход и сервисные нормы.");
return;
}
hint.textContent = [car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || car.name;
[
"plate_number",
"vin",
"generation",
"body_type",
"engine_volume_l",
"transmission",
"drive_type",
"fuel_type",
"target_consumption_l_per_100km",
"fuel_tank_volume_l",
"engine_oil_type",
"engine_oil_volume_l",
"transmission_fluid_type",
"transmission_fluid_volume_l",
"coolant_type",
"brake_fluid_type",
"tire_pressure_front_bar",
"tire_pressure_rear_bar",
"tire_size",
"oil_change_interval_km",
"oil_change_interval_months",
"purchase_price",
"purchase_date",
"purchase_type",
"loan_principal",
"loan_down_payment",
"loan_term_months",
"loan_annual_interest_rate",
"loan_first_payment_date",
"include_depreciation",
"notes",
].forEach((name) => setInputValue(form, name, car[name]));
}
async function loadConfirmations() {
const root = document.querySelector("#confirmationRequests");
if (!root) return;
try {
state.confirmations = await api("/my/confirmations");
const visits = state.confirmations.service_visits || [];
const changes = state.confirmations.change_requests || [];
const links = state.confirmations.service_links || [];
if (!visits.length && !changes.length && !links.length) {
root.innerHTML = `Новых запросов нет
`;
return;
}
root.innerHTML = [
...visits.map((visit) => `
Визит СТО #${visit.id}
${visit.visit_date} · ${visit.odometer || "-"} км · ${money(visit.total_cost || 0)}
`),
...changes.map((item) => `
Изменение ${item.field_name}
${item.old_value || "-"} → ${item.new_value || "-"}
`),
...links.map((link) => `
Запрос доступа от СТО #${link.service_center_id}
Авто #${link.car_id} · ${link.access_level}
`),
].join("");
bindConfirmationActions(root);
} catch (error) {
root.innerHTML = `Не удалось загрузить подтверждения
`;
}
}
function bindConfirmationActions(root) {
root.querySelectorAll("[data-confirm-visit]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Подтверждаю...", async () => {
await api(`/service-visits/${button.dataset.confirmVisit}/confirm`, { method: "POST" });
await loadConfirmations();
await loadSelectedCar();
}));
});
root.querySelectorAll("[data-dispute-visit]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Отмечаю спор...", async () => {
await api(`/service-visits/${button.dataset.disputeVisit}/dispute`, { method: "POST" });
await loadConfirmations();
}));
});
root.querySelectorAll("[data-approve-change]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Применяю...", async () => {
await api(`/vehicle-change-requests/${button.dataset.approveChange}/approve`, { method: "POST" });
await loadConfirmations();
await loadCars();
}));
});
root.querySelectorAll("[data-reject-change]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Отклоняю...", async () => {
await api(`/vehicle-change-requests/${button.dataset.rejectChange}/reject`, { method: "POST" });
await loadConfirmations();
}));
});
root.querySelectorAll("[data-approve-link]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Разрешаю доступ...", async () => {
await api(`/service-centers/links/${button.dataset.approveLink}/approve`, { method: "POST" });
await loadConfirmations();
await loadConnectedServices();
}));
});
root.querySelectorAll("[data-revoke-link]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Отклоняю...", async () => {
await api(`/service-centers/links/${button.dataset.revokeLink}/revoke`, { method: "POST" });
await loadConfirmations();
}));
});
}
async function loadConnectedServices() {
const root = document.querySelector("#connectedServices");
if (!root) return;
try {
state.connectedServices = await api("/my/service-links");
root.innerHTML = state.connectedServices.length
? state.connectedServices.map((link) => `
${link.service_center_name}
${link.car_name} · ${link.access_level} · ${link.status}
${link.status === "approved" ? `` : ""}
`).join("")
: `Подключенных автосервисов пока нет
`;
root.querySelectorAll("[data-revoke-link]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Отзываю доступ...", async () => {
await api(`/service-centers/links/${button.dataset.revokeLink}/revoke`, { method: "POST" });
await loadConnectedServices();
}));
});
} catch (error) {
root.innerHTML = `Не удалось загрузить подключения
`;
}
}
async function loadAdminPendingServices() {
const root = document.querySelector("#adminPendingServices");
if (!root) return;
try {
state.adminPendingServices = await api("/admin/service-centers/pending");
root.innerHTML = state.adminPendingServices.length
? state.adminPendingServices.map((center) => `
#${center.id} ${center.display_name || center.name}
${[center.legal_name, center.city, center.address].filter(Boolean).join(" · ") || "Данные не заполнены"}
Документы: ${(center.document_photo_urls || []).length}
`).join("")
: `Pending-заявок нет
`;
root.querySelectorAll("[data-admin-action]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Сохраняю решение...", async () => {
const comment = button.dataset.adminAction === "verify" ? "Одобрено" : window.prompt("Комментарий для владельца СТО") || "";
await api(`/admin/service-centers/${button.dataset.adminCenter}/${button.dataset.adminAction}`, {
method: "POST",
body: JSON.stringify({ reason: comment, comment }),
});
await loadAdminPendingServices();
}));
});
} catch (error) {
root.innerHTML = `Нет доступа или сервер не ответил
`;
}
}
function openCarProfile() {
openDrawerSection("carProfileSection");
}
async function loadServiceCenters() {
const centers = await api("/service-centers/my");
state.serviceCenters = await Promise.all(
centers.map(async (center) => {
try {
return { ...center, trust_score: await api(`/service-centers/${center.id}/trust-score`) };
} catch (_) {
return center;
}
}),
);
renderServiceCenters();
}
function renderServiceCenters() {
const root = document.querySelector("#serviceCentersList");
if (!root) return;
if (!state.serviceCenters.length) {
root.innerHTML = `СТО пока не создано
`;
return;
}
root.innerHTML = state.serviceCenters
.map(
(center) => `
${center.display_name || center.name}
${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"}
Статус: ${center.verification_status}
${center.trust_score ? `${trustLabel(center.trust_score.trust_level)} · ${center.trust_score.trust_score}/100` : ""}
`,
)
.join("");
}
async function loadPublicServiceCenters() {
const root = document.querySelector("#publicServiceCenters");
if (!root) return;
try {
const centers = await api("/sto/catalog");
state.publicServiceCenters = centers;
root.innerHTML = centers.length
? centers
.map(
(center) => `
`,
)
.join("")
: `Проверенных СТО пока нет
`;
root.querySelectorAll("[data-service-card]").forEach((button) => {
button.addEventListener("click", () => openServiceCard(Number(button.dataset.serviceCard)));
});
} catch (error) {
root.innerHTML = `Не удалось загрузить СТО
`;
}
}
function renderServiceReviews() {
const root = document.querySelector("#serviceReviews");
if (!root) return;
root.innerHTML = state.publicServiceCenters.length
? state.publicServiceCenters
.map((center) => ``)
.join("")
: `Откройте раздел «СТО», чтобы загрузить проверенные сервисы.
`;
root.querySelectorAll("[data-service-card]").forEach((button) => {
button.addEventListener("click", async () => {
await openDrawerSection("publicServicesSection");
await openServiceCard(Number(button.dataset.serviceCard));
});
});
}
async function openServiceCard(serviceCenterId) {
const card = document.querySelector("#serviceCard");
if (!card) return;
const [center, reviews] = await Promise.all([
api(`/service-centers/${serviceCenterId}`),
api(`/service-centers/${serviceCenterId}/reviews?limit=20`),
]);
card.classList.remove("hidden");
card.innerHTML = `
СТО
${center.display_name || center.name}
${center.rating_avg ? `★ ${center.rating_avg} · ${center.reviews_count}` : "Проверенный сервис"}
${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"}
${center.phone || "Телефон не указан"}
${center.description || "Описание появится после заполнения карточки сервисом."}
${reviews.length
? reviews.map((review) => `
★ ${review.rating}
${review.text || "Без текста"}
${review.service_response ? `Ответ СТО: ${review.service_response}` : ""}
`).join("")
: `
Отзывов еще нет
`}
`;
card.querySelector("#serviceReviewForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
await api(`/service-centers/${serviceCenterId}/reviews`, {
method: "POST",
body: JSON.stringify({ rating: Number(data.rating), text: data.text || null }),
});
await openServiceCard(serviceCenterId);
await loadPublicServiceCenters();
toast("Сохранено");
haptic("success");
});
});
card.querySelector("#attachServiceBtn").addEventListener("click", async (event) => {
if (!state.selectedCarId) {
toast("Выбери автомобиль", "error");
return;
}
await runAction(event.currentTarget, "Сохраняю...", async () => {
await api(`/service-centers/${serviceCenterId}/vehicle-links/owner-attach`, {
method: "POST",
body: JSON.stringify({ car_id: state.selectedCarId, access_level: "basic" }),
});
toast("Авто привязано к СТО");
haptic("success");
});
});
const bookingForm = card.querySelector("#serviceBookingForm");
const reloadSlots = () => loadServiceBookingSlots(serviceCenterId, bookingForm);
bookingForm.querySelector('[name="service_type"]').addEventListener("change", reloadSlots);
bookingForm.querySelector('[name="date"]').addEventListener("change", reloadSlots);
bookingForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.selectedCarId) {
toast("Выбери автомобиль", "error");
return;
}
const data = formData(bookingForm);
if (!data.slot) {
toast("Выбери свободное окно", "error");
return;
}
await runAction(bookingForm.querySelector('button[type="submit"]'), "Создаю запись...", async () => {
await api("/appointments", {
method: "POST",
body: JSON.stringify({
service_center_id: serviceCenterId,
vehicle_id: state.selectedCarId,
service_type: data.service_type,
service_name: bookingServiceName(data.service_type),
requested_start_at: data.slot,
customer_comment: data.customer_comment || null,
}),
});
await loadAppointments();
toast("Заявка отправлена в СТО");
haptic("success");
});
});
await reloadSlots();
card.scrollIntoView({ behavior: "smooth", block: "start" });
}
function bookingServiceName(type) {
const names = {
oil_change: "Замена масла",
diagnostics: "Диагностика",
maintenance: "ТО",
tire_service: "Шиномонтаж",
brakes: "Тормоза",
repair: "Ремонт",
other: "Другое",
};
return names[type] || "Обслуживание";
}
function formatDateTime(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value).slice(0, 16).replace("T", " ");
return date.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
}
async function loadServiceBookingSlots(serviceCenterId, form) {
const select = form.querySelector("#bookingSlotSelect");
const serviceType = form.querySelector('[name="service_type"]').value;
const date = form.querySelector('[name="date"]').value || today();
select.innerHTML = ``;
try {
const slots = await api(`/sto/${serviceCenterId}/available-slots?service_type=${encodeURIComponent(serviceType)}&date_from=${date}&date_to=${date}`);
select.innerHTML = slots.length
? slots.map((slot) => ``).join("")
: ``;
} catch (error) {
select.innerHTML = ``;
}
}
async function loadAppointments() {
const root = document.querySelector("#appointmentsList");
if (!root) return;
try {
state.appointments = await api("/appointments/my");
root.innerHTML = state.appointments.length
? state.appointments.map((item) => `
${item.service_name}
${formatDateTime(item.confirmed_start_at || item.proposed_start_at || item.requested_start_at)}
${item.status}
${item.status === "proposed_new_time" ? `
` : ""}
`).join("")
: `Записей пока нет
`;
root.querySelectorAll("[data-accept-appointment]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Сохраняю...", async () => {
await api(`/appointments/${button.dataset.acceptAppointment}/accept-proposed-time`, { method: "POST" });
await loadAppointments();
}));
});
root.querySelectorAll("[data-reject-appointment]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Сохраняю...", async () => {
await api(`/appointments/${button.dataset.rejectAppointment}/reject-proposed-time`, {
method: "POST",
body: JSON.stringify({ comment: "Отклонено в Mini App" }),
});
await loadAppointments();
}));
});
} catch (error) {
root.innerHTML = `Записи не загрузились
`;
}
}
async function loadMaintenanceRecommendations() {
const root = document.querySelector("#maintenanceRecommendations");
if (!root) return;
if (!state.selectedCarId) {
root.innerHTML = `Выбери автомобиль
`;
return;
}
try {
state.maintenanceRecommendations = await api(`/vehicles/${state.selectedCarId}/maintenance-recommendations`);
root.innerHTML = state.maintenanceRecommendations.length
? state.maintenanceRecommendations.map((item) => `
${item.title}
${item.description || "Плановое обслуживание"}
${[item.due_odometer_km ? `${item.due_odometer_km} км` : "", item.due_date || ""].filter(Boolean).join(" · ")}
${item.priority} · ${item.status}
${item.status === "active" ? `` : ""}
`).join("")
: `Рекомендаций пока нет
`;
root.querySelectorAll("[data-dismiss-recommendation]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Скрываю...", async () => {
await api(`/maintenance-recommendations/${button.dataset.dismissRecommendation}/dismiss`, { method: "POST" });
await loadMaintenanceRecommendations();
}));
});
} catch (error) {
root.innerHTML = `Рекомендации не загрузились
`;
}
}
async function loadStoCalendar() {
const summary = document.querySelector("#stoDashboardSummary");
const list = document.querySelector("#stoCalendarList");
if (!summary || !list) return;
try {
if (!state.serviceCenters.length) {
const centers = await api("/service-centers/my");
state.serviceCenters = centers;
}
const center = state.serviceCenters[0];
if (!center) {
summary.innerHTML = "";
list.innerHTML = `СТО пока не создано
`;
return;
}
const [dashboard, appointments] = await Promise.all([
api(`/sto/dashboard?service_center_id=${center.id}`),
api(`/sto/calendar?service_center_id=${center.id}`),
]);
summary.innerHTML = `
Авто${dashboard.connected_vehicles}
Новые заявки${dashboard.pending_appointments}
Подтверждено${dashboard.confirmed_appointments}
Месяц${money(dashboard.revenue_month || 0)}
`;
list.innerHTML = appointments.length
? appointments.map((item) => `
${item.service_name}
${formatDateTime(item.confirmed_start_at || item.requested_start_at)} · авто #${item.vehicle_id}
${item.status}
${item.status === "requested" ? `
` : ""}
`).join("")
: `Записей на ближайший период нет
`;
list.querySelectorAll("[data-confirm-sto-appointment]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Подтверждаю...", async () => {
await api(`/sto/appointments/${button.dataset.confirmStoAppointment}/confirm`, {
method: "POST",
body: JSON.stringify({ comment: "Подтверждено в Mini App" }),
});
await loadStoCalendar();
}));
});
list.querySelectorAll("[data-reject-sto-appointment]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Отклоняю...", async () => {
await api(`/sto/appointments/${button.dataset.rejectStoAppointment}/reject`, {
method: "POST",
body: JSON.stringify({ comment: "Отклонено в Mini App" }),
});
await loadStoCalendar();
}));
});
} catch (error) {
summary.innerHTML = "";
list.innerHTML = `Календарь СТО не загрузился
`;
}
}
function trustLabel(level) {
const labels = {
new_service: "Новый сервис",
verified_service: "Проверенный сервис",
reliable_service: "Надежный сервис",
high_confidence_service: "Высокое доверие",
};
return labels[level] || "Новый сервис";
}
function renderPlaceholderList(selector, message) {
const root = document.querySelector(selector);
if (root) root.innerHTML = `${message}
`;
}
function renderStats(stats) {
const root = document.querySelector("#stats");
if (!stats) {
root.innerHTML = `${t("Выбери автомобиль для статистики")}
`;
updateHero(null);
updateScore();
drawCharts([], [], null);
return;
}
updateHero(stats);
updateScore();
const all = state.allStats || stats;
const periodDays = Math.max(
1,
Math.ceil((new Date(stats.date_to) - new Date(stats.date_from)) / 86400000) + 1,
);
const costPerDay = Number(stats.total_cost || 0) / periodDays;
const costPer100 = stats.cost_per_km ? stats.cost_per_km * 100 : null;
const periodTitles = {
all: t("За весь срок"),
"7d": "7 дней",
"30d": "30 дней",
"3m": "3 месяца",
"6m": "6 месяцев",
"12m": "12 месяцев",
month: t("За месяц"),
day: t("За день"),
quarter: t("За квартал"),
year: t("За год"),
custom: t("За период"),
};
const periodTitle = periodTitles[state.period.preset] || t("За период");
root.innerHTML = `
${stats.cost_warning ? `Предупреждение${stats.cost_warning}мягкая проверка расходов
` : ""}
`;
root.querySelectorAll("[data-report]").forEach((button) => {
button.addEventListener("click", () => openReport(button.dataset.report));
});
}
function recordsForPeriod() {
return [
...state.latestFuel.map((item) => ({
date: item.entry_date,
type: "fuel",
title: `Заправка ${Number(item.liters).toFixed(1)} л`,
meta: item.station || `${item.odometer} км`,
cost: item.total_cost,
})),
...state.latestService.map((item) => ({
date: item.entry_date,
type: "service",
title: item.title,
meta: item.vendor || serviceLabel(item.service_type),
cost: item.total_cost,
})),
...state.latestExpenses.map((item) => ({
date: item.entry_date,
type: "expense",
title: item.title,
meta: expenseLabel(item.category),
cost: item.total_cost,
})),
].sort((a, b) => b.date.localeCompare(a.date));
}
function updateScore() {
const car = selectedCar();
const score = state.vehicleScore?.completeness_score || 0;
const title = scoreLabel(state.vehicleScore?.profile_quality, score);
const ring = document.querySelector("#scoreRing");
document.querySelector("#scoreValue").textContent = score;
document.querySelector("#scoreTitle").textContent = car ? title : t("Старт");
document.querySelector("#verifiedHistoryStatus").textContent = historyLabel(state.vehicleScore?.verified_history_status);
document.querySelector("#maintenanceStatus").textContent = healthLabel(state.vehicleScore?.maintenance_status);
if (ring) {
ring.style.background = `conic-gradient(#5ee0bd ${score * 3.6}deg, rgba(255,255,255,0.12) 0deg)`;
}
document.querySelector("#scoreHint").textContent = car
? "Качество паспорта растет от подтвержденных данных, сервисной истории и точного пробега."
: t("Добавь авто и первую запись, чтобы видеть точные отчеты");
renderScoreActions(state.vehicleScore?.missing_items || []);
renderAchievements();
renderVehicleTimeline();
}
function scoreLabel(quality, score) {
if (quality === "high_confidence" || score >= 86) return "High-confidence passport";
if (quality === "strong" || score >= 61) return "Strong profile";
if (quality === "useful" || score >= 31) return "Useful profile";
return "Basic profile";
}
function historyLabel(status) {
const labels = {
verified: "Verified maintenance history",
partially_verified: "Partially verified",
self_reported: "Self-reported",
};
return labels[status] || "Self-reported";
}
function healthLabel(status) {
const labels = {
green: "Green",
yellow: "Attention soon",
red: "Overdue",
unknown: "Needs baseline",
};
return labels[status] || "Needs baseline";
}
function renderScoreActions(items) {
const root = document.querySelector("#scoreActions");
if (!root) return;
const visible = items.slice(0, 3);
if (!visible.length) {
root.innerHTML = `Паспорт выглядит надежно. Следующий рост даст подтвержденная сервисная история.
`;
return;
}
root.innerHTML = visible
.map(
(item) => `
${item.title}
${item.description}
`,
)
.join("");
}
function renderAchievements() {
const root = document.querySelector("#achievementList");
if (!root) return;
const car = selectedCar();
const achievements = state.achievements.filter((item) => !item.vehicle_id || item.vehicle_id === car?.id).slice(0, 4);
if (!achievements.length) {
root.innerHTML = `Evidence badgesПоявятся после первых качественных записей.
`;
return;
}
root.innerHTML = achievements
.map(
(item) => `
${item.title}
${item.description}
`,
)
.join("");
}
function renderVehicleTimeline() {
const root = document.querySelector("#vehicleTimeline");
if (!root) return;
const items = state.vehicleTimeline.slice(0, 5);
if (!items.length) {
root.innerHTML = `Timeline появится после заправок, сервиса и подтверждений.
`;
return;
}
root.innerHTML = `
Vehicle timeline
${items
.map(
(item) => `
${item.title}
${String(item.date).slice(0, 10)}${item.status ? ` · ${item.status}` : ""}
`,
)
.join("")}
`;
}
function openReport(type = "summary") {
const stats = state.latestStats;
const sheet = document.querySelector("#reportSheet");
const title = document.querySelector("#reportTitle");
const body = document.querySelector("#reportBody");
const records = recordsForPeriod();
const titles = {
summary: t("Стоимость владения"),
fuel: t("Топливо"),
service: t("Сервис"),
efficiency: t("Эффективность"),
};
title.textContent = titles[type] || t("Отчет");
if (!stats) {
body.innerHTML = `${t("Выбери автомобиль")}
`;
sheet.classList.remove("hidden");
return;
}
const fuelLiters = Number(stats.liters || 0);
const fuelRecords = state.latestFuel;
const serviceRecords = state.latestService;
const avgFill = fuelRecords.length ? fuelLiters / fuelRecords.length : 0;
const serviceByType = serviceRecords.reduce((acc, item) => {
const key = serviceLabel(item.service_type);
acc[key] = (acc[key] || 0) + Number(item.total_cost || 0);
return acc;
}, {});
const topService = Object.entries(serviceByType).sort((a, b) => b[1] - a[1])[0];
const analytics = state.analytics;
const blocks = {
summary: `
${reportMetric(t("Итого"), money(stats.total_cost))}
${reportMetric(t("Стоимость 1 км"), stats.cost_per_km ? money(stats.cost_per_km) : "-")}
${reportMetric(t("Пробег"), `${stats.distance_km} км`)}
${reportMetric(t("Записей"), `${stats.fuel_entries_count + stats.service_entries_count}`)}
${reportRecords(records.slice(0, 8))}
`,
fuel: `
${reportMetric(t("Потрачено"), money(stats.fuel_cost))}
${reportMetric(t("Литров"), fuelLiters.toFixed(1))}
${reportMetric(t("Средняя заправка"), avgFill ? `${avgFill.toFixed(1)} л` : "-")}
${reportMetric(t("Расход"), stats.avg_consumption_l_per_100km ? `${stats.avg_consumption_l_per_100km.toFixed(2)} л/100` : "-")}
${reportRecords(records.filter((item) => item.type === "fuel").slice(0, 10))}
`,
service: `
${reportMetric(t("Потрачено"), money(stats.service_cost))}
${reportMetric(t("Записей"), stats.service_entries_count)}
${reportMetric(t("Главная категория"), topService ? topService[0] : "-")}
${reportMetric(t("Макс. категория"), topService ? money(topService[1]) : "-")}
${reportRecords(records.filter((item) => item.type === "service").slice(0, 10))}
`,
efficiency: `
${reportMetric("1 км", stats.cost_per_km ? money(stats.cost_per_km) : "-")}
${reportMetric("100 км", stats.cost_per_km ? money(stats.cost_per_km * 100) : "-")}
${reportMetric(t("Расход"), stats.avg_consumption_l_per_100km ? `${stats.avg_consumption_l_per_100km.toFixed(2)} л/100` : "-")}
${reportMetric(t("Пробег"), `${stats.distance_km} км`)}
${reportMetric(t("Прогноз сегодня"), analytics?.predicted_today ? `${analytics.predicted_today} км` : "-")}
${reportMetric(t("+30 дней"), analytics?.predicted_30_days ? `${analytics.predicted_30_days} км` : "-")}
${reportMetric("Средний полный бак", analytics?.average_full_tank_distance ? `${analytics.average_full_tank_distance} км` : "-")}
${reportMetric("Средний бак", analytics?.average_cost_per_full_tank ? money(analytics.average_cost_per_full_tank) : "-")}
${reportMetric(t("Текущая цена"), analytics?.current_price_per_liter ? `${formatFuelPrice(analytics.current_price_per_liter)} / л` : "-")}
${reportMetric(t("Прогноз цены"), analytics?.predicted_price_per_liter_30_days ? `${formatFuelPrice(analytics.predicted_price_per_liter_30_days)} / л` : "-")}
${analytics?.full_tank_warning ? `${analytics.full_tank_warning}
` : ""}
${analytics?.insight || t("Лучший рост точности даст привычка заносить одометр при каждой заправке и сервисе.")}
`,
};
body.innerHTML = blocks[type] || blocks.summary;
applyTranslations(body);
sheet.classList.remove("hidden");
}
function reportMetric(label, value) {
return `${label}${value}
`;
}
function reportRecords(records) {
if (!records.length) return `${t("Нет записей за выбранный период")}
`;
return `${records
.map(
(item) => `
${item.date}
${item.title}
${item.meta || ""}
${money(item.cost)}
`,
)
.join("")}
`;
}
function serviceLabel(value) {
return {
maintenance: t("Обслуживание"),
repair: t("Ремонт"),
fluid: t("Жидкости"),
tire: t("Шины"),
inspection: t("Осмотр"),
insurance: t("Страховка"),
tax: t("Налог"),
other: t("Другое"),
}[value] || value;
}
function expenseLabel(value) {
return {
insurance: "Страховка",
tax: "Налог",
fine: "Штраф",
parking: "Парковка",
car_wash: "Мойка",
toll: "Платная дорога",
tires: "Шины",
wheels: "Диски",
battery: "Аккумулятор",
parts: "Запчасти",
repair: "Ремонт",
maintenance: "Плановое ТО",
diagnostics: "Диагностика",
towing: "Эвакуатор",
loan_payment: "Кредит / лизинг",
loan_interest: "Проценты",
state_fee: "Госпошлина",
registration: "Регистрация",
inspection: "Техосмотр",
other: "Прочее",
}[value] || value;
}
function monthlySeries(fuel, service, expenses = []) {
const map = new Map();
[
...fuel.map((item) => ({ ...item, type: "fuel" })),
...service.map((item) => ({ ...item, type: "service" })),
...expenses.map((item) => ({ ...item, type: "other" })),
].forEach((item) => {
const key = item.entry_date.slice(0, 7);
const current = map.get(key) || { label: key, fuel: 0, service: 0, other: 0 };
current[item.type] += Number(item.total_cost || 0);
map.set(key, current);
});
return [...map.values()].sort((a, b) => a.label.localeCompare(b.label)).slice(-8);
}
function drawCharts(fuel, service, stats) {
drawExpensesChart(monthlySeries(fuel, service, state.latestExpenses));
drawSplitChart(stats?.cost_by_category || { fuel: Number(stats?.fuel_cost || 0), service: Number(stats?.service_cost || 0) });
}
function setupCanvas(canvas) {
const ctx = canvas.getContext("2d");
const ratio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * ratio;
canvas.height = rect.height * ratio;
ctx.scale(ratio, ratio);
return { ctx, width: rect.width, height: rect.height };
}
function drawEmpty(ctx, width, height, text) {
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#7c8783";
ctx.font = "14px system-ui";
ctx.textAlign = "center";
ctx.fillText(t(text), width / 2, height / 2);
}
function drawExpensesChart(series) {
const canvas = document.querySelector("#expensesChart");
const { ctx, width, height } = setupCanvas(canvas);
if (!series.length) {
drawEmpty(ctx, width, height, "Добавь заправку или сервисную запись");
return;
}
ctx.clearRect(0, 0, width, height);
const pad = 28;
const chartH = height - pad * 2;
const max = Math.max(...series.map((item) => item.fuel + item.service + item.other), 1);
const barGap = 12;
const barW = Math.max(18, (width - pad * 2 - barGap * (series.length - 1)) / series.length);
ctx.strokeStyle = "#e1e7e4";
ctx.lineWidth = 1;
for (let i = 0; i < 4; i += 1) {
const y = pad + (chartH / 3) * i;
ctx.beginPath();
ctx.moveTo(pad, y);
ctx.lineTo(width - pad, y);
ctx.stroke();
}
series.forEach((item, index) => {
const x = pad + index * (barW + barGap);
const total = item.fuel + item.service + item.other;
const totalH = (total / max) * chartH;
const fuelH = total ? (item.fuel / total) * totalH : 0;
const serviceH = total ? (item.service / total) * totalH : 0;
const otherH = Math.max(totalH - fuelH - serviceH, 0);
const y = height - pad - totalH;
ctx.fillStyle = "#36a388";
roundRect(ctx, x, y + serviceH + otherH, barW, fuelH, 6);
ctx.fill();
ctx.fillStyle = "#3f7fba";
roundRect(ctx, x, y + otherH, barW, serviceH, 6);
ctx.fill();
ctx.fillStyle = "#d6a64f";
roundRect(ctx, x, y, barW, otherH, 6);
ctx.fill();
ctx.fillStyle = "#7c8783";
ctx.font = "12px system-ui";
ctx.textAlign = "center";
ctx.fillText(item.label.slice(5), x + barW / 2, height - 8);
});
}
function drawSplitChart(categories) {
const canvas = document.querySelector("#splitChart");
const { ctx, width, height } = setupCanvas(canvas);
const entries = Object.entries(categories || {})
.map(([key, value]) => [key, Number(value || 0)])
.filter(([, value]) => value > 0)
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
const total = entries.reduce((sum, [, value]) => sum + value, 0);
if (!total) {
drawEmpty(ctx, width, height, "Нет расходов");
return;
}
ctx.clearRect(0, 0, width, height);
const cx = width / 2;
const cy = height / 2 - 8;
const radius = Math.min(width, height) * 0.31;
ctx.lineWidth = 22;
ctx.lineCap = "round";
let start = -Math.PI / 2;
const colors = ["#36a388", "#3f7fba", "#d6a64f", "#c7645d", "#768a82"];
entries.forEach(([, value], index) => {
const angle = (value / total) * Math.PI * 2;
ctx.strokeStyle = colors[index % colors.length];
ctx.beginPath();
ctx.arc(cx, cy, radius, start, start + Math.max(angle - 0.05, 0.02));
ctx.stroke();
start += angle;
});
ctx.fillStyle = "#1d2522";
ctx.font = "700 22px system-ui";
ctx.textAlign = "center";
ctx.fillText(`${Math.round((entries[0][1] / total) * 100)}%`, cx, cy + 5);
ctx.fillStyle = "#7c8783";
ctx.font = "12px system-ui";
ctx.fillText(expenseLabel(entries[0][0]), cx, cy + 25);
}
function roundRect(ctx, x, y, width, height, radius) {
const r = Math.min(radius, Math.abs(height) / 2, width / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + width, y, x + width, y + height, r);
ctx.arcTo(x + width, y + height, x, y + height, r);
ctx.arcTo(x, y + height, x, y, r);
ctx.arcTo(x, y, x + width, y, r);
ctx.closePath();
}
async function loadCars() {
document.body.classList.add("loading");
setStatus("Обновляю данные...");
try {
state.cars = await api(`/cars?owner_id=${state.user.id}`);
if (!state.selectedCarId && state.cars.length) state.selectedCarId = state.cars[0].id;
if (state.selectedCarId && !state.cars.some((car) => car.id === state.selectedCarId)) {
state.selectedCarId = state.cars[0]?.id || null;
}
renderCars();
fillCarProfileForm();
await loadSelectedCar();
} finally {
document.body.classList.remove("loading");
setStatus("Готов к работе");
}
}
async function selectCar(carId) {
state.selectedCarId = carId;
renderCars();
fillCarProfileForm();
await loadSelectedCar();
}
async function loadSelectedCar() {
if (!state.selectedCarId) {
state.latestFuel = [];
state.latestService = [];
state.latestExpenses = [];
state.latestStats = null;
state.allStats = null;
state.analytics = null;
state.vehicleScore = null;
state.vehicleTimeline = [];
state.achievements = [];
renderStats(null);
return;
}
const [stats, allStats, fuel, service, expenses, analytics, vehicleScore] = await Promise.all([
api(`/cars/${state.selectedCarId}/stats${periodQuery()}`),
api(`/cars/${state.selectedCarId}/stats${allPeriodQuery()}`),
api(`/cars/${state.selectedCarId}/fuel${periodQuery()}`),
api(`/cars/${state.selectedCarId}/service${periodQuery()}`),
api(`/cars/${state.selectedCarId}/expenses${periodQuery()}`),
api(`/cars/${state.selectedCarId}/analytics`),
api(`/my/vehicles/${state.selectedCarId}/score`),
]);
const [timeline, achievements] = await Promise.all([
api(`/my/vehicles/${state.selectedCarId}/timeline?limit=30`),
api("/me/achievements"),
]);
state.latestStats = stats;
state.allStats = allStats;
state.latestFuel = fuel;
state.latestService = service;
state.latestExpenses = expenses;
state.analytics = analytics;
state.vehicleScore = vehicleScore;
state.vehicleTimeline = timeline;
state.achievements = achievements;
renderStats(stats);
drawCharts(fuel, service, stats);
}
document.querySelectorAll('input[name="entry_date"]').forEach((input) => {
input.value = today();
});
applyPeriodPreset("30d");
document.querySelector("#refreshBtn").addEventListener("click", (event) => {
runAction(event.currentTarget, "Обновляю данные...", loadCars).then(() => {
toast("Готов к работе");
});
});
document.querySelector("#telegramRetryBtn")?.addEventListener("click", () => {
runAction(document.querySelector("#telegramRetryBtn"), "Обновляю данные...", async () => {
await ensureUser();
await loadCatalog();
initCarCatalog();
await loadCars();
});
});
document.querySelector("#periodPreset").addEventListener("change", async (event) => {
await runAction(event.currentTarget, "Обновляю данные...", async () => {
applyPeriodPreset(event.currentTarget.value);
await loadSelectedCar();
});
});
document.querySelectorAll("#periodFrom, #periodTo").forEach((input) => {
input.addEventListener("change", async () => {
await runAction(input, "Обновляю данные...", async () => {
document.querySelector("#periodPreset").value = "custom";
applyPeriodPreset("custom");
await loadSelectedCar();
});
});
});
document.querySelector("#carForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
await api("/cars", {
method: "POST",
body: JSON.stringify({
owner_id: state.user.id,
name: data.name,
make: data.make || null,
model: data.model || null,
trim: data.trim || null,
year: data.year ? Number(data.year) : null,
plate_number: data.plate_number || null,
vin: data.vin || null,
current_odometer: numberOrNull(data.current_odometer),
fuel_type: data.fuel_type || null,
purchase_price: numberOrNull(data.purchase_price),
purchase_date: data.purchase_date || null,
purchase_type: data.purchase_type || "unknown",
purchase_currency: state.user?.currency || "RUB",
currency: state.user?.currency || "RUB",
}),
});
form.reset();
resetCarCatalog();
document.querySelector("#userDrawer").classList.add("hidden");
await loadCars();
toast("Сохранено");
haptic("success");
});
});
document.querySelector("#carProfileForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const car = selectedCar();
if (!car) {
toast("Выбери автомобиль", "error");
return;
}
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
const updated = await api(`/cars/${car.id}`, {
method: "PATCH",
body: JSON.stringify({
plate_number: data.plate_number || null,
vin: data.vin || null,
generation: data.generation || null,
body_type: data.body_type || null,
engine_volume_l: numberOrNull(data.engine_volume_l),
transmission: data.transmission || null,
drive_type: data.drive_type || null,
fuel_type: data.fuel_type || null,
target_consumption_l_per_100km: numberOrNull(data.target_consumption_l_per_100km),
fuel_tank_volume_l: numberOrNull(data.fuel_tank_volume_l),
engine_oil_type: data.engine_oil_type || null,
engine_oil_volume_l: numberOrNull(data.engine_oil_volume_l),
transmission_fluid_type: data.transmission_fluid_type || null,
transmission_fluid_volume_l: numberOrNull(data.transmission_fluid_volume_l),
coolant_type: data.coolant_type || null,
brake_fluid_type: data.brake_fluid_type || null,
tire_pressure_front_bar: numberOrNull(data.tire_pressure_front_bar),
tire_pressure_rear_bar: numberOrNull(data.tire_pressure_rear_bar),
tire_size: data.tire_size || null,
oil_change_interval_km: numberOrNull(data.oil_change_interval_km),
oil_change_interval_months: numberOrNull(data.oil_change_interval_months),
purchase_price: numberOrNull(data.purchase_price),
purchase_date: data.purchase_date || null,
purchase_type: data.purchase_type || "unknown",
include_depreciation: Boolean(data.include_depreciation),
loan_principal: numberOrNull(data.loan_principal),
loan_down_payment: numberOrNull(data.loan_down_payment),
loan_term_months: numberOrNull(data.loan_term_months),
loan_annual_interest_rate: numberOrNull(data.loan_annual_interest_rate),
loan_first_payment_date: data.loan_first_payment_date || null,
loan_currency: state.user?.currency || car.currency || "RUB",
notes: data.notes || null,
}),
});
state.cars = state.cars.map((item) => (item.id === updated.id ? updated : item));
renderCars();
fillCarProfileForm();
await loadSelectedCar();
toast("Параметры сохранены");
haptic("success");
});
});
document.querySelector("#settingsForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
state.user = await api(`/users/${state.user.id}/preferences`, {
method: "PATCH",
body: JSON.stringify({ locale: data.locale, currency: data.currency }),
});
applyTranslations();
initCarCatalog();
await loadSelectedCar();
document.querySelector("#userDrawer").classList.add("hidden");
toast("Сохранено");
haptic("success");
});
});
document.querySelector("#fuelForm").addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.selectedCarId) return;
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
await api("/fuel", {
method: "POST",
body: JSON.stringify({
car_id: state.selectedCarId,
entry_date: data.entry_date,
odometer: Number(data.odometer),
liters: Number(data.liters),
price_per_liter: Number(data.price_per_liter),
station: data.station || null,
is_full_tank: Boolean(data.is_full_tank),
}),
});
form.reset();
form.entry_date.value = today();
await loadSelectedCar();
toast("Сохранено");
haptic("success");
});
});
document.querySelector("#serviceForm").addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.selectedCarId) return;
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
await api("/service", {
method: "POST",
body: JSON.stringify({
car_id: state.selectedCarId,
entry_date: data.entry_date,
odometer: data.odometer ? Number(data.odometer) : null,
service_type: data.service_type,
title: data.title,
total_cost: Number(data.total_cost),
vendor: data.vendor || null,
next_due_date: data.next_due_date || null,
next_due_odometer: data.next_due_odometer ? Number(data.next_due_odometer) : null,
}),
});
form.reset();
form.entry_date.value = today();
await loadSelectedCar();
toast("Сохранено");
haptic("success");
});
});
document.querySelector("#expenseForm").addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.selectedCarId) {
toast("Выбери автомобиль", "error");
return;
}
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
await api("/expenses", {
method: "POST",
body: JSON.stringify({
car_id: state.selectedCarId,
entry_date: data.entry_date,
category: data.category,
title: data.title,
total_cost: Number(data.total_cost),
currency: data.currency || state.user?.currency || "RUB",
vendor: data.vendor || null,
odometer: numberOrNull(data.odometer),
period_start: data.period_start || null,
period_end: data.period_end || null,
period_months: numberOrNull(data.period_months),
payment_period_months: numberOrNull(data.period_months),
policy_number: data.policy_number || null,
insurance_type: data.insurance_type || null,
notes: data.notes || null,
is_recurring: Boolean(data.is_recurring),
}),
});
form.reset();
form.entry_date.value = today();
form.currency.value = state.user?.currency || "RUB";
await loadSelectedCar();
toast("Сохранено");
haptic("success");
});
});
function setAction(action) {
document.querySelectorAll(".action-card[data-action]").forEach((button) => {
button.classList.toggle("active", button.dataset.action === action);
});
document.querySelector("#fuelForm").classList.toggle("hidden", action !== "fuel");
document.querySelector("#serviceForm").classList.toggle("hidden", action !== "service");
}
function openScanModal() {
haptic();
document.querySelector("#userDrawer").classList.add("hidden");
document.querySelector("#scanModal").classList.remove("hidden");
}
function mountEntryForms() {
const fuelMount = document.querySelector("#fuelFormMount");
const serviceMount = document.querySelector("#serviceFormMount");
const fuelForm = document.querySelector("#fuelForm");
const serviceForm = document.querySelector("#serviceForm");
if (fuelMount && fuelForm && !fuelMount.contains(fuelForm)) {
fuelForm.classList.remove("hidden");
fuelMount.appendChild(fuelForm);
}
if (serviceMount && serviceForm && !serviceMount.contains(serviceForm)) {
serviceForm.classList.remove("hidden");
serviceMount.appendChild(serviceForm);
}
}
async function openDrawerSection(sectionId, options = {}) {
document.querySelector("#userDrawer").classList.remove("hidden");
document.querySelectorAll(".drawer-section").forEach((section) => {
section.classList.toggle("hidden", section.id !== sectionId);
});
document.querySelectorAll(".menu-row").forEach((button) => {
button.classList.toggle("active", button.dataset.menuSection === sectionId);
});
mountEntryForms();
if (sectionId === "carProfileSection") fillCarProfileForm();
if (sectionId === "settingsSection") {
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
}
if (sectionId === "notificationsSection") {
updateNotificationStatus(
"Notification" in window && Notification.permission === "granted"
? "Уведомления включены"
: "Напомним о ТО, страховке и регулярном внесении пробега.",
);
}
if (sectionId === "confirmationsSection") await loadConfirmations();
if (sectionId === "connectedServicesSection") await loadConnectedServices();
if (sectionId === "servicePanelSection") await loadServiceCenters();
if (sectionId === "publicServicesSection") await loadPublicServiceCenters();
if (sectionId === "appointmentsSection") await loadAppointments();
if (sectionId === "maintenanceRecommendationsSection") await loadMaintenanceRecommendations();
if (sectionId === "stoCalendarSection") await loadStoCalendar();
if (sectionId === "reviewsSection") renderServiceReviews();
if (sectionId === "adminSection") await loadAdminPendingServices();
if (options.expenseCategory) {
openDrawerSection("expensesSection");
presetExpense(options.expenseCategory);
return;
}
document.querySelector(`#${sectionId}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
}
function presetExpense(category) {
const form = document.querySelector("#expenseForm");
form.category.value = category;
form.title.value = expenseLabel(category);
form.is_recurring.checked = category === "insurance" || category === "tax";
if (category === "insurance") {
form.period_months.value = "12";
form.insurance_type.value = "mandatory";
}
}
document.querySelectorAll("[data-action]").forEach((button) => {
button.addEventListener("click", () => {
haptic();
if (button.dataset.action === "scan") {
openScanModal();
return;
}
setAction(button.dataset.action);
});
});
document.querySelectorAll("[data-report]").forEach((button) => {
button.addEventListener("click", () => {
document.querySelector("#userDrawer").classList.add("hidden");
openReport(button.dataset.report);
});
});
document.querySelectorAll("[data-service-title]").forEach((button) => {
button.addEventListener("click", () => {
haptic();
const form = document.querySelector("#serviceForm");
form.title.value = button.dataset.serviceTitle;
form.service_type.value = button.dataset.serviceType;
});
});
document.querySelector("#menuBtn").addEventListener("click", () => {
document.querySelector("#userDrawer").classList.remove("hidden");
openDrawerSection("carsSection");
});
document.querySelector("#addCarQuickBtn").addEventListener("click", () => {
openDrawerSection("carFormSection");
});
document.querySelector("#addRecordPrimaryBtn").addEventListener("click", () => {
openDrawerSection("expensesSection");
});
document.querySelectorAll("[data-menu-section]").forEach((button) => {
button.addEventListener("click", async (event) => {
await runAction(event.currentTarget, "Обновляю данные...", async () => {
await openDrawerSection(event.currentTarget.dataset.menuSection);
});
});
});
document.querySelectorAll("[data-expense-preset]").forEach((button) => {
button.addEventListener("click", () => {
openDrawerSection("expensesSection");
presetExpense(button.dataset.expensePreset);
});
});
document.querySelector("#serviceCenterForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Создаю СТО...", async () => {
const data = formData(form);
await api("/service-centers", {
method: "POST",
body: JSON.stringify({
display_name: data.display_name,
legal_name: data.legal_name || null,
country: data.country || null,
city: data.city || null,
address: data.address || null,
phone: data.phone || null,
contact_person: data.contact_person || null,
description: data.description || null,
specializations: data.specializations
? data.specializations.split(",").map((item) => item.trim()).filter(Boolean)
: null,
working_hours: data.working_hours || null,
business_registration_number: data.business_registration_number || null,
facade_photo_url: data.facade_photo_url || null,
document_photo_urls: csvList(data.document_photo_urls),
additional_photo_urls: csvList(data.additional_photo_urls),
}),
});
form.reset();
await loadServiceCenters();
toast("СТО создано");
});
});
document.querySelector("#enableNotificationsBtn").addEventListener("click", enableNotifications);
document.querySelector("#fuelScanBtn").addEventListener("click", () => {
openScanModal();
});
document.querySelector("#closeScanBtn").addEventListener("click", () => {
document.querySelector("#scanModal").classList.add("hidden");
});
function setReceiptFile(file) {
state.receiptFile = file || null;
document.querySelector("#receiptFileName").textContent = file?.name || t("Файл не выбран");
}
document.querySelector("#scanCameraBtn").addEventListener("click", () => {
document.querySelector("#receiptCameraInput").click();
});
document.querySelector("#scanFileBtn").addEventListener("click", () => {
document.querySelector("#receiptFileInput").click();
});
document.querySelector("#receiptCameraInput").addEventListener("change", (event) => {
setReceiptFile(event.currentTarget.files[0]);
});
document.querySelector("#receiptFileInput").addEventListener("change", (event) => {
setReceiptFile(event.currentTarget.files[0]);
});
document.querySelector("#ocrForm").addEventListener("submit", async (event) => {
event.preventDefault();
const file = state.receiptFile;
if (!file) {
toast("Выбери файл чека", "error");
haptic("error");
return;
}
const formButton = event.currentTarget.querySelector('button[type="submit"]');
await runAction(formButton, "Распознаю чек...", async () => {
const payload = new FormData();
payload.append("file", file);
const response = await fetch("/api/ocr/parse-text-receipt", {
method: "POST",
headers: authHeaders(),
body: payload,
});
if (!response.ok) throw new Error(await response.text());
const result = await response.json();
document.querySelector("#ocrResult").textContent = `${result.message} ${Math.round((result.confidence || 0) * 100)}%`;
const fuelForm = document.querySelector("#fuelForm");
if (result.liters) fuelForm.liters.value = result.liters;
if (result.price_per_liter) fuelForm.price_per_liter.value = result.price_per_liter;
if (result.station) fuelForm.station.value = result.station;
document.querySelector("#scanModal").classList.add("hidden");
await openDrawerSection("fuelSection");
toast("Проверь распознанные значения");
haptic("success");
});
});
document.querySelector("#closeMenuBtn").addEventListener("click", () => {
document.querySelector("#userDrawer").classList.add("hidden");
});
document.querySelector("#closeReportBtn").addEventListener("click", () => {
document.querySelector("#reportSheet").classList.add("hidden");
});
window.addEventListener("resize", () => {
drawCharts(state.latestFuel, state.latestService, state.latestStats);
});
initPwa();
Promise.all([loadAuthConfig()])
.then(() => Promise.all([ensureUser(), loadCatalog()]))
.then(() => {
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
document.querySelector("#expenseForm").currency.value = state.user?.currency || "RUB";
mountEntryForms();
applyTranslations();
initCarCatalog();
return loadCars();
})
.catch((error) => {
if (error.message === "Требуется вход через Telegram") return;
document.body.insertAdjacentHTML("afterbegin", `${error.message}
`);
});