Files
drivers_bot/web/static/app.js
2026-05-12 04:26:24 +09:00

1242 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

const tg = window.Telegram?.WebApp;
tg?.ready();
tg?.expand();
const textNodes = new WeakMap();
const attrOriginals = new WeakMap();
const i18n = {
en: {
"Гараж": "Garage",
"Автомобиль": "Vehicle",
"Не выбран": "Not selected",
"Добавь авто или выбери из списка": "Add a vehicle or choose one",
"Расходы": "Expenses",
"топливо, сервис и ремонты": "fuel, service and repairs",
"Средний расход": "Average consumption",
"л/100 км по полным данным": "L/100 km from complete data",
"Автомобили": "Vehicles",
"Профиль учета": "Tracking profile",
"Старт": "Start",
"Добавь авто и первую запись, чтобы видеть точные отчеты": "Add a vehicle and first entry to see accurate reports",
"Отчет": "Report",
"Стоимость владения": "Ownership cost",
"Весь срок": "All time",
"Выбери марку": "Choose make",
"Выбери модель": "Choose model",
"Сначала марка": "Choose make first",
"Топливо": "Fuel",
"Эффективность": "Efficiency",
"Месяц": "Month",
"День": "Day",
"Квартал": "Quarter",
"Год": "Year",
"Свой период": "Custom period",
"Заправка": "Fuel",
"Сервис": "Service",
"Скан чека": "Receipt scan",
"30 сек": "30 sec",
"ТО / ремонт": "Maintenance / repair",
"Динамика расходов": "Expense trend",
"Структура": "Breakdown",
"Дата": "Date",
"Одометр, км": "Odometer, km",
"Литры": "Liters",
"Цена за литр": "Price per liter",
"АЗС": "Fuel station",
"Не выбрано": "Not selected",
"Полный бак": "Full tank",
"Сохранить заправку": "Save fuel entry",
"Тип": "Type",
"Обслуживание": "Maintenance",
"Ремонт": "Repair",
"Жидкости": "Fluids",
"Шины": "Tires",
"Осмотр": "Inspection",
"Страховка": "Insurance",
"Налог": "Tax",
"Другое": "Other",
"Что сделано": "Work done",
"Масло": "Oil",
"Стоимость": "Cost",
"Исполнитель": "Vendor",
"Сохранить запись": "Save entry",
"Меню": "Menu",
"Добавить автомобиль": "Add vehicle",
"Локаль и валюта": "Language and currency",
"Уведомления": "Notifications",
"Сканировать чек": "Scan receipt",
"Настройки": "Settings",
"Язык": "Language",
"Валюта": "Currency",
"Сохранить настройки": "Save settings",
"Напомним о ТО, страховке и регулярном внесении пробега.": "We'll remind you about maintenance, insurance and regular odometer updates.",
"Включить уведомления": "Enable notifications",
"Фото или файл чека": "Receipt photo or file",
"Сфотографировать": "Take photo",
"Выбрать файл": "Choose file",
"Файл не выбран": "No file selected",
"Распознать": "Recognize",
"После распознавания поля заправки заполнятся автоматически.": "After recognition, fuel fields will be filled automatically.",
"Новое авто": "New vehicle",
"Название авто": "Vehicle name",
"Марка": "Make",
"Модель": "Model",
"Добавить авто": "Add vehicle",
"За весь срок": "All time",
"За месяц": "This month",
"За день": "Per day",
"За квартал": "Quarter",
"За год": "Year",
"За период": "Period",
"На 100 км": "Per 100 km",
"На 1 км": "Per 1 km",
"записей": "entries",
"среднее в периоде": "average in period",
"нет данных": "no data",
"Выбери автомобиль": "Choose a vehicle",
"Выбери автомобиль для статистики": "Choose a vehicle for stats",
"Добавь первый автомобиль": "Add your first vehicle",
"Без деталей": "No details",
"Профиль точный": "Accurate profile",
"Хороший учет": "Good tracking",
"Набираем данные": "Collecting data",
"Отчеты уже достаточно надежны для решений по расходам": "Reports are reliable enough for expense decisions",
"Чем регулярнее записи, тем точнее расход, цена километра и напоминания": "More regular entries make consumption, cost per km and reminders more accurate",
"Итого": "Total",
"Стоимость 1 км": "Cost per km",
"Пробег": "Mileage",
"Записей": "Entries",
"Потрачено": "Spent",
"Литров": "Liters",
"Средняя заправка": "Average refill",
"Расход": "Consumption",
"Главная категория": "Top category",
"Макс. категория": "Max category",
"Прогноз сегодня": "Today forecast",
"+30 дней": "+30 days",
"Лучший рост точности даст привычка заносить одометр при каждой заправке и сервисе.": "Accuracy improves most when odometer is entered at every fuel and service record.",
"Нет записей за выбранный период": "No entries for selected period",
"Добавь заправку или сервисную запись": "Add fuel or service entry",
"Нет расходов": "No expenses",
"топливо": "fuel",
"Уведомления включены": "Notifications enabled",
"Уведомления запрещены в настройках браузера": "Notifications are blocked in browser settings",
"Браузер не поддерживает уведомления": "Browser does not support notifications",
"PWA установлена и работает офлайн после первого открытия.": "PWA is installed and works offline after first open.",
"Напоминания готовы": "Reminders are ready",
"Мы напомним о ТО, страховке и обновлении пробега.": "We'll remind you about maintenance, insurance and mileage updates.",
"Готов к работе": "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,
cars: [],
catalog: [],
selectedCarId: null,
latestFuel: [],
latestService: [],
latestStats: null,
allStats: null,
analytics: null,
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));
}
if (registration?.showNotification) {
await registration.showNotification(t("Напоминания готовы"), {
body: t("Мы напомним о ТО, страховке и обновлении пробега."),
icon: "/static/icon.svg",
badge: "/static/icon.svg",
tag: "drivers-bot-ready",
});
}
updateNotificationStatus("Уведомления включены");
}
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)));
}
const fallbackUser = {
id: 1,
username: "demo",
first_name: "Demo",
last_name: null,
locale: "ru",
currency: "RUB",
};
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 response = await fetch(`/api${path}`, {
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
...options,
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || response.statusText);
}
if (response.status === 204) return null;
return response.json();
}
function 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() {
const tgUser = tg?.initDataUnsafe?.user || fallbackUser;
state.user = await api("/users", {
method: "POST",
body: JSON.stringify({
telegram_id: tgUser.id,
username: tgUser.username || null,
first_name: tgUser.first_name || null,
last_name: tgUser.last_name || null,
}),
});
}
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 shiftMonths(base, count) {
const copy = new Date(base);
copy.setMonth(copy.getMonth() + count);
return copy;
}
function dateValue(date) {
return date.toISOString().slice(0, 10);
}
function applyPeriodPreset(preset = "month") {
document.querySelector("#periodPreset").value = preset;
const now = new Date();
const to = dateValue(now);
let fromDate = new Date(now.getFullYear(), now.getMonth(), 1);
if (preset === "all") fromDate = new Date(2000, 0, 1);
if (preset === "day") fromDate = now;
if (preset === "quarter") fromDate = shiftMonths(now, -3);
if (preset === "year") fromDate = shiftMonths(now, -12);
if (preset !== "custom") {
document.querySelector("#periodFrom").value = dateValue(fromDate);
document.querySelector("#periodTo").value = to;
}
state.period = {
preset,
dateFrom: document.querySelector("#periodFrom").value,
dateTo: document.querySelector("#periodTo").value,
};
}
function periodQuery() {
const params = new URLSearchParams();
if (state.period.dateFrom) params.set("date_from", state.period.dateFrom);
if (state.period.dateTo) params.set("date_to", state.period.dateTo);
const query = params.toString();
return query ? `?${query}` : "";
}
function allPeriodQuery() {
return "?date_from=2000-01-01&date_to=2100-01-01";
}
async function loadCatalog() {
state.catalog = await api("/catalog/makes");
}
function initCarCatalog() {
const makeSelect = document.querySelector("#makeSelect");
const modelSelect = document.querySelector("#modelSelect");
const 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 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>`;
}
makeSelect.addEventListener("change", syncModels);
syncModels();
}
function resetCarCatalog() {
document.querySelector("#makeSelect").value = "";
const modelSelect = document.querySelector("#modelSelect");
modelSelect.disabled = true;
modelSelect.innerHTML = `<option value="">${t("Сначала марка")}</option>`;
}
function updateHero(stats) {
const car = selectedCar();
document.querySelector("#selectedCarTitle").textContent = car?.name || t("Не выбран");
document.querySelector("#selectedCarMeta").textContent = car
? [car.make, car.model, car.year].filter(Boolean).join(" ") || t("Без деталей")
: t("Добавь авто или выбери из списка");
document.querySelector("#summaryTotal").textContent = money(stats?.total_cost);
document.querySelector("#summaryConsumption").textContent = stats?.avg_consumption_l_per_100km
? `${stats.avg_consumption_l_per_100km.toFixed(2)} л`
: "-";
}
function formatFuelPrice(value) {
if (!value) return "-";
return money(value).replace(/\s?₽|RUB/i, "").trim();
}
function renderCars() {
const root = document.querySelector("#cars");
if (!state.cars.length) {
root.innerHTML = `<div class="empty">${t("Добавь первый автомобиль")}</div>`;
updateHero(null);
return;
}
root.innerHTML = 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>
<strong>${car.name}</strong>
<small>${[car.make, car.model, car.year].filter(Boolean).join(" ") || t("Без деталей")}</small>
</span>
</button>
`,
)
.join("");
root.querySelectorAll("[data-car]").forEach((button) => {
button.addEventListener("click", () => selectCar(Number(button.dataset.car)));
});
}
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("За весь срок"),
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="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>
`;
root.querySelectorAll("[data-report]").forEach((button) => {
button.addEventListener("click", () => openReport(button.dataset.report));
});
}
function recordsForPeriod() {
return [
...state.latestFuel.map((item) => ({
date: item.entry_date,
type: "fuel",
title: `Заправка ${Number(item.liters).toFixed(1)} л`,
meta: item.station || `${item.odometer} км`,
cost: item.total_cost,
})),
...state.latestService.map((item) => ({
date: item.entry_date,
type: "service",
title: item.title,
meta: item.vendor || serviceLabel(item.service_type),
cost: item.total_cost,
})),
].sort((a, b) => b.date.localeCompare(a.date));
}
function updateScore() {
const car = selectedCar();
const fuelCount = state.latestFuel.length;
const serviceCount = state.latestService.length;
let score = 0;
if (car) score += 25;
if (fuelCount > 0) score += 25;
if (serviceCount > 0) score += 20;
if (state.latestStats?.distance_km > 0) score += 20;
if (fuelCount + serviceCount >= 6) score += 10;
const title = score >= 90 ? t("Профиль точный") : score >= 60 ? t("Хороший учет") : score >= 30 ? t("Набираем данные") : t("Старт");
document.querySelector("#scoreTitle").textContent = title;
document.querySelector("#scoreBar").style.width = `${score}%`;
document.querySelector("#scoreHint").textContent =
score >= 90
? t("Отчеты уже достаточно надежны для решений по расходам")
: t("Чем регулярнее записи, тем точнее расход, цена километра и напоминания");
}
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(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>
<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 monthlySeries(fuel, service) {
const map = new Map();
[...fuel.map((item) => ({ ...item, type: "fuel" })), ...service.map((item) => ({ ...item, type: "service" }))].forEach((item) => {
const key = item.entry_date.slice(0, 7);
const current = map.get(key) || { label: key, fuel: 0, service: 0 };
current[item.type] += Number(item.total_cost || 0);
map.set(key, current);
});
return [...map.values()].sort((a, b) => a.label.localeCompare(b.label)).slice(-8);
}
function drawCharts(fuel, service, stats) {
drawExpensesChart(monthlySeries(fuel, service));
drawSplitChart(Number(stats?.fuel_cost || 0), Number(stats?.service_cost || 0));
}
function setupCanvas(canvas) {
const ctx = canvas.getContext("2d");
const ratio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * ratio;
canvas.height = rect.height * ratio;
ctx.scale(ratio, ratio);
return { ctx, width: rect.width, height: rect.height };
}
function drawEmpty(ctx, width, height, text) {
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#7c8783";
ctx.font = "14px system-ui";
ctx.textAlign = "center";
ctx.fillText(t(text), width / 2, height / 2);
}
function drawExpensesChart(series) {
const canvas = document.querySelector("#expensesChart");
const { ctx, width, height } = setupCanvas(canvas);
if (!series.length) {
drawEmpty(ctx, width, height, "Добавь заправку или сервисную запись");
return;
}
ctx.clearRect(0, 0, width, height);
const pad = 28;
const chartH = height - pad * 2;
const max = Math.max(...series.map((item) => item.fuel + item.service), 1);
const barGap = 12;
const barW = Math.max(18, (width - pad * 2 - barGap * (series.length - 1)) / series.length);
ctx.strokeStyle = "#e1e7e4";
ctx.lineWidth = 1;
for (let i = 0; i < 4; i += 1) {
const y = pad + (chartH / 3) * i;
ctx.beginPath();
ctx.moveTo(pad, y);
ctx.lineTo(width - pad, y);
ctx.stroke();
}
series.forEach((item, index) => {
const x = pad + index * (barW + barGap);
const total = item.fuel + item.service;
const totalH = (total / max) * chartH;
const fuelH = total ? (item.fuel / total) * totalH : 0;
const serviceH = totalH - fuelH;
const y = height - pad - totalH;
ctx.fillStyle = "#36a388";
roundRect(ctx, x, y + serviceH, barW, fuelH, 6);
ctx.fill();
ctx.fillStyle = "#3f7fba";
roundRect(ctx, x, y, barW, serviceH, 6);
ctx.fill();
ctx.fillStyle = "#7c8783";
ctx.font = "12px system-ui";
ctx.textAlign = "center";
ctx.fillText(item.label.slice(5), x + barW / 2, height - 8);
});
}
function drawSplitChart(fuelCost, serviceCost) {
const canvas = document.querySelector("#splitChart");
const { ctx, width, height } = setupCanvas(canvas);
const total = fuelCost + serviceCost;
if (!total) {
drawEmpty(ctx, width, height, "Нет расходов");
return;
}
ctx.clearRect(0, 0, width, height);
const cx = width / 2;
const cy = height / 2 - 8;
const radius = Math.min(width, height) * 0.31;
const fuelAngle = (fuelCost / total) * Math.PI * 2;
ctx.lineWidth = 22;
ctx.lineCap = "round";
ctx.strokeStyle = "#36a388";
ctx.beginPath();
ctx.arc(cx, cy, radius, -Math.PI / 2, -Math.PI / 2 + fuelAngle);
ctx.stroke();
ctx.strokeStyle = "#3f7fba";
ctx.beginPath();
ctx.arc(cx, cy, radius, -Math.PI / 2 + fuelAngle + 0.05, Math.PI * 1.5 - 0.05);
ctx.stroke();
ctx.fillStyle = "#1d2522";
ctx.font = "700 22px system-ui";
ctx.textAlign = "center";
ctx.fillText(`${Math.round((fuelCost / total) * 100)}%`, cx, cy + 5);
ctx.fillStyle = "#7c8783";
ctx.font = "12px system-ui";
ctx.fillText(t("топливо"), cx, cy + 25);
}
function roundRect(ctx, x, y, width, height, radius) {
const r = Math.min(radius, Math.abs(height) / 2, width / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + width, y, x + width, y + height, r);
ctx.arcTo(x + width, y + height, x, y + height, r);
ctx.arcTo(x, y + height, x, y, r);
ctx.arcTo(x, y, x + width, y, r);
ctx.closePath();
}
async function loadCars() {
document.body.classList.add("loading");
setStatus("Обновляю данные...");
try {
state.cars = await api(`/cars?owner_id=${state.user.id}`);
if (!state.selectedCarId && state.cars.length) state.selectedCarId = state.cars[0].id;
if (state.selectedCarId && !state.cars.some((car) => car.id === state.selectedCarId)) {
state.selectedCarId = state.cars[0]?.id || null;
}
renderCars();
await loadSelectedCar();
} finally {
document.body.classList.remove("loading");
setStatus("Готов к работе");
}
}
async function selectCar(carId) {
state.selectedCarId = carId;
renderCars();
await loadSelectedCar();
}
async function loadSelectedCar() {
if (!state.selectedCarId) {
state.latestFuel = [];
state.latestService = [];
state.latestStats = null;
state.allStats = null;
state.analytics = null;
renderStats(null);
return;
}
const [stats, allStats, fuel, service, analytics] = await Promise.all([
api(`/cars/${state.selectedCarId}/stats${periodQuery()}`),
api(`/cars/${state.selectedCarId}/stats${allPeriodQuery()}`),
api(`/cars/${state.selectedCarId}/fuel${periodQuery()}`),
api(`/cars/${state.selectedCarId}/service${periodQuery()}`),
api(`/cars/${state.selectedCarId}/analytics`),
]);
state.latestStats = stats;
state.allStats = allStats;
state.latestFuel = fuel;
state.latestService = service;
state.analytics = analytics;
renderStats(stats);
drawCharts(fuel, service, stats);
}
document.querySelectorAll('input[type="date"]').forEach((input) => {
input.value = today();
});
applyPeriodPreset("month");
document.querySelector("#refreshBtn").addEventListener("click", (event) => {
runAction(event.currentTarget, "Обновляю данные...", loadCars).then(() => {
toast("Готов к работе");
});
});
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,
year: data.year ? Number(data.year) : null,
}),
});
form.reset();
resetCarCatalog();
document.querySelector("#userDrawer").classList.add("hidden");
await loadCars();
toast("Сохранено");
haptic("success");
});
});
document.querySelector("#settingsForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
state.user = await api(`/users/${state.user.id}/preferences`, {
method: "PATCH",
body: JSON.stringify({ locale: data.locale, currency: data.currency }),
});
applyTranslations();
initCarCatalog();
await loadSelectedCar();
document.querySelector("#userDrawer").classList.add("hidden");
toast("Сохранено");
haptic("success");
});
});
document.querySelector("#fuelForm").addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.selectedCarId) return;
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
await api("/fuel", {
method: "POST",
body: JSON.stringify({
car_id: state.selectedCarId,
entry_date: data.entry_date,
odometer: Number(data.odometer),
liters: Number(data.liters),
price_per_liter: Number(data.price_per_liter),
station: data.station || null,
is_full_tank: Boolean(data.is_full_tank),
}),
});
form.reset();
form.entry_date.value = today();
await loadSelectedCar();
toast("Сохранено");
haptic("success");
});
});
document.querySelector("#serviceForm").addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.selectedCarId) return;
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
await api("/service", {
method: "POST",
body: JSON.stringify({
car_id: state.selectedCarId,
entry_date: data.entry_date,
odometer: data.odometer ? Number(data.odometer) : null,
service_type: data.service_type,
title: data.title,
total_cost: Number(data.total_cost),
vendor: data.vendor || null,
}),
});
form.reset();
form.entry_date.value = today();
await loadSelectedCar();
toast("Сохранено");
haptic("success");
});
});
function setAction(action) {
document.querySelectorAll(".action-card[data-action]").forEach((button) => {
button.classList.toggle("active", button.dataset.action === action);
});
document.querySelector("#fuelForm").classList.toggle("hidden", action !== "fuel");
document.querySelector("#serviceForm").classList.toggle("hidden", action !== "service");
}
document.querySelectorAll("[data-action]").forEach((button) => {
button.addEventListener("click", () => {
haptic();
if (button.dataset.action === "scan") {
document.querySelector("#userDrawer").classList.remove("hidden");
document.querySelector("#scanSection").classList.remove("hidden");
document.querySelector("#scanSection").scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
setAction(button.dataset.action);
});
});
document.querySelectorAll("[data-report]").forEach((button) => {
button.addEventListener("click", () => {
document.querySelector("#userDrawer").classList.add("hidden");
openReport(button.dataset.report);
});
});
document.querySelectorAll("[data-service-title]").forEach((button) => {
button.addEventListener("click", () => {
haptic();
const form = document.querySelector("#serviceForm");
form.title.value = button.dataset.serviceTitle;
form.service_type.value = button.dataset.serviceType;
});
});
document.querySelector("#menuBtn").addEventListener("click", () => {
document.querySelector("#userDrawer").classList.remove("hidden");
});
document.querySelector("#addCarQuickBtn").addEventListener("click", () => {
document.querySelector("#userDrawer").classList.remove("hidden");
document.querySelector("#carFormSection").scrollIntoView({ behavior: "smooth", block: "start" });
});
document.querySelector("#openCarFormBtn").addEventListener("click", () => {
document.querySelector("#carFormSection").classList.remove("hidden");
document.querySelector("#carFormSection").scrollIntoView({ behavior: "smooth", block: "start" });
});
document.querySelector("#openSettingsBtn").addEventListener("click", () => {
document.querySelector("#settingsSection").classList.remove("hidden");
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
document.querySelector("#settingsSection").scrollIntoView({ behavior: "smooth", block: "start" });
});
document.querySelector("#openNotificationsBtn").addEventListener("click", () => {
document.querySelector("#notificationsSection").classList.remove("hidden");
updateNotificationStatus(
"Notification" in window && Notification.permission === "granted"
? "Уведомления включены"
: "Напомним о ТО, страховке и регулярном внесении пробега.",
);
document.querySelector("#notificationsSection").scrollIntoView({ behavior: "smooth", block: "start" });
});
document.querySelector("#enableNotificationsBtn").addEventListener("click", enableNotifications);
document.querySelector("#openScanBtn").addEventListener("click", () => {
document.querySelector("#scanSection").classList.remove("hidden");
document.querySelector("#scanSection").scrollIntoView({ behavior: "smooth", block: "start" });
});
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/fuel-receipt", { method: "POST", body: payload });
if (!response.ok) throw new Error(await response.text());
const result = await response.json();
document.querySelector("#ocrResult").textContent = `${result.message} ${Math.round((result.confidence || 0) * 100)}%`;
const fuelForm = document.querySelector("#fuelForm");
if (result.liters) fuelForm.liters.value = result.liters;
if (result.price_per_liter) fuelForm.price_per_liter.value = result.price_per_liter;
if (result.station) fuelForm.station.value = result.station;
setAction("fuel");
document.querySelector("#userDrawer").classList.add("hidden");
toast("Проверь распознанные значения");
haptic("success");
});
});
document.querySelector("#closeMenuBtn").addEventListener("click", () => {
document.querySelector("#userDrawer").classList.add("hidden");
});
document.querySelector("#closeReportBtn").addEventListener("click", () => {
document.querySelector("#reportSheet").classList.add("hidden");
});
window.addEventListener("resize", () => {
drawCharts(state.latestFuel, state.latestService, state.latestStats);
});
initPwa();
Promise.all([ensureUser(), loadCatalog()])
.then(() => {
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
applyTranslations();
initCarCatalog();
return loadCars();
})
.catch((error) => {
document.body.insertAdjacentHTML("afterbegin", `<div class="error">${error.message}</div>`);
});