const tg = window.Telegram?.WebApp; tg?.ready(); tg?.expand(); const textNodes = new WeakMap(); const attrOriginals = new WeakMap(); let translationObserver = null; let translationTimer = null; 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", "Добавить запись": "Add entry", "Расход": "Expense", "дата, пробег, литры, цена": "date, odometer, liters, price", "работа, стоимость, следующий срок": "work, cost, next due", "страховка, штраф, парковка, прочее": "insurance, fine, parking, other", "фото или файл": "photo or file", "Дополнительно": "More options", "Напоминание о следующем ТО": "Next maintenance reminder", "За весь срок": "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])); }); }); } function observeTranslations(root = document.body) { if (translationObserver || !root) return; translationObserver = new MutationObserver(() => { window.clearTimeout(translationTimer); translationTimer = window.setTimeout(() => applyTranslations(root), 40); }); translationObserver.observe(root, { childList: true, subtree: true }); } const state = { user: null, authConfig: null, cars: [], catalog: [], selectedCarId: null, latestFuel: [], latestService: [], latestFuelAllTime: [], latestServiceAllTime: [], selectedCarHighlights: null, latestExpenses: [], latestStats: null, allStats: null, analytics: null, serviceCenters: [], activeServiceCenterId: null, mechanicAppointments: [], mechanicWorkOrders: [], publicServiceCenters: [], appointments: [], maintenanceRecommendations: [], stoCalendar: [], confirmations: null, connectedServices: [], adminPendingServices: [], vehicleScore: null, 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); } function updateNotificationHelp(message = "") { const node = document.querySelector("#notificationHelp"); if (!node) return; node.textContent = message ? t(message) : ""; node.classList.toggle("hidden", !message); } function notificationBlockedMessage() { const isIos = /iPhone|iPad|iPod/i.test(navigator.userAgent); const isAndroid = /Android/i.test(navigator.userAgent); if (isIos) { return "Браузерные уведомления заблокированы. Открой настройки iOS: Settings → Notifications → браузер или Telegram, разреши уведомления и вернись сюда. Сервисные уведомления в Telegram продолжают работать."; } if (isAndroid) { return "Браузерные уведомления заблокированы. Открой настройки сайта в браузере или настройки приложения Telegram/браузера на Android и разреши уведомления. Сервисные уведомления в Telegram продолжают работать."; } return "Браузерные уведомления заблокированы. Нажми на значок замка рядом с адресом сайта, открой Site settings / Настройки сайта и разреши Notifications. Сервисные уведомления в Telegram продолжают работать."; } function refreshNotificationUi() { const button = document.querySelector("#enableNotificationsBtn"); if (!("Notification" in window)) { updateNotificationStatus("Браузер не поддерживает уведомления"); updateNotificationHelp("Важные события по СТО и заказ-нарядам все равно отправляются через Telegram-бота."); if (button) button.disabled = true; return; } if (Notification.permission === "granted") { updateNotificationStatus("Уведомления включены"); updateNotificationHelp(""); if (button) { button.disabled = false; button.textContent = t("Проверить уведомления"); } return; } if (Notification.permission === "denied") { updateNotificationStatus("Уведомления запрещены в настройках браузера"); updateNotificationHelp(notificationBlockedMessage()); if (button) { button.disabled = false; button.textContent = t("Проверить снова"); } return; } updateNotificationStatus("Напомним о ТО, страховке и регулярном внесении пробега."); updateNotificationHelp("Если браузерный запрос будет отклонен, включить уведомления можно только вручную в настройках сайта."); if (button) { button.disabled = false; button.textContent = t("Включить уведомления"); } } async function enableNotifications() { if (!("Notification" in window)) { refreshNotificationUi(); return; } if (Notification.permission === "denied") { refreshNotificationUi(); return; } const permission = await Notification.requestPermission(); if (permission !== "granted") { refreshNotificationUi(); 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("Уведомления включены"); updateNotificationHelp(""); refreshNotificationUi(); } 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: optionHeaders = {}, ...fetchOptions } = options; const headers = { "Content-Type": "application/json", ...authHeaders(optionHeaders) }; if (options.body instanceof FormData) delete headers["Content-Type"]; const response = await fetch(`/api${path}`, { ...fetchOptions, headers, }); 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 }), }); localStorage.setItem("carpassLocale", state.user.locale || "ru"); 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"); localStorage.setItem("carpassLocale", state.user.locale || "ru"); hideAuthOverlay(); updateRoleVisibility(); return; } await showTelegramLogin(); throw new Error("Требуется вход через Telegram"); } function installLocaleSwitch() { const topActions = document.querySelector(".topbar .top-actions"); if (!topActions || document.querySelector("#globalLocaleSelect")) return; const select = document.createElement("select"); select.id = "globalLocaleSelect"; select.className = "locale-switch"; select.setAttribute("aria-label", "Язык"); select.innerHTML = ` `; select.value = state.user?.locale || "ru"; select.addEventListener("change", async () => { await runAction(select, "Сохраняю...", async () => { state.user = await api(`/users/${state.user.id}/preferences`, { method: "PATCH", body: JSON.stringify({ locale: select.value, currency: state.user.currency }), }); localStorage.setItem("carpassLocale", state.user.locale || "ru"); document.querySelector("#localeSelect").value = state.user.locale || "ru"; applyTranslations(); renderCars(); renderStats(state.latestStats); toast("Сохранено"); }); }); topActions.prepend(select); } function hideAuthOverlay() { document.querySelector("#authOverlay")?.classList.add("hidden"); document.body.classList.remove("auth-required"); } const APPROVED_SERVICE_STATUSES = new Set(["approved", "verified"]); const STO_WORKPLACE_ROLES = new Set(["owner", "manager", "receptionist", "mechanic"]); const STO_CALENDAR_ROLES = new Set(["owner", "manager", "receptionist"]); function isPlatformAdmin() { return ["admin", "verifier", "moderator"].includes(state.user?.platform_role); } function approvedServiceCenters() { return state.serviceCenters.filter((center) => APPROVED_SERVICE_STATUSES.has(center.verification_status)); } function stoWorkplaceCenters() { return approvedServiceCenters().filter((center) => STO_WORKPLACE_ROLES.has(center.employee_role || "owner")); } function stoCalendarCenters() { return approvedServiceCenters().filter((center) => STO_CALENDAR_ROLES.has(center.employee_role || "owner")); } function canUseServiceProfile() { return state.serviceCenters.length > 0 || state.user?.platform_role === "service_owner" || isPlatformAdmin(); } function canOpenDrawerSection(sectionId) { if (sectionId === "adminSection") return isPlatformAdmin(); if (sectionId === "mechanicWorkplaceSection") return stoWorkplaceCenters().length > 0; if (sectionId === "stoCalendarSection") return stoCalendarCenters().length > 0; if (sectionId === "servicePanelSection") return canUseServiceProfile(); return true; } function updateRoleVisibility() { const isAdmin = isPlatformAdmin(); const hasWorkplace = stoWorkplaceCenters().length > 0; const hasCalendar = stoCalendarCenters().length > 0; const hasServiceProfile = canUseServiceProfile(); document.querySelectorAll(".admin-only").forEach((node) => node.classList.toggle("hidden", !isAdmin)); document.querySelectorAll(".sto-workplace-only").forEach((node) => node.classList.toggle("hidden", !hasWorkplace)); document.querySelectorAll(".sto-calendar-only").forEach((node) => node.classList.toggle("hidden", !hasCalendar)); document.querySelectorAll(".service-owner-only").forEach((node) => node.classList.toggle("hidden", !hasServiceProfile)); } 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 activeServiceCenter() { return state.serviceCenters.find((center) => center.id === state.activeServiceCenterId) || state.serviceCenters[0] || 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 renderSelectedCarHighlights(car) { const highlights = state.selectedCarHighlights?.carId === car.id ? state.selectedCarHighlights : buildCarHighlights(car, [], []); const rows = [ ["Одометр", highlights.odometer], ["Заправка", highlights.lastFuel], ["Масло", highlights.lastOil], ["До масла", highlights.oilRemaining], ]; return ` ${rows .map( ([label, value]) => ` ${label} ${escapeHtml(value)} `, ) .join("")} `; } function buildCarHighlights(car, fuelEntries, serviceEntries) { const currentOdometer = Number(car?.current_odometer || 0); const lastFuel = fuelEntries[0] || null; const lastOil = serviceEntries.find(isOilService) || null; const oilDueOdometer = oilDueKm(car, lastOil); const oilRemainingKm = oilDueOdometer != null && car?.current_odometer != null ? oilDueOdometer - currentOdometer : null; return { carId: car?.id, odometer: car?.current_odometer != null ? `${formatKm(car.current_odometer)}` : "-", lastFuel: lastFuel ? `${formatShortDate(lastFuel.entry_date)} · ${formatLiters(lastFuel.liters)}` : "-", lastOil: lastOil ? formatShortDate(lastOil.entry_date) : "-", oilRemaining: formatOilRemaining(oilRemainingKm), }; } function isOilService(item) { const text = [item?.title, item?.category, item?.notes, item?.service_type].filter(Boolean).join(" ").toLowerCase(); return /масл|oil|engine_oil/.test(text); } function oilDueKm(car, latestOil) { if (latestOil?.next_due_odometer != null) return Number(latestOil.next_due_odometer); const interval = Number(car?.oil_change_interval_km || 0); if (!interval) return null; if (latestOil?.odometer != null) return Number(latestOil.odometer) + interval; if (car?.current_odometer != null) return Number(car.current_odometer) + interval; return null; } function formatKm(value) { if (value == null || value === "") return "-"; return `${Number(value).toLocaleString("ru-RU")} км`; } function formatLiters(value) { if (value == null || value === "") return "-"; return `${Number(value).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} л`; } function formatShortDate(value) { if (!value) return "-"; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value).slice(0, 10); return date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit" }); } function formatOilRemaining(value) { if (value == null || Number.isNaN(value)) return "-"; if (value < 0) return `просрочено ${formatKm(Math.abs(value))}`; return formatKm(value); } 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 loadMyServiceCenters({ withTrust = false } = {}) { const centers = await api("/service-centers/my"); state.serviceCenters = withTrust ? await Promise.all( centers.map(async (center) => { try { return { ...center, trust_score: await api(`/service-centers/${center.id}/trust-score`) }; } catch (_) { return center; } }), ) : centers; if (!state.activeServiceCenterId && state.serviceCenters.length) { state.activeServiceCenterId = state.serviceCenters[0].id; } if (state.activeServiceCenterId && !state.serviceCenters.some((center) => center.id === state.activeServiceCenterId)) { state.activeServiceCenterId = state.serviceCenters[0]?.id || null; } renderServiceProfileCard(); updateRoleVisibility(); return state.serviceCenters; } function renderServiceProfileCard() { const card = document.querySelector("#serviceProfileCard"); if (!card) return; updateRoleVisibility(); const centers = stoWorkplaceCenters(); const hasWorkplace = centers.length > 0; card.classList.toggle("hidden", !hasWorkplace); if (!hasWorkplace) return; const center = centers.find((item) => item.id === state.activeServiceCenterId) || centers[0]; state.activeServiceCenterId = center.id; const role = serviceRoleLabel(center.employee_role || "owner"); document.querySelector("#serviceProfileTitle").textContent = center.display_name || center.name || "Рабочее место"; document.querySelector("#serviceProfileMeta").textContent = `${role} · ${serviceStatusLabel(center.verification_status)}`; } async function loadServiceCenters() { await loadMyServiceCenters({ withTrust: true }); 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"); }); }); card.scrollIntoView({ behavior: "smooth", block: "start" }); } 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 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" ? ` ` : ""} ${!["converted_to_work_order", "completed"].includes(item.status) ? `` : ""}
`).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(); })); }); root.querySelectorAll("[data-delete-appointment]").forEach((button) => { button.addEventListener("click", () => { if (!window.confirm("Удалить запись в СТО?")) return; runAction(button, "Удаляю запись...", async () => { await api(`/appointments/${button.dataset.deleteAppointment}`, { method: "DELETE" }); await loadAppointments(); toast("Запись удалена"); }); }); }); } 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) { await loadMyServiceCenters(); } const center = stoCalendarCenters()[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" ? `
` : ""} ${!["converted_to_work_order", "completed"].includes(item.status) ? ` ` : ""}
`).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(); })); }); list.querySelectorAll("[data-delete-sto-appointment]").forEach((button) => { button.addEventListener("click", () => { if (!window.confirm("Удалить бронь из календаря СТО?")) return; runAction(button, "Удаляю бронь...", async () => { await api(`/sto/appointments/${button.dataset.deleteStoAppointment}`, { method: "DELETE" }); await loadStoCalendar(); toast("Бронь удалена"); }); }); }); } catch (error) { summary.innerHTML = ""; list.innerHTML = `
Календарь СТО не загрузился
`; } } async function loadMechanicWorkplace() { const centerSelect = document.querySelector("#mechanicCenterSelect"); const summary = document.querySelector("#mechanicDashboardSummary"); const list = document.querySelector("#mechanicWorkplaceList"); if (!centerSelect || !summary || !list) return; try { if (!state.serviceCenters.length) await loadMyServiceCenters(); const centers = stoWorkplaceCenters(); if (!centers.length) { centerSelect.innerHTML = ""; summary.innerHTML = ""; list.innerHTML = `
Рабочее место доступно владельцу подтвержденного СТО и активным механикам.
`; return; } centerSelect.innerHTML = state.serviceCenters .filter((center) => centers.some((item) => item.id === center.id)) .map((center) => ``) .join(""); const selectedCenter = centers.find((item) => item.id === state.activeServiceCenterId) || centers[0]; centerSelect.value = String(selectedCenter.id); const serviceCenterId = Number(centerSelect.value); const center = centers.find((item) => item.id === serviceCenterId) || centers[0]; state.activeServiceCenterId = serviceCenterId; renderServiceProfileCard(); const [dashboard, appointments, visits] = await Promise.all([ api(`/sto/dashboard?service_center_id=${serviceCenterId}`).catch(() => null), api(`/sto/appointments?service_center_id=${serviceCenterId}`).catch(() => []), api(`/service-centers/${serviceCenterId}/visits`).catch(() => []), ]); state.mechanicAppointments = appointments.filter((item) => ["requested", "confirmed", "confirmed_by_sto", "proposed_new_time"].includes(item.status), ); state.mechanicWorkOrders = visits.filter((item) => !["completed", "cancelled", "archived", "confirmed", "disputed"].includes(item.status), ); summary.innerHTML = dashboard ? `
Заявки${dashboard.pending_appointments}
Подтверждено${dashboard.confirmed_appointments}
Заказ-наряды${dashboard.active_work_orders}
Авто${dashboard.connected_vehicles}
` : `
Сводка недоступна
`; const centerNotice = center.verification_status && !["approved", "verified"].includes(center.verification_status) ? `
СТО сейчас в статусе «${serviceStatusLabel(center.verification_status)}». Часть действий может быть недоступна до проверки.
` : ""; const appointmentMarkup = state.mechanicAppointments.map(renderMechanicAppointment).join(""); const workOrderMarkup = state.mechanicWorkOrders.map(renderMechanicWorkOrder).join(""); list.innerHTML = ` ${centerNotice}

Записи

${appointmentMarkup || `
Новых записей нет
`}

Заказ-наряды

${workOrderMarkup || `
Активных заказ-нарядов нет
`} `; bindMechanicWorkplaceActions(list); } catch (error) { summary.innerHTML = ""; list.innerHTML = `
Рабочее место не загрузилось
`; } } function renderMechanicAppointment(item) { const role = activeServiceCenter()?.employee_role || "owner"; const canManageAppointments = ["owner", "manager", "receptionist"].includes(role); const canCreateWorkOrder = canManageAppointments && ["confirmed", "confirmed_by_sto"].includes(item.status); return `
${escapeHtml(item.service_name)} ${formatDateTime(item.confirmed_start_at || item.requested_start_at)} · авто #${item.vehicle_id} ${appointmentStatusLabel(item.status)}
${canManageAppointments && item.status === "requested" ? `` : ""} ${canCreateWorkOrder ? `` : ""} ${canManageAppointments ? `` : ""} ${canManageAppointments && !["converted_to_work_order", "completed"].includes(item.status) ? `` : ""}
`; } function renderMechanicWorkOrder(item) { const role = activeServiceCenter()?.employee_role || "owner"; const canEditItems = ["owner", "manager", "mechanic"].includes(role); const canStart = ["owner", "manager", "mechanic"].includes(role) && ["draft", "diagnosis", "approved_by_owner"].includes(item.status); const canSubmitApproval = ["owner", "manager", "receptionist"].includes(role) && ["draft", "diagnosis", "in_progress", "rejected_by_owner"].includes(item.status); const canComplete = ["owner", "manager"].includes(role) && ["draft", "diagnosis", "approved_by_owner", "in_progress"].includes(item.status); return `
${escapeHtml(item.work_order_number || `Заказ-наряд #${item.id}`)} ${item.visit_date} · авто #${item.vehicle_id} · ${item.odometer || "-"} км
${workOrderStatusLabel(item.status)}
${item.customer_complaint ? `Жалоба: ${escapeHtml(item.customer_complaint)}` : ""} ${item.diagnosis ? `Диагностика: ${escapeHtml(item.diagnosis)}` : ""}
Работы: ${money(item.labor_total || 0)} Запчасти: ${money(item.product_total || 0)} Итого: ${money(item.final_total || item.total_cost || 0)}
${canEditItems ? `
` : ""}
${canStart ? `` : ""} ${canSubmitApproval ? `` : ""} ${canComplete ? `` : ""}
`; } function bindMechanicWorkplaceActions(root) { root.querySelectorAll("[data-mechanic-confirm-appointment]").forEach((button) => { button.addEventListener("click", () => runAction(button, "Подтверждаю...", async () => { await api(`/sto/appointments/${button.dataset.mechanicConfirmAppointment}/confirm`, { method: "POST", body: JSON.stringify({ comment: "Подтверждено в рабочем месте СТО" }), }); await loadMechanicWorkplace(); })); }); root.querySelectorAll("[data-mechanic-reject-appointment]").forEach((button) => { button.addEventListener("click", () => runAction(button, "Отклоняю...", async () => { await api(`/sto/appointments/${button.dataset.mechanicRejectAppointment}/reject`, { method: "POST", body: JSON.stringify({ comment: "Отклонено в рабочем месте СТО" }), }); await loadMechanicWorkplace(); })); }); root.querySelectorAll("[data-mechanic-delete-appointment]").forEach((button) => { button.addEventListener("click", () => { if (!window.confirm("Удалить бронь из рабочего места?")) return; runAction(button, "Удаляю бронь...", async () => { await api(`/sto/appointments/${button.dataset.mechanicDeleteAppointment}`, { method: "DELETE" }); await loadMechanicWorkplace(); toast("Бронь удалена"); }); }); }); root.querySelectorAll("[data-create-work-order]").forEach((button) => { button.addEventListener("click", () => runAction(button, "Открываю заказ-наряд...", async () => { const odometerValue = window.prompt("Пробег на приемке, км") || ""; await api(`/sto/appointments/${button.dataset.createWorkOrder}/create-work-order`, { method: "POST", body: JSON.stringify({ odometer: numberOrNull(odometerValue), notes: "Создано в рабочем месте СТО" }), }); await loadMechanicWorkplace(); })); }); root.querySelectorAll("[data-labor-form]").forEach((form) => { form.addEventListener("submit", async (event) => { event.preventDefault(); await runAction(form.querySelector('button[type="submit"]'), "Добавляю работу...", async () => { const data = formData(form); await api(`/work-orders/${form.dataset.laborForm}/labor-items`, { method: "POST", body: JSON.stringify({ title: data.title, quantity: Number(data.quantity || 1), unit: "job", unit_price: Number(data.unit_price || 0), work_type: "repair", }), }); await loadMechanicWorkplace(); }); }); }); root.querySelectorAll("[data-product-form]").forEach((form) => { form.addEventListener("submit", async (event) => { event.preventDefault(); await runAction(form.querySelector('button[type="submit"]'), "Добавляю материал...", async () => { const data = formData(form); await api(`/work-orders/${form.dataset.productForm}/product-items`, { method: "POST", body: JSON.stringify({ title: data.title, quantity: Number(data.quantity || 1), unit: "pcs", unit_price: Number(data.unit_price || 0), product_type: "part", }), }); await loadMechanicWorkplace(); }); }); }); root.querySelectorAll("[data-start-work-order]").forEach((button) => { button.addEventListener("click", () => runAction(button, "Запускаю работу...", async () => { await api(`/work-orders/${button.dataset.startWorkOrder}/start`, { method: "POST", body: JSON.stringify({ comment: "Взято в работу" }), }); await loadMechanicWorkplace(); })); }); root.querySelectorAll("[data-submit-work-order]").forEach((button) => { button.addEventListener("click", () => runAction(button, "Отправляю на согласование...", async () => { await api(`/work-orders/${button.dataset.submitWorkOrder}/submit-approval`, { method: "POST", body: JSON.stringify({ comment: "Смета готова к согласованию" }), }); await loadMechanicWorkplace(); })); }); root.querySelectorAll("[data-complete-work-order]").forEach((button) => { button.addEventListener("click", () => runAction(button, "Завершаю заказ-наряд...", async () => { const odometerValue = window.prompt("Пробег на закрытии, км. Можно оставить пустым, если пробег уже указан.") || ""; await api(`/work-orders/${button.dataset.completeWorkOrder}/complete`, { method: "POST", body: JSON.stringify({ comment: "Работы завершены", odometer: numberOrNull(odometerValue) }), }); await loadMechanicWorkplace(); })); }); } function trustLabel(level) { const labels = { new_service: "Новый сервис", verified_service: "Проверенный сервис", reliable_service: "Надежный сервис", high_confidence_service: "Высокое доверие", }; return labels[level] || "Новый сервис"; } function serviceRoleLabel(role) { const labels = { owner: "Владелец", manager: "Менеджер", receptionist: "Администратор", mechanic: "Механик", }; return labels[role] || role || "Сотрудник"; } function serviceStatusLabel(status) { const labels = { draft: "Черновик", pending: "На проверке", needs_changes: "Нужны правки", rejected: "Отклонено", approved: "Проверено", verified: "Проверено", suspended: "Приостановлено", }; return labels[status] || status || "Статус не указан"; } function workOrderStatusLabel(status) { const labels = { draft: "Черновик", diagnosis: "Диагностика", waiting_owner_approval: "Ждет согласования", approved_by_owner: "Согласован", rejected_by_owner: "Отклонен клиентом", in_progress: "В работе", completed: "Завершен", cancelled: "Отменен", archived: "Архив", pending_owner_confirmation: "Ждет клиента", confirmed: "Подтвержден", disputed: "Спор", }; return labels[status] || status || "Без статуса"; } function appointmentStatusLabel(status) { const labels = { requested: "Новая заявка", confirmed: "Подтверждена клиентом", confirmed_by_sto: "Подтверждена СТО", proposed_new_time: "Предложено другое время", converted_to_work_order: "Заказ-наряд создан", completed: "Завершена", rejected_by_sto: "Отклонена СТО", cancelled_by_owner: "Отменена владельцем", cancelled_by_customer: "Отменена клиентом", cancelled_by_sto: "Отменена СТО", }; return labels[status] || status || "Без статуса"; } function escapeHtml(value) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } 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) => ({ id: item.id, date: item.entry_date, type: "fuel", title: `Заправка ${Number(item.liters).toFixed(1)} л`, meta: item.station || `${item.odometer} км`, cost: item.total_cost, deleteEndpoint: `/fuel/${item.id}`, })), ...state.latestService.map((item) => ({ id: item.id, date: item.entry_date, type: "service", title: item.title, meta: item.vendor || serviceLabel(item.service_type), cost: item.total_cost, deleteEndpoint: `/service/${item.id}`, })), ...state.latestExpenses.map((item) => ({ id: item.id, date: item.entry_date, type: "expense", title: item.title, meta: expenseLabel(item.category), cost: item.total_cost, deleteEndpoint: `/expenses/${item.id}`, })), ].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("Добавь авто и первую запись, чтобы видеть точные отчеты"); renderPassportVehicleSummary(car); renderScoreActions(state.vehicleScore?.missing_items || []); } function renderPassportVehicleSummary(car) { const root = document.querySelector("#passportVehicleSummary"); if (!root) return; if (!car) { root.innerHTML = `
Выберите автомобиль Здесь появятся номер, VIN, пробег, заправка и ближайшее ТО.
`; return; } const highlights = state.selectedCarHighlights?.carId === car.id ? state.selectedCarHighlights : buildCarHighlights(car, [], []); const identity = [car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || "Паспорт без деталей"; const plate = car.license_plate_display || car.plate_number || "номер не указан"; const vin = car.vin || "VIN не указан"; const oilSpec = [car.engine_oil_type, car.engine_oil_volume_l ? `${Number(car.engine_oil_volume_l).toLocaleString("ru-RU")} л` : ""] .filter(Boolean) .join(" · "); const fuelAndOil = [car.fuel_type || "топливо не указано", oilSpec || "масло не указано"].join(" · "); const rows = [ ["Одометр", highlights.odometer], ["Последняя заправка", highlights.lastFuel], ["Замена масла", highlights.lastOil], ["До следующей", highlights.oilRemaining], ]; root.innerHTML = `
${escapeHtml(car.name)} ${escapeHtml(identity)}
Паспорт
${escapeHtml(plate)} ${escapeHtml(vin)} ${escapeHtml(fuelAndOil)}
${rows.map(([label, value]) => `
${label} ${escapeHtml(value)}
`).join("")}
`; } 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, 1); 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; bindRecordDeleteActions(body, type); 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)} ${item.deleteEndpoint ? `` : ""}
`, ) .join("")}
`; } function bindRecordDeleteActions(root, reportType) { root.querySelectorAll("[data-delete-record]").forEach((button) => { button.addEventListener("click", () => { if (!window.confirm("Удалить запись из истории?")) return; runAction(button, "Удаляю запись...", async () => { await api(button.dataset.deleteRecord, { method: "DELETE" }); await loadSelectedCar(); openReport(reportType); toast("Запись удалена"); }); }); }); } 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 applyInitialRoute() { const params = new URLSearchParams(window.location.search); const section = params.get("section"); const carId = Number(params.get("car_id") || 0); if (carId && state.cars.some((car) => car.id === carId)) { state.selectedCarId = carId; renderCars(); fillCarProfileForm(); await loadSelectedCar(); } if (section === "carProfile") { const target = carId ? `/car_profile.html?car_id=${carId}` : "/car_profile.html"; window.location.replace(target); return; } if (section) { const sectionId = `${section}Section`; if (document.getElementById(sectionId)) { await openDrawerSection(sectionId); window.history.replaceState({}, "", window.location.pathname); } } } async function selectCar(carId) { state.selectedCarId = carId; state.selectedCarHighlights = null; renderCars(); fillCarProfileForm(); await loadSelectedCar(); } async function loadSelectedCar() { if (!state.selectedCarId) { state.latestFuel = []; state.latestService = []; state.latestFuelAllTime = []; state.latestServiceAllTime = []; state.selectedCarHighlights = null; state.latestExpenses = []; state.latestStats = null; state.allStats = null; state.analytics = null; state.vehicleScore = null; state.achievements = []; renderStats(null); return; } const [stats, allStats, fuel, service, expenses, analytics, vehicleScore, allFuel, allService] = 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`), api(`/cars/${state.selectedCarId}/fuel?limit=1`), api(`/cars/${state.selectedCarId}/service?limit=100`), ]); state.latestStats = stats; state.allStats = allStats; state.latestFuel = fuel; state.latestService = service; state.latestFuelAllTime = allFuel; state.latestServiceAllTime = allService; state.latestExpenses = expenses; state.analytics = analytics; state.vehicleScore = vehicleScore; state.selectedCarHighlights = buildCarHighlights(selectedCar(), allFuel, allService); renderCars(); 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("#deleteCarBtn")?.addEventListener("click", (event) => { const car = selectedCar(); if (!car) { toast("Выбери автомобиль", "error"); return; } if (!window.confirm(`Удалить автомобиль «${car.name}» и все его записи?`)) return; runAction(event.currentTarget, "Удаляю автомобиль...", async () => { await api(`/cars/${car.id}`, { method: "DELETE" }); state.selectedCarId = null; document.querySelector("#userDrawer").classList.add("hidden"); await loadCars(); 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 }), }); localStorage.setItem("carpassLocale", state.user.locale || "ru"); const globalLocale = document.querySelector("#globalLocaleSelect"); if (globalLocale) globalLocale.value = state.user.locale || "ru"; 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(); form.is_full_tank.checked = true; 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); } } function fillEntryDefaults(sectionId) { const car = selectedCar(); const odometer = car?.current_odometer || ""; const sections = sectionId ? [document.querySelector(`#${sectionId}`)] : [...document.querySelectorAll(".drawer-section")]; sections.filter(Boolean).forEach((section) => { section.querySelectorAll('input[name="entry_date"]').forEach((input) => { if (!input.value) input.value = today(); }); section.querySelectorAll('input[name="odometer"]').forEach((input) => { if (!input.value && odometer) input.value = odometer; }); }); } async function openDrawerSection(sectionId, options = {}) { if (!canOpenDrawerSection(sectionId)) { toast("Этот раздел недоступен для вашей роли", "error"); haptic("error"); sectionId = "carsSection"; } document.querySelector("#userDrawer").classList.remove("hidden"); const drawerContent = document.querySelector(".drawer-content"); 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(); fillEntryDefaults(sectionId); 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") { refreshNotificationUi(); } 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 === "mechanicWorkplaceSection") await loadMechanicWorkplace(); if (sectionId === "reviewsSection") renderServiceReviews(); if (sectionId === "adminSection") await loadAdminPendingServices(); if (options.expenseCategory) { await openDrawerSection("expensesSection"); presetExpense(options.expenseCategory); return; } if (drawerContent) drawerContent.scrollTo({ top: 0, behavior: "smooth" }); } 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("quickAddSection"); }); document.querySelectorAll("[data-quick-entry]").forEach((button) => { button.addEventListener("click", async () => { haptic(); if (button.dataset.quickEntry === "scan") { openScanModal(); return; } await openDrawerSection(button.dataset.quickEntry); }); }); document.querySelectorAll("[data-menu-section]").forEach((button) => { button.addEventListener("click", async (event) => { await runAction(event.currentTarget, "Обновляю данные...", async () => { await openDrawerSection(event.currentTarget.dataset.menuSection); }); }); }); document.addEventListener("click", (event) => { const link = event.target.closest("[data-page-link]"); if (!link) return; event.preventDefault(); window.location.href = link.dataset.pageLink; }); document.querySelectorAll("[data-open-sto-page]").forEach((button) => { button.addEventListener("click", () => { if (!stoWorkplaceCenters().length) { toast("Панель СТО доступна владельцу подтвержденного СТО и активным механикам", "error"); haptic("error"); return; } window.location.href = "/sto.html"; }); }); document.querySelector("#mechanicCenterSelect")?.addEventListener("change", async (event) => { state.activeServiceCenterId = Number(event.currentTarget.value); await runAction(event.currentTarget, "Обновляю рабочее место...", loadMechanicWorkplace); }); 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(() => { installLocaleSwitch(); observeTranslations(); 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 Promise.all([loadMyServiceCenters().catch(() => []), loadCars()]); }) .then(() => applyInitialRoute()) .catch((error) => { if (error.message === "Требуется вход через Telegram") return; document.body.insertAdjacentHTML("afterbegin", `
${error.message}
`); });