2848 lines
118 KiB
JavaScript
2848 lines
118 KiB
JavaScript
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>
|
||
<form class="grid-form drawer-form" id="serviceBookingForm">
|
||
<label>
|
||
Услуга
|
||
<select name="service_type">
|
||
<option value="oil_change">Замена масла</option>
|
||
<option value="diagnostics">Диагностика</option>
|
||
<option value="maintenance">ТО</option>
|
||
<option value="tire_service">Шиномонтаж</option>
|
||
<option value="brakes">Тормоза</option>
|
||
<option value="repair">Ремонт</option>
|
||
<option value="other">Другое</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Дата
|
||
<input name="date" type="date" value="${today()}" />
|
||
</label>
|
||
<label>
|
||
Свободное окно
|
||
<select name="slot" id="bookingSlotSelect"></select>
|
||
</label>
|
||
<label>
|
||
Комментарий
|
||
<input name="customer_comment" placeholder="Что нужно сделать" />
|
||
</label>
|
||
<button type="submit">Записаться</button>
|
||
</form>
|
||
<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");
|
||
});
|
||
});
|
||
const bookingForm = card.querySelector("#serviceBookingForm");
|
||
const reloadSlots = () => loadServiceBookingSlots(serviceCenterId, bookingForm);
|
||
bookingForm.querySelector('[name="service_type"]').addEventListener("change", reloadSlots);
|
||
bookingForm.querySelector('[name="date"]').addEventListener("change", reloadSlots);
|
||
bookingForm.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
if (!state.selectedCarId) {
|
||
toast("Выбери автомобиль", "error");
|
||
return;
|
||
}
|
||
const data = formData(bookingForm);
|
||
if (!data.slot) {
|
||
toast("Выбери свободное окно", "error");
|
||
return;
|
||
}
|
||
await runAction(bookingForm.querySelector('button[type="submit"]'), "Создаю запись...", async () => {
|
||
await api("/appointments", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
service_center_id: serviceCenterId,
|
||
vehicle_id: state.selectedCarId,
|
||
service_type: data.service_type,
|
||
service_name: bookingServiceName(data.service_type),
|
||
requested_start_at: data.slot,
|
||
customer_comment: data.customer_comment || null,
|
||
}),
|
||
});
|
||
await loadAppointments();
|
||
toast("Заявка отправлена в СТО");
|
||
haptic("success");
|
||
});
|
||
});
|
||
await reloadSlots();
|
||
card.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}
|
||
|
||
function bookingServiceName(type) {
|
||
const names = {
|
||
oil_change: "Замена масла",
|
||
diagnostics: "Диагностика",
|
||
maintenance: "ТО",
|
||
tire_service: "Шиномонтаж",
|
||
brakes: "Тормоза",
|
||
repair: "Ремонт",
|
||
other: "Другое",
|
||
};
|
||
return names[type] || "Обслуживание";
|
||
}
|
||
|
||
function formatDateTime(value) {
|
||
if (!value) return "-";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return String(value).slice(0, 16).replace("T", " ");
|
||
return date.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||
}
|
||
|
||
async function loadServiceBookingSlots(serviceCenterId, form) {
|
||
const select = form.querySelector("#bookingSlotSelect");
|
||
const serviceType = form.querySelector('[name="service_type"]').value;
|
||
const date = form.querySelector('[name="date"]').value || today();
|
||
select.innerHTML = `<option value="">Загружаю...</option>`;
|
||
try {
|
||
const slots = await api(`/sto/${serviceCenterId}/available-slots?service_type=${encodeURIComponent(serviceType)}&date_from=${date}&date_to=${date}`);
|
||
select.innerHTML = slots.length
|
||
? slots.map((slot) => `<option value="${slot.start_at}">${formatDateTime(slot.start_at)}</option>`).join("")
|
||
: `<option value="">Нет свободных окон</option>`;
|
||
} catch (error) {
|
||
select.innerHTML = `<option value="">Слоты не загрузились</option>`;
|
||
}
|
||
}
|
||
|
||
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>
|
||
${item.status === "proposed_new_time" ? `
|
||
<div class="service-actions">
|
||
<button type="button" data-accept-appointment="${item.id}">Принять время</button>
|
||
<button type="button" class="ghost-btn" data-reject-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();
|
||
}));
|
||
});
|
||
} 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>
|
||
` : ""}
|
||
</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();
|
||
}));
|
||
});
|
||
} 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>` : ""}
|
||
</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-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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
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) => ({
|
||
date: item.entry_date,
|
||
type: "fuel",
|
||
title: `Заправка ${Number(item.liters).toFixed(1)} л`,
|
||
meta: item.station || `${item.odometer} км`,
|
||
cost: item.total_cost,
|
||
})),
|
||
...state.latestService.map((item) => ({
|
||
date: item.entry_date,
|
||
type: "service",
|
||
title: item.title,
|
||
meta: item.vendor || serviceLabel(item.service_type),
|
||
cost: item.total_cost,
|
||
})),
|
||
...state.latestExpenses.map((item) => ({
|
||
date: item.entry_date,
|
||
type: "expense",
|
||
title: item.title,
|
||
meta: expenseLabel(item.category),
|
||
cost: item.total_cost,
|
||
})),
|
||
].sort((a, b) => b.date.localeCompare(a.date));
|
||
}
|
||
|
||
function updateScore() {
|
||
const car = selectedCar();
|
||
const score = state.vehicleScore?.completeness_score || 0;
|
||
const title = scoreLabel(state.vehicleScore?.profile_quality, score);
|
||
const ring = document.querySelector("#scoreRing");
|
||
document.querySelector("#scoreValue").textContent = score;
|
||
document.querySelector("#scoreTitle").textContent = car ? title : t("Старт");
|
||
document.querySelector("#verifiedHistoryStatus").textContent = historyLabel(state.vehicleScore?.verified_history_status);
|
||
document.querySelector("#maintenanceStatus").textContent = healthLabel(state.vehicleScore?.maintenance_status);
|
||
if (ring) {
|
||
ring.style.background = `conic-gradient(#5ee0bd ${score * 3.6}deg, rgba(255,255,255,0.12) 0deg)`;
|
||
}
|
||
document.querySelector("#scoreHint").textContent = car
|
||
? "Качество паспорта растет от подтвержденных данных, сервисной истории и точного пробега."
|
||
: t("Добавь авто и первую запись, чтобы видеть точные отчеты");
|
||
renderScoreActions(state.vehicleScore?.missing_items || []);
|
||
renderAchievements();
|
||
renderVehicleTimeline();
|
||
}
|
||
|
||
function scoreLabel(quality, score) {
|
||
if (quality === "high_confidence" || score >= 86) return "High-confidence passport";
|
||
if (quality === "strong" || score >= 61) return "Strong profile";
|
||
if (quality === "useful" || score >= 31) return "Useful profile";
|
||
return "Basic profile";
|
||
}
|
||
|
||
function historyLabel(status) {
|
||
const labels = {
|
||
verified: "Verified maintenance history",
|
||
partially_verified: "Partially verified",
|
||
self_reported: "Self-reported",
|
||
};
|
||
return labels[status] || "Self-reported";
|
||
}
|
||
|
||
function healthLabel(status) {
|
||
const labels = {
|
||
green: "Green",
|
||
yellow: "Attention soon",
|
||
red: "Overdue",
|
||
unknown: "Needs baseline",
|
||
};
|
||
return labels[status] || "Needs baseline";
|
||
}
|
||
|
||
function renderScoreActions(items) {
|
||
const root = document.querySelector("#scoreActions");
|
||
if (!root) return;
|
||
const visible = items.slice(0, 3);
|
||
if (!visible.length) {
|
||
root.innerHTML = `<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">
|
||
<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;
|
||
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>
|
||
</div>
|
||
`,
|
||
)
|
||
.join("")}</div>`;
|
||
}
|
||
|
||
function serviceLabel(value) {
|
||
return {
|
||
maintenance: t("Обслуживание"),
|
||
repair: t("Ремонт"),
|
||
fluid: t("Жидкости"),
|
||
tire: t("Шины"),
|
||
inspection: t("Осмотр"),
|
||
insurance: t("Страховка"),
|
||
tax: t("Налог"),
|
||
other: t("Другое"),
|
||
}[value] || value;
|
||
}
|
||
|
||
function expenseLabel(value) {
|
||
return {
|
||
insurance: "Страховка",
|
||
tax: "Налог",
|
||
fine: "Штраф",
|
||
parking: "Парковка",
|
||
car_wash: "Мойка",
|
||
toll: "Платная дорога",
|
||
tires: "Шины",
|
||
wheels: "Диски",
|
||
battery: "Аккумулятор",
|
||
parts: "Запчасти",
|
||
repair: "Ремонт",
|
||
maintenance: "Плановое ТО",
|
||
diagnostics: "Диагностика",
|
||
towing: "Эвакуатор",
|
||
loan_payment: "Кредит / лизинг",
|
||
loan_interest: "Проценты",
|
||
state_fee: "Госпошлина",
|
||
registration: "Регистрация",
|
||
inspection: "Техосмотр",
|
||
other: "Прочее",
|
||
}[value] || value;
|
||
}
|
||
|
||
function monthlySeries(fuel, service, expenses = []) {
|
||
const map = new Map();
|
||
[
|
||
...fuel.map((item) => ({ ...item, type: "fuel" })),
|
||
...service.map((item) => ({ ...item, type: "service" })),
|
||
...expenses.map((item) => ({ ...item, type: "other" })),
|
||
].forEach((item) => {
|
||
const key = item.entry_date.slice(0, 7);
|
||
const current = map.get(key) || { label: key, fuel: 0, service: 0, other: 0 };
|
||
current[item.type] += Number(item.total_cost || 0);
|
||
map.set(key, current);
|
||
});
|
||
return [...map.values()].sort((a, b) => a.label.localeCompare(b.label)).slice(-8);
|
||
}
|
||
|
||
function drawCharts(fuel, service, stats) {
|
||
drawExpensesChart(monthlySeries(fuel, service, state.latestExpenses));
|
||
drawSplitChart(stats?.cost_by_category || { fuel: Number(stats?.fuel_cost || 0), service: Number(stats?.service_cost || 0) });
|
||
}
|
||
|
||
function setupCanvas(canvas) {
|
||
const ctx = canvas.getContext("2d");
|
||
const ratio = window.devicePixelRatio || 1;
|
||
const rect = canvas.getBoundingClientRect();
|
||
canvas.width = rect.width * ratio;
|
||
canvas.height = rect.height * ratio;
|
||
ctx.scale(ratio, ratio);
|
||
return { ctx, width: rect.width, height: rect.height };
|
||
}
|
||
|
||
function drawEmpty(ctx, width, height, text) {
|
||
ctx.clearRect(0, 0, width, height);
|
||
ctx.fillStyle = "#7c8783";
|
||
ctx.font = "14px system-ui";
|
||
ctx.textAlign = "center";
|
||
ctx.fillText(t(text), width / 2, height / 2);
|
||
}
|
||
|
||
function drawExpensesChart(series) {
|
||
const canvas = document.querySelector("#expensesChart");
|
||
const { ctx, width, height } = setupCanvas(canvas);
|
||
if (!series.length) {
|
||
drawEmpty(ctx, width, height, "Добавь заправку или сервисную запись");
|
||
return;
|
||
}
|
||
ctx.clearRect(0, 0, width, height);
|
||
const pad = 28;
|
||
const chartH = height - pad * 2;
|
||
const max = Math.max(...series.map((item) => item.fuel + item.service + item.other), 1);
|
||
const barGap = 12;
|
||
const barW = Math.max(18, (width - pad * 2 - barGap * (series.length - 1)) / series.length);
|
||
|
||
ctx.strokeStyle = "#e1e7e4";
|
||
ctx.lineWidth = 1;
|
||
for (let i = 0; i < 4; i += 1) {
|
||
const y = pad + (chartH / 3) * i;
|
||
ctx.beginPath();
|
||
ctx.moveTo(pad, y);
|
||
ctx.lineTo(width - pad, y);
|
||
ctx.stroke();
|
||
}
|
||
|
||
series.forEach((item, index) => {
|
||
const x = pad + index * (barW + barGap);
|
||
const total = item.fuel + item.service + item.other;
|
||
const totalH = (total / max) * chartH;
|
||
const fuelH = total ? (item.fuel / total) * totalH : 0;
|
||
const serviceH = total ? (item.service / total) * totalH : 0;
|
||
const otherH = Math.max(totalH - fuelH - serviceH, 0);
|
||
const y = height - pad - totalH;
|
||
|
||
ctx.fillStyle = "#36a388";
|
||
roundRect(ctx, x, y + serviceH + otherH, barW, fuelH, 6);
|
||
ctx.fill();
|
||
ctx.fillStyle = "#3f7fba";
|
||
roundRect(ctx, x, y + otherH, barW, serviceH, 6);
|
||
ctx.fill();
|
||
ctx.fillStyle = "#d6a64f";
|
||
roundRect(ctx, x, y, barW, otherH, 6);
|
||
ctx.fill();
|
||
|
||
ctx.fillStyle = "#7c8783";
|
||
ctx.font = "12px system-ui";
|
||
ctx.textAlign = "center";
|
||
ctx.fillText(item.label.slice(5), x + barW / 2, height - 8);
|
||
});
|
||
}
|
||
|
||
function drawSplitChart(categories) {
|
||
const canvas = document.querySelector("#splitChart");
|
||
const { ctx, width, height } = setupCanvas(canvas);
|
||
const entries = Object.entries(categories || {})
|
||
.map(([key, value]) => [key, Number(value || 0)])
|
||
.filter(([, value]) => value > 0)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 5);
|
||
const total = entries.reduce((sum, [, value]) => sum + value, 0);
|
||
if (!total) {
|
||
drawEmpty(ctx, width, height, "Нет расходов");
|
||
return;
|
||
}
|
||
ctx.clearRect(0, 0, width, height);
|
||
const cx = width / 2;
|
||
const cy = height / 2 - 8;
|
||
const radius = Math.min(width, height) * 0.31;
|
||
ctx.lineWidth = 22;
|
||
ctx.lineCap = "round";
|
||
let start = -Math.PI / 2;
|
||
const colors = ["#36a388", "#3f7fba", "#d6a64f", "#c7645d", "#768a82"];
|
||
entries.forEach(([, value], index) => {
|
||
const angle = (value / total) * Math.PI * 2;
|
||
ctx.strokeStyle = colors[index % colors.length];
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, radius, start, start + Math.max(angle - 0.05, 0.02));
|
||
ctx.stroke();
|
||
start += angle;
|
||
});
|
||
|
||
ctx.fillStyle = "#1d2522";
|
||
ctx.font = "700 22px system-ui";
|
||
ctx.textAlign = "center";
|
||
ctx.fillText(`${Math.round((entries[0][1] / total) * 100)}%`, cx, cy + 5);
|
||
ctx.fillStyle = "#7c8783";
|
||
ctx.font = "12px system-ui";
|
||
ctx.fillText(expenseLabel(entries[0][0]), cx, cy + 25);
|
||
}
|
||
|
||
function roundRect(ctx, x, y, width, height, radius) {
|
||
const r = Math.min(radius, Math.abs(height) / 2, width / 2);
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + r, y);
|
||
ctx.arcTo(x + width, y, x + width, y + height, r);
|
||
ctx.arcTo(x + width, y + height, x, y + height, r);
|
||
ctx.arcTo(x, y + height, x, y, r);
|
||
ctx.arcTo(x, y, x + width, y, r);
|
||
ctx.closePath();
|
||
}
|
||
|
||
async function loadCars() {
|
||
document.body.classList.add("loading");
|
||
setStatus("Обновляю данные...");
|
||
try {
|
||
state.cars = await api(`/cars?owner_id=${state.user.id}`);
|
||
if (!state.selectedCarId && state.cars.length) state.selectedCarId = state.cars[0].id;
|
||
if (state.selectedCarId && !state.cars.some((car) => car.id === state.selectedCarId)) {
|
||
state.selectedCarId = state.cars[0]?.id || null;
|
||
}
|
||
renderCars();
|
||
fillCarProfileForm();
|
||
await loadSelectedCar();
|
||
} finally {
|
||
document.body.classList.remove("loading");
|
||
setStatus("Готов к работе");
|
||
}
|
||
}
|
||
|
||
async function selectCar(carId) {
|
||
state.selectedCarId = carId;
|
||
renderCars();
|
||
fillCarProfileForm();
|
||
await loadSelectedCar();
|
||
}
|
||
|
||
async function loadSelectedCar() {
|
||
if (!state.selectedCarId) {
|
||
state.latestFuel = [];
|
||
state.latestService = [];
|
||
state.latestExpenses = [];
|
||
state.latestStats = null;
|
||
state.allStats = null;
|
||
state.analytics = null;
|
||
state.vehicleScore = null;
|
||
state.vehicleTimeline = [];
|
||
state.achievements = [];
|
||
renderStats(null);
|
||
return;
|
||
}
|
||
const [stats, allStats, fuel, service, expenses, analytics, vehicleScore] = await Promise.all([
|
||
api(`/cars/${state.selectedCarId}/stats${periodQuery()}`),
|
||
api(`/cars/${state.selectedCarId}/stats${allPeriodQuery()}`),
|
||
api(`/cars/${state.selectedCarId}/fuel${periodQuery()}`),
|
||
api(`/cars/${state.selectedCarId}/service${periodQuery()}`),
|
||
api(`/cars/${state.selectedCarId}/expenses${periodQuery()}`),
|
||
api(`/cars/${state.selectedCarId}/analytics`),
|
||
api(`/my/vehicles/${state.selectedCarId}/score`),
|
||
]);
|
||
const [timeline, achievements] = await Promise.all([
|
||
api(`/my/vehicles/${state.selectedCarId}/timeline?limit=30`),
|
||
api("/me/achievements"),
|
||
]);
|
||
state.latestStats = stats;
|
||
state.allStats = allStats;
|
||
state.latestFuel = fuel;
|
||
state.latestService = service;
|
||
state.latestExpenses = expenses;
|
||
state.analytics = analytics;
|
||
state.vehicleScore = vehicleScore;
|
||
state.vehicleTimeline = timeline;
|
||
state.achievements = achievements;
|
||
renderStats(stats);
|
||
drawCharts(fuel, service, stats);
|
||
}
|
||
|
||
document.querySelectorAll('input[name="entry_date"]').forEach((input) => {
|
||
input.value = today();
|
||
});
|
||
|
||
applyPeriodPreset("30d");
|
||
|
||
document.querySelector("#refreshBtn").addEventListener("click", (event) => {
|
||
runAction(event.currentTarget, "Обновляю данные...", loadCars).then(() => {
|
||
toast("Готов к работе");
|
||
});
|
||
});
|
||
|
||
document.querySelector("#telegramRetryBtn")?.addEventListener("click", () => {
|
||
runAction(document.querySelector("#telegramRetryBtn"), "Обновляю данные...", async () => {
|
||
await ensureUser();
|
||
await loadCatalog();
|
||
initCarCatalog();
|
||
await loadCars();
|
||
});
|
||
});
|
||
|
||
document.querySelector("#periodPreset").addEventListener("change", async (event) => {
|
||
await runAction(event.currentTarget, "Обновляю данные...", async () => {
|
||
applyPeriodPreset(event.currentTarget.value);
|
||
await loadSelectedCar();
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll("#periodFrom, #periodTo").forEach((input) => {
|
||
input.addEventListener("change", async () => {
|
||
await runAction(input, "Обновляю данные...", async () => {
|
||
document.querySelector("#periodPreset").value = "custom";
|
||
applyPeriodPreset("custom");
|
||
await loadSelectedCar();
|
||
});
|
||
});
|
||
});
|
||
|
||
document.querySelector("#carForm").addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
|
||
const data = formData(form);
|
||
await api("/cars", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
owner_id: state.user.id,
|
||
name: data.name,
|
||
make: data.make || null,
|
||
model: data.model || null,
|
||
trim: data.trim || null,
|
||
year: data.year ? Number(data.year) : null,
|
||
plate_number: data.plate_number || null,
|
||
vin: data.vin || null,
|
||
current_odometer: numberOrNull(data.current_odometer),
|
||
fuel_type: data.fuel_type || null,
|
||
purchase_price: numberOrNull(data.purchase_price),
|
||
purchase_date: data.purchase_date || null,
|
||
purchase_type: data.purchase_type || "unknown",
|
||
purchase_currency: state.user?.currency || "RUB",
|
||
currency: state.user?.currency || "RUB",
|
||
}),
|
||
});
|
||
form.reset();
|
||
resetCarCatalog();
|
||
document.querySelector("#userDrawer").classList.add("hidden");
|
||
await loadCars();
|
||
toast("Сохранено");
|
||
haptic("success");
|
||
});
|
||
});
|
||
|
||
document.querySelector("#carProfileForm").addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
const car = selectedCar();
|
||
if (!car) {
|
||
toast("Выбери автомобиль", "error");
|
||
return;
|
||
}
|
||
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
|
||
const data = formData(form);
|
||
const updated = await api(`/cars/${car.id}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify({
|
||
plate_number: data.plate_number || null,
|
||
vin: data.vin || null,
|
||
generation: data.generation || null,
|
||
body_type: data.body_type || null,
|
||
engine_volume_l: numberOrNull(data.engine_volume_l),
|
||
transmission: data.transmission || null,
|
||
drive_type: data.drive_type || null,
|
||
fuel_type: data.fuel_type || null,
|
||
target_consumption_l_per_100km: numberOrNull(data.target_consumption_l_per_100km),
|
||
fuel_tank_volume_l: numberOrNull(data.fuel_tank_volume_l),
|
||
engine_oil_type: data.engine_oil_type || null,
|
||
engine_oil_volume_l: numberOrNull(data.engine_oil_volume_l),
|
||
transmission_fluid_type: data.transmission_fluid_type || null,
|
||
transmission_fluid_volume_l: numberOrNull(data.transmission_fluid_volume_l),
|
||
coolant_type: data.coolant_type || null,
|
||
brake_fluid_type: data.brake_fluid_type || null,
|
||
tire_pressure_front_bar: numberOrNull(data.tire_pressure_front_bar),
|
||
tire_pressure_rear_bar: numberOrNull(data.tire_pressure_rear_bar),
|
||
tire_size: data.tire_size || null,
|
||
oil_change_interval_km: numberOrNull(data.oil_change_interval_km),
|
||
oil_change_interval_months: numberOrNull(data.oil_change_interval_months),
|
||
purchase_price: numberOrNull(data.purchase_price),
|
||
purchase_date: data.purchase_date || null,
|
||
purchase_type: data.purchase_type || "unknown",
|
||
include_depreciation: Boolean(data.include_depreciation),
|
||
loan_principal: numberOrNull(data.loan_principal),
|
||
loan_down_payment: numberOrNull(data.loan_down_payment),
|
||
loan_term_months: numberOrNull(data.loan_term_months),
|
||
loan_annual_interest_rate: numberOrNull(data.loan_annual_interest_rate),
|
||
loan_first_payment_date: data.loan_first_payment_date || null,
|
||
loan_currency: state.user?.currency || car.currency || "RUB",
|
||
notes: data.notes || null,
|
||
}),
|
||
});
|
||
state.cars = state.cars.map((item) => (item.id === updated.id ? updated : item));
|
||
renderCars();
|
||
fillCarProfileForm();
|
||
await loadSelectedCar();
|
||
toast("Параметры сохранены");
|
||
haptic("success");
|
||
});
|
||
});
|
||
|
||
document.querySelector("#settingsForm").addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
|
||
const data = formData(form);
|
||
state.user = await api(`/users/${state.user.id}/preferences`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify({ locale: data.locale, currency: data.currency }),
|
||
});
|
||
applyTranslations();
|
||
initCarCatalog();
|
||
await loadSelectedCar();
|
||
document.querySelector("#userDrawer").classList.add("hidden");
|
||
toast("Сохранено");
|
||
haptic("success");
|
||
});
|
||
});
|
||
|
||
document.querySelector("#fuelForm").addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
if (!state.selectedCarId) return;
|
||
const form = event.currentTarget;
|
||
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
|
||
const data = formData(form);
|
||
await api("/fuel", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
car_id: state.selectedCarId,
|
||
entry_date: data.entry_date,
|
||
odometer: Number(data.odometer),
|
||
liters: Number(data.liters),
|
||
price_per_liter: Number(data.price_per_liter),
|
||
station: data.station || null,
|
||
is_full_tank: Boolean(data.is_full_tank),
|
||
}),
|
||
});
|
||
form.reset();
|
||
form.entry_date.value = today();
|
||
await loadSelectedCar();
|
||
toast("Сохранено");
|
||
haptic("success");
|
||
});
|
||
});
|
||
|
||
document.querySelector("#serviceForm").addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
if (!state.selectedCarId) return;
|
||
const form = event.currentTarget;
|
||
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
|
||
const data = formData(form);
|
||
await api("/service", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
car_id: state.selectedCarId,
|
||
entry_date: data.entry_date,
|
||
odometer: data.odometer ? Number(data.odometer) : null,
|
||
service_type: data.service_type,
|
||
title: data.title,
|
||
total_cost: Number(data.total_cost),
|
||
vendor: data.vendor || null,
|
||
next_due_date: data.next_due_date || null,
|
||
next_due_odometer: data.next_due_odometer ? Number(data.next_due_odometer) : null,
|
||
}),
|
||
});
|
||
form.reset();
|
||
form.entry_date.value = today();
|
||
await loadSelectedCar();
|
||
toast("Сохранено");
|
||
haptic("success");
|
||
});
|
||
});
|
||
|
||
document.querySelector("#expenseForm").addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
if (!state.selectedCarId) {
|
||
toast("Выбери автомобиль", "error");
|
||
return;
|
||
}
|
||
const form = event.currentTarget;
|
||
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
|
||
const data = formData(form);
|
||
await api("/expenses", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
car_id: state.selectedCarId,
|
||
entry_date: data.entry_date,
|
||
category: data.category,
|
||
title: data.title,
|
||
total_cost: Number(data.total_cost),
|
||
currency: data.currency || state.user?.currency || "RUB",
|
||
vendor: data.vendor || null,
|
||
odometer: numberOrNull(data.odometer),
|
||
period_start: data.period_start || null,
|
||
period_end: data.period_end || null,
|
||
period_months: numberOrNull(data.period_months),
|
||
payment_period_months: numberOrNull(data.period_months),
|
||
policy_number: data.policy_number || null,
|
||
insurance_type: data.insurance_type || null,
|
||
notes: data.notes || null,
|
||
is_recurring: Boolean(data.is_recurring),
|
||
}),
|
||
});
|
||
form.reset();
|
||
form.entry_date.value = today();
|
||
form.currency.value = state.user?.currency || "RUB";
|
||
await loadSelectedCar();
|
||
toast("Сохранено");
|
||
haptic("success");
|
||
});
|
||
});
|
||
|
||
function setAction(action) {
|
||
document.querySelectorAll(".action-card[data-action]").forEach((button) => {
|
||
button.classList.toggle("active", button.dataset.action === action);
|
||
});
|
||
document.querySelector("#fuelForm").classList.toggle("hidden", action !== "fuel");
|
||
document.querySelector("#serviceForm").classList.toggle("hidden", action !== "service");
|
||
}
|
||
|
||
function openScanModal() {
|
||
haptic();
|
||
document.querySelector("#userDrawer").classList.add("hidden");
|
||
document.querySelector("#scanModal").classList.remove("hidden");
|
||
}
|
||
|
||
function mountEntryForms() {
|
||
const fuelMount = document.querySelector("#fuelFormMount");
|
||
const serviceMount = document.querySelector("#serviceFormMount");
|
||
const fuelForm = document.querySelector("#fuelForm");
|
||
const serviceForm = document.querySelector("#serviceForm");
|
||
if (fuelMount && fuelForm && !fuelMount.contains(fuelForm)) {
|
||
fuelForm.classList.remove("hidden");
|
||
fuelMount.appendChild(fuelForm);
|
||
}
|
||
if (serviceMount && serviceForm && !serviceMount.contains(serviceForm)) {
|
||
serviceForm.classList.remove("hidden");
|
||
serviceMount.appendChild(serviceForm);
|
||
}
|
||
}
|
||
|
||
async function openDrawerSection(sectionId, options = {}) {
|
||
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.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()]);
|
||
})
|
||
.catch((error) => {
|
||
if (error.message === "Требуется вход через Telegram") return;
|
||
document.body.insertAdjacentHTML("afterbegin", `<div class="error">${error.message}</div>`);
|
||
});
|