This commit is contained in:
457
app/api/my.py
457
app/api/my.py
@@ -1,3 +1,6 @@
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from sqlalchemy import or_, select
|
||||
@@ -8,11 +11,13 @@ from app.db.session import get_session
|
||||
from app.models.car import (
|
||||
Car,
|
||||
CarServiceLink,
|
||||
ServiceAppointment,
|
||||
ServiceCenter,
|
||||
ServiceVisit,
|
||||
VehicleAccess,
|
||||
VehicleDataChangeRequest,
|
||||
)
|
||||
from app.models.expense import ExpenseEntry, FuelEntry, ServiceEntry
|
||||
from app.models.user import User
|
||||
from app.schemas.service_center import (
|
||||
VehicleAccessGrant,
|
||||
@@ -22,11 +27,294 @@ from app.schemas.service_center import (
|
||||
VehicleUpdate,
|
||||
)
|
||||
from app.schemas.user import UserRead
|
||||
from app.services.odometer import add_odometer_history, validate_odometer_change
|
||||
from app.services.odometer import (
|
||||
add_odometer_history,
|
||||
recalculate_current_odometer,
|
||||
validate_odometer_change,
|
||||
)
|
||||
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||
|
||||
router = APIRouter(tags=["my"])
|
||||
|
||||
EXPORT_SCHEMA = "carpass.exchange.v1"
|
||||
|
||||
VEHICLE_IMPORT_FIELDS = {
|
||||
"name",
|
||||
"make",
|
||||
"model",
|
||||
"trim",
|
||||
"generation",
|
||||
"body_type",
|
||||
"year",
|
||||
"plate_number",
|
||||
"vin",
|
||||
"fuel_type",
|
||||
"engine_volume_l",
|
||||
"transmission",
|
||||
"drive_type",
|
||||
"target_consumption_l_per_100km",
|
||||
"fuel_tank_volume_l",
|
||||
"engine_oil_type",
|
||||
"engine_oil_volume_l",
|
||||
"transmission_fluid_type",
|
||||
"transmission_fluid_volume_l",
|
||||
"coolant_type",
|
||||
"brake_fluid_type",
|
||||
"tire_pressure_front_bar",
|
||||
"tire_pressure_rear_bar",
|
||||
"tire_size",
|
||||
"oil_change_interval_km",
|
||||
"oil_change_interval_months",
|
||||
"purchase_date",
|
||||
"purchase_price",
|
||||
"purchase_currency",
|
||||
"purchase_type",
|
||||
"currency",
|
||||
"include_depreciation",
|
||||
"expected_ownership_months",
|
||||
"expected_residual_value",
|
||||
"loan_principal",
|
||||
"loan_down_payment",
|
||||
"loan_term_months",
|
||||
"loan_annual_interest_rate",
|
||||
"loan_first_payment_date",
|
||||
"loan_payment_day",
|
||||
"loan_payment_type",
|
||||
"loan_currency",
|
||||
"loan_comment",
|
||||
"current_odometer",
|
||||
"notes",
|
||||
}
|
||||
|
||||
|
||||
def _parse_date(value: str | date | None) -> date | None:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
return date.fromisoformat(str(value)[:10])
|
||||
|
||||
|
||||
def _decimal(value: object) -> Decimal | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def _vehicle_import_data(raw: dict) -> dict:
|
||||
data = {key: raw.get(key) for key in VEHICLE_IMPORT_FIELDS if key in raw}
|
||||
for key in ("purchase_date", "loan_first_payment_date"):
|
||||
if key in data:
|
||||
data[key] = _parse_date(data[key])
|
||||
if "plate_number" in data:
|
||||
data["license_plate_display"] = data["plate_number"]
|
||||
data["license_plate_normalized"] = normalize_license_plate(data["plate_number"])
|
||||
if "vin" in data:
|
||||
data["vin_normalized"] = validate_vin(data["vin"])
|
||||
return data
|
||||
|
||||
|
||||
def _exchange_counts(payload: dict) -> dict:
|
||||
vehicles = payload.get("vehicles") or []
|
||||
return {
|
||||
"vehicles": len(vehicles),
|
||||
"fuel_entries": sum(len(item.get("fuel_entries") or []) for item in vehicles),
|
||||
"service_entries": sum(len(item.get("service_entries") or []) for item in vehicles),
|
||||
"expense_entries": sum(len(item.get("expense_entries") or []) for item in vehicles),
|
||||
"appointments": sum(len(item.get("appointments") or []) for item in vehicles),
|
||||
"service_visits": sum(len(item.get("service_visits") or []) for item in vehicles),
|
||||
}
|
||||
|
||||
|
||||
def _model_dict(item) -> dict:
|
||||
return {column.name: getattr(item, column.name) for column in item.__table__.columns}
|
||||
|
||||
|
||||
async def _find_import_vehicle(session: AsyncSession, current_user: User, data: dict) -> Car | None:
|
||||
if data.get("vin_normalized"):
|
||||
found = (
|
||||
await session.execute(
|
||||
select(Car).where(Car.owner_id == current_user.id, Car.vin_normalized == data["vin_normalized"])
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if found is not None:
|
||||
return found
|
||||
if data.get("license_plate_normalized"):
|
||||
found = (
|
||||
await session.execute(
|
||||
select(Car).where(
|
||||
Car.owner_id == current_user.id,
|
||||
Car.license_plate_normalized == data["license_plate_normalized"],
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if found is not None:
|
||||
return found
|
||||
return (
|
||||
await session.execute(
|
||||
select(Car).where(
|
||||
Car.owner_id == current_user.id,
|
||||
Car.name == data.get("name", "Импортированное авто"),
|
||||
Car.make == data.get("make"),
|
||||
Car.model == data.get("model"),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
async def _clear_conflicting_unique_identity(session: AsyncSession, current_user: User, data: dict) -> None:
|
||||
if not data.get("vin_normalized"):
|
||||
return
|
||||
existing_owner_id = (
|
||||
await session.execute(
|
||||
select(Car.owner_id).where(
|
||||
Car.vin_normalized == data["vin_normalized"],
|
||||
Car.owner_id != current_user.id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing_owner_id is not None:
|
||||
data.pop("vin", None)
|
||||
data.pop("vin_normalized", None)
|
||||
|
||||
|
||||
def _supplement_empty_vehicle_fields(car: Car, data: dict) -> list[str]:
|
||||
updated: list[str] = []
|
||||
for field, value in data.items():
|
||||
if field in {"id", "owner_id", "created_at", "updated_at"} or value in (None, ""):
|
||||
continue
|
||||
if getattr(car, field, None) in (None, ""):
|
||||
setattr(car, field, value)
|
||||
updated.append(field)
|
||||
return updated
|
||||
|
||||
|
||||
async def _import_fuel_entries(session: AsyncSession, car: Car, rows: list[dict]) -> int:
|
||||
imported = 0
|
||||
for raw in rows:
|
||||
entry_date = _parse_date(raw.get("entry_date"))
|
||||
if entry_date is None or raw.get("odometer") is None:
|
||||
continue
|
||||
liters = _decimal(raw.get("liters"))
|
||||
price_per_liter = _decimal(raw.get("price_per_liter"))
|
||||
if liters is None or price_per_liter is None:
|
||||
continue
|
||||
total_cost = _decimal(raw.get("total_cost")) or (liters * price_per_liter)
|
||||
exists = (
|
||||
await session.execute(
|
||||
select(FuelEntry.id).where(
|
||||
FuelEntry.car_id == car.id,
|
||||
FuelEntry.entry_date == entry_date,
|
||||
FuelEntry.odometer == int(raw["odometer"]),
|
||||
FuelEntry.liters == liters,
|
||||
FuelEntry.total_cost == total_cost,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is not None:
|
||||
continue
|
||||
session.add(
|
||||
FuelEntry(
|
||||
car_id=car.id,
|
||||
entry_date=entry_date,
|
||||
odometer=int(raw["odometer"]),
|
||||
liters=liters,
|
||||
price_per_liter=price_per_liter,
|
||||
total_cost=total_cost,
|
||||
station=raw.get("station"),
|
||||
fuel_brand=raw.get("fuel_brand"),
|
||||
is_full_tank=raw.get("is_full_tank"),
|
||||
notes=raw.get("notes"),
|
||||
)
|
||||
)
|
||||
imported += 1
|
||||
return imported
|
||||
|
||||
|
||||
async def _import_service_entries(session: AsyncSession, car: Car, rows: list[dict]) -> int:
|
||||
imported = 0
|
||||
for raw in rows:
|
||||
entry_date = _parse_date(raw.get("entry_date"))
|
||||
title = (raw.get("title") or "").strip()
|
||||
total_cost = _decimal(raw.get("total_cost"))
|
||||
if entry_date is None or not title or total_cost is None:
|
||||
continue
|
||||
exists = (
|
||||
await session.execute(
|
||||
select(ServiceEntry.id).where(
|
||||
ServiceEntry.car_id == car.id,
|
||||
ServiceEntry.entry_date == entry_date,
|
||||
ServiceEntry.title == title,
|
||||
ServiceEntry.total_cost == total_cost,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is not None:
|
||||
continue
|
||||
session.add(
|
||||
ServiceEntry(
|
||||
car_id=car.id,
|
||||
entry_date=entry_date,
|
||||
odometer=raw.get("odometer"),
|
||||
service_type=raw.get("service_type") or "maintenance",
|
||||
title=title,
|
||||
category=raw.get("category"),
|
||||
vendor=raw.get("vendor"),
|
||||
total_cost=total_cost,
|
||||
next_due_date=_parse_date(raw.get("next_due_date")),
|
||||
next_due_odometer=raw.get("next_due_odometer"),
|
||||
notes=raw.get("notes"),
|
||||
)
|
||||
)
|
||||
imported += 1
|
||||
return imported
|
||||
|
||||
|
||||
async def _import_expense_entries(session: AsyncSession, car: Car, rows: list[dict]) -> int:
|
||||
imported = 0
|
||||
for raw in rows:
|
||||
entry_date = _parse_date(raw.get("entry_date"))
|
||||
title = (raw.get("title") or "").strip()
|
||||
total_cost = _decimal(raw.get("total_cost"))
|
||||
if entry_date is None or not title or total_cost is None:
|
||||
continue
|
||||
exists = (
|
||||
await session.execute(
|
||||
select(ExpenseEntry.id).where(
|
||||
ExpenseEntry.car_id == car.id,
|
||||
ExpenseEntry.entry_date == entry_date,
|
||||
ExpenseEntry.title == title,
|
||||
ExpenseEntry.total_cost == total_cost,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is not None:
|
||||
continue
|
||||
session.add(
|
||||
ExpenseEntry(
|
||||
car_id=car.id,
|
||||
entry_date=entry_date,
|
||||
category=raw.get("category") or "other",
|
||||
title=title,
|
||||
vendor=raw.get("vendor"),
|
||||
total_cost=total_cost,
|
||||
currency=raw.get("currency") or car.currency or "RUB",
|
||||
odometer=raw.get("odometer"),
|
||||
period_start=_parse_date(raw.get("period_start")),
|
||||
period_end=_parse_date(raw.get("period_end")),
|
||||
period_months=raw.get("period_months"),
|
||||
is_recurring=bool(raw.get("is_recurring")),
|
||||
policy_number=raw.get("policy_number"),
|
||||
insurance_type=raw.get("insurance_type"),
|
||||
payment_period_months=raw.get("payment_period_months"),
|
||||
document_urls=raw.get("document_urls"),
|
||||
metadata_json=raw.get("metadata_json"),
|
||||
notes=raw.get("notes"),
|
||||
)
|
||||
)
|
||||
imported += 1
|
||||
return imported
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
async def me(current_user: User = Depends(get_current_telegram_user)) -> User:
|
||||
@@ -232,6 +520,173 @@ async def my_service_links(
|
||||
]
|
||||
|
||||
|
||||
@router.get("/my/export")
|
||||
async def export_my_data(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> dict:
|
||||
cars = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(Car).where(Car.owner_id == current_user.id).order_by(Car.created_at.asc(), Car.id.asc())
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
exported_vehicles = []
|
||||
for car in cars:
|
||||
fuel_entries = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(FuelEntry).where(FuelEntry.car_id == car.id).order_by(FuelEntry.entry_date.asc(), FuelEntry.id.asc())
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
service_entries = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(ServiceEntry)
|
||||
.where(ServiceEntry.car_id == car.id)
|
||||
.order_by(ServiceEntry.entry_date.asc(), ServiceEntry.id.asc())
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
expense_entries = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(ExpenseEntry)
|
||||
.where(ExpenseEntry.car_id == car.id)
|
||||
.order_by(ExpenseEntry.entry_date.asc(), ExpenseEntry.id.asc())
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
appointments = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(ServiceAppointment)
|
||||
.where(ServiceAppointment.vehicle_id == car.id, ServiceAppointment.owner_id == current_user.id)
|
||||
.order_by(ServiceAppointment.created_at.asc(), ServiceAppointment.id.asc())
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
visits = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(ServiceVisit)
|
||||
.where(ServiceVisit.vehicle_id == car.id)
|
||||
.order_by(ServiceVisit.visit_date.asc(), ServiceVisit.id.asc())
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
exported_vehicles.append(
|
||||
{
|
||||
"vehicle": _model_dict(car),
|
||||
"fuel_entries": [_model_dict(item) for item in fuel_entries],
|
||||
"service_entries": [_model_dict(item) for item in service_entries],
|
||||
"expense_entries": [_model_dict(item) for item in expense_entries],
|
||||
"appointments": [_model_dict(item) for item in appointments],
|
||||
"service_visits": [_model_dict(item) for item in visits],
|
||||
}
|
||||
)
|
||||
centers = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(ServiceCenter)
|
||||
.where(ServiceCenter.owner_user_id == current_user.id)
|
||||
.order_by(ServiceCenter.created_at.asc(), ServiceCenter.id.asc())
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
payload = {
|
||||
"schema": EXPORT_SCHEMA,
|
||||
"exported_at": datetime.now(UTC),
|
||||
"user": {
|
||||
"telegram_id": current_user.telegram_id,
|
||||
"username": current_user.username,
|
||||
"locale": current_user.locale,
|
||||
"currency": current_user.currency,
|
||||
},
|
||||
"vehicles": exported_vehicles,
|
||||
"service_centers": [_model_dict(item) for item in centers],
|
||||
"exchange_policy": {
|
||||
"import_mode": "create_missing",
|
||||
"dedupe": ["vin", "license_plate", "vehicle_name_make_model", "entry_date_title_amount"],
|
||||
"work_orders": "archived_export_only",
|
||||
},
|
||||
}
|
||||
return jsonable_encoder(payload)
|
||||
|
||||
|
||||
@router.post("/my/import/preview")
|
||||
async def preview_my_data_import(
|
||||
payload: dict,
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> dict:
|
||||
warnings = []
|
||||
if payload.get("schema") != EXPORT_SCHEMA:
|
||||
warnings.append("Импорт ожидает JSON CarPass exchange v1. Данные будут обработаны в режиме совместимости.")
|
||||
counts = _exchange_counts(payload)
|
||||
if counts["service_visits"] or counts["appointments"]:
|
||||
warnings.append("Брони и заказ-наряды импортируются как архивные данные экспорта, без создания активных заявок.")
|
||||
return {
|
||||
"valid": bool(payload.get("vehicles")),
|
||||
"owner_telegram_id": current_user.telegram_id,
|
||||
"counts": counts,
|
||||
"warnings": warnings,
|
||||
"mode": "create_missing",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/my/import")
|
||||
async def import_my_data(
|
||||
payload: dict,
|
||||
dry_run: bool = False,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> dict:
|
||||
preview = await preview_my_data_import(payload, current_user)
|
||||
if dry_run:
|
||||
return preview
|
||||
imported = {
|
||||
"vehicles_created": 0,
|
||||
"vehicles_matched": 0,
|
||||
"vehicle_fields_updated": 0,
|
||||
"fuel_entries": 0,
|
||||
"service_entries": 0,
|
||||
"expense_entries": 0,
|
||||
}
|
||||
for bundle in payload.get("vehicles") or []:
|
||||
raw_vehicle = bundle.get("vehicle") or bundle
|
||||
vehicle_payload = _vehicle_import_data(raw_vehicle)
|
||||
if not vehicle_payload.get("name"):
|
||||
vehicle_payload["name"] = "Импортированное авто"
|
||||
await _clear_conflicting_unique_identity(session, current_user, vehicle_payload)
|
||||
car = await _find_import_vehicle(session, current_user, vehicle_payload)
|
||||
if car is None:
|
||||
car = Car(**vehicle_payload, owner_id=current_user.id)
|
||||
session.add(car)
|
||||
await session.flush()
|
||||
session.add(VehicleAccess(vehicle_id=car.id, user_id=current_user.id, role="owner", status="active"))
|
||||
imported["vehicles_created"] += 1
|
||||
else:
|
||||
imported["vehicles_matched"] += 1
|
||||
imported["vehicle_fields_updated"] += len(_supplement_empty_vehicle_fields(car, vehicle_payload))
|
||||
imported["fuel_entries"] += await _import_fuel_entries(session, car, bundle.get("fuel_entries") or [])
|
||||
imported["service_entries"] += await _import_service_entries(session, car, bundle.get("service_entries") or [])
|
||||
imported["expense_entries"] += await _import_expense_entries(session, car, bundle.get("expense_entries") or [])
|
||||
await session.flush()
|
||||
await recalculate_current_odometer(session, car.id, changed_by=current_user.id, source_record_type="data_import")
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
action="data_exchange.import",
|
||||
target_type="user",
|
||||
target_id=current_user.id,
|
||||
metadata={"imported": imported, "counts": preview["counts"]},
|
||||
)
|
||||
await session.commit()
|
||||
return {"status": "imported", "imported": imported, "preview": preview}
|
||||
|
||||
|
||||
@router.post("/my/vehicles/{vehicle_id}/grant-service-access", response_model=VehicleAccessRead)
|
||||
async def grant_vehicle_access(
|
||||
vehicle_id: int,
|
||||
|
||||
@@ -683,6 +683,16 @@ async def create_work_order_from_appointment(
|
||||
return visit
|
||||
|
||||
|
||||
@router.get("/sto/settings/booking", response_model=ServiceCenterBookingSettingsRead)
|
||||
async def read_booking_settings(
|
||||
service_center_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceCenterBookingSettings:
|
||||
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
|
||||
return await get_booking_settings(session, service_center_id)
|
||||
|
||||
|
||||
@router.post("/sto/settings/booking", response_model=ServiceCenterBookingSettingsRead)
|
||||
async def upsert_booking_settings(
|
||||
payload: ServiceCenterBookingSettingsUpsert,
|
||||
|
||||
@@ -62,7 +62,7 @@ def work_order_webapp_url(work_order_id: int) -> str:
|
||||
|
||||
|
||||
def vehicle_profile_webapp_url(vehicle_id: int) -> str:
|
||||
return webapp_url(f"?section=carProfile&car_id={vehicle_id}")
|
||||
return webapp_url(f"car_profile.html?car_id={vehicle_id}")
|
||||
|
||||
|
||||
async def get_work_order(session: AsyncSession, work_order_id: int) -> ServiceVisit:
|
||||
|
||||
@@ -128,3 +128,90 @@ async def test_expense_crud_and_insurance_allocation(client, auth_headers) -> No
|
||||
|
||||
deleted = await client.delete(f"/api/expenses/{entry_id}", headers=auth_headers)
|
||||
assert deleted.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_my_data_export_preview_and_import(client, auth_headers, other_auth_headers) -> None:
|
||||
car = (
|
||||
await client.post(
|
||||
"/api/cars",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"name": "Export car",
|
||||
"make": "Kia",
|
||||
"model": "K5",
|
||||
"plate_number": "12가3456",
|
||||
"vin": "1HGCM82633A004352",
|
||||
"engine_oil_type": "5W-30 API SP",
|
||||
"current_odometer": 10000,
|
||||
},
|
||||
)
|
||||
).json()
|
||||
await client.post(
|
||||
"/api/fuel",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"car_id": car["id"],
|
||||
"entry_date": "2026-05-12",
|
||||
"odometer": 10100,
|
||||
"liters": 40,
|
||||
"price_per_liter": 2,
|
||||
},
|
||||
)
|
||||
await client.post(
|
||||
"/api/service",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"car_id": car["id"],
|
||||
"entry_date": "2026-05-13",
|
||||
"odometer": 10200,
|
||||
"service_type": "maintenance",
|
||||
"title": "Oil change",
|
||||
"total_cost": 120,
|
||||
},
|
||||
)
|
||||
await client.post(
|
||||
"/api/expenses",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"car_id": car["id"],
|
||||
"entry_date": "2026-05-14",
|
||||
"category": "parking",
|
||||
"title": "Parking",
|
||||
"total_cost": 10,
|
||||
},
|
||||
)
|
||||
|
||||
exported = await client.get("/api/my/export", headers=auth_headers)
|
||||
assert exported.status_code == 200
|
||||
payload = exported.json()
|
||||
assert payload["schema"] == "carpass.exchange.v1"
|
||||
assert payload["vehicles"][0]["vehicle"]["name"] == "Export car"
|
||||
|
||||
preview = await client.post("/api/my/import/preview", headers=other_auth_headers, json=payload)
|
||||
assert preview.status_code == 200
|
||||
assert preview.json()["counts"] == {
|
||||
"vehicles": 1,
|
||||
"fuel_entries": 1,
|
||||
"service_entries": 1,
|
||||
"expense_entries": 1,
|
||||
"appointments": 0,
|
||||
"service_visits": 0,
|
||||
}
|
||||
|
||||
imported = await client.post("/api/my/import", headers=other_auth_headers, json=payload)
|
||||
assert imported.status_code == 200
|
||||
assert imported.json()["imported"]["vehicles_created"] == 1
|
||||
assert imported.json()["imported"]["fuel_entries"] == 1
|
||||
|
||||
repeated = await client.post("/api/my/import", headers=other_auth_headers, json=payload)
|
||||
assert repeated.status_code == 200
|
||||
assert repeated.json()["imported"]["vehicles_matched"] == 1
|
||||
assert repeated.json()["imported"]["fuel_entries"] == 0
|
||||
|
||||
imported_cars = await client.get("/api/cars", headers=other_auth_headers)
|
||||
assert imported_cars.status_code == 200
|
||||
imported_car = imported_cars.json()[0]
|
||||
assert imported_car["name"] == "Export car"
|
||||
assert imported_car["engine_oil_type"] == "5W-30 API SP"
|
||||
assert imported_car["current_odometer"] == 10200
|
||||
|
||||
@@ -74,6 +74,10 @@ async def test_available_slots_skip_weekend_lunch_and_holidays(
|
||||
"accepts_online_booking": True,
|
||||
},
|
||||
)
|
||||
settings = await client.get(f"/api/sto/settings/booking?service_center_id={center['id']}", headers=auth_headers)
|
||||
assert settings.status_code == 200
|
||||
assert settings.json()["working_days"] == [0]
|
||||
assert settings.json()["open_time"] == "09:00:00"
|
||||
await client.post(
|
||||
"/api/sto/settings/holidays",
|
||||
headers=auth_headers,
|
||||
|
||||
132
web/book_sto.html
Normal file
132
web/book_sto.html
Normal file
@@ -0,0 +1,132 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#16806a" />
|
||||
<title>Запись в СТО</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
</head>
|
||||
<body class="auth-required flow-page">
|
||||
<div class="auth-overlay" id="authOverlay">
|
||||
<div class="auth-panel">
|
||||
<p class="eyebrow">CarPass</p>
|
||||
<h1>Запись в СТО</h1>
|
||||
<p>Откройте страницу через Telegram, чтобы выбрать автомобиль и свободное окно.</p>
|
||||
<div class="auth-actions">
|
||||
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" rel="noreferrer">Открыть в Telegram</a>
|
||||
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="shell flow-shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">СТО</p>
|
||||
<h1>Создать запись</h1>
|
||||
</div>
|
||||
<a class="ghost-btn" href="/">Меню</a>
|
||||
</header>
|
||||
|
||||
<section class="flow-hero">
|
||||
<div>
|
||||
<p class="eyebrow">Заявка</p>
|
||||
<h2 id="bookingTitle">Выберите сервис</h2>
|
||||
<small id="bookingHint">После отправки СТО подтвердит время или предложит другое окно.</small>
|
||||
</div>
|
||||
<div class="flow-steps">
|
||||
<span class="active">1</span>
|
||||
<span class="active">2</span>
|
||||
<span>3</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flow-layout">
|
||||
<aside class="workspace flow-side">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Каталог</p>
|
||||
<h2>Сервисы</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form id="filterForm" class="grid-form drawer-form compact-form">
|
||||
<label>
|
||||
Город
|
||||
<input name="city" placeholder="Seoul" />
|
||||
</label>
|
||||
<label>
|
||||
Специализация
|
||||
<input name="specialization" placeholder="BMW, масло, тормоза" />
|
||||
</label>
|
||||
<button type="submit">Найти</button>
|
||||
</form>
|
||||
<div id="serviceList" class="stack-list"></div>
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<form id="bookingForm" class="grid-form drawer-form flow-form">
|
||||
<div class="form-block wide">
|
||||
<p class="eyebrow">1. Авто и услуга</p>
|
||||
<h3>Что нужно сделать</h3>
|
||||
<small>Если в карточке авто есть жидкости и нормы, СТО увидит их в заказ-наряде.</small>
|
||||
</div>
|
||||
<label>
|
||||
Автомобиль
|
||||
<select name="vehicle_id" id="vehicleSelect" required></select>
|
||||
</label>
|
||||
<label>
|
||||
Услуга
|
||||
<select name="service_type" id="serviceTypeSelect">
|
||||
<option value="oil_change">Замена масла</option>
|
||||
<option value="diagnostics">Диагностика</option>
|
||||
<option value="maintenance">ТО</option>
|
||||
<option value="tire_service">Шиномонтаж</option>
|
||||
<option value="brakes">Тормоза</option>
|
||||
<option value="repair">Ремонт</option>
|
||||
<option value="other">Другое</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Длительность
|
||||
<select name="estimated_duration_minutes" id="durationSelect">
|
||||
<option value="60">1 час</option>
|
||||
<option value="90">1.5 часа</option>
|
||||
<option value="120">2 часа</option>
|
||||
<option value="180">3 часа</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Дата
|
||||
<input name="date" id="bookingDateInput" type="date" />
|
||||
</label>
|
||||
|
||||
<div class="form-block wide">
|
||||
<p class="eyebrow">2. Время</p>
|
||||
<h3>Свободное окно</h3>
|
||||
<small id="slotHint">Выберите СТО слева, затем дату и услугу.</small>
|
||||
</div>
|
||||
<label class="wide">
|
||||
Окно записи
|
||||
<select name="slot" id="slotSelect" required></select>
|
||||
</label>
|
||||
<label class="wide">
|
||||
Комментарий
|
||||
<textarea name="customer_comment" placeholder="Например: стук спереди справа, масло свое, нужен осмотр подвески"></textarea>
|
||||
</label>
|
||||
<div class="row-actions wide">
|
||||
<button id="createBookingBtn" type="submit">Отправить заявку</button>
|
||||
<a class="ghost-btn" href="/car_profile.html">Проверить карточку авто</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="toast hidden" id="toast" role="status" aria-live="polite"></div>
|
||||
<script src="/static/page_common.js"></script>
|
||||
<script src="/static/book_sto.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
211
web/car_profile.html
Normal file
211
web/car_profile.html
Normal file
@@ -0,0 +1,211 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#16806a" />
|
||||
<title>Паспорт автомобиля</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
</head>
|
||||
<body class="auth-required flow-page">
|
||||
<div class="auth-overlay" id="authOverlay">
|
||||
<div class="auth-panel">
|
||||
<p class="eyebrow">CarPass</p>
|
||||
<h1>Паспорт автомобиля</h1>
|
||||
<p>Откройте страницу через Telegram, чтобы безопасно редактировать данные авто.</p>
|
||||
<div class="auth-actions">
|
||||
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" rel="noreferrer">Открыть в Telegram</a>
|
||||
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="shell flow-shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Автомобиль</p>
|
||||
<h1>Паспорт автомобиля</h1>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<a class="ghost-btn" href="/">Гараж</a>
|
||||
<button class="icon-btn" id="newVehicleBtn" title="Новое авто" aria-label="Новое авто">+</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="flow-hero">
|
||||
<div>
|
||||
<p class="eyebrow">Карточка авто</p>
|
||||
<h2 id="pageTitle">Выберите автомобиль</h2>
|
||||
<small id="pageHint">Данные отсюда используются в заказ-нарядах, подборе жидкостей и рекомендациях ТО.</small>
|
||||
</div>
|
||||
<div class="flow-steps">
|
||||
<span class="active">1</span>
|
||||
<span>2</span>
|
||||
<span>3</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flow-layout">
|
||||
<aside class="workspace flow-side">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Гараж</p>
|
||||
<h2>Автомобили</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="vehicleList" class="stack-list"></div>
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<form id="vehicleProfileForm" class="grid-form drawer-form flow-form">
|
||||
<div class="form-block wide">
|
||||
<p class="eyebrow">1. Основа</p>
|
||||
<h3>Идентификация</h3>
|
||||
<small>VIN и госномер помогают СТО точно связать заказ-наряд с автомобилем.</small>
|
||||
</div>
|
||||
<label>
|
||||
Название
|
||||
<input name="name" placeholder="Kia K5" required />
|
||||
</label>
|
||||
<label>
|
||||
Марка
|
||||
<select name="make" id="makeSelect"></select>
|
||||
</label>
|
||||
<label>
|
||||
Модель
|
||||
<select name="model" id="modelSelect"></select>
|
||||
</label>
|
||||
<label>
|
||||
Комплектация
|
||||
<select name="trim" id="trimSelect"></select>
|
||||
</label>
|
||||
<label>
|
||||
Год
|
||||
<input name="year" type="number" min="1900" max="2100" />
|
||||
</label>
|
||||
<label>
|
||||
Госномер
|
||||
<input name="plate_number" placeholder="12가3456" />
|
||||
</label>
|
||||
<label>
|
||||
VIN
|
||||
<input name="vin" maxlength="17" placeholder="17 символов без I/O/Q" />
|
||||
</label>
|
||||
<label>
|
||||
Текущий пробег
|
||||
<input name="current_odometer" type="number" min="0" />
|
||||
</label>
|
||||
|
||||
<div class="form-block wide">
|
||||
<p class="eyebrow">2. Техника</p>
|
||||
<h3>Жидкости и нормы</h3>
|
||||
<small>Если СТО добавит материалы в заказ-наряд, пустые поля здесь будут дополняться автоматически.</small>
|
||||
</div>
|
||||
<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>
|
||||
Объем двигателя, л
|
||||
<input name="engine_volume_l" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
<label>
|
||||
Коробка
|
||||
<select name="transmission">
|
||||
<option value="">Не задано</option>
|
||||
<option value="manual">Механика</option>
|
||||
<option value="automatic">Автомат</option>
|
||||
<option value="cvt">CVT</option>
|
||||
<option value="dct">DCT</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Привод
|
||||
<select name="drive_type">
|
||||
<option value="">Не задано</option>
|
||||
<option value="fwd">Передний</option>
|
||||
<option value="rwd">Задний</option>
|
||||
<option value="awd">Полный</option>
|
||||
</select>
|
||||
</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" />
|
||||
</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" />
|
||||
</label>
|
||||
<label>
|
||||
Антифриз
|
||||
<input name="coolant_type" placeholder="LLC / G48" />
|
||||
</label>
|
||||
<label>
|
||||
Тормозная жидкость
|
||||
<input name="brake_fluid_type" placeholder="DOT 4 LV" />
|
||||
</label>
|
||||
<label>
|
||||
Размер шин
|
||||
<input name="tire_size" placeholder="205/55 R16" />
|
||||
</label>
|
||||
<label>
|
||||
Интервал масла, км
|
||||
<input name="oil_change_interval_km" type="number" min="0" />
|
||||
</label>
|
||||
|
||||
<div class="form-block wide">
|
||||
<p class="eyebrow">3. Владение</p>
|
||||
<h3>Расходы и заметки</h3>
|
||||
<small>Эти данные влияют на стоимость владения и прогнозы.</small>
|
||||
</div>
|
||||
<label>
|
||||
Стоимость покупки
|
||||
<input name="purchase_price" type="number" min="0" step="0.01" />
|
||||
</label>
|
||||
<label>
|
||||
Дата покупки
|
||||
<input name="purchase_date" type="date" />
|
||||
</label>
|
||||
<label>
|
||||
Тип покупки
|
||||
<select name="purchase_type">
|
||||
<option value="unknown">Не указано</option>
|
||||
<option value="cash">Наличные</option>
|
||||
<option value="credit">Кредит</option>
|
||||
<option value="lease">Лизинг</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Заметки
|
||||
<input name="notes" placeholder="Особенности авто" />
|
||||
</label>
|
||||
<div class="row-actions wide">
|
||||
<button type="submit" id="saveVehicleBtn">Сохранить паспорт</button>
|
||||
<button type="button" class="danger-btn hidden" id="deleteVehicleBtn">Удалить автомобиль</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="toast hidden" id="toast" role="status" aria-live="polite"></div>
|
||||
<script src="/static/page_common.js"></script>
|
||||
<script src="/static/car_profile.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
81
web/data_exchange.html
Normal file
81
web/data_exchange.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#16806a" />
|
||||
<title>Импорт и экспорт</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
</head>
|
||||
<body class="auth-required flow-page">
|
||||
<div class="auth-overlay" id="authOverlay">
|
||||
<div class="auth-panel">
|
||||
<p class="eyebrow">CarPass</p>
|
||||
<h1>Импорт и экспорт</h1>
|
||||
<p>Откройте страницу через Telegram, чтобы выгрузить или перенести данные гаража.</p>
|
||||
<div class="auth-actions">
|
||||
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" rel="noreferrer">Открыть в Telegram</a>
|
||||
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="shell flow-shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Данные</p>
|
||||
<h1>Импорт и экспорт</h1>
|
||||
</div>
|
||||
<a class="ghost-btn" href="/">Меню</a>
|
||||
</header>
|
||||
|
||||
<section class="flow-hero">
|
||||
<div>
|
||||
<p class="eyebrow">Обмен данными</p>
|
||||
<h2>CarPass exchange v1</h2>
|
||||
<small>Экспорт включает автомобили, расходы, записи, заказ-наряды как архив и собственные СТО.</small>
|
||||
</div>
|
||||
<span class="trust-badge">JSON</span>
|
||||
</section>
|
||||
|
||||
<section class="flow-layout">
|
||||
<section class="workspace">
|
||||
<div class="form-block">
|
||||
<p class="eyebrow">Экспорт</p>
|
||||
<h3>Выгрузить мои данные</h3>
|
||||
<small>Файл подходит для резервной копии или переноса в другой аккаунт. Активные заказ-наряды не создаются при импорте.</small>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button id="exportBtn" type="button">Скачать JSON</button>
|
||||
</div>
|
||||
<div id="exportSummary" class="stack-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="workspace">
|
||||
<div class="form-block">
|
||||
<p class="eyebrow">Импорт</p>
|
||||
<h3>Загрузить файл</h3>
|
||||
<small>Импорт создает недостающие автомобили и записи, а совпавшие карточки дополняет только пустыми полями.</small>
|
||||
</div>
|
||||
<form id="importForm" class="grid-form drawer-form flow-form">
|
||||
<label class="wide">
|
||||
Файл JSON
|
||||
<input id="importFile" name="file" type="file" accept="application/json,.json" required />
|
||||
</label>
|
||||
<div class="row-actions wide">
|
||||
<button id="previewBtn" type="button">Проверить файл</button>
|
||||
<button id="importBtn" type="submit">Импортировать</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="importSummary" class="stack-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="toast hidden" id="toast" role="status" aria-live="polite"></div>
|
||||
<script src="/static/page_common.js"></script>
|
||||
<script src="/static/data_exchange.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -257,8 +257,8 @@
|
||||
<details class="menu-group" open>
|
||||
<summary>Автомобиль</summary>
|
||||
<button class="menu-row" data-menu-section="carsSection">Мои автомобили</button>
|
||||
<button class="menu-row" data-menu-section="carFormSection">Добавить авто</button>
|
||||
<button class="menu-row" data-menu-section="carProfileSection">Параметры авто</button>
|
||||
<button class="menu-row" data-page-link="/car_profile.html?action=new">Добавить авто</button>
|
||||
<button class="menu-row" data-page-link="/car_profile.html">Паспорт и параметры авто</button>
|
||||
<button class="menu-row" data-menu-section="maintenanceRecommendationsSection">Рекомендации ТО</button>
|
||||
<button class="menu-row" data-menu-section="confirmationsSection">Подтверждения</button>
|
||||
<button class="menu-row" data-menu-section="connectedServicesSection">Подключенные СТО</button>
|
||||
@@ -277,17 +277,20 @@
|
||||
<details class="menu-group" open>
|
||||
<summary>СТО</summary>
|
||||
<button class="menu-row" data-menu-section="publicServicesSection">Каталог СТО</button>
|
||||
<button class="menu-row" data-page-link="/book_sto.html">Записаться в СТО</button>
|
||||
<button class="menu-row" data-menu-section="appointmentsSection">Мои записи</button>
|
||||
<button class="menu-row" data-menu-section="reviewsSection">Отзывы</button>
|
||||
<button class="menu-row sto-workplace-only hidden" data-open-sto-page>Панель СТО</button>
|
||||
<button class="menu-row sto-calendar-only hidden" data-page-link="/sto_settings.html">Настройки СТО</button>
|
||||
<button class="menu-row sto-calendar-only hidden" data-menu-section="stoCalendarSection">Календарь СТО</button>
|
||||
<button class="menu-row service-owner-only hidden" data-menu-section="servicePanelSection">Мое СТО</button>
|
||||
<button class="menu-row service-owner-only hidden" data-page-link="/service_profile.html">Профиль СТО</button>
|
||||
</details>
|
||||
|
||||
<details class="menu-group">
|
||||
<summary>Аккаунт</summary>
|
||||
<button class="menu-row" data-menu-section="settingsSection">Настройки</button>
|
||||
<button class="menu-row" data-menu-section="notificationsSection">Уведомления</button>
|
||||
<button class="menu-row" data-page-link="/data_exchange.html">Импорт / экспорт</button>
|
||||
<button class="menu-row admin-only hidden" data-menu-section="adminSection">Админ</button>
|
||||
</details>
|
||||
</div>
|
||||
@@ -428,7 +431,7 @@
|
||||
<section class="drawer-section hidden" id="publicServicesSection">
|
||||
<h2>СТО</h2>
|
||||
<div class="tip-card">Обычный профиль не показывает панель СТО. Для бизнеса отправьте заявку на проверку.</div>
|
||||
<button class="wide-btn service-owner-only hidden" type="button" data-menu-section="servicePanelSection">Открыть профиль СТО</button>
|
||||
<button class="wide-btn service-owner-only hidden" type="button" data-page-link="/service_profile.html">Открыть профиль СТО</button>
|
||||
<div id="publicServiceCenters" class="stack-list"></div>
|
||||
<div id="serviceCard" class="service-card hidden"></div>
|
||||
</section>
|
||||
|
||||
139
web/service_profile.html
Normal file
139
web/service_profile.html
Normal file
@@ -0,0 +1,139 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#16806a" />
|
||||
<title>Профиль СТО</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
</head>
|
||||
<body class="auth-required flow-page">
|
||||
<div class="auth-overlay" id="authOverlay">
|
||||
<div class="auth-panel">
|
||||
<p class="eyebrow">CarPass Business</p>
|
||||
<h1>Профиль СТО</h1>
|
||||
<p>Откройте страницу через Telegram, чтобы безопасно отправить или обновить заявку СТО.</p>
|
||||
<div class="auth-actions">
|
||||
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" rel="noreferrer">Открыть в Telegram</a>
|
||||
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="shell flow-shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">СТО</p>
|
||||
<h1>Профиль сервиса</h1>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<a class="ghost-btn" href="/">Меню</a>
|
||||
<button class="icon-btn" id="newCenterBtn" title="Новая заявка" aria-label="Новая заявка">+</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="flow-hero">
|
||||
<div>
|
||||
<p class="eyebrow">Регистрация</p>
|
||||
<h2 id="centerTitle">Заявка СТО</h2>
|
||||
<small id="centerHint">Карточка используется в каталоге, записи клиентов и уведомлениях по заказ-нарядам.</small>
|
||||
</div>
|
||||
<span class="trust-badge" id="centerStatus">Черновик</span>
|
||||
</section>
|
||||
|
||||
<section class="flow-layout">
|
||||
<aside class="workspace flow-side">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Доступные СТО</p>
|
||||
<h2>Мои сервисы</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="centersList" class="stack-list"></div>
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<form id="serviceProfileForm" class="grid-form drawer-form flow-form">
|
||||
<div class="form-block wide">
|
||||
<p class="eyebrow">1. Карточка</p>
|
||||
<h3>Название и контакты</h3>
|
||||
<small>Клиент видит понятное название, город, адрес и телефон для связи.</small>
|
||||
</div>
|
||||
<label>
|
||||
Название СТО
|
||||
<input name="display_name" placeholder="Smart Service" required />
|
||||
</label>
|
||||
<label>
|
||||
Юридическое название
|
||||
<input name="legal_name" placeholder="ООО Smart Service" />
|
||||
</label>
|
||||
<label>
|
||||
Страна
|
||||
<input name="country" maxlength="2" placeholder="KR" />
|
||||
</label>
|
||||
<label>
|
||||
Город
|
||||
<input name="city" placeholder="Seoul" />
|
||||
</label>
|
||||
<label>
|
||||
Адрес
|
||||
<input name="address" placeholder="Gangnam-daero 12" />
|
||||
</label>
|
||||
<label>
|
||||
Телефон
|
||||
<input name="phone" placeholder="+82..." />
|
||||
</label>
|
||||
<label>
|
||||
Контактное лицо
|
||||
<input name="contact_person" placeholder="Имя администратора" />
|
||||
</label>
|
||||
<label>
|
||||
График работы
|
||||
<input name="working_hours" placeholder="Пн-Сб 09:00-19:00" />
|
||||
</label>
|
||||
|
||||
<div class="form-block wide">
|
||||
<p class="eyebrow">2. Проверка</p>
|
||||
<h3>Документы и специализация</h3>
|
||||
<small>После отправки модератор проверит документы. Подтвержденная СТО получает запись клиентов и рабочее место.</small>
|
||||
</div>
|
||||
<label class="wide">
|
||||
Специализация
|
||||
<input name="specializations" placeholder="Hyundai, Kia, BMW, электрика, шиномонтаж" />
|
||||
</label>
|
||||
<label class="wide">
|
||||
Описание
|
||||
<textarea name="description" placeholder="Коротко о сервисе, оборудовании и сильных сторонах"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Регистрационный номер
|
||||
<input name="business_registration_number" />
|
||||
</label>
|
||||
<label>
|
||||
Фото фасада, URL
|
||||
<input name="facade_photo_url" placeholder="https://..." />
|
||||
</label>
|
||||
<label class="wide">
|
||||
Фото документов, URL через запятую
|
||||
<input name="document_photo_urls" placeholder="https://..., https://..." />
|
||||
</label>
|
||||
<label class="wide">
|
||||
Дополнительные фото, URL через запятую
|
||||
<input name="additional_photo_urls" placeholder="https://..., https://..." />
|
||||
</label>
|
||||
<div class="row-actions wide">
|
||||
<button id="saveCenterBtn" type="submit">Отправить заявку</button>
|
||||
<a class="ghost-btn hidden" id="openSettingsLink" href="/sto_settings.html">Настройки СТО</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="toast hidden" id="toast" role="status" aria-live="polite"></div>
|
||||
<script src="/static/page_common.js"></script>
|
||||
<script src="/static/service_profile.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1203,33 +1203,10 @@ async function openServiceCard(serviceCenterId) {
|
||||
<div class="service-actions">
|
||||
<button type="button" class="ghost-btn" id="attachServiceBtn">Привязать выбранное авто</button>
|
||||
</div>
|
||||
<form class="grid-form drawer-form" id="serviceBookingForm">
|
||||
<label>
|
||||
Услуга
|
||||
<select name="service_type">
|
||||
<option value="oil_change">Замена масла</option>
|
||||
<option value="diagnostics">Диагностика</option>
|
||||
<option value="maintenance">ТО</option>
|
||||
<option value="tire_service">Шиномонтаж</option>
|
||||
<option value="brakes">Тормоза</option>
|
||||
<option value="repair">Ремонт</option>
|
||||
<option value="other">Другое</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Дата
|
||||
<input name="date" type="date" value="${today()}" />
|
||||
</label>
|
||||
<label>
|
||||
Свободное окно
|
||||
<select name="slot" id="bookingSlotSelect"></select>
|
||||
</label>
|
||||
<label>
|
||||
Комментарий
|
||||
<input name="customer_comment" placeholder="Что нужно сделать" />
|
||||
</label>
|
||||
<button type="submit">Записаться</button>
|
||||
</form>
|
||||
<div class="tip-card">
|
||||
Запись вынесена на отдельную страницу: там можно выбрать автомобиль, услугу, дату и свободное окно без тесного меню.
|
||||
<button type="button" class="wide-btn" data-page-link="/book_sto.html?service_center_id=${center.id}">Записаться в это СТО</button>
|
||||
</div>
|
||||
<form class="grid-form drawer-form" id="serviceReviewForm">
|
||||
<label>
|
||||
Оценка
|
||||
@@ -1288,55 +1265,9 @@ async function openServiceCard(serviceCenterId) {
|
||||
haptic("success");
|
||||
});
|
||||
});
|
||||
const bookingForm = card.querySelector("#serviceBookingForm");
|
||||
const reloadSlots = () => loadServiceBookingSlots(serviceCenterId, bookingForm);
|
||||
bookingForm.querySelector('[name="service_type"]').addEventListener("change", reloadSlots);
|
||||
bookingForm.querySelector('[name="date"]').addEventListener("change", reloadSlots);
|
||||
bookingForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!state.selectedCarId) {
|
||||
toast("Выбери автомобиль", "error");
|
||||
return;
|
||||
}
|
||||
const data = formData(bookingForm);
|
||||
if (!data.slot) {
|
||||
toast("Выбери свободное окно", "error");
|
||||
return;
|
||||
}
|
||||
await runAction(bookingForm.querySelector('button[type="submit"]'), "Создаю запись...", async () => {
|
||||
await api("/appointments", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
service_center_id: serviceCenterId,
|
||||
vehicle_id: state.selectedCarId,
|
||||
service_type: data.service_type,
|
||||
service_name: bookingServiceName(data.service_type),
|
||||
requested_start_at: data.slot,
|
||||
customer_comment: data.customer_comment || null,
|
||||
}),
|
||||
});
|
||||
await loadAppointments();
|
||||
toast("Заявка отправлена в СТО");
|
||||
haptic("success");
|
||||
});
|
||||
});
|
||||
await reloadSlots();
|
||||
card.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
function bookingServiceName(type) {
|
||||
const names = {
|
||||
oil_change: "Замена масла",
|
||||
diagnostics: "Диагностика",
|
||||
maintenance: "ТО",
|
||||
tire_service: "Шиномонтаж",
|
||||
brakes: "Тормоза",
|
||||
repair: "Ремонт",
|
||||
other: "Другое",
|
||||
};
|
||||
return names[type] || "Обслуживание";
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
@@ -1344,21 +1275,6 @@ function formatDateTime(value) {
|
||||
return date.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
async function loadServiceBookingSlots(serviceCenterId, form) {
|
||||
const select = form.querySelector("#bookingSlotSelect");
|
||||
const serviceType = form.querySelector('[name="service_type"]').value;
|
||||
const date = form.querySelector('[name="date"]').value || today();
|
||||
select.innerHTML = `<option value="">Загружаю...</option>`;
|
||||
try {
|
||||
const slots = await api(`/sto/${serviceCenterId}/available-slots?service_type=${encodeURIComponent(serviceType)}&date_from=${date}&date_to=${date}`);
|
||||
select.innerHTML = slots.length
|
||||
? slots.map((slot) => `<option value="${slot.start_at}">${formatDateTime(slot.start_at)}</option>`).join("")
|
||||
: `<option value="">Нет свободных окон</option>`;
|
||||
} catch (error) {
|
||||
select.innerHTML = `<option value="">Слоты не загрузились</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAppointments() {
|
||||
const root = document.querySelector("#appointmentsList");
|
||||
if (!root) return;
|
||||
@@ -2348,10 +2264,18 @@ async function applyInitialRoute() {
|
||||
await loadSelectedCar();
|
||||
}
|
||||
if (section === "carProfile") {
|
||||
await openDrawerSection("carProfileSection");
|
||||
const target = carId ? `/car_profile.html?car_id=${carId}` : "/car_profile.html";
|
||||
window.location.replace(target);
|
||||
return;
|
||||
}
|
||||
if (section) {
|
||||
const sectionId = `${section}Section`;
|
||||
if (document.getElementById(sectionId)) {
|
||||
await openDrawerSection(sectionId);
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function selectCar(carId) {
|
||||
state.selectedCarId = carId;
|
||||
@@ -2789,6 +2713,13 @@ document.querySelectorAll("[data-menu-section]").forEach((button) => {
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const link = event.target.closest("[data-page-link]");
|
||||
if (!link) return;
|
||||
event.preventDefault();
|
||||
window.location.href = link.dataset.pageLink;
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-open-sto-page]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
if (!stoWorkplaceCenters().length) {
|
||||
|
||||
150
web/static/book_sto.js
Normal file
150
web/static/book_sto.js
Normal file
@@ -0,0 +1,150 @@
|
||||
const page = CarPassPage;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const state = {
|
||||
centers: [],
|
||||
vehicles: [],
|
||||
selectedCenterId: Number(params.get("service_center_id") || 0) || null,
|
||||
};
|
||||
|
||||
const SERVICE_NAMES = {
|
||||
oil_change: "Замена масла",
|
||||
diagnostics: "Диагностика",
|
||||
maintenance: "Техническое обслуживание",
|
||||
tire_service: "Шиномонтаж",
|
||||
brakes: "Тормозная система",
|
||||
repair: "Ремонт",
|
||||
other: "Другое",
|
||||
};
|
||||
|
||||
function selectedCenter() {
|
||||
return state.centers.find((item) => item.id === state.selectedCenterId) || null;
|
||||
}
|
||||
|
||||
function renderCenters() {
|
||||
const root = document.querySelector("#serviceList");
|
||||
root.innerHTML = state.centers.length
|
||||
? state.centers.map((center) => `
|
||||
<button type="button" class="service-list-card ${center.id === state.selectedCenterId ? "active" : ""}" data-center="${center.id}">
|
||||
<strong>${page.escapeHtml(center.display_name || center.name)}</strong>
|
||||
<small>${page.escapeHtml([center.city, center.address].filter(Boolean).join(", ") || "Адрес уточняется")}</small>
|
||||
<small>${center.nearest_slot_at ? `Ближайшее окно: ${page.formatDateTime(center.nearest_slot_at)}` : "Онлайн-запись по графику СТО"}</small>
|
||||
</button>
|
||||
`).join("")
|
||||
: `<div class="empty">Подходящих СТО не найдено.</div>`;
|
||||
root.querySelectorAll("[data-center]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
state.selectedCenterId = Number(button.dataset.center);
|
||||
renderCenters();
|
||||
renderBookingHead();
|
||||
await loadSlots();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderBookingHead() {
|
||||
const center = selectedCenter();
|
||||
document.querySelector("#bookingTitle").textContent = center ? (center.display_name || center.name) : "Выберите сервис";
|
||||
document.querySelector("#bookingHint").textContent = center
|
||||
? [center.city, center.address, center.working_hours].filter(Boolean).join(" · ") || "Выберите удобное время записи."
|
||||
: "Выберите СТО слева, потом автомобиль, услугу и свободное окно.";
|
||||
}
|
||||
|
||||
function renderVehicles() {
|
||||
const select = document.querySelector("#vehicleSelect");
|
||||
select.innerHTML = state.vehicles.length
|
||||
? state.vehicles.map((car) => `<option value="${car.id}">${page.escapeHtml([car.name, car.make, car.model, car.license_plate_display].filter(Boolean).join(" · "))}</option>`).join("")
|
||||
: `<option value="">Сначала добавьте автомобиль</option>`;
|
||||
select.disabled = !state.vehicles.length;
|
||||
}
|
||||
|
||||
async function loadCenters(filters = {}) {
|
||||
const query = new URLSearchParams();
|
||||
query.set("has_slots", "true");
|
||||
if (filters.city) query.set("city", filters.city);
|
||||
if (filters.specialization) query.set("specialization", filters.specialization);
|
||||
state.centers = await page.api(`/sto/catalog?${query.toString()}`);
|
||||
if (state.selectedCenterId && !state.centers.some((item) => item.id === state.selectedCenterId)) {
|
||||
state.centers = [await page.api(`/service-centers/${state.selectedCenterId}`).catch(() => null), ...state.centers].filter(Boolean);
|
||||
}
|
||||
if (!state.selectedCenterId && state.centers.length) state.selectedCenterId = state.centers[0].id;
|
||||
renderCenters();
|
||||
renderBookingHead();
|
||||
}
|
||||
|
||||
async function loadVehicles() {
|
||||
state.vehicles = await page.api("/my/vehicles");
|
||||
renderVehicles();
|
||||
}
|
||||
|
||||
async function loadSlots() {
|
||||
const center = selectedCenter();
|
||||
const select = document.querySelector("#slotSelect");
|
||||
if (!center) {
|
||||
select.innerHTML = `<option value="">Выберите СТО</option>`;
|
||||
select.disabled = true;
|
||||
return;
|
||||
}
|
||||
const form = document.querySelector("#bookingForm");
|
||||
const data = page.formData(form);
|
||||
const date = data.date || page.today();
|
||||
const serviceType = data.service_type || "maintenance";
|
||||
const duration = data.estimated_duration_minutes || "60";
|
||||
document.querySelector("#slotHint").textContent = "Проверяю свободные окна...";
|
||||
const slots = await page.api(`/sto/${center.id}/available-slots?service_type=${encodeURIComponent(serviceType)}&date_from=${date}&date_to=${date}&duration_minutes=${duration}`);
|
||||
select.disabled = !slots.length;
|
||||
select.innerHTML = slots.length
|
||||
? slots.map((slot) => `<option value="${slot.start_at}">${page.formatDateTime(slot.start_at)} - ${page.formatDateTime(slot.end_at).slice(-5)}</option>`).join("")
|
||||
: `<option value="">На эту дату окон нет</option>`;
|
||||
document.querySelector("#slotHint").textContent = slots.length ? "Выберите удобное окно." : "Попробуйте другую дату или длительность.";
|
||||
}
|
||||
|
||||
document.querySelector("#filterForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await page.runAction(event.currentTarget.querySelector("button"), "Ищу СТО...", async () => {
|
||||
await loadCenters(page.formData(event.currentTarget));
|
||||
await loadSlots();
|
||||
});
|
||||
});
|
||||
|
||||
["#serviceTypeSelect", "#durationSelect", "#bookingDateInput"].forEach((selector) => {
|
||||
document.querySelector(selector).addEventListener("change", () => {
|
||||
loadSlots().catch((error) => page.toast(error.message || "Не удалось обновить окна", "error"));
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("#bookingForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const center = selectedCenter();
|
||||
const data = page.formData(event.currentTarget);
|
||||
if (!center) {
|
||||
page.toast("Выберите СТО", "error");
|
||||
return;
|
||||
}
|
||||
if (!data.vehicle_id) {
|
||||
page.toast("Добавьте автомобиль перед записью", "error");
|
||||
return;
|
||||
}
|
||||
await page.runAction(document.querySelector("#createBookingBtn"), "Отправляю заявку...", async () => {
|
||||
await page.api("/appointments", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
service_center_id: center.id,
|
||||
vehicle_id: Number(data.vehicle_id),
|
||||
service_type: data.service_type,
|
||||
service_name: SERVICE_NAMES[data.service_type] || "Обслуживание",
|
||||
requested_start_at: data.slot,
|
||||
estimated_duration_minutes: Number(data.estimated_duration_minutes || 60),
|
||||
customer_comment: data.customer_comment || null,
|
||||
}),
|
||||
});
|
||||
page.toast("Заявка отправлена в СТО");
|
||||
window.setTimeout(() => { window.location.href = "/?section=appointments"; }, 700);
|
||||
});
|
||||
});
|
||||
|
||||
page.boot(async () => {
|
||||
document.querySelector("#bookingDateInput").value = page.today();
|
||||
await Promise.all([loadCenters(), loadVehicles()]);
|
||||
await loadSlots();
|
||||
});
|
||||
215
web/static/car_profile.js
Normal file
215
web/static/car_profile.js
Normal file
@@ -0,0 +1,215 @@
|
||||
const page = CarPassPage;
|
||||
|
||||
const state = {
|
||||
cars: [],
|
||||
catalog: [],
|
||||
selectedCarId: null,
|
||||
};
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
function selectedCar() {
|
||||
return state.cars.find((item) => item.id === state.selectedCarId) || null;
|
||||
}
|
||||
|
||||
function ensureOption(select, value) {
|
||||
if (!value) return;
|
||||
if (![...select.options].some((option) => option.value === value)) {
|
||||
select.insertAdjacentHTML("beforeend", `<option value="${page.escapeHtml(value)}">${page.escapeHtml(value)}</option>`);
|
||||
}
|
||||
}
|
||||
|
||||
function selectedModel() {
|
||||
const makeName = document.querySelector("#makeSelect").value;
|
||||
const modelName = document.querySelector("#modelSelect").value;
|
||||
const make = state.catalog.find((item) => item.name === makeName);
|
||||
return make?.models?.find((item) => item.name === modelName) || null;
|
||||
}
|
||||
|
||||
function syncModels(modelValue = "", trimValue = "") {
|
||||
const makeName = document.querySelector("#makeSelect").value;
|
||||
const modelSelect = document.querySelector("#modelSelect");
|
||||
const models = state.catalog.find((item) => item.name === makeName)?.models || [];
|
||||
modelSelect.disabled = !models.length;
|
||||
modelSelect.innerHTML = models.length
|
||||
? `<option value="">Модель</option>` + models.map((model) => `<option value="${page.escapeHtml(model.name)}">${page.escapeHtml(model.name)}</option>`).join("")
|
||||
: `<option value="">Сначала марка</option>`;
|
||||
ensureOption(modelSelect, modelValue);
|
||||
modelSelect.value = modelValue || "";
|
||||
syncTrims(trimValue);
|
||||
}
|
||||
|
||||
function syncTrims(trimValue = "") {
|
||||
const trimSelect = document.querySelector("#trimSelect");
|
||||
const trims = selectedModel()?.trims || [];
|
||||
trimSelect.disabled = !trims.length;
|
||||
trimSelect.innerHTML = trims.length
|
||||
? `<option value="">Комплектация</option>` + trims.map((trim) => `<option value="${page.escapeHtml(trim.name)}">${page.escapeHtml(trim.name)}</option>`).join("")
|
||||
: `<option value="">Сначала модель</option>`;
|
||||
ensureOption(trimSelect, trimValue);
|
||||
trimSelect.value = trimValue || "";
|
||||
const trim = trims.find((item) => item.name === trimSelect.value);
|
||||
const fuel = document.querySelector('[name="fuel_type"]');
|
||||
if (trim?.fuel_type && !fuel.value) fuel.value = trim.fuel_type;
|
||||
if (trim?.body_type && !document.querySelector('[name="body_type"]')?.value) {
|
||||
const bodyInput = document.querySelector('[name="body_type"]');
|
||||
if (bodyInput) bodyInput.value = trim.body_type;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCatalog() {
|
||||
state.catalog = await page.api("/catalog/makes");
|
||||
const makeSelect = document.querySelector("#makeSelect");
|
||||
const makes = [...state.catalog].sort((a, b) => a.name.localeCompare(b.name, "ru"));
|
||||
makeSelect.innerHTML = `<option value="">Марка</option>` + makes
|
||||
.map((make) => `<option value="${page.escapeHtml(make.name)}">${page.escapeHtml(make.name)}</option>`)
|
||||
.join("");
|
||||
makeSelect.addEventListener("change", () => syncModels());
|
||||
document.querySelector("#modelSelect").addEventListener("change", () => syncTrims());
|
||||
document.querySelector("#trimSelect").addEventListener("change", () => syncTrims(document.querySelector("#trimSelect").value));
|
||||
}
|
||||
|
||||
async function loadCars() {
|
||||
state.cars = await page.api(`/cars?owner_id=${page.state.user.id}`);
|
||||
const routeCarId = Number(params.get("car_id") || 0);
|
||||
if (params.get("action") === "new") state.selectedCarId = null;
|
||||
else if (routeCarId && state.cars.some((item) => item.id === routeCarId)) state.selectedCarId = routeCarId;
|
||||
else if (!state.selectedCarId && state.cars.length) state.selectedCarId = state.cars[0].id;
|
||||
renderVehicles();
|
||||
fillForm();
|
||||
}
|
||||
|
||||
function renderVehicles() {
|
||||
const root = document.querySelector("#vehicleList");
|
||||
root.innerHTML = state.cars.length
|
||||
? state.cars.map((car) => `
|
||||
<button type="button" class="service-list-card ${car.id === state.selectedCarId ? "active" : ""}" data-vehicle="${car.id}">
|
||||
<strong>${page.escapeHtml(car.name)}</strong>
|
||||
<small>${page.escapeHtml([car.make, car.model, car.year, car.license_plate_display].filter(Boolean).join(" · ") || "Паспорт без деталей")}</small>
|
||||
</button>
|
||||
`).join("")
|
||||
: `<div class="empty">Автомобилей пока нет. Заполните форму справа.</div>`;
|
||||
root.querySelectorAll("[data-vehicle]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
state.selectedCarId = Number(button.dataset.vehicle);
|
||||
renderVehicles();
|
||||
fillForm();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setValue(form, name, value) {
|
||||
const input = form.elements[name];
|
||||
if (!input) return;
|
||||
input.value = value ?? "";
|
||||
}
|
||||
|
||||
function fillForm() {
|
||||
const form = document.querySelector("#vehicleProfileForm");
|
||||
const car = selectedCar();
|
||||
form.reset();
|
||||
document.querySelector("#deleteVehicleBtn").classList.toggle("hidden", !car);
|
||||
document.querySelector("#pageTitle").textContent = car ? car.name : "Новый автомобиль";
|
||||
document.querySelector("#pageHint").textContent = car
|
||||
? [car.make, car.model, car.year, car.license_plate_display].filter(Boolean).join(" · ") || "Заполните недостающие данные паспорта."
|
||||
: "Создайте карточку, а потом дополняйте ее по мере обслуживания.";
|
||||
if (!car) {
|
||||
syncModels();
|
||||
return;
|
||||
}
|
||||
[
|
||||
"name",
|
||||
"year",
|
||||
"plate_number",
|
||||
"vin",
|
||||
"current_odometer",
|
||||
"fuel_type",
|
||||
"engine_volume_l",
|
||||
"transmission",
|
||||
"drive_type",
|
||||
"engine_oil_type",
|
||||
"engine_oil_volume_l",
|
||||
"transmission_fluid_type",
|
||||
"transmission_fluid_volume_l",
|
||||
"coolant_type",
|
||||
"brake_fluid_type",
|
||||
"tire_size",
|
||||
"oil_change_interval_km",
|
||||
"purchase_price",
|
||||
"purchase_date",
|
||||
"purchase_type",
|
||||
"notes",
|
||||
].forEach((name) => setValue(form, name, car[name]));
|
||||
ensureOption(form.elements.make, car.make);
|
||||
form.elements.make.value = car.make || "";
|
||||
syncModels(car.model || "", car.trim || "");
|
||||
}
|
||||
|
||||
function payloadFromForm(form) {
|
||||
const data = page.formData(form);
|
||||
return {
|
||||
name: data.name,
|
||||
make: data.make || null,
|
||||
model: data.model || null,
|
||||
trim: data.trim || null,
|
||||
year: page.numberOrNull(data.year),
|
||||
plate_number: data.plate_number || null,
|
||||
vin: data.vin || null,
|
||||
current_odometer: page.numberOrNull(data.current_odometer),
|
||||
fuel_type: data.fuel_type || null,
|
||||
engine_volume_l: page.numberOrNull(data.engine_volume_l),
|
||||
transmission: data.transmission || null,
|
||||
drive_type: data.drive_type || null,
|
||||
engine_oil_type: data.engine_oil_type || null,
|
||||
engine_oil_volume_l: page.numberOrNull(data.engine_oil_volume_l),
|
||||
transmission_fluid_type: data.transmission_fluid_type || null,
|
||||
transmission_fluid_volume_l: page.numberOrNull(data.transmission_fluid_volume_l),
|
||||
coolant_type: data.coolant_type || null,
|
||||
brake_fluid_type: data.brake_fluid_type || null,
|
||||
tire_size: data.tire_size || null,
|
||||
oil_change_interval_km: page.numberOrNull(data.oil_change_interval_km),
|
||||
purchase_price: page.numberOrNull(data.purchase_price),
|
||||
purchase_date: data.purchase_date || null,
|
||||
purchase_type: data.purchase_type || "unknown",
|
||||
purchase_currency: page.state.user?.currency || "RUB",
|
||||
currency: page.state.user?.currency || "RUB",
|
||||
notes: data.notes || null,
|
||||
};
|
||||
}
|
||||
|
||||
document.querySelector("#newVehicleBtn").addEventListener("click", () => {
|
||||
state.selectedCarId = null;
|
||||
renderVehicles();
|
||||
fillForm();
|
||||
});
|
||||
|
||||
document.querySelector("#vehicleProfileForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const car = selectedCar();
|
||||
await page.runAction(document.querySelector("#saveVehicleBtn"), "Сохраняю паспорт...", async () => {
|
||||
const saved = await page.api(car ? `/cars/${car.id}` : "/cars", {
|
||||
method: car ? "PATCH" : "POST",
|
||||
body: JSON.stringify(payloadFromForm(form)),
|
||||
});
|
||||
state.selectedCarId = saved.id;
|
||||
await loadCars();
|
||||
page.toast("Паспорт сохранен");
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("#deleteVehicleBtn").addEventListener("click", async (event) => {
|
||||
const car = selectedCar();
|
||||
if (!car || !window.confirm(`Удалить автомобиль «${car.name}» и все его записи?`)) return;
|
||||
await page.runAction(event.currentTarget, "Удаляю автомобиль...", async () => {
|
||||
await page.api(`/cars/${car.id}`, { method: "DELETE" });
|
||||
state.selectedCarId = null;
|
||||
await loadCars();
|
||||
page.toast("Автомобиль удален");
|
||||
});
|
||||
});
|
||||
|
||||
page.boot(async () => {
|
||||
await loadCatalog();
|
||||
await loadCars();
|
||||
});
|
||||
96
web/static/data_exchange.js
Normal file
96
web/static/data_exchange.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const page = CarPassPage;
|
||||
|
||||
let selectedPayload = null;
|
||||
|
||||
function summaryItems(counts = {}) {
|
||||
const labels = {
|
||||
vehicles: "Автомобили",
|
||||
fuel_entries: "Заправки",
|
||||
service_entries: "ТО и ремонт",
|
||||
expense_entries: "Расходы",
|
||||
appointments: "Записи в СТО",
|
||||
service_visits: "Заказ-наряды",
|
||||
};
|
||||
return Object.entries(labels).map(([key, label]) => `
|
||||
<div class="stack-item">
|
||||
<strong>${label}</strong>
|
||||
<small>${page.escapeHtml(counts[key] ?? 0)}</small>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function countExport(payload) {
|
||||
const vehicles = payload.vehicles || [];
|
||||
return {
|
||||
vehicles: vehicles.length,
|
||||
fuel_entries: vehicles.reduce((sum, item) => sum + (item.fuel_entries || []).length, 0),
|
||||
service_entries: vehicles.reduce((sum, item) => sum + (item.service_entries || []).length, 0),
|
||||
expense_entries: vehicles.reduce((sum, item) => sum + (item.expense_entries || []).length, 0),
|
||||
appointments: vehicles.reduce((sum, item) => sum + (item.appointments || []).length, 0),
|
||||
service_visits: vehicles.reduce((sum, item) => sum + (item.service_visits || []).length, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function renderPreview(preview) {
|
||||
document.querySelector("#importSummary").innerHTML = `
|
||||
${summaryItems(preview.counts)}
|
||||
${preview.warnings?.length ? `<div class="tip-card warning">${preview.warnings.map(page.escapeHtml).join("<br />")}</div>` : ""}
|
||||
`;
|
||||
}
|
||||
|
||||
async function readSelectedFile() {
|
||||
const file = document.querySelector("#importFile").files[0];
|
||||
if (!file) throw new Error("Выберите JSON-файл");
|
||||
selectedPayload = JSON.parse(await file.text());
|
||||
return selectedPayload;
|
||||
}
|
||||
|
||||
document.querySelector("#exportBtn").addEventListener("click", async (event) => {
|
||||
await page.runAction(event.currentTarget, "Готовлю файл...", async () => {
|
||||
const payload = await page.api("/my/export");
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
||||
link.href = url;
|
||||
link.download = `carpass-export-${stamp}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
document.querySelector("#exportSummary").innerHTML = summaryItems(countExport(payload));
|
||||
page.toast("Экспорт подготовлен");
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("#previewBtn").addEventListener("click", async (event) => {
|
||||
await page.runAction(event.currentTarget, "Проверяю файл...", async () => {
|
||||
const payload = await readSelectedFile();
|
||||
const preview = await page.api("/my/import/preview", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
renderPreview(preview);
|
||||
page.toast("Файл проверен");
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("#importForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await page.runAction(document.querySelector("#importBtn"), "Импортирую...", async () => {
|
||||
const payload = selectedPayload || await readSelectedFile();
|
||||
const result = await page.api("/my/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
renderPreview(result.preview);
|
||||
document.querySelector("#importSummary").insertAdjacentHTML("afterbegin", `
|
||||
<div class="tip-card">
|
||||
Импортировано: авто ${result.imported.vehicles_created}, заправок ${result.imported.fuel_entries}, сервисных записей ${result.imported.service_entries}, расходов ${result.imported.expense_entries}.
|
||||
</div>
|
||||
`);
|
||||
page.toast("Импорт завершен");
|
||||
});
|
||||
});
|
||||
|
||||
page.boot(async () => {});
|
||||
152
web/static/page_common.js
Normal file
152
web/static/page_common.js
Normal file
@@ -0,0 +1,152 @@
|
||||
const tg = window.Telegram?.WebApp;
|
||||
tg?.ready();
|
||||
tg?.expand();
|
||||
|
||||
const CarPassPage = (() => {
|
||||
const state = { user: null, authConfig: null };
|
||||
|
||||
function authHeaders(extra = {}) {
|
||||
const headers = { ...extra };
|
||||
if (tg?.initData) headers["X-Telegram-Init-Data"] = tg.initData;
|
||||
if (!tg?.initData && state.authConfig?.allow_dev_auth) {
|
||||
headers["X-Dev-Telegram-Id"] = localStorage.getItem("driversDevTelegramId") || "1";
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const { headers: optionHeaders = {}, ...fetchOptions } = options;
|
||||
const headers = { "Content-Type": "application/json", ...authHeaders(optionHeaders) };
|
||||
if (options.body instanceof FormData) delete headers["Content-Type"];
|
||||
const response = await fetch(`/api${path}`, { ...fetchOptions, headers });
|
||||
if (!response.ok) throw new Error(await response.text() || response.statusText);
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function loadAuthConfig() {
|
||||
state.authConfig = await api("/users/auth/config");
|
||||
}
|
||||
|
||||
function showAuthOverlay() {
|
||||
document.body.classList.add("auth-required");
|
||||
const link = document.querySelector("#telegramLoginLink");
|
||||
if (state.authConfig?.bot_username && link) {
|
||||
link.href = `https://t.me/${state.authConfig.bot_username}`;
|
||||
link.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureUser() {
|
||||
if (tg?.initData) {
|
||||
state.user = await api("/users/webapp-auth", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ init_data: tg.initData }),
|
||||
});
|
||||
} else if (state.authConfig?.allow_dev_auth) {
|
||||
state.user = await api("/users/me");
|
||||
} else {
|
||||
showAuthOverlay();
|
||||
throw new Error("Требуется вход через Telegram");
|
||||
}
|
||||
document.body.classList.remove("auth-required");
|
||||
document.querySelector("#authOverlay")?.classList.add("hidden");
|
||||
return state.user;
|
||||
}
|
||||
|
||||
function toast(message, tone = "success") {
|
||||
const node = document.querySelector("#toast");
|
||||
if (!node) return;
|
||||
node.textContent = message;
|
||||
node.className = `toast ${tone}`;
|
||||
window.clearTimeout(toast.timer);
|
||||
toast.timer = window.setTimeout(() => node.classList.add("hidden"), 2600);
|
||||
}
|
||||
|
||||
function setBusy(button, busy, label = "Сохраняю...") {
|
||||
if (!button) return;
|
||||
if (busy) {
|
||||
button.dataset.label = button.textContent;
|
||||
button.disabled = true;
|
||||
button.classList.add("is-busy");
|
||||
button.innerHTML = `<span class="spinner"></span><span>${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, label, callback) {
|
||||
setBusy(button, true, label);
|
||||
try {
|
||||
const result = await callback();
|
||||
return result;
|
||||
} catch (error) {
|
||||
toast(error.message || "Ошибка", "error");
|
||||
throw error;
|
||||
} finally {
|
||||
setBusy(button, false, label);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function formData(form) {
|
||||
return Object.fromEntries(new FormData(form).entries());
|
||||
}
|
||||
|
||||
function numberOrNull(value) {
|
||||
return value === "" || value == null ? null : Number(value);
|
||||
}
|
||||
|
||||
function csvList(value) {
|
||||
return value ? value.split(",").map((item) => item.trim()).filter(Boolean) : null;
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value).slice(0, 16).replace("T", " ");
|
||||
return date.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function boot(init) {
|
||||
try {
|
||||
await loadAuthConfig();
|
||||
await ensureUser();
|
||||
await init();
|
||||
} catch (error) {
|
||||
if (error.message === "Требуется вход через Telegram") return;
|
||||
console.error(error);
|
||||
toast(error.message || "Ошибка", "error");
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector("#telegramRetryBtn")?.addEventListener("click", () => window.location.reload());
|
||||
|
||||
return {
|
||||
state,
|
||||
api,
|
||||
boot,
|
||||
toast,
|
||||
runAction,
|
||||
escapeHtml,
|
||||
formData,
|
||||
numberOrNull,
|
||||
csvList,
|
||||
formatDateTime,
|
||||
today,
|
||||
};
|
||||
})();
|
||||
129
web/static/service_profile.js
Normal file
129
web/static/service_profile.js
Normal file
@@ -0,0 +1,129 @@
|
||||
const page = CarPassPage;
|
||||
|
||||
const APPROVED_STATUSES = new Set(["approved", "verified"]);
|
||||
const state = {
|
||||
centers: [],
|
||||
selectedCenterId: null,
|
||||
};
|
||||
|
||||
function selectedCenter() {
|
||||
return state.centers.find((item) => item.id === state.selectedCenterId) || null;
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
return {
|
||||
pending: "На проверке",
|
||||
approved: "Подтверждено",
|
||||
verified: "Подтверждено",
|
||||
rejected: "Отклонено",
|
||||
needs_changes: "Нужны правки",
|
||||
draft: "Черновик",
|
||||
suspended: "Приостановлено",
|
||||
}[status] || status || "Черновик";
|
||||
}
|
||||
|
||||
function setValue(form, name, value) {
|
||||
const input = form.elements[name];
|
||||
if (input) input.value = Array.isArray(value) ? value.join(", ") : value ?? "";
|
||||
}
|
||||
|
||||
function renderCenters() {
|
||||
const root = document.querySelector("#centersList");
|
||||
root.innerHTML = state.centers.length
|
||||
? state.centers.map((center) => `
|
||||
<button type="button" class="service-list-card ${center.id === state.selectedCenterId ? "active" : ""}" data-center="${center.id}">
|
||||
<strong>${page.escapeHtml(center.display_name || center.name)}</strong>
|
||||
<small>${page.escapeHtml([statusLabel(center.verification_status), center.city, center.employee_role].filter(Boolean).join(" · "))}</small>
|
||||
</button>
|
||||
`).join("")
|
||||
: `<div class="empty">Заявок СТО пока нет. Заполните форму справа.</div>`;
|
||||
root.querySelectorAll("[data-center]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
state.selectedCenterId = Number(button.dataset.center);
|
||||
renderCenters();
|
||||
fillForm();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fillForm() {
|
||||
const form = document.querySelector("#serviceProfileForm");
|
||||
const center = selectedCenter();
|
||||
form.reset();
|
||||
document.querySelector("#centerTitle").textContent = center ? (center.display_name || center.name) : "Новая заявка СТО";
|
||||
document.querySelector("#centerHint").textContent = center
|
||||
? [center.city, center.address, center.phone].filter(Boolean).join(" · ") || "Дополните карточку, чтобы клиентам было проще записаться."
|
||||
: "Заполните карточку сервиса и отправьте ее на проверку.";
|
||||
document.querySelector("#centerStatus").textContent = statusLabel(center?.verification_status);
|
||||
document.querySelector("#saveCenterBtn").textContent = center ? "Сохранить профиль" : "Отправить заявку";
|
||||
document.querySelector("#openSettingsLink").classList.toggle("hidden", !center || !APPROVED_STATUSES.has(center.verification_status));
|
||||
if (!center) return;
|
||||
[
|
||||
"display_name",
|
||||
"legal_name",
|
||||
"country",
|
||||
"city",
|
||||
"address",
|
||||
"phone",
|
||||
"contact_person",
|
||||
"working_hours",
|
||||
"specializations",
|
||||
"description",
|
||||
"business_registration_number",
|
||||
"facade_photo_url",
|
||||
"document_photo_urls",
|
||||
"additional_photo_urls",
|
||||
].forEach((name) => setValue(form, name, center[name]));
|
||||
}
|
||||
|
||||
function payloadFromForm(form) {
|
||||
const data = page.formData(form);
|
||||
return {
|
||||
display_name: data.display_name,
|
||||
legal_name: data.legal_name || null,
|
||||
country: data.country || null,
|
||||
city: data.city || null,
|
||||
address: data.address || null,
|
||||
phone: data.phone || null,
|
||||
contact_person: data.contact_person || null,
|
||||
working_hours: data.working_hours || null,
|
||||
specializations: page.csvList(data.specializations),
|
||||
description: data.description || null,
|
||||
business_registration_number: data.business_registration_number || null,
|
||||
facade_photo_url: data.facade_photo_url || null,
|
||||
document_photo_urls: page.csvList(data.document_photo_urls),
|
||||
additional_photo_urls: page.csvList(data.additional_photo_urls),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadCenters() {
|
||||
state.centers = await page.api("/service-centers/my");
|
||||
if (!state.selectedCenterId && state.centers.length) state.selectedCenterId = state.centers[0].id;
|
||||
if (state.selectedCenterId && !state.centers.some((item) => item.id === state.selectedCenterId)) {
|
||||
state.selectedCenterId = state.centers[0]?.id || null;
|
||||
}
|
||||
renderCenters();
|
||||
fillForm();
|
||||
}
|
||||
|
||||
document.querySelector("#newCenterBtn").addEventListener("click", () => {
|
||||
state.selectedCenterId = null;
|
||||
renderCenters();
|
||||
fillForm();
|
||||
});
|
||||
|
||||
document.querySelector("#serviceProfileForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const center = selectedCenter();
|
||||
await page.runAction(document.querySelector("#saveCenterBtn"), center ? "Сохраняю..." : "Отправляю...", async () => {
|
||||
const saved = await page.api(center ? `/service-centers/${center.id}` : "/service-centers", {
|
||||
method: center ? "PATCH" : "POST",
|
||||
body: JSON.stringify(payloadFromForm(event.currentTarget)),
|
||||
});
|
||||
state.selectedCenterId = saved.id;
|
||||
await loadCenters();
|
||||
page.toast(center ? "Профиль СТО обновлен" : "Заявка отправлена на проверку");
|
||||
});
|
||||
});
|
||||
|
||||
page.boot(loadCenters);
|
||||
179
web/static/sto_settings.js
Normal file
179
web/static/sto_settings.js
Normal file
@@ -0,0 +1,179 @@
|
||||
const page = CarPassPage;
|
||||
|
||||
const APPROVED_STATUSES = new Set(["approved", "verified"]);
|
||||
const MANAGER_ROLES = new Set(["owner", "manager"]);
|
||||
const state = {
|
||||
centers: [],
|
||||
activeCenterId: null,
|
||||
catalog: null,
|
||||
};
|
||||
|
||||
function activeCenter() {
|
||||
return state.centers.find((item) => item.id === state.activeCenterId) || null;
|
||||
}
|
||||
|
||||
function roleLabel(role) {
|
||||
return { owner: "Владелец", manager: "Менеджер", receptionist: "Администратор", mechanic: "Механик" }[role] || role || "СТО";
|
||||
}
|
||||
|
||||
function timeValue(value) {
|
||||
return String(value || "").slice(0, 5);
|
||||
}
|
||||
|
||||
function setScheduleForm(settings) {
|
||||
const form = document.querySelector("#bookingSettingsForm");
|
||||
form.open_time.value = timeValue(settings.open_time || "09:00");
|
||||
form.close_time.value = timeValue(settings.close_time || "18:00");
|
||||
form.lunch_break_start.value = timeValue(settings.lunch_break_start || "");
|
||||
form.lunch_break_end.value = timeValue(settings.lunch_break_end || "");
|
||||
form.slot_duration_minutes.value = settings.slot_duration_minutes ?? 30;
|
||||
form.booking_buffer_minutes.value = settings.booking_buffer_minutes ?? 0;
|
||||
form.max_parallel_bookings.value = settings.max_parallel_bookings ?? 1;
|
||||
form.timezone.value = settings.timezone || "Asia/Seoul";
|
||||
form.accepts_online_booking.checked = settings.accepts_online_booking !== false;
|
||||
const days = new Set(settings.working_days || [0, 1, 2, 3, 4]);
|
||||
form.querySelectorAll('[name="working_days"]').forEach((input) => {
|
||||
input.checked = days.has(Number(input.value));
|
||||
});
|
||||
}
|
||||
|
||||
function schedulePayload(form, centerId) {
|
||||
const data = page.formData(form);
|
||||
const workingDays = [...form.querySelectorAll('[name="working_days"]:checked')].map((input) => Number(input.value));
|
||||
return {
|
||||
service_center_id: centerId,
|
||||
working_days: workingDays,
|
||||
open_time: data.open_time || "09:00",
|
||||
close_time: data.close_time || "18:00",
|
||||
lunch_break_start: data.lunch_break_start || null,
|
||||
lunch_break_end: data.lunch_break_end || null,
|
||||
timezone: data.timezone || "Asia/Seoul",
|
||||
slot_duration_minutes: Number(data.slot_duration_minutes || 30),
|
||||
booking_buffer_minutes: Number(data.booking_buffer_minutes || 0),
|
||||
max_parallel_bookings: Number(data.max_parallel_bookings || 1),
|
||||
accepts_online_booking: Boolean(data.accepts_online_booking),
|
||||
};
|
||||
}
|
||||
|
||||
function catalogPayload(form, centerId) {
|
||||
const data = page.formData(form);
|
||||
const isWork = data.item_type === "work";
|
||||
return {
|
||||
service_center_id: centerId,
|
||||
item_type: data.item_type,
|
||||
title: data.title,
|
||||
category: data.category || null,
|
||||
description: data.description || null,
|
||||
work_type: isWork ? (data.category || "other") : null,
|
||||
product_type: isWork ? null : (data.category || "other"),
|
||||
brand: data.brand || null,
|
||||
sku: data.sku || null,
|
||||
unit: data.unit || (isWork ? "pcs" : "pcs"),
|
||||
default_quantity: page.numberOrNull(data.default_quantity) || 1,
|
||||
default_unit_price: page.numberOrNull(data.default_unit_price) || 0,
|
||||
viscosity: data.viscosity || null,
|
||||
specification: data.specification || null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
const center = activeCenter();
|
||||
document.querySelector("#centerSelect").innerHTML = state.centers
|
||||
.map((item) => `<option value="${item.id}">${page.escapeHtml(item.display_name || item.name)}</option>`)
|
||||
.join("");
|
||||
if (center) document.querySelector("#centerSelect").value = String(center.id);
|
||||
document.querySelector("#settingsTitle").textContent = center ? (center.display_name || center.name) : "Нет доступной СТО";
|
||||
document.querySelector("#settingsHint").textContent = center
|
||||
? [center.city, center.address].filter(Boolean).join(", ") || "Заполните график и каталог для команды."
|
||||
: "Настройки доступны владельцу или менеджеру подтвержденной СТО.";
|
||||
document.querySelector("#roleBadge").textContent = center ? roleLabel(center.employee_role) : "Нет доступа";
|
||||
}
|
||||
|
||||
function renderCatalog() {
|
||||
const root = document.querySelector("#catalogList");
|
||||
const centerId = activeCenter()?.id;
|
||||
const items = (state.catalog?.items || []).filter((item) => item.service_center_id === centerId);
|
||||
root.innerHTML = items.length
|
||||
? items.map((item) => `
|
||||
<div class="stack-item">
|
||||
<strong>${page.escapeHtml(item.title)}</strong>
|
||||
<small>${page.escapeHtml([item.item_type === "work" ? "Работа" : "Материал", item.category, item.brand, item.sku].filter(Boolean).join(" · "))}</small>
|
||||
<small>${page.escapeHtml(item.default_quantity)} ${page.escapeHtml(item.unit)} · ${page.escapeHtml(item.default_unit_price)}</small>
|
||||
</div>
|
||||
`).join("")
|
||||
: `<div class="empty">Пока нет собственных позиций. Системный каталог уже доступен в заказ-нарядах.</div>`;
|
||||
}
|
||||
|
||||
async function loadCenters() {
|
||||
const centers = await page.api("/service-centers/my");
|
||||
state.centers = centers.filter((center) =>
|
||||
APPROVED_STATUSES.has(center.verification_status) && MANAGER_ROLES.has(center.employee_role || "owner"),
|
||||
);
|
||||
if (!state.activeCenterId && state.centers.length) state.activeCenterId = state.centers[0].id;
|
||||
if (state.activeCenterId && !state.centers.some((item) => item.id === state.activeCenterId)) {
|
||||
state.activeCenterId = state.centers[0]?.id || null;
|
||||
}
|
||||
renderHeader();
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const center = activeCenter();
|
||||
if (!center) {
|
||||
document.querySelector("#bookingSettingsForm").classList.add("hidden");
|
||||
document.querySelector("#catalogForm").classList.add("hidden");
|
||||
document.querySelector("#catalogList").innerHTML = `<div class="empty">Нет подтвержденной СТО с ролью владельца или менеджера.</div>`;
|
||||
return;
|
||||
}
|
||||
document.querySelector("#bookingSettingsForm").classList.remove("hidden");
|
||||
document.querySelector("#catalogForm").classList.remove("hidden");
|
||||
const [settings, catalog] = await Promise.all([
|
||||
page.api(`/sto/settings/booking?service_center_id=${center.id}`),
|
||||
page.api(`/work-orders/catalog?service_center_id=${center.id}`),
|
||||
]);
|
||||
state.catalog = catalog;
|
||||
setScheduleForm(settings);
|
||||
renderCatalog();
|
||||
}
|
||||
|
||||
document.querySelector("#centerSelect").addEventListener("change", async (event) => {
|
||||
state.activeCenterId = Number(event.currentTarget.value);
|
||||
renderHeader();
|
||||
await loadSettings();
|
||||
});
|
||||
|
||||
document.querySelector("#bookingSettingsForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const center = activeCenter();
|
||||
if (!center) return;
|
||||
await page.runAction(document.querySelector("#saveScheduleBtn"), "Сохраняю график...", async () => {
|
||||
const settings = await page.api("/sto/settings/booking", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(schedulePayload(event.currentTarget, center.id)),
|
||||
});
|
||||
setScheduleForm(settings);
|
||||
page.toast("График сохранен");
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("#catalogForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const center = activeCenter();
|
||||
if (!center) return;
|
||||
await page.runAction(document.querySelector("#saveCatalogBtn"), "Добавляю позицию...", async () => {
|
||||
await page.api("/work-orders/catalog", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(catalogPayload(event.currentTarget, center.id)),
|
||||
});
|
||||
event.currentTarget.reset();
|
||||
event.currentTarget.default_quantity.value = 1;
|
||||
event.currentTarget.default_unit_price.value = 0;
|
||||
state.catalog = await page.api(`/work-orders/catalog?service_center_id=${center.id}`);
|
||||
renderCatalog();
|
||||
page.toast("Позиция добавлена");
|
||||
});
|
||||
});
|
||||
|
||||
page.boot(async () => {
|
||||
await loadCenters();
|
||||
await loadSettings();
|
||||
});
|
||||
@@ -1556,6 +1556,151 @@ select {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
display: inline-flex;
|
||||
min-height: 42px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.flow-page {
|
||||
background:
|
||||
linear-gradient(180deg, #ffffff 0, #f3f7f5 260px),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.flow-shell {
|
||||
width: min(1220px, 100%);
|
||||
}
|
||||
|
||||
.flow-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(208, 220, 214, 0.92);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(18, 115, 95, 0.1), rgba(47, 111, 159, 0.08)),
|
||||
#fff;
|
||||
box-shadow: var(--shadow);
|
||||
animation: rise 360ms ease both;
|
||||
}
|
||||
|
||||
.flow-hero h2 {
|
||||
margin: 2px 0 4px;
|
||||
font-size: clamp(22px, 3vw, 32px);
|
||||
}
|
||||
|
||||
.flow-hero small,
|
||||
.form-block small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.flow-steps {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.flow-steps span {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.flow-steps span.active {
|
||||
border-color: rgba(18, 115, 95, 0.3);
|
||||
background: #dff4ed;
|
||||
color: #0f604f;
|
||||
}
|
||||
|
||||
.flow-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.85fr) minmax(0, 1.45fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.flow-side {
|
||||
position: sticky;
|
||||
top: 88px;
|
||||
max-height: calc(100vh - 104px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.flow-form {
|
||||
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.flow-form .wide,
|
||||
.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-block {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(208, 220, 214, 0.9);
|
||||
border-radius: 8px;
|
||||
background: #fbfdfc;
|
||||
}
|
||||
|
||||
.form-block h3 {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 17px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.compact-form {
|
||||
grid-template-columns: 1fr;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.weekday-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.weekday-grid label {
|
||||
display: flex;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.weekday-grid input {
|
||||
width: 16px;
|
||||
min-height: 16px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.service-list-card.active {
|
||||
border-color: rgba(18, 115, 95, 0.55);
|
||||
background: #eef8f4;
|
||||
box-shadow: 0 12px 26px rgba(18, 115, 95, 0.12);
|
||||
}
|
||||
|
||||
.scan-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -1914,11 +2059,25 @@ select {
|
||||
}
|
||||
|
||||
.sto-grid,
|
||||
.flow-layout,
|
||||
.work-order-layout,
|
||||
.staff-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.flow-side {
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.flow-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.weekday-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.mini-stats {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
@@ -2042,6 +2201,15 @@ select {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.flow-hero {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.weekday-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sto-page .top-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 44px;
|
||||
|
||||
@@ -150,7 +150,7 @@ function renderProfileLink(detail) {
|
||||
link.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
link.href = `/?section=carProfile&car_id=${detail.vehicle.id}`;
|
||||
link.href = `/car_profile.html?car_id=${detail.vehicle.id}`;
|
||||
link.classList.remove("hidden");
|
||||
}
|
||||
|
||||
|
||||
176
web/sto_settings.html
Normal file
176
web/sto_settings.html
Normal file
@@ -0,0 +1,176 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#16806a" />
|
||||
<title>Настройки СТО</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
</head>
|
||||
<body class="auth-required flow-page">
|
||||
<div class="auth-overlay" id="authOverlay">
|
||||
<div class="auth-panel">
|
||||
<p class="eyebrow">CarPass Business</p>
|
||||
<h1>Настройки СТО</h1>
|
||||
<p>Откройте страницу через Telegram, чтобы редактировать график и каталог материалов.</p>
|
||||
<div class="auth-actions">
|
||||
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" rel="noreferrer">Открыть в Telegram</a>
|
||||
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="shell flow-shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">СТО</p>
|
||||
<h1>Параметры сервиса</h1>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<a class="ghost-btn" href="/sto.html">Рабочее место</a>
|
||||
<select id="centerSelect" aria-label="СТО"></select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="flow-hero">
|
||||
<div>
|
||||
<p class="eyebrow">Управление</p>
|
||||
<h2 id="settingsTitle">Выберите СТО</h2>
|
||||
<small id="settingsHint">График влияет на свободные окна записи, каталог ускоряет создание заказ-нарядов.</small>
|
||||
</div>
|
||||
<span class="trust-badge" id="roleBadge">Проверка</span>
|
||||
</section>
|
||||
|
||||
<section class="flow-layout">
|
||||
<section class="workspace">
|
||||
<form id="bookingSettingsForm" class="grid-form drawer-form flow-form">
|
||||
<div class="form-block wide">
|
||||
<p class="eyebrow">1. График</p>
|
||||
<h3>Онлайн-запись</h3>
|
||||
<small>Клиент видит только свободные окна по этому расписанию.</small>
|
||||
</div>
|
||||
<label class="wide">
|
||||
Рабочие дни
|
||||
<div class="weekday-grid" id="weekdayGrid">
|
||||
<label><input type="checkbox" name="working_days" value="0" /> Пн</label>
|
||||
<label><input type="checkbox" name="working_days" value="1" /> Вт</label>
|
||||
<label><input type="checkbox" name="working_days" value="2" /> Ср</label>
|
||||
<label><input type="checkbox" name="working_days" value="3" /> Чт</label>
|
||||
<label><input type="checkbox" name="working_days" value="4" /> Пт</label>
|
||||
<label><input type="checkbox" name="working_days" value="5" /> Сб</label>
|
||||
<label><input type="checkbox" name="working_days" value="6" /> Вс</label>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
Открытие
|
||||
<input name="open_time" type="time" />
|
||||
</label>
|
||||
<label>
|
||||
Закрытие
|
||||
<input name="close_time" type="time" />
|
||||
</label>
|
||||
<label>
|
||||
Обед с
|
||||
<input name="lunch_break_start" type="time" />
|
||||
</label>
|
||||
<label>
|
||||
Обед до
|
||||
<input name="lunch_break_end" type="time" />
|
||||
</label>
|
||||
<label>
|
||||
Длительность слота, мин
|
||||
<input name="slot_duration_minutes" type="number" min="10" max="240" />
|
||||
</label>
|
||||
<label>
|
||||
Буфер между записями, мин
|
||||
<input name="booking_buffer_minutes" type="number" min="0" max="240" />
|
||||
</label>
|
||||
<label>
|
||||
Параллельных записей
|
||||
<input name="max_parallel_bookings" type="number" min="1" max="20" />
|
||||
</label>
|
||||
<label>
|
||||
Часовой пояс
|
||||
<input name="timezone" placeholder="Asia/Seoul" />
|
||||
</label>
|
||||
<label class="check wide">
|
||||
<input name="accepts_online_booking" type="checkbox" />
|
||||
Принимать онлайн-запись
|
||||
</label>
|
||||
<div class="row-actions wide">
|
||||
<button id="saveScheduleBtn" type="submit">Сохранить график</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="workspace">
|
||||
<form id="catalogForm" class="grid-form drawer-form flow-form">
|
||||
<div class="form-block wide">
|
||||
<p class="eyebrow">2. Каталог</p>
|
||||
<h3>Работы и материалы</h3>
|
||||
<small>Добавленные позиции будут доступны в заказ-наряде вместе с системным каталогом.</small>
|
||||
</div>
|
||||
<label>
|
||||
Тип
|
||||
<select name="item_type">
|
||||
<option value="work">Работа</option>
|
||||
<option value="product">Материал</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Название
|
||||
<input name="title" placeholder="Замена масла ДВС" required />
|
||||
</label>
|
||||
<label>
|
||||
Категория
|
||||
<input name="category" placeholder="engine_oil" />
|
||||
</label>
|
||||
<label>
|
||||
Бренд
|
||||
<input name="brand" placeholder="Hyundai / BMW / Shell" />
|
||||
</label>
|
||||
<label>
|
||||
Артикул
|
||||
<input name="sku" placeholder="SKU" />
|
||||
</label>
|
||||
<label>
|
||||
Единица
|
||||
<input name="unit" placeholder="pcs / l / hour" />
|
||||
</label>
|
||||
<label>
|
||||
Количество
|
||||
<input name="default_quantity" type="number" min="0.01" step="0.01" value="1" />
|
||||
</label>
|
||||
<label>
|
||||
Цена
|
||||
<input name="default_unit_price" type="number" min="0" step="0.01" value="0" />
|
||||
</label>
|
||||
<label>
|
||||
Вязкость
|
||||
<input name="viscosity" placeholder="5W-30" />
|
||||
</label>
|
||||
<label>
|
||||
Спецификация
|
||||
<input name="specification" placeholder="API SP / MB 229.5" />
|
||||
</label>
|
||||
<label class="wide">
|
||||
Описание
|
||||
<input name="description" placeholder="Комментарий для сотрудников" />
|
||||
</label>
|
||||
<div class="row-actions wide">
|
||||
<button id="saveCatalogBtn" type="submit">Добавить позицию</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="list-heading">Каталог СТО</div>
|
||||
<div id="catalogList" class="stack-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="toast hidden" id="toast" role="status" aria-live="polite"></div>
|
||||
<script src="/static/page_common.js"></script>
|
||||
<script src="/static/sto_settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user