Files
drivers_bot/web/static/app.js
VPN SaaS Dev ecfb5aa949
Some checks failed
ci / test (push) Has been cancelled
Refactor menu flows into dedicated pages
2026-05-16 11:59:09 +09:00

2870 lines
120 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const tg = window.Telegram?.WebApp;
tg?.ready();
tg?.expand();
const textNodes = new WeakMap();
const attrOriginals = new WeakMap();
const i18n = {
en: {
"Гараж": "Garage",
"Автомобиль": "Vehicle",
"Не выбран": "Not selected",
"Добавь авто или выбери из списка": "Add a vehicle or choose one",
"Расходы": "Expenses",
"топливо, сервис и ремонты": "fuel, service and repairs",
"Средний расход": "Average consumption",
"л/100 км по полным данным": "L/100 km from complete data",
"Автомобили": "Vehicles",
"Профиль учета": "Tracking profile",
"Старт": "Start",
"Добавь авто и первую запись, чтобы видеть точные отчеты": "Add a vehicle and first entry to see accurate reports",
"Отчет": "Report",
"Стоимость владения": "Ownership cost",
"Весь срок": "All time",
"Выбери марку": "Choose make",
"Выбери модель": "Choose model",
"Сначала марка": "Choose make first",
"Топливо": "Fuel",
"Эффективность": "Efficiency",
"Месяц": "Month",
"День": "Day",
"Квартал": "Quarter",
"Год": "Year",
"Свой период": "Custom period",
"Заправка": "Fuel",
"Сервис": "Service",
"Скан чека": "Receipt scan",
"30 сек": "30 sec",
"ТО / ремонт": "Maintenance / repair",
"Динамика расходов": "Expense trend",
"Структура": "Breakdown",
"Дата": "Date",
"Одометр, км": "Odometer, km",
"Литры": "Liters",
"Цена за литр": "Price per liter",
"АЗС": "Fuel station",
"Не выбрано": "Not selected",
"Полный бак": "Full tank",
"Сохранить заправку": "Save fuel entry",
"Тип": "Type",
"Обслуживание": "Maintenance",
"Ремонт": "Repair",
"Жидкости": "Fluids",
"Шины": "Tires",
"Осмотр": "Inspection",
"Страховка": "Insurance",
"Налог": "Tax",
"Другое": "Other",
"Что сделано": "Work done",
"Масло": "Oil",
"Стоимость": "Cost",
"Исполнитель": "Vendor",
"Сохранить запись": "Save entry",
"Меню": "Menu",
"Добавить автомобиль": "Add vehicle",
"Локаль и валюта": "Language and currency",
"Уведомления": "Notifications",
"Сканировать чек": "Scan receipt",
"Настройки": "Settings",
"Язык": "Language",
"Валюта": "Currency",
"Сохранить настройки": "Save settings",
"Напомним о ТО, страховке и регулярном внесении пробега.": "We'll remind you about maintenance, insurance and regular odometer updates.",
"Включить уведомления": "Enable notifications",
"Фото или файл чека": "Receipt photo or file",
"Сфотографировать": "Take photo",
"Выбрать файл": "Choose file",
"Файл не выбран": "No file selected",
"Распознать": "Recognize",
"После распознавания поля заправки заполнятся автоматически.": "After recognition, fuel fields will be filled automatically.",
"Новое авто": "New vehicle",
"Название авто": "Vehicle name",
"Марка": "Make",
"Модель": "Model",
"Добавить авто": "Add vehicle",
"За весь срок": "All time",
"За месяц": "This month",
"За день": "Per day",
"За квартал": "Quarter",
"За год": "Year",
"За период": "Period",
"На 100 км": "Per 100 km",
"На 1 км": "Per 1 km",
"записей": "entries",
"среднее в периоде": "average in period",
"нет данных": "no data",
"Выбери автомобиль": "Choose a vehicle",
"Выбери автомобиль для статистики": "Choose a vehicle for stats",
"Добавь первый автомобиль": "Add your first vehicle",
"Без деталей": "No details",
"Профиль точный": "Accurate profile",
"Хороший учет": "Good tracking",
"Набираем данные": "Collecting data",
"Отчеты уже достаточно надежны для решений по расходам": "Reports are reliable enough for expense decisions",
"Чем регулярнее записи, тем точнее расход, цена километра и напоминания": "More regular entries make consumption, cost per km and reminders more accurate",
"Итого": "Total",
"Стоимость 1 км": "Cost per km",
"Пробег": "Mileage",
"Записей": "Entries",
"Потрачено": "Spent",
"Литров": "Liters",
"Средняя заправка": "Average refill",
"Расход": "Consumption",
"Главная категория": "Top category",
"Макс. категория": "Max category",
"Прогноз сегодня": "Today forecast",
"+30 дней": "+30 days",
"Лучший рост точности даст привычка заносить одометр при каждой заправке и сервисе.": "Accuracy improves most when odometer is entered at every fuel and service record.",
"Нет записей за выбранный период": "No entries for selected period",
"Добавь заправку или сервисную запись": "Add fuel or service entry",
"Нет расходов": "No expenses",
"топливо": "fuel",
"Уведомления включены": "Notifications enabled",
"Уведомления запрещены в настройках браузера": "Notifications are blocked in browser settings",
"Браузер не поддерживает уведомления": "Browser does not support notifications",
"PWA установлена и работает офлайн после первого открытия.": "PWA is installed and works offline after first open.",
"Напоминания готовы": "Reminders are ready",
"Мы напомним о ТО, страховке и обновлении пробега.": "We'll remind you about maintenance, insurance and mileage updates.",
"Напоминаний на ближайшее время нет": "No reminders due soon",
"Готов к работе": "Ready",
"Обновляю данные...": "Refreshing data...",
"Сохраняю...": "Saving...",
"Сохранено": "Saved",
"Распознаю чек...": "Recognizing receipt...",
"Выбери файл чека": "Choose receipt file",
"Проверь распознанные значения": "Check recognized values",
"Ошибка": "Error",
"Прогноз цены": "Price forecast",
"Текущая цена": "Current price",
"Средняя цена": "Average price",
},
ko: {
"Гараж": "차고",
"Автомобиль": "차량",
"Не выбран": "선택 안 됨",
"Добавь авто или выбери из списка": "차량을 추가하거나 목록에서 선택하세요",
"Расходы": "지출",
"топливо, сервис и ремонты": "연료, 정비, 수리",
"Средний расход": "평균 연비",
"л/100 км по полным данным": "완전한 데이터 기준 L/100km",
"Автомобили": "차량",
"Профиль учета": "기록 프로필",
"Старт": "시작",
"Отчет": "리포트",
"Стоимость владения": "소유 비용",
"Весь срок": "전체",
"Выбери марку": "브랜드 선택",
"Выбери модель": "모델 선택",
"Сначала марка": "브랜드를 먼저 선택하세요",
"Топливо": "연료",
"Эффективность": "효율",
"Месяц": "월",
"День": "일",
"Квартал": "분기",
"Год": "년",
"Свой период": "직접 선택",
"Заправка": "주유",
"Сервис": "정비",
"Скан чека": "영수증 스캔",
"30 сек": "30초",
"ТО / ремонт": "정비 / 수리",
"Динамика расходов": "지출 추이",
"Структура": "구성",
"Дата": "날짜",
"Одометр, км": "주행거리, km",
"Литры": "리터",
"Цена за литр": "리터당 가격",
"АЗС": "주유소",
"Не выбрано": "선택 안 됨",
"Полный бак": "가득 주유",
"Сохранить заправку": "주유 저장",
"Тип": "유형",
"Обслуживание": "정비",
"Ремонт": "수리",
"Жидкости": "오일/액체",
"Шины": "타이어",
"Осмотр": "점검",
"Страховка": "보험",
"Налог": "세금",
"Другое": "기타",
"Что сделано": "작업 내용",
"Масло": "오일",
"Стоимость": "비용",
"Исполнитель": "업체",
"Сохранить запись": "기록 저장",
"Меню": "메뉴",
"Добавить автомобиль": "차량 추가",
"Локаль и валюта": "언어와 통화",
"Уведомления": "알림",
"Сканировать чек": "영수증 스캔",
"Настройки": "설정",
"Язык": "언어",
"Валюта": "통화",
"Сохранить настройки": "설정 저장",
"Напомним о ТО, страховке и регулярном внесении пробега.": "정비, 보험, 주행거리 입력을 알려드릴게요.",
"Включить уведомления": "알림 켜기",
"Фото или файл чека": "영수증 사진 또는 파일",
"Сфотографировать": "사진 촬영",
"Выбрать файл": "파일 선택",
"Файл не выбран": "선택된 파일 없음",
"Распознать": "인식",
"После распознавания поля заправки заполнятся автоматически.": "인식 후 주유 입력란이 자동으로 채워집니다.",
"Новое авто": "새 차량",
"Название авто": "차량 이름",
"Марка": "브랜드",
"Модель": "모델",
"Добавить авто": "차량 추가",
"За весь срок": "전체",
"За месяц": "월",
"За день": "일 평균",
"За квартал": "분기",
"За год": "년",
"За период": "기간",
"На 100 км": "100km당",
"На 1 км": "1km당",
"записей": "개 기록",
"среднее в периоде": "기간 평균",
"нет данных": "데이터 없음",
"Выбери автомобиль": "차량을 선택하세요",
"Выбери автомобиль для статистики": "통계를 볼 차량을 선택하세요",
"Добавь первый автомобиль": "첫 차량을 추가하세요",
"Без деталей": "상세 정보 없음",
"Профиль точный": "정확한 프로필",
"Хороший учет": "좋은 기록",
"Набираем данные": "데이터 수집 중",
"Итого": "합계",
"Стоимость 1 км": "1km 비용",
"Пробег": "주행거리",
"Записей": "기록",
"Потрачено": "지출",
"Литров": "리터",
"Средняя заправка": "평균 주유",
"Расход": "연비",
"Главная категория": "주요 카테고리",
"Макс. категория": "최대 카테고리",
"Прогноз сегодня": "오늘 예측",
"+30 дней": "+30일",
"Нет записей за выбранный период": "선택한 기간에 기록이 없습니다",
"Добавь заправку или сервисную запись": "주유 또는 정비 기록을 추가하세요",
"Нет расходов": "지출 없음",
"топливо": "연료",
"Уведомления включены": "알림이 켜졌습니다",
"Уведомления запрещены в настройках браузера": "브라우저 설정에서 알림이 차단되었습니다",
"Браузер не поддерживает уведомления": "브라우저가 알림을 지원하지 않습니다",
"PWA установлена и работает офлайн после первого открытия.": "PWA는 첫 실행 후 오프라인에서도 작동합니다.",
"Напоминания готовы": "알림 준비 완료",
"Мы напомним о ТО, страховке и обновлении пробега.": "정비, 보험, 주행거리 업데이트를 알려드릴게요.",
"Напоминаний на ближайшее время нет": "다가오는 알림이 없습니다",
"Готов к работе": "준비 완료",
"Обновляю данные...": "데이터 새로고침 중...",
"Сохраняю...": "저장 중...",
"Сохранено": "저장됨",
"Распознаю чек...": "영수증 인식 중...",
"Выбери файл чека": "영수증 파일을 선택하세요",
"Проверь распознанные значения": "인식된 값을 확인하세요",
"Ошибка": "오류",
"Прогноз цены": "가격 예측",
"Текущая цена": "현재 가격",
"Средняя цена": "평균 가격",
},
};
function t(text) {
return i18n[state.user?.locale]?.[text] || text;
}
function applyTranslations(root = document.body) {
document.documentElement.lang = state.user?.locale || "ru";
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent || ["SCRIPT", "STYLE"].includes(parent.tagName)) return NodeFilter.FILTER_REJECT;
return node.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
},
});
while (walker.nextNode()) {
const node = walker.currentNode;
if (!textNodes.has(node)) textNodes.set(node, node.nodeValue.trim());
const original = textNodes.get(node);
node.nodeValue = node.nodeValue.replace(node.nodeValue.trim(), t(original));
}
root.querySelectorAll?.("[placeholder], [aria-label], [title]").forEach((element) => {
["placeholder", "aria-label", "title"].forEach((attr) => {
const value = element.getAttribute(attr);
if (!value) return;
let originals = attrOriginals.get(element);
if (!originals) {
originals = {};
attrOriginals.set(element, originals);
}
originals[attr] ||= value;
element.setAttribute(attr, t(originals[attr]));
});
});
}
const state = {
user: null,
authConfig: null,
cars: [],
catalog: [],
selectedCarId: null,
latestFuel: [],
latestService: [],
latestExpenses: [],
latestStats: null,
allStats: null,
analytics: null,
serviceCenters: [],
activeServiceCenterId: null,
mechanicAppointments: [],
mechanicWorkOrders: [],
publicServiceCenters: [],
appointments: [],
maintenanceRecommendations: [],
stoCalendar: [],
confirmations: null,
connectedServices: [],
adminPendingServices: [],
vehicleScore: null,
vehicleTimeline: [],
achievements: [],
receiptFile: null,
serviceWorkerRegistration: null,
period: {
preset: "month",
dateFrom: null,
dateTo: null,
},
};
async function initPwa() {
if (!("serviceWorker" in navigator)) return;
try {
state.serviceWorkerRegistration = await navigator.serviceWorker.register("/sw.js");
} catch (error) {
console.warn("Service worker registration failed", error);
}
}
function updateNotificationStatus(message) {
const node = document.querySelector("#notificationStatus");
if (node) node.textContent = t(message);
}
async function enableNotifications() {
if (!("Notification" in window)) {
updateNotificationStatus("Браузер не поддерживает уведомления");
return;
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
updateNotificationStatus("Уведомления запрещены в настройках браузера");
return;
}
const registration = state.serviceWorkerRegistration || (await navigator.serviceWorker?.ready);
if (registration?.pushManager && window.APP_VAPID_PUBLIC_KEY) {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(window.APP_VAPID_PUBLIC_KEY),
});
localStorage.setItem("driversPushSubscription", JSON.stringify(subscription));
await savePushSubscription(subscription);
}
await showDueReminders(registration);
if (registration?.showNotification) {
await registration.showNotification(t("Напоминания готовы"), {
body: t("Мы напомним о ТО, страховке и обновлении пробега."),
icon: "/static/icon.svg",
badge: "/static/icon.svg",
tag: "drivers-bot-ready",
});
}
updateNotificationStatus("Уведомления включены");
}
async function showDueReminders(registration) {
if (!state.user) return;
const reminders = await api(`/users/${state.user.id}/reminders`);
if (!reminders.length) {
updateNotificationStatus("Напоминаний на ближайшее время нет");
return;
}
updateNotificationStatus(`Есть напоминаний: ${reminders.length}`);
if (Notification.permission !== "granted" || !registration?.showNotification) return;
const item = reminders[0];
const body = item.due_odometer
? `${item.car_name}: ${item.title}, срок ${item.due_odometer} км`
: `${item.car_name}: ${item.title}, срок ${item.due_date}`;
await registration.showNotification(t("Напоминания готовы"), {
body,
icon: "/static/icon.svg",
badge: "/static/icon.svg",
tag: `drivers-reminder-${item.id}`,
data: "/",
});
}
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
function today() {
return new Date().toISOString().slice(0, 10);
}
function formData(form) {
return Object.fromEntries(new FormData(form).entries());
}
async function api(path, options = {}) {
const { headers: 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 = `<span class="spinner"></span><span>${t(label)}</span>`;
} else {
button.disabled = false;
button.classList.remove("is-busy");
button.textContent = button.dataset.label || button.textContent;
delete button.dataset.label;
}
}
async function runAction(button, statusMessage, callback) {
haptic();
setStatus(statusMessage);
setButtonBusy(button, true, statusMessage);
try {
const result = await callback();
setStatus("Готов к работе");
return result;
} catch (error) {
console.error(error);
setStatus("Ошибка");
toast(error.message || "Ошибка", "error");
haptic("error");
throw error;
} finally {
setButtonBusy(button, false);
}
}
async function ensureUser() {
if (tg?.initData) {
state.user = await api("/users/webapp-auth", {
method: "POST",
body: JSON.stringify({ init_data: tg.initData }),
});
hideAuthOverlay();
updateRoleVisibility();
return;
}
if (state.authConfig?.allow_dev_auth) {
const devId = localStorage.getItem("driversDevTelegramId") || "1";
localStorage.setItem("driversDevTelegramId", devId);
state.user = await api("/users/me");
hideAuthOverlay();
updateRoleVisibility();
return;
}
await showTelegramLogin();
throw new Error("Требуется вход через Telegram");
}
function hideAuthOverlay() {
document.querySelector("#authOverlay")?.classList.add("hidden");
document.body.classList.remove("auth-required");
}
const APPROVED_SERVICE_STATUSES = new Set(["approved", "verified"]);
const STO_WORKPLACE_ROLES = new Set(["owner", "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 = `<span class="telegram-login-help">В мобильном браузере авторизация проходит через Telegram-бота.</span>`;
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 = `<option value="">${t("Выбери марку")}</option>` + makes
.map((make) => `<option value="${make.name}">${make.name}</option>`)
.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 = `<strong>${t("Выбери модель")}</strong><span>Покажем кузов, топливо, привод и годы выпуска.</span>`;
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 = `
<strong>${makeSelect.value} ${model.name}${trim ? ` · ${trim.name}` : ""}</strong>
<span>${chips.length ? chips.join(" · ") : "Базовые параметры можно уточнить позже"}</span>
`;
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
? `<option value="">Комплектация не выбрана</option>` + trims.map((trim) => `<option value="${trim.name}">${trim.name}</option>`).join("")
: `<option value="">Сначала модель</option>`;
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
? `<option value="">${t("Выбери модель")}</option>` + models.map((model) => `<option value="${model.name}">${model.name}</option>`).join("")
: `<option value="">${t("Сначала марка")}</option>`;
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 = `<option value="">${t("Сначала марка")}</option>`;
trimSelect.disabled = true;
trimSelect.innerHTML = `<option value="">Сначала модель</option>`;
document.querySelector("#catalogPreview").innerHTML =
`<strong>${t("Выбери модель")}</strong><span>Покажем кузов, топливо, привод и годы выпуска.</span>`;
}
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 = `<div class="empty">${t("Добавь первый автомобиль")}</div>`;
if (drawerRoot) drawerRoot.innerHTML = root.innerHTML;
updateHero(null);
return;
}
const markup = state.cars
.map(
(car) => `
<button class="car-item ${car.id === state.selectedCarId ? "active" : ""}" data-car="${car.id}">
<span class="car-badge">${(car.make || car.name).slice(0, 2).toUpperCase()}</span>
<span class="car-copy">
<strong>${car.name}</strong>
<small>${[car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей")}</small>
</span>
</button>
`,
)
.join("");
root.innerHTML = markup;
if (drawerRoot) drawerRoot.innerHTML = markup;
document.querySelectorAll("[data-car]").forEach((button) => {
button.addEventListener("click", () => selectCar(Number(button.dataset.car)));
});
}
function setInputValue(form, name, value) {
if (!form?.elements[name]) return;
const input = form.elements[name];
if (input.type === "checkbox") {
input.checked = Boolean(value);
return;
}
input.value = value ?? "";
}
function csvList(value) {
return value ? value.split(",").map((item) => item.trim()).filter(Boolean) : null;
}
function fillCarProfileForm() {
const form = document.querySelector("#carProfileForm");
const hint = document.querySelector("#carProfileHint");
const car = selectedCar();
form.querySelectorAll("input, select, button").forEach((node) => {
node.disabled = !car;
});
if (!car) {
form.reset();
hint.textContent = t("Выбери автомобиль, чтобы настроить жидкости, расход и сервисные нормы.");
return;
}
hint.textContent = [car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || car.name;
[
"plate_number",
"vin",
"generation",
"body_type",
"engine_volume_l",
"transmission",
"drive_type",
"fuel_type",
"target_consumption_l_per_100km",
"fuel_tank_volume_l",
"engine_oil_type",
"engine_oil_volume_l",
"transmission_fluid_type",
"transmission_fluid_volume_l",
"coolant_type",
"brake_fluid_type",
"tire_pressure_front_bar",
"tire_pressure_rear_bar",
"tire_size",
"oil_change_interval_km",
"oil_change_interval_months",
"purchase_price",
"purchase_date",
"purchase_type",
"loan_principal",
"loan_down_payment",
"loan_term_months",
"loan_annual_interest_rate",
"loan_first_payment_date",
"include_depreciation",
"notes",
].forEach((name) => setInputValue(form, name, car[name]));
}
async function loadConfirmations() {
const root = document.querySelector("#confirmationRequests");
if (!root) return;
try {
state.confirmations = await api("/my/confirmations");
const visits = state.confirmations.service_visits || [];
const changes = state.confirmations.change_requests || [];
const links = state.confirmations.service_links || [];
if (!visits.length && !changes.length && !links.length) {
root.innerHTML = `<div class="empty">Новых запросов нет</div>`;
return;
}
root.innerHTML = [
...visits.map((visit) => `
<div class="stack-item">
<strong>Визит СТО #${visit.id}</strong>
<small>${visit.visit_date} · ${visit.odometer || "-"} км · ${money(visit.total_cost || 0)}</small>
<div class="row-actions">
<button type="button" data-confirm-visit="${visit.id}">Подтвердить</button>
<button type="button" data-dispute-visit="${visit.id}">Спор</button>
</div>
</div>`),
...changes.map((item) => `
<div class="stack-item">
<strong>Изменение ${item.field_name}</strong>
<small>${item.old_value || "-"}${item.new_value || "-"}</small>
<div class="row-actions">
<button type="button" data-approve-change="${item.id}">Принять</button>
<button type="button" data-reject-change="${item.id}">Отклонить</button>
</div>
</div>`),
...links.map((link) => `
<div class="stack-item">
<strong>Запрос доступа от СТО #${link.service_center_id}</strong>
<small>Авто #${link.car_id} · ${link.access_level}</small>
<div class="row-actions">
<button type="button" data-approve-link="${link.id}">Разрешить</button>
<button type="button" data-revoke-link="${link.id}">Отклонить</button>
</div>
</div>`),
].join("");
bindConfirmationActions(root);
} catch (error) {
root.innerHTML = `<div class="empty">Не удалось загрузить подтверждения</div>`;
}
}
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) => `
<div class="stack-item">
<strong>${link.service_center_name}</strong>
<small>${link.car_name} · ${link.access_level} · ${link.status}</small>
${link.status === "approved" ? `<button type="button" data-revoke-link="${link.id}">Отозвать доступ</button>` : ""}
</div>`).join("")
: `<div class="empty">Подключенных автосервисов пока нет</div>`;
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 = `<div class="empty">Не удалось загрузить подключения</div>`;
}
}
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) => `
<div class="stack-item">
<strong>#${center.id} ${center.display_name || center.name}</strong>
<small>${[center.legal_name, center.city, center.address].filter(Boolean).join(" · ") || "Данные не заполнены"}</small>
<small>Документы: ${(center.document_photo_urls || []).length}</small>
<div class="row-actions">
<button type="button" data-admin-action="verify" data-admin-center="${center.id}">Одобрить</button>
<button type="button" data-admin-action="request-changes" data-admin-center="${center.id}">Правки</button>
<button type="button" data-admin-action="reject" data-admin-center="${center.id}">Отклонить</button>
</div>
</div>`).join("")
: `<div class="empty">Pending-заявок нет</div>`;
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 = `<div class="empty">Нет доступа или сервер не ответил</div>`;
}
}
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 = `<div class="empty">СТО пока не создано</div>`;
return;
}
root.innerHTML = state.serviceCenters
.map(
(center) => `
<div class="stack-item">
<strong>${center.display_name || center.name}</strong>
<small>${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"}</small>
<small>Статус: ${center.verification_status}</small>
${center.trust_score ? `<span class="trust-badge">${trustLabel(center.trust_score.trust_level)} · ${center.trust_score.trust_score}/100</span>` : ""}
</div>
`,
)
.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) => `
<button class="stack-item service-list-card" type="button" data-service-card="${center.id}">
<strong>${center.display_name || center.name}</strong>
<small>${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"}</small>
<small>${center.specializations?.join(", ") || "Специализация не указана"}</small>
<span class="trust-badge">${center.nearest_slot_at ? `Окно ${formatDateTime(center.nearest_slot_at)}` : center.rating_avg ? `${center.rating_avg}` : "Проверка пройдена"}</span>
</button>
`,
)
.join("")
: `<div class="empty">Проверенных СТО пока нет</div>`;
root.querySelectorAll("[data-service-card]").forEach((button) => {
button.addEventListener("click", () => openServiceCard(Number(button.dataset.serviceCard)));
});
} catch (error) {
root.innerHTML = `<div class="empty">Не удалось загрузить СТО</div>`;
}
}
function renderServiceReviews() {
const root = document.querySelector("#serviceReviews");
if (!root) return;
root.innerHTML = state.publicServiceCenters.length
? state.publicServiceCenters
.map((center) => `<button class="menu-row" type="button" data-service-card="${center.id}">${center.display_name || center.name}</button>`)
.join("")
: `<div class="empty">Откройте раздел «СТО», чтобы загрузить проверенные сервисы.</div>`;
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 = `
<div class="section-head">
<div>
<p class="eyebrow">СТО</p>
<h2>${center.display_name || center.name}</h2>
</div>
<span class="trust-badge">${center.rating_avg ? `${center.rating_avg} · ${center.reviews_count}` : "Проверенный сервис"}</span>
</div>
<div class="tip-card">
<strong>${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"}</strong><br />
<small>${center.phone || "Телефон не указан"}</small><br />
<span>${center.description || "Описание появится после заполнения карточки сервисом."}</span>
</div>
<div class="service-actions">
<button type="button" class="ghost-btn" id="attachServiceBtn">Привязать выбранное авто</button>
</div>
<div class="tip-card">
Запись вынесена на отдельную страницу: там можно выбрать автомобиль, услугу, дату и свободное окно без тесного меню.
<button type="button" class="wide-btn" data-page-link="/book_sto.html?service_center_id=${center.id}">Записаться в это СТО</button>
</div>
<form class="grid-form drawer-form" id="serviceReviewForm">
<label>
Оценка
<select name="rating">
<option value="5">5 · Отлично</option>
<option value="4">4 · Хорошо</option>
<option value="3">3 · Нормально</option>
<option value="2">2 · Есть проблемы</option>
<option value="1">1 · Плохо</option>
</select>
</label>
<label>
Отзыв
<input name="text" placeholder="Что понравилось или что улучшить" />
</label>
<button type="submit">Оставить отзыв</button>
</form>
<div class="stack-list">
${reviews.length
? reviews.map((review) => `
<div class="stack-item">
<strong>★ ${review.rating}</strong>
<small>${review.text || "Без текста"}</small>
${review.service_response ? `<small>Ответ СТО: ${review.service_response}</small>` : ""}
</div>
`).join("")
: `<div class="empty">Отзывов еще нет</div>`}
</div>
`;
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) => `
<div class="stack-item">
<strong>${item.service_name}</strong>
<small>${formatDateTime(item.confirmed_start_at || item.proposed_start_at || item.requested_start_at)}</small>
<span class="trust-badge">${item.status}</span>
<div class="service-actions">
${item.status === "proposed_new_time" ? `
<button type="button" data-accept-appointment="${item.id}">Принять время</button>
<button type="button" class="ghost-btn" data-reject-appointment="${item.id}">Отклонить</button>
` : ""}
${!["converted_to_work_order", "completed"].includes(item.status) ? `<button type="button" class="danger-btn" data-delete-appointment="${item.id}">Удалить запись</button>` : ""}
</div>
</div>
`).join("")
: `<div class="empty">Записей пока нет</div>`;
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 = `<div class="empty">Записи не загрузились</div>`;
}
}
async function loadMaintenanceRecommendations() {
const root = document.querySelector("#maintenanceRecommendations");
if (!root) return;
if (!state.selectedCarId) {
root.innerHTML = `<div class="empty">Выбери автомобиль</div>`;
return;
}
try {
state.maintenanceRecommendations = await api(`/vehicles/${state.selectedCarId}/maintenance-recommendations`);
root.innerHTML = state.maintenanceRecommendations.length
? state.maintenanceRecommendations.map((item) => `
<div class="stack-item">
<strong>${item.title}</strong>
<small>${item.description || "Плановое обслуживание"}</small>
<small>${[item.due_odometer_km ? `${item.due_odometer_km} км` : "", item.due_date || ""].filter(Boolean).join(" · ")}</small>
<span class="trust-badge">${item.priority} · ${item.status}</span>
${item.status === "active" ? `<button type="button" class="ghost-btn" data-dismiss-recommendation="${item.id}">Скрыть</button>` : ""}
</div>
`).join("")
: `<div class="empty">Рекомендаций пока нет</div>`;
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 = `<div class="empty">Рекомендации не загрузились</div>`;
}
}
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 = `<div class="empty">Календарь доступен только сотрудникам подтвержденного СТО.</div>`;
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 = `
<div class="stat-card"><span>Авто</span><strong>${dashboard.connected_vehicles}</strong></div>
<div class="stat-card"><span>Новые заявки</span><strong>${dashboard.pending_appointments}</strong></div>
<div class="stat-card"><span>Подтверждено</span><strong>${dashboard.confirmed_appointments}</strong></div>
<div class="stat-card"><span>Месяц</span><strong>${money(dashboard.revenue_month || 0)}</strong></div>
`;
list.innerHTML = appointments.length
? appointments.map((item) => `
<div class="stack-item">
<strong>${item.service_name}</strong>
<small>${formatDateTime(item.confirmed_start_at || item.requested_start_at)} · авто #${item.vehicle_id}</small>
<span class="trust-badge">${item.status}</span>
${item.status === "requested" ? `
<div class="service-actions">
<button type="button" data-confirm-sto-appointment="${item.id}">Подтвердить</button>
<button type="button" class="ghost-btn" data-reject-sto-appointment="${item.id}">Отклонить</button>
</div>
` : ""}
${!["converted_to_work_order", "completed"].includes(item.status) ? `
<button type="button" class="danger-btn" data-delete-sto-appointment="${item.id}">Удалить бронь</button>
` : ""}
</div>
`).join("")
: `<div class="empty">Записей на ближайший период нет</div>`;
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 = `<div class="empty">Календарь СТО не загрузился</div>`;
}
}
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 = `<div class="empty">Рабочее место доступно владельцу подтвержденного СТО и активным механикам.</div>`;
return;
}
centerSelect.innerHTML = state.serviceCenters
.filter((center) => centers.some((item) => item.id === center.id))
.map((center) => `<option value="${center.id}">${escapeHtml(center.display_name || center.name)}</option>`)
.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
? `
<div class="stat-card"><span>Заявки</span><strong>${dashboard.pending_appointments}</strong></div>
<div class="stat-card"><span>Подтверждено</span><strong>${dashboard.confirmed_appointments}</strong></div>
<div class="stat-card"><span>Заказ-наряды</span><strong>${dashboard.active_work_orders}</strong></div>
<div class="stat-card"><span>Авто</span><strong>${dashboard.connected_vehicles}</strong></div>
`
: `<div class="empty">Сводка недоступна</div>`;
const centerNotice = center.verification_status && !["approved", "verified"].includes(center.verification_status)
? `<div class="tip-card">СТО сейчас в статусе «${serviceStatusLabel(center.verification_status)}». Часть действий может быть недоступна до проверки.</div>`
: "";
const appointmentMarkup = state.mechanicAppointments.map(renderMechanicAppointment).join("");
const workOrderMarkup = state.mechanicWorkOrders.map(renderMechanicWorkOrder).join("");
list.innerHTML = `
${centerNotice}
<h3 class="list-heading">Записи</h3>
${appointmentMarkup || `<div class="empty">Новых записей нет</div>`}
<h3 class="list-heading">Заказ-наряды</h3>
${workOrderMarkup || `<div class="empty">Активных заказ-нарядов нет</div>`}
`;
bindMechanicWorkplaceActions(list);
} catch (error) {
summary.innerHTML = "";
list.innerHTML = `<div class="empty">Рабочее место не загрузилось</div>`;
}
}
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 `
<div class="stack-item work-order-card">
<strong>${escapeHtml(item.service_name)}</strong>
<small>${formatDateTime(item.confirmed_start_at || item.requested_start_at)} · авто #${item.vehicle_id}</small>
<span class="trust-badge">${appointmentStatusLabel(item.status)}</span>
<div class="row-actions">
${canManageAppointments && item.status === "requested" ? `<button type="button" data-mechanic-confirm-appointment="${item.id}">Подтвердить</button>` : ""}
${canCreateWorkOrder ? `<button type="button" data-create-work-order="${item.id}">Открыть заказ-наряд</button>` : ""}
${canManageAppointments ? `<button type="button" class="ghost-btn" data-mechanic-reject-appointment="${item.id}">Отклонить</button>` : ""}
${canManageAppointments && !["converted_to_work_order", "completed"].includes(item.status) ? `<button type="button" class="danger-btn" data-mechanic-delete-appointment="${item.id}">Удалить</button>` : ""}
</div>
</div>
`;
}
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 `
<div class="stack-item work-order-card">
<div class="work-order-head">
<div>
<strong>${escapeHtml(item.work_order_number || `Заказ-наряд #${item.id}`)}</strong>
<small>${item.visit_date} · авто #${item.vehicle_id} · ${item.odometer || "-"} км</small>
</div>
<span class="trust-badge">${workOrderStatusLabel(item.status)}</span>
</div>
${item.customer_complaint ? `<small>Жалоба: ${escapeHtml(item.customer_complaint)}</small>` : ""}
${item.diagnosis ? `<small>Диагностика: ${escapeHtml(item.diagnosis)}</small>` : ""}
<div class="work-order-totals">
<span>Работы: <strong>${money(item.labor_total || 0)}</strong></span>
<span>Запчасти: <strong>${money(item.product_total || 0)}</strong></span>
<span>Итого: <strong>${money(item.final_total || item.total_cost || 0)}</strong></span>
</div>
${canEditItems ? `<form class="inline-work-form" data-labor-form="${item.id}">
<input name="title" placeholder="Работа" required />
<input name="quantity" type="number" min="0.001" step="0.001" value="1" aria-label="Количество" />
<input name="unit_price" type="number" min="0" step="0.01" placeholder="Цена" required />
<button type="submit">+ Работа</button>
</form>
<form class="inline-work-form" data-product-form="${item.id}">
<input name="title" placeholder="Запчасть / материал" required />
<input name="quantity" type="number" min="0.001" step="0.001" value="1" aria-label="Количество" />
<input name="unit_price" type="number" min="0" step="0.01" placeholder="Цена" required />
<button type="submit">+ Материал</button>
</form>` : ""}
<div class="row-actions">
${canStart ? `<button type="button" data-start-work-order="${item.id}">В работу</button>` : ""}
${canSubmitApproval ? `<button type="button" data-submit-work-order="${item.id}">На согласование</button>` : ""}
${canComplete ? `<button type="button" class="ghost-btn" data-complete-work-order="${item.id}">Завершить</button>` : ""}
</div>
</div>
`;
}
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 () => {
await api(`/work-orders/${button.dataset.completeWorkOrder}/complete`, {
method: "POST",
body: JSON.stringify({ comment: "Работы завершены" }),
});
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderPlaceholderList(selector, message) {
const root = document.querySelector(selector);
if (root) root.innerHTML = `<div class="empty">${message}</div>`;
}
function renderStats(stats) {
const root = document.querySelector("#stats");
if (!stats) {
root.innerHTML = `<div class="empty">${t("Выбери автомобиль для статистики")}</div>`;
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 = `
<button class="stat pop" data-report="summary"><span>${t("За весь срок")}</span><strong>${money(all.total_cost)}</strong><em>${all.fuel_entries_count + all.service_entries_count} ${t("записей")}</em></button>
<button class="stat pop" data-report="summary"><span>${periodTitle}</span><strong>${money(stats.total_cost)}</strong><em>${stats.date_from} - ${stats.date_to}</em></button>
<button class="stat pop" data-report="summary"><span>В месяц</span><strong>${money(stats.cost_per_month || 0)}</strong><em>${t("среднее в периоде")}</em></button>
<button class="stat pop" data-report="summary"><span>Прогноз</span><strong>${money(stats.forecast_next_month || 0)}</strong><em>ближайший месяц</em></button>
<button class="stat pop" data-report="summary"><span>Фиксированные</span><strong>${money(stats.fixed_costs || 0)}</strong><em>страховка, налоги, кредит</em></button>
<button class="stat pop" data-report="summary"><span>Переменные</span><strong>${money(stats.variable_costs || 0)}</strong><em>топливо, ремонт, услуги</em></button>
<button class="stat pop" data-report="summary"><span>Кредит</span><strong>${money((Number(stats.loan_principal_cost || 0) + Number(stats.loan_interest_cost || 0)))}</strong><em>тело и проценты</em></button>
<button class="stat pop" data-report="efficiency"><span>${t("За день")}</span><strong>${money(costPerDay)}</strong><em>${t("среднее в периоде")}</em></button>
<button class="stat pop" data-report="efficiency"><span>${t("На 100 км")}</span><strong>${costPer100 ? money(costPer100) : "-"}</strong><em>${stats.distance_km} км</em></button>
<button class="stat pop" data-report="efficiency"><span>${t("На 1 км")}</span><strong>${stats.cost_per_km ? money(stats.cost_per_km) : "-"}</strong><em>${stats.avg_consumption_l_per_100km ? `${stats.avg_consumption_l_per_100km.toFixed(2)} л/100` : t("нет данных")}</em></button>
${stats.cost_warning ? `<div class="stat wide warning"><span>Предупреждение</span><strong>${stats.cost_warning}</strong><em>мягкая проверка расходов</em></div>` : ""}
`;
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("Добавь авто и первую запись, чтобы видеть точные отчеты");
renderScoreActions(state.vehicleScore?.missing_items || []);
renderAchievements();
renderVehicleTimeline();
}
function scoreLabel(quality, score) {
if (quality === "high_confidence" || score >= 86) return "High-confidence passport";
if (quality === "strong" || score >= 61) return "Strong profile";
if (quality === "useful" || score >= 31) return "Useful profile";
return "Basic profile";
}
function historyLabel(status) {
const labels = {
verified: "Verified maintenance history",
partially_verified: "Partially verified",
self_reported: "Self-reported",
};
return labels[status] || "Self-reported";
}
function healthLabel(status) {
const labels = {
green: "Green",
yellow: "Attention soon",
red: "Overdue",
unknown: "Needs baseline",
};
return labels[status] || "Needs baseline";
}
function renderScoreActions(items) {
const root = document.querySelector("#scoreActions");
if (!root) return;
const visible = items.slice(0, 3);
if (!visible.length) {
root.innerHTML = `<div class="passport-note">Паспорт выглядит надежно. Следующий рост даст подтвержденная сервисная история.</div>`;
return;
}
root.innerHTML = visible
.map(
(item) => `
<div class="passport-action">
<strong>${item.title}</strong>
<span>${item.description}</span>
</div>
`,
)
.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 = `<div class="achievement-card muted"><strong>Evidence badges</strong><span>Появятся после первых качественных записей.</span></div>`;
return;
}
root.innerHTML = achievements
.map(
(item) => `
<div class="achievement-card is-earned">
<strong>${item.title}</strong>
<span>${item.description}</span>
</div>
`,
)
.join("");
}
function renderVehicleTimeline() {
const root = document.querySelector("#vehicleTimeline");
if (!root) return;
const items = state.vehicleTimeline.slice(0, 5);
if (!items.length) {
root.innerHTML = `<div class="timeline-empty">Timeline появится после заправок, сервиса и подтверждений.</div>`;
return;
}
root.innerHTML = `
<div class="timeline-title">Vehicle timeline</div>
${items
.map(
(item) => `
<div class="timeline-item ${item.type}">
<span></span>
<div>
<strong>${item.title}</strong>
<small>${String(item.date).slice(0, 10)}${item.status ? ` · ${item.status}` : ""}</small>
</div>
</div>
`,
)
.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 = `<div class="empty">${t("Выбери автомобиль")}</div>`;
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: `
<div class="report-grid">
${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}`)}
</div>
${reportRecords(records.slice(0, 8))}
`,
fuel: `
<div class="report-grid">
${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` : "-")}
</div>
${reportRecords(records.filter((item) => item.type === "fuel").slice(0, 10))}
`,
service: `
<div class="report-grid">
${reportMetric(t("Потрачено"), money(stats.service_cost))}
${reportMetric(t("Записей"), stats.service_entries_count)}
${reportMetric(t("Главная категория"), topService ? topService[0] : "-")}
${reportMetric(t("Макс. категория"), topService ? money(topService[1]) : "-")}
</div>
${reportRecords(records.filter((item) => item.type === "service").slice(0, 10))}
`,
efficiency: `
<div class="report-grid">
${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)} / л` : "-")}
</div>
${analytics?.full_tank_warning ? `<div class="tip-card warning">${analytics.full_tank_warning}</div>` : ""}
<div class="tip-card">${analytics?.insight || t("Лучший рост точности даст привычка заносить одометр при каждой заправке и сервисе.")}</div>
`,
};
body.innerHTML = blocks[type] || blocks.summary;
bindRecordDeleteActions(body, type);
applyTranslations(body);
sheet.classList.remove("hidden");
}
function reportMetric(label, value) {
return `<div class="report-metric"><span>${label}</span><strong>${value}</strong></div>`;
}
function reportRecords(records) {
if (!records.length) return `<div class="empty">${t("Нет записей за выбранный период")}</div>`;
return `<div class="report-records">${records
.map(
(item) => `
<div class="record">
<small>${item.date}</small>
<div><strong>${item.title}</strong><br><small>${item.meta || ""}</small></div>
<strong class="${item.type}">${money(item.cost)}</strong>
${item.deleteEndpoint ? `<button type="button" class="icon-btn" data-delete-record="${item.deleteEndpoint}" aria-label="Удалить">×</button>` : ""}
</div>
`,
)
.join("")}</div>`;
}
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;
renderCars();
fillCarProfileForm();
await loadSelectedCar();
}
async function loadSelectedCar() {
if (!state.selectedCarId) {
state.latestFuel = [];
state.latestService = [];
state.latestExpenses = [];
state.latestStats = null;
state.allStats = null;
state.analytics = null;
state.vehicleScore = null;
state.vehicleTimeline = [];
state.achievements = [];
renderStats(null);
return;
}
const [stats, allStats, fuel, service, expenses, analytics, vehicleScore] = await Promise.all([
api(`/cars/${state.selectedCarId}/stats${periodQuery()}`),
api(`/cars/${state.selectedCarId}/stats${allPeriodQuery()}`),
api(`/cars/${state.selectedCarId}/fuel${periodQuery()}`),
api(`/cars/${state.selectedCarId}/service${periodQuery()}`),
api(`/cars/${state.selectedCarId}/expenses${periodQuery()}`),
api(`/cars/${state.selectedCarId}/analytics`),
api(`/my/vehicles/${state.selectedCarId}/score`),
]);
const [timeline, achievements] = await Promise.all([
api(`/my/vehicles/${state.selectedCarId}/timeline?limit=30`),
api("/me/achievements"),
]);
state.latestStats = stats;
state.allStats = allStats;
state.latestFuel = fuel;
state.latestService = service;
state.latestExpenses = expenses;
state.analytics = analytics;
state.vehicleScore = vehicleScore;
state.vehicleTimeline = timeline;
state.achievements = achievements;
renderStats(stats);
drawCharts(fuel, service, stats);
}
document.querySelectorAll('input[name="entry_date"]').forEach((input) => {
input.value = today();
});
applyPeriodPreset("30d");
document.querySelector("#refreshBtn").addEventListener("click", (event) => {
runAction(event.currentTarget, "Обновляю данные...", loadCars).then(() => {
toast("Готов к работе");
});
});
document.querySelector("#telegramRetryBtn")?.addEventListener("click", () => {
runAction(document.querySelector("#telegramRetryBtn"), "Обновляю данные...", async () => {
await ensureUser();
await loadCatalog();
initCarCatalog();
await loadCars();
});
});
document.querySelector("#periodPreset").addEventListener("change", async (event) => {
await runAction(event.currentTarget, "Обновляю данные...", async () => {
applyPeriodPreset(event.currentTarget.value);
await loadSelectedCar();
});
});
document.querySelectorAll("#periodFrom, #periodTo").forEach((input) => {
input.addEventListener("change", async () => {
await runAction(input, "Обновляю данные...", async () => {
document.querySelector("#periodPreset").value = "custom";
applyPeriodPreset("custom");
await loadSelectedCar();
});
});
});
document.querySelector("#carForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
await api("/cars", {
method: "POST",
body: JSON.stringify({
owner_id: state.user.id,
name: data.name,
make: data.make || null,
model: data.model || null,
trim: data.trim || null,
year: data.year ? Number(data.year) : null,
plate_number: data.plate_number || null,
vin: data.vin || null,
current_odometer: numberOrNull(data.current_odometer),
fuel_type: data.fuel_type || null,
purchase_price: numberOrNull(data.purchase_price),
purchase_date: data.purchase_date || null,
purchase_type: data.purchase_type || "unknown",
purchase_currency: state.user?.currency || "RUB",
currency: state.user?.currency || "RUB",
}),
});
form.reset();
resetCarCatalog();
document.querySelector("#userDrawer").classList.add("hidden");
await loadCars();
toast("Сохранено");
haptic("success");
});
});
document.querySelector("#carProfileForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const car = selectedCar();
if (!car) {
toast("Выбери автомобиль", "error");
return;
}
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
const updated = await api(`/cars/${car.id}`, {
method: "PATCH",
body: JSON.stringify({
plate_number: data.plate_number || null,
vin: data.vin || null,
generation: data.generation || null,
body_type: data.body_type || null,
engine_volume_l: numberOrNull(data.engine_volume_l),
transmission: data.transmission || null,
drive_type: data.drive_type || null,
fuel_type: data.fuel_type || null,
target_consumption_l_per_100km: numberOrNull(data.target_consumption_l_per_100km),
fuel_tank_volume_l: numberOrNull(data.fuel_tank_volume_l),
engine_oil_type: data.engine_oil_type || null,
engine_oil_volume_l: numberOrNull(data.engine_oil_volume_l),
transmission_fluid_type: data.transmission_fluid_type || null,
transmission_fluid_volume_l: numberOrNull(data.transmission_fluid_volume_l),
coolant_type: data.coolant_type || null,
brake_fluid_type: data.brake_fluid_type || null,
tire_pressure_front_bar: numberOrNull(data.tire_pressure_front_bar),
tire_pressure_rear_bar: numberOrNull(data.tire_pressure_rear_bar),
tire_size: data.tire_size || null,
oil_change_interval_km: numberOrNull(data.oil_change_interval_km),
oil_change_interval_months: numberOrNull(data.oil_change_interval_months),
purchase_price: numberOrNull(data.purchase_price),
purchase_date: data.purchase_date || null,
purchase_type: data.purchase_type || "unknown",
include_depreciation: Boolean(data.include_depreciation),
loan_principal: numberOrNull(data.loan_principal),
loan_down_payment: numberOrNull(data.loan_down_payment),
loan_term_months: numberOrNull(data.loan_term_months),
loan_annual_interest_rate: numberOrNull(data.loan_annual_interest_rate),
loan_first_payment_date: data.loan_first_payment_date || null,
loan_currency: state.user?.currency || car.currency || "RUB",
notes: data.notes || null,
}),
});
state.cars = state.cars.map((item) => (item.id === updated.id ? updated : item));
renderCars();
fillCarProfileForm();
await loadSelectedCar();
toast("Параметры сохранены");
haptic("success");
});
});
document.querySelector("#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 }),
});
applyTranslations();
initCarCatalog();
await loadSelectedCar();
document.querySelector("#userDrawer").classList.add("hidden");
toast("Сохранено");
haptic("success");
});
});
document.querySelector("#fuelForm").addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.selectedCarId) return;
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
await api("/fuel", {
method: "POST",
body: JSON.stringify({
car_id: state.selectedCarId,
entry_date: data.entry_date,
odometer: Number(data.odometer),
liters: Number(data.liters),
price_per_liter: Number(data.price_per_liter),
station: data.station || null,
is_full_tank: Boolean(data.is_full_tank),
}),
});
form.reset();
form.entry_date.value = today();
await loadSelectedCar();
toast("Сохранено");
haptic("success");
});
});
document.querySelector("#serviceForm").addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.selectedCarId) return;
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
await api("/service", {
method: "POST",
body: JSON.stringify({
car_id: state.selectedCarId,
entry_date: data.entry_date,
odometer: data.odometer ? Number(data.odometer) : null,
service_type: data.service_type,
title: data.title,
total_cost: Number(data.total_cost),
vendor: data.vendor || null,
next_due_date: data.next_due_date || null,
next_due_odometer: data.next_due_odometer ? Number(data.next_due_odometer) : null,
}),
});
form.reset();
form.entry_date.value = today();
await loadSelectedCar();
toast("Сохранено");
haptic("success");
});
});
document.querySelector("#expenseForm").addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.selectedCarId) {
toast("Выбери автомобиль", "error");
return;
}
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
await api("/expenses", {
method: "POST",
body: JSON.stringify({
car_id: state.selectedCarId,
entry_date: data.entry_date,
category: data.category,
title: data.title,
total_cost: Number(data.total_cost),
currency: data.currency || state.user?.currency || "RUB",
vendor: data.vendor || null,
odometer: numberOrNull(data.odometer),
period_start: data.period_start || null,
period_end: data.period_end || null,
period_months: numberOrNull(data.period_months),
payment_period_months: numberOrNull(data.period_months),
policy_number: data.policy_number || null,
insurance_type: data.insurance_type || null,
notes: data.notes || null,
is_recurring: Boolean(data.is_recurring),
}),
});
form.reset();
form.entry_date.value = today();
form.currency.value = state.user?.currency || "RUB";
await loadSelectedCar();
toast("Сохранено");
haptic("success");
});
});
function setAction(action) {
document.querySelectorAll(".action-card[data-action]").forEach((button) => {
button.classList.toggle("active", button.dataset.action === action);
});
document.querySelector("#fuelForm").classList.toggle("hidden", action !== "fuel");
document.querySelector("#serviceForm").classList.toggle("hidden", action !== "service");
}
function openScanModal() {
haptic();
document.querySelector("#userDrawer").classList.add("hidden");
document.querySelector("#scanModal").classList.remove("hidden");
}
function mountEntryForms() {
const fuelMount = document.querySelector("#fuelFormMount");
const serviceMount = document.querySelector("#serviceFormMount");
const fuelForm = document.querySelector("#fuelForm");
const serviceForm = document.querySelector("#serviceForm");
if (fuelMount && fuelForm && !fuelMount.contains(fuelForm)) {
fuelForm.classList.remove("hidden");
fuelMount.appendChild(fuelForm);
}
if (serviceMount && serviceForm && !serviceMount.contains(serviceForm)) {
serviceForm.classList.remove("hidden");
serviceMount.appendChild(serviceForm);
}
}
async function openDrawerSection(sectionId, options = {}) {
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();
if (sectionId === "carProfileSection") fillCarProfileForm();
if (sectionId === "settingsSection") {
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
}
if (sectionId === "notificationsSection") {
updateNotificationStatus(
"Notification" in window && Notification.permission === "granted"
? "Уведомления включены"
: "Напомним о ТО, страховке и регулярном внесении пробега.",
);
}
if (sectionId === "confirmationsSection") await loadConfirmations();
if (sectionId === "connectedServicesSection") await loadConnectedServices();
if (sectionId === "servicePanelSection") await loadServiceCenters();
if (sectionId === "publicServicesSection") await loadPublicServiceCenters();
if (sectionId === "appointmentsSection") await loadAppointments();
if (sectionId === "maintenanceRecommendationsSection") await loadMaintenanceRecommendations();
if (sectionId === "stoCalendarSection") await loadStoCalendar();
if (sectionId === "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("expensesSection");
});
document.querySelectorAll("[data-menu-section]").forEach((button) => {
button.addEventListener("click", async (event) => {
await runAction(event.currentTarget, "Обновляю данные...", async () => {
await openDrawerSection(event.currentTarget.dataset.menuSection);
});
});
});
document.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(() => {
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", `<div class="error">${error.message}</div>`);
});