diff --git a/alembic/versions/202605120003_vehicle_service_profile.py b/alembic/versions/202605120003_vehicle_service_profile.py new file mode 100644 index 0000000..c484da5 --- /dev/null +++ b/alembic/versions/202605120003_vehicle_service_profile.py @@ -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") diff --git a/app/api/service_centers.py b/app/api/service_centers.py new file mode 100644 index 0000000..04515df --- /dev/null +++ b/app/api/service_centers.py @@ -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 diff --git a/app/db/seed.py b/app/db/seed.py index 16e4f45..1373630 100644 --- a/app/db/seed.py +++ b/app/db/seed.py @@ -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, diff --git a/app/main.py b/app/main.py index bb61a93..ecfa3e7 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/app/models/car.py b/app/models/car.py index a7b6586..efafae1 100644 --- a/app/models/car.py +++ b/app/models/car.py @@ -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") diff --git a/app/schemas/car.py b/app/schemas/car.py index 23d6856..866e0fa 100644 --- a/app/schemas/car.py +++ b/app/schemas/car.py @@ -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 diff --git a/app/schemas/service_center.py b/app/schemas/service_center.py new file mode 100644 index 0000000..9b3f751 --- /dev/null +++ b/app/schemas/service_center.py @@ -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) diff --git a/web/index.html b/web/index.html index 75fbf03..0270ae6 100644 --- a/web/index.html +++ b/web/index.html @@ -11,13 +11,14 @@ - +

Drivers

Гараж

Войди через Telegram, чтобы привязать гараж к твоему chat_id.

+
@@ -216,6 +217,7 @@ + @@ -291,6 +293,64 @@ + + diff --git a/web/static/app.js b/web/static/app.js index 24797cc..765976a 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -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() { ${(car.make || car.name).slice(0, 2).toUpperCase()} ${car.name} - ${[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("Без деталей")} `, @@ -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"; diff --git a/web/static/styles.css b/web/static/styles.css index c7f2f71..388cf16 100644 --- a/web/static/styles.css +++ b/web/static/styles.css @@ -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;