add vehicle service profile settings

This commit is contained in:
VPN SaaS Dev
2026-05-12 04:52:42 +09:00
parent b5012ec6e7
commit e75697f83e
10 changed files with 496 additions and 5 deletions

View File

@@ -0,0 +1,109 @@
"""vehicle service profile
Revision ID: 202605120003
Revises: 202605120002
Create Date: 2026-05-12
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "202605120003"
down_revision: str | None = "202605120002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("cars", sa.Column("target_consumption_l_per_100km", sa.Numeric(6, 2), nullable=True))
op.add_column("cars", sa.Column("fuel_tank_volume_l", sa.Numeric(6, 2), nullable=True))
op.add_column("cars", sa.Column("engine_oil_type", sa.String(length=80), nullable=True))
op.add_column("cars", sa.Column("engine_oil_volume_l", sa.Numeric(5, 2), nullable=True))
op.add_column("cars", sa.Column("transmission_fluid_type", sa.String(length=80), nullable=True))
op.add_column("cars", sa.Column("transmission_fluid_volume_l", sa.Numeric(5, 2), nullable=True))
op.add_column("cars", sa.Column("coolant_type", sa.String(length=80), nullable=True))
op.add_column("cars", sa.Column("brake_fluid_type", sa.String(length=80), nullable=True))
op.add_column("cars", sa.Column("tire_pressure_front_bar", sa.Numeric(4, 2), nullable=True))
op.add_column("cars", sa.Column("tire_pressure_rear_bar", sa.Numeric(4, 2), nullable=True))
op.create_table(
"service_centers",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("telegram_chat_id", sa.String(length=80), nullable=True),
sa.Column("contact_phone", sa.String(length=40), nullable=True),
sa.Column("address", sa.String(length=240), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
sa.UniqueConstraint("telegram_chat_id"),
)
op.create_index(op.f("ix_service_centers_name"), "service_centers", ["name"])
op.create_index(op.f("ix_service_centers_telegram_chat_id"), "service_centers", ["telegram_chat_id"])
op.create_table(
"car_service_links",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("car_id", sa.Integer(), nullable=False),
sa.Column("service_center_id", sa.Integer(), nullable=False),
sa.Column("external_vehicle_ref", sa.String(length=120), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["car_id"], ["cars.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("car_id", "service_center_id", name="uq_car_service_link"),
)
op.create_index(op.f("ix_car_service_links_car_id"), "car_service_links", ["car_id"])
op.create_index(op.f("ix_car_service_links_external_vehicle_ref"), "car_service_links", ["external_vehicle_ref"])
op.create_index(op.f("ix_car_service_links_service_center_id"), "car_service_links", ["service_center_id"])
op.create_table(
"service_inbox_messages",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("service_center_id", sa.Integer(), nullable=True),
sa.Column("car_id", sa.Integer(), nullable=True),
sa.Column("source_chat_id", sa.String(length=80), nullable=True),
sa.Column("raw_text", sa.Text(), nullable=False),
sa.Column("parsed_status", sa.String(length=32), nullable=False, server_default="pending"),
sa.Column("parsed_payload", sa.Text(), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["car_id"], ["cars.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_service_inbox_messages_car_id"), "service_inbox_messages", ["car_id"])
op.create_index(op.f("ix_service_inbox_messages_parsed_status"), "service_inbox_messages", ["parsed_status"])
op.create_index(op.f("ix_service_inbox_messages_service_center_id"), "service_inbox_messages", ["service_center_id"])
op.create_index(op.f("ix_service_inbox_messages_source_chat_id"), "service_inbox_messages", ["source_chat_id"])
def downgrade() -> None:
op.drop_index(op.f("ix_service_inbox_messages_source_chat_id"), table_name="service_inbox_messages")
op.drop_index(op.f("ix_service_inbox_messages_service_center_id"), table_name="service_inbox_messages")
op.drop_index(op.f("ix_service_inbox_messages_parsed_status"), table_name="service_inbox_messages")
op.drop_index(op.f("ix_service_inbox_messages_car_id"), table_name="service_inbox_messages")
op.drop_table("service_inbox_messages")
op.drop_index(op.f("ix_car_service_links_service_center_id"), table_name="car_service_links")
op.drop_index(op.f("ix_car_service_links_external_vehicle_ref"), table_name="car_service_links")
op.drop_index(op.f("ix_car_service_links_car_id"), table_name="car_service_links")
op.drop_table("car_service_links")
op.drop_index(op.f("ix_service_centers_telegram_chat_id"), table_name="service_centers")
op.drop_index(op.f("ix_service_centers_name"), table_name="service_centers")
op.drop_table("service_centers")
op.drop_column("cars", "tire_pressure_rear_bar")
op.drop_column("cars", "tire_pressure_front_bar")
op.drop_column("cars", "brake_fluid_type")
op.drop_column("cars", "coolant_type")
op.drop_column("cars", "transmission_fluid_volume_l")
op.drop_column("cars", "transmission_fluid_type")
op.drop_column("cars", "engine_oil_volume_l")
op.drop_column("cars", "engine_oil_type")
op.drop_column("cars", "fuel_tank_volume_l")
op.drop_column("cars", "target_consumption_l_per_100km")

View File

@@ -0,0 +1,73 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_session
from app.models.car import Car, CarServiceLink, ServiceCenter, ServiceInboxMessage
from app.schemas.service_center import (
CarServiceLinkCreate,
CarServiceLinkRead,
ServiceCenterCreate,
ServiceCenterRead,
ServiceInboxCreate,
ServiceInboxRead,
)
router = APIRouter(prefix="/service-centers", tags=["service-centers"])
@router.post("", response_model=ServiceCenterRead, status_code=status.HTTP_201_CREATED)
async def create_service_center(
payload: ServiceCenterCreate, session: AsyncSession = Depends(get_session)
) -> ServiceCenter:
center = ServiceCenter(**payload.model_dump())
session.add(center)
await session.commit()
await session.refresh(center)
return center
@router.get("", response_model=list[ServiceCenterRead])
async def list_service_centers(session: AsyncSession = Depends(get_session)) -> list[ServiceCenter]:
result = await session.execute(select(ServiceCenter).order_by(ServiceCenter.name))
return list(result.scalars())
@router.post("/links", response_model=CarServiceLinkRead, status_code=status.HTTP_201_CREATED)
async def link_car_to_service(
payload: CarServiceLinkCreate, session: AsyncSession = Depends(get_session)
) -> CarServiceLink:
if await session.get(Car, payload.car_id) is None:
raise HTTPException(status_code=404, detail="Car not found")
if await session.get(ServiceCenter, payload.service_center_id) is None:
raise HTTPException(status_code=404, detail="Service center not found")
link = CarServiceLink(**payload.model_dump())
session.add(link)
await session.commit()
await session.refresh(link)
return link
@router.post("/inbox", response_model=ServiceInboxRead, status_code=status.HTTP_201_CREATED)
async def receive_service_message(
payload: ServiceInboxCreate, session: AsyncSession = Depends(get_session)
) -> ServiceInboxMessage:
service_center_id = payload.service_center_id
if not service_center_id and payload.source_chat_id:
result = await session.execute(
select(ServiceCenter).where(ServiceCenter.telegram_chat_id == payload.source_chat_id)
)
center = result.scalar_one_or_none()
service_center_id = center.id if center else None
message = ServiceInboxMessage(
source_chat_id=payload.source_chat_id,
raw_text=payload.raw_text,
car_id=payload.car_id,
service_center_id=service_center_id,
parsed_status="pending",
)
session.add(message)
await session.commit()
await session.refresh(message)
return message

View File

@@ -121,6 +121,16 @@ async def seed_mock_usage(session: AsyncSession, owner: User) -> None:
year=year,
plate_number=f"{MOCK_PLATE_PREFIX}-{index:02d}",
fuel_type=fuel_type,
target_consumption_l_per_100km=None if fuel_type == "electric" else Decimal("7.80"),
fuel_tank_volume_l=None if fuel_type == "electric" else Decimal("58.00"),
engine_oil_type=None if fuel_type == "electric" else "5W-30 API SP",
engine_oil_volume_l=None if fuel_type == "electric" else Decimal("4.50"),
transmission_fluid_type="EV reduction gear oil" if fuel_type == "electric" else "ATF / CVTF по допуску",
transmission_fluid_volume_l=Decimal("1.20") if fuel_type == "electric" else Decimal("7.00"),
coolant_type="LLC",
brake_fluid_type="DOT 4",
tire_pressure_front_bar=Decimal("2.30"),
tire_pressure_rear_bar=Decimal("2.20"),
purchase_date=date(year, min(index, 12), 10),
purchase_price=price,
current_odometer=start_odo,

View File

@@ -2,7 +2,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api import cars, catalog, entries, ocr, users
from app.api import cars, catalog, entries, ocr, service_centers, users
app = FastAPI(title="Drivers Bot API", version="0.1.0")
@@ -19,6 +19,7 @@ app.include_router(catalog.router, prefix="/api")
app.include_router(cars.router, prefix="/api")
app.include_router(entries.router, prefix="/api")
app.include_router(ocr.router, prefix="/api")
app.include_router(service_centers.router, prefix="/api")
@app.get("/health")

View File

@@ -1,7 +1,7 @@
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, UniqueConstraint, func
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
@@ -20,6 +20,16 @@ class Car(Base):
plate_number: Mapped[str | None] = mapped_column(String(32))
vin: Mapped[str | None] = mapped_column(String(32))
fuel_type: Mapped[str | None] = mapped_column(String(32))
target_consumption_l_per_100km: Mapped[Decimal | None] = mapped_column(Numeric(6, 2))
fuel_tank_volume_l: Mapped[Decimal | None] = mapped_column(Numeric(6, 2))
engine_oil_type: Mapped[str | None] = mapped_column(String(80))
engine_oil_volume_l: Mapped[Decimal | None] = mapped_column(Numeric(5, 2))
transmission_fluid_type: Mapped[str | None] = mapped_column(String(80))
transmission_fluid_volume_l: Mapped[Decimal | None] = mapped_column(Numeric(5, 2))
coolant_type: Mapped[str | None] = mapped_column(String(80))
brake_fluid_type: Mapped[str | None] = mapped_column(String(80))
tire_pressure_front_bar: Mapped[Decimal | None] = mapped_column(Numeric(4, 2))
tire_pressure_rear_bar: Mapped[Decimal | None] = mapped_column(Numeric(4, 2))
purchase_date: Mapped[date | None] = mapped_column(Date)
purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
current_odometer: Mapped[int | None]
@@ -31,6 +41,7 @@ class Car(Base):
owner = relationship("User", back_populates="cars")
fuel_entries = relationship("FuelEntry", back_populates="car", cascade="all, delete-orphan")
service_entries = relationship("ServiceEntry", back_populates="car", cascade="all, delete-orphan")
service_links = relationship("CarServiceLink", back_populates="car", cascade="all, delete-orphan")
class CarMake(Base):
@@ -74,3 +85,48 @@ class CarTrim(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
model = relationship("CarModel", back_populates="trims")
class ServiceCenter(Base):
__tablename__ = "service_centers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
telegram_chat_id: Mapped[str | None] = mapped_column(String(80), unique=True, index=True)
contact_phone: Mapped[str | None] = mapped_column(String(40))
address: Mapped[str | None] = mapped_column(String(240))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
car_links = relationship("CarServiceLink", back_populates="service_center", cascade="all, delete-orphan")
inbox_messages = relationship("ServiceInboxMessage", back_populates="service_center")
class CarServiceLink(Base):
__tablename__ = "car_service_links"
__table_args__ = (UniqueConstraint("car_id", "service_center_id", name="uq_car_service_link"),)
id: Mapped[int] = mapped_column(primary_key=True)
car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
external_vehicle_ref: Mapped[str | None] = mapped_column(String(120), index=True)
is_active: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
car = relationship("Car", back_populates="service_links")
service_center = relationship("ServiceCenter", back_populates="car_links")
class ServiceInboxMessage(Base):
__tablename__ = "service_inbox_messages"
id: Mapped[int] = mapped_column(primary_key=True)
service_center_id: Mapped[int | None] = mapped_column(ForeignKey("service_centers.id", ondelete="SET NULL"), index=True)
car_id: Mapped[int | None] = mapped_column(ForeignKey("cars.id", ondelete="SET NULL"), index=True)
source_chat_id: Mapped[str | None] = mapped_column(String(80), index=True)
raw_text: Mapped[str] = mapped_column(Text)
parsed_status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
parsed_payload: Mapped[str | None] = mapped_column(Text)
error: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
service_center = relationship("ServiceCenter", back_populates="inbox_messages")

View File

@@ -13,6 +13,16 @@ class CarBase(BaseModel):
plate_number: str | None = None
vin: str | None = None
fuel_type: str | None = None
target_consumption_l_per_100km: Decimal | None = None
fuel_tank_volume_l: Decimal | None = None
engine_oil_type: str | None = None
engine_oil_volume_l: Decimal | None = None
transmission_fluid_type: str | None = None
transmission_fluid_volume_l: Decimal | None = None
coolant_type: str | None = None
brake_fluid_type: str | None = None
tire_pressure_front_bar: Decimal | None = None
tire_pressure_rear_bar: Decimal | None = None
purchase_date: date | None = None
purchase_price: Decimal | None = None
current_odometer: int | None = None
@@ -31,6 +41,16 @@ class CarUpdate(BaseModel):
plate_number: str | None = None
vin: str | None = None
fuel_type: str | None = None
target_consumption_l_per_100km: Decimal | None = None
fuel_tank_volume_l: Decimal | None = None
engine_oil_type: str | None = None
engine_oil_volume_l: Decimal | None = None
transmission_fluid_type: str | None = None
transmission_fluid_volume_l: Decimal | None = None
coolant_type: str | None = None
brake_fluid_type: str | None = None
tire_pressure_front_bar: Decimal | None = None
tire_pressure_rear_bar: Decimal | None = None
purchase_date: date | None = None
purchase_price: Decimal | None = None
current_odometer: int | None = None

View File

@@ -0,0 +1,48 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class ServiceCenterCreate(BaseModel):
name: str
telegram_chat_id: str | None = None
contact_phone: str | None = None
address: str | None = None
class ServiceCenterRead(ServiceCenterCreate):
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class CarServiceLinkCreate(BaseModel):
car_id: int
service_center_id: int
external_vehicle_ref: str | None = None
is_active: bool = True
class CarServiceLinkRead(CarServiceLinkCreate):
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class ServiceInboxCreate(BaseModel):
source_chat_id: str | None = None
raw_text: str
car_id: int | None = None
service_center_id: int | None = None
class ServiceInboxRead(ServiceInboxCreate):
id: int
parsed_status: str
parsed_payload: str | None = None
error: str | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@@ -11,13 +11,14 @@
<link rel="stylesheet" href="/static/styles.css" />
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body>
<body class="auth-required">
<div class="auth-overlay" id="authOverlay">
<div class="auth-panel">
<p class="eyebrow">Drivers</p>
<h1>Гараж</h1>
<p>Войди через Telegram, чтобы привязать гараж к твоему chat_id.</p>
<div id="telegramLoginSlot" class="telegram-login-slot"></div>
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" target="_blank" rel="noreferrer">Войти через Telegram</a>
</div>
</div>
<main class="shell">
@@ -216,6 +217,7 @@
<button class="icon-btn" id="closeMenuBtn" aria-label="Закрыть">×</button>
</div>
<button class="menu-row" id="openCarFormBtn">Добавить автомобиль</button>
<button class="menu-row" id="openCarProfileBtn">Параметры автомобиля</button>
<button class="menu-row" id="openSettingsBtn">Локаль и валюта</button>
<button class="menu-row" id="openNotificationsBtn">Уведомления</button>
<button class="menu-row" id="openScanBtn">Сканировать чек</button>
@@ -291,6 +293,64 @@
<button type="submit">Добавить авто</button>
</form>
</section>
<section class="drawer-section hidden" id="carProfileSection">
<h2>Параметры авто</h2>
<div class="tip-card" id="carProfileHint">Выбери автомобиль, чтобы настроить жидкости, расход и сервисные нормы.</div>
<form id="carProfileForm" class="grid-form drawer-form">
<label>
Тип топлива
<select name="fuel_type">
<option value="">Не задано</option>
<option value="gasoline">Бензин</option>
<option value="diesel">Дизель</option>
<option value="hybrid">Гибрид</option>
<option value="electric">Электро</option>
</select>
</label>
<label>
Нормальный расход, л/100 км
<input name="target_consumption_l_per_100km" type="number" min="0" step="0.01" placeholder="8.50" />
</label>
<label>
Бак, л
<input name="fuel_tank_volume_l" type="number" min="0" step="0.01" placeholder="60" />
</label>
<label>
Моторное масло
<input name="engine_oil_type" placeholder="5W-30 API SP" />
</label>
<label>
Объем масла, л
<input name="engine_oil_volume_l" type="number" min="0" step="0.01" placeholder="4.50" />
</label>
<label>
Трансмиссионная жидкость
<input name="transmission_fluid_type" placeholder="ATF WS / DCTF / CVT" />
</label>
<label>
Объем трансмиссии, л
<input name="transmission_fluid_volume_l" type="number" min="0" step="0.01" placeholder="7.20" />
</label>
<label>
Антифриз
<input name="coolant_type" placeholder="G12+ / LLC" />
</label>
<label>
Тормозная жидкость
<input name="brake_fluid_type" placeholder="DOT 4" />
</label>
<label>
Давление перед, bar
<input name="tire_pressure_front_bar" type="number" min="0" step="0.01" placeholder="2.30" />
</label>
<label>
Давление зад, bar
<input name="tire_pressure_rear_bar" type="number" min="0" step="0.01" placeholder="2.20" />
</label>
<button type="submit">Сохранить параметры</button>
</form>
</section>
</div>
</div>

View File

@@ -509,18 +509,25 @@ async function ensureUser() {
function hideAuthOverlay() {
document.querySelector("#authOverlay")?.classList.add("hidden");
document.body.classList.remove("auth-required");
}
async function showTelegramLogin() {
const overlay = document.querySelector("#authOverlay");
const slot = document.querySelector("#telegramLoginSlot");
const link = document.querySelector("#telegramLoginLink");
overlay?.classList.remove("hidden");
document.body.classList.add("auth-required");
if (!slot || slot.dataset.ready) return;
const botUsername = state.authConfig?.bot_username;
if (!botUsername) {
slot.textContent = "Telegram Login временно недоступен";
return;
}
if (link) {
link.href = `https://t.me/${botUsername}?start=web_login`;
link.classList.remove("hidden");
}
window.onTelegramAuth = async (user) => {
state.user = await api("/users/telegram-login", {
method: "POST",
@@ -538,6 +545,9 @@ async function showTelegramLogin() {
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);
}
@@ -565,6 +575,10 @@ function selectedCar() {
return state.cars.find((car) => car.id === state.selectedCarId) || null;
}
function numberOrNull(value) {
return value === "" || value == null ? null : Number(value);
}
function shiftMonths(base, count) {
const copy = new Date(base);
copy.setMonth(copy.getMonth() + count);
@@ -690,7 +704,7 @@ function updateHero(stats) {
const car = selectedCar();
document.querySelector("#selectedCarTitle").textContent = car?.name || t("Не выбран");
document.querySelector("#selectedCarMeta").textContent = car
? [car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || t("Без деталей")
? [car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей")
: t("Добавь авто или выбери из списка");
document.querySelector("#summaryTotal").textContent = money(stats?.total_cost);
document.querySelector("#summaryConsumption").textContent = stats?.avg_consumption_l_per_100km
@@ -717,7 +731,7 @@ function renderCars() {
<span class="car-badge">${(car.make || car.name).slice(0, 2).toUpperCase()}</span>
<span>
<strong>${car.name}</strong>
<small>${[car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || t("Без деталей")}</small>
<small>${[car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей")}</small>
</span>
</button>
`,
@@ -728,6 +742,45 @@ function renderCars() {
});
}
function setInputValue(form, name, value) {
if (form?.elements[name]) form.elements[name].value = value ?? "";
}
function fillCarProfileForm() {
const form = document.querySelector("#carProfileForm");
const hint = document.querySelector("#carProfileHint");
const car = selectedCar();
form.querySelectorAll("input, select, button").forEach((node) => {
node.disabled = !car;
});
if (!car) {
form.reset();
hint.textContent = t("Выбери автомобиль, чтобы настроить жидкости, расход и сервисные нормы.");
return;
}
hint.textContent = [car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || car.name;
[
"fuel_type",
"target_consumption_l_per_100km",
"fuel_tank_volume_l",
"engine_oil_type",
"engine_oil_volume_l",
"transmission_fluid_type",
"transmission_fluid_volume_l",
"coolant_type",
"brake_fluid_type",
"tire_pressure_front_bar",
"tire_pressure_rear_bar",
].forEach((name) => setInputValue(form, name, car[name]));
}
function openCarProfile() {
document.querySelector("#userDrawer").classList.remove("hidden");
document.querySelector("#carProfileSection").classList.remove("hidden");
fillCarProfileForm();
document.querySelector("#carProfileSection").scrollIntoView({ behavior: "smooth", block: "start" });
}
function renderStats(stats) {
const root = document.querySelector("#stats");
if (!stats) {
@@ -1051,6 +1104,7 @@ async function loadCars() {
state.selectedCarId = state.cars[0]?.id || null;
}
renderCars();
fillCarProfileForm();
await loadSelectedCar();
} finally {
document.body.classList.remove("loading");
@@ -1061,6 +1115,7 @@ async function loadCars() {
async function selectCar(carId) {
state.selectedCarId = carId;
renderCars();
fillCarProfileForm();
await loadSelectedCar();
}
@@ -1145,6 +1200,41 @@ document.querySelector("#carForm").addEventListener("submit", async (event) => {
});
});
document.querySelector("#carProfileForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const car = selectedCar();
if (!car) {
toast("Выбери автомобиль", "error");
return;
}
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
const updated = await api(`/cars/${car.id}`, {
method: "PATCH",
body: JSON.stringify({
fuel_type: data.fuel_type || null,
target_consumption_l_per_100km: numberOrNull(data.target_consumption_l_per_100km),
fuel_tank_volume_l: numberOrNull(data.fuel_tank_volume_l),
engine_oil_type: data.engine_oil_type || null,
engine_oil_volume_l: numberOrNull(data.engine_oil_volume_l),
transmission_fluid_type: data.transmission_fluid_type || null,
transmission_fluid_volume_l: numberOrNull(data.transmission_fluid_volume_l),
coolant_type: data.coolant_type || null,
brake_fluid_type: data.brake_fluid_type || null,
tire_pressure_front_bar: numberOrNull(data.tire_pressure_front_bar),
tire_pressure_rear_bar: numberOrNull(data.tire_pressure_rear_bar),
}),
});
state.cars = state.cars.map((item) => (item.id === updated.id ? updated : item));
renderCars();
fillCarProfileForm();
await loadSelectedCar();
toast("Параметры сохранены");
haptic("success");
});
});
document.querySelector("#settingsForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
@@ -1272,6 +1362,8 @@ document.querySelector("#openCarFormBtn").addEventListener("click", () => {
document.querySelector("#carFormSection").scrollIntoView({ behavior: "smooth", block: "start" });
});
document.querySelector("#openCarProfileBtn").addEventListener("click", openCarProfile);
document.querySelector("#openSettingsBtn").addEventListener("click", () => {
document.querySelector("#settingsSection").classList.remove("hidden");
document.querySelector("#localeSelect").value = state.user?.locale || "ru";

View File

@@ -14,6 +14,10 @@
--shadow: 0 14px 40px rgba(24, 33, 31, 0.08);
}
body.auth-required .shell {
display: none;
}
.top-actions {
display: flex;
gap: 8px;
@@ -1194,6 +1198,24 @@ select {
min-height: 46px;
}
.telegram-login-link {
display: inline-flex;
width: 100%;
min-height: 46px;
align-items: center;
justify-content: center;
margin-top: 10px;
border-radius: 8px;
background: #16806a;
color: #fff;
font-weight: 800;
text-decoration: none;
}
.telegram-login-link.hidden {
display: none;
}
.scan-form {
display: grid;
gap: 10px;