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: [], latestStats: null, allStats: null, analytics: null, serviceCenters: [], 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", ...(options.headers || {}) }; 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"; } 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(); } 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(); return; } if (state.authConfig?.allow_dev_auth) { const devId = localStorage.getItem("driversDevTelegramId") || "1"; localStorage.setItem("driversDevTelegramId", devId); state.user = await api("/users/me"); hideAuthOverlay(); return; } await showTelegramLogin(); throw new Error("Требуется вход через Telegram"); } function hideAuthOverlay() { document.querySelector("#authOverlay")?.classList.add("hidden"); document.body.classList.remove("auth-required"); } 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() ? "После перехода в Telegram нажмите в боте кнопку «Открыть гараж»." : "На компьютере можно войти кнопкой 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(); 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); } 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 === "day") fromDate = now; if (preset === "quarter") fromDate = shiftMonths(now, -3); if (preset === "year") fromDate = shiftMonths(now, -12); 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 = car?.name || 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 = money(stats?.total_cost); document.querySelector("#summaryConsumption").textContent = stats?.avg_consumption_l_per_100km ? `${stats.avg_consumption_l_per_100km.toFixed(2)} л` : "-"; } function formatFuelPrice(value) { if (!value) return "-"; return money(value).replace(/\s?₽|RUB/i, "").trim(); } function renderCars() { const root = document.querySelector("#cars"); if (!state.cars.length) { root.innerHTML = `
${t("Добавь первый автомобиль")}
`; updateHero(null); return; } root.innerHTML = state.cars .map( (car) => ` `, ) .join(""); root.querySelectorAll("[data-car]").forEach((button) => { button.addEventListener("click", () => selectCar(Number(button.dataset.car))); }); } function setInputValue(form, name, value) { if (form?.elements[name]) form.elements[name].value = value ?? ""; } 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; [ "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", ].forEach((name) => setInputValue(form, name, car[name])); } function openCarProfile() { document.querySelector("#userDrawer").classList.remove("hidden"); document.querySelector("#carProfileSection").classList.remove("hidden"); fillCarProfileForm(); document.querySelector("#carProfileSection").scrollIntoView({ behavior: "smooth", block: "start" }); } 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(""); } 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("За весь срок"), month: t("За месяц"), day: t("За день"), quarter: t("За квартал"), year: t("За год"), custom: t("За период"), }; const periodTitle = periodTitles[state.period.preset] || t("За период"); root.innerHTML = ` `; 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, })), ].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(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?.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 monthlySeries(fuel, service) { const map = new Map(); [...fuel.map((item) => ({ ...item, type: "fuel" })), ...service.map((item) => ({ ...item, type: "service" }))].forEach((item) => { const key = item.entry_date.slice(0, 7); const current = map.get(key) || { label: key, fuel: 0, service: 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)); drawSplitChart(Number(stats?.fuel_cost || 0), 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), 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; const totalH = (total / max) * chartH; const fuelH = total ? (item.fuel / total) * totalH : 0; const serviceH = totalH - fuelH; const y = height - pad - totalH; ctx.fillStyle = "#36a388"; roundRect(ctx, x, y + serviceH, barW, fuelH, 6); ctx.fill(); ctx.fillStyle = "#3f7fba"; roundRect(ctx, x, y, barW, serviceH, 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(fuelCost, serviceCost) { const canvas = document.querySelector("#splitChart"); const { ctx, width, height } = setupCanvas(canvas); const total = fuelCost + serviceCost; 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; const fuelAngle = (fuelCost / total) * Math.PI * 2; ctx.lineWidth = 22; ctx.lineCap = "round"; ctx.strokeStyle = "#36a388"; ctx.beginPath(); ctx.arc(cx, cy, radius, -Math.PI / 2, -Math.PI / 2 + fuelAngle); ctx.stroke(); ctx.strokeStyle = "#3f7fba"; ctx.beginPath(); ctx.arc(cx, cy, radius, -Math.PI / 2 + fuelAngle + 0.05, Math.PI * 1.5 - 0.05); ctx.stroke(); ctx.fillStyle = "#1d2522"; ctx.font = "700 22px system-ui"; ctx.textAlign = "center"; ctx.fillText(`${Math.round((fuelCost / total) * 100)}%`, cx, cy + 5); ctx.fillStyle = "#7c8783"; ctx.font = "12px system-ui"; ctx.fillText(t("топливо"), 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.latestStats = null; state.allStats = null; state.analytics = null; state.vehicleScore = null; state.vehicleTimeline = []; state.achievements = []; renderStats(null); return; } const [stats, allStats, fuel, service, 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}/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.analytics = analytics; state.vehicleScore = vehicleScore; state.vehicleTimeline = timeline; state.achievements = achievements; renderStats(stats); drawCharts(fuel, service, stats); } document.querySelectorAll('input[type="date"]').forEach((input) => { if (input.name !== "next_due_date") input.value = today(); }); applyPeriodPreset("month"); 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, fuel_type: data.fuel_type || null, }), }); 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({ 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), }), }); 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"); }); }); 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"); } 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"); }); document.querySelector("#addCarQuickBtn").addEventListener("click", () => { document.querySelector("#userDrawer").classList.remove("hidden"); document.querySelector("#carFormSection").scrollIntoView({ behavior: "smooth", block: "start" }); }); document.querySelector("#openCarFormBtn").addEventListener("click", () => { document.querySelector("#carFormSection").classList.remove("hidden"); document.querySelector("#carFormSection").scrollIntoView({ behavior: "smooth", block: "start" }); }); document.querySelector("#openCarProfileBtn").addEventListener("click", openCarProfile); document.querySelector("#openSettingsBtn").addEventListener("click", () => { document.querySelector("#settingsSection").classList.remove("hidden"); document.querySelector("#localeSelect").value = state.user?.locale || "ru"; document.querySelector("#currencySelect").value = state.user?.currency || "RUB"; document.querySelector("#settingsSection").scrollIntoView({ behavior: "smooth", block: "start" }); }); document.querySelector("#openNotificationsBtn").addEventListener("click", () => { document.querySelector("#notificationsSection").classList.remove("hidden"); updateNotificationStatus( "Notification" in window && Notification.permission === "granted" ? "Уведомления включены" : "Напомним о ТО, страховке и регулярном внесении пробега.", ); document.querySelector("#notificationsSection").scrollIntoView({ behavior: "smooth", block: "start" }); }); document.querySelector("#openConfirmationsBtn").addEventListener("click", () => { document.querySelector("#confirmationsSection").classList.remove("hidden"); renderPlaceholderList("#confirmationRequests", "Новых запросов нет"); document.querySelector("#confirmationsSection").scrollIntoView({ behavior: "smooth", block: "start" }); }); document.querySelector("#openConnectedServicesBtn").addEventListener("click", () => { document.querySelector("#connectedServicesSection").classList.remove("hidden"); renderPlaceholderList("#connectedServices", "Подключенных автосервисов пока нет"); document.querySelector("#connectedServicesSection").scrollIntoView({ behavior: "smooth", block: "start" }); }); document.querySelector("#openServicePanelBtn").addEventListener("click", async (event) => { await runAction(event.currentTarget, "Загружаю СТО...", async () => { document.querySelector("#servicePanelSection").classList.remove("hidden"); await loadServiceCenters(); document.querySelector("#servicePanelSection").scrollIntoView({ behavior: "smooth", block: "start" }); }); }); 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, business_registration_number: data.business_registration_number || null, }), }); form.reset(); await loadServiceCenters(); toast("СТО создано"); }); }); document.querySelector("#enableNotificationsBtn").addEventListener("click", enableNotifications); document.querySelector("#openScanBtn").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: tg?.initData ? { "X-Telegram-Init-Data": tg.initData } : {}, 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; setAction("fuel"); document.querySelector("#scanModal").classList.add("hidden"); 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"; applyTranslations(); initCarCatalog(); return loadCars(); }) .catch((error) => { if (error.message === "Требуется вход через Telegram") return; document.body.insertAdjacentHTML("afterbegin", `
${error.message}
`); });