Compare commits
2 Commits
5e5582664a
...
admin-data
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99bc9aa6a1 | ||
|
|
58ff6ff614 |
10
ADMIN.md
10
ADMIN.md
@@ -24,12 +24,19 @@ Admin Control Center дает администраторам закрытого
|
||||
|
||||
- новый пользователь;
|
||||
- первое авто пользователя;
|
||||
- первая запись пользователя;
|
||||
- новая заявка СТО;
|
||||
- обновление документов или повторная отправка заявки СТО;
|
||||
- изменение статуса заявки СТО;
|
||||
- одобрение, блокировка и разблокировка СТО;
|
||||
- новая запись в СТО, отмена записи СТО;
|
||||
- создание, завершение и отклонение заказ-наряда;
|
||||
- запрос и решение коррекции заказ-наряда;
|
||||
- низкая оценка СТО;
|
||||
- OCR/upload/rate-limit события;
|
||||
- security/system события через общий admin notification service.
|
||||
|
||||
Idempotency key защищает от дублей.
|
||||
Idempotency key защищает от дублей. Telegram-сообщение содержит кнопку открытия соответствующего раздела админки.
|
||||
|
||||
Env:
|
||||
|
||||
@@ -113,6 +120,7 @@ Data Explorer работает только по whitelist источников
|
||||
- `notifications`
|
||||
- `admin_notifications`
|
||||
- `audit_logs`
|
||||
- `ocr_results`
|
||||
- `imports_exports`
|
||||
|
||||
Поддержаны фильтры по дате, статусу, пользователю, Telegram ID, авто, СТО, городу, роли, категории, сумме, ошибкам и текстовому поиску. Каждый запрос ограничен `limit` до 500 строк и пишет audit log.
|
||||
|
||||
52
alembic/versions/202605190001_ocr_results.py
Normal file
52
alembic/versions/202605190001_ocr_results.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""persist ocr preview results for admin explorer
|
||||
|
||||
Revision ID: 202605190001
|
||||
Revises: 202605170001
|
||||
Create Date: 2026-05-19 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "202605190001"
|
||||
down_revision: str | None = "202605170001"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"ocr_results",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("vehicle_id", sa.Integer(), nullable=True),
|
||||
sa.Column("scope", sa.String(length=80), nullable=False),
|
||||
sa.Column("filename", sa.String(length=255), nullable=True),
|
||||
sa.Column("content_type", sa.String(length=120), nullable=True),
|
||||
sa.Column("status", sa.String(length=24), server_default="preview", nullable=False),
|
||||
sa.Column("provider", sa.String(length=80), nullable=True),
|
||||
sa.Column("confidence", sa.Numeric(5, 4), nullable=True),
|
||||
sa.Column("recognized_text", sa.Text(), nullable=True),
|
||||
sa.Column("candidates_json", sa.JSON(), nullable=True),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["vehicle_id"], ["cars.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_ocr_results_created_at", "ocr_results", ["created_at"])
|
||||
op.create_index("ix_ocr_results_scope", "ocr_results", ["scope"])
|
||||
op.create_index("ix_ocr_results_status", "ocr_results", ["status"])
|
||||
op.create_index("ix_ocr_results_user_id", "ocr_results", ["user_id"])
|
||||
op.create_index("ix_ocr_results_vehicle_id", "ocr_results", ["vehicle_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ocr_results_vehicle_id", table_name="ocr_results")
|
||||
op.drop_index("ix_ocr_results_user_id", table_name="ocr_results")
|
||||
op.drop_index("ix_ocr_results_status", table_name="ocr_results")
|
||||
op.drop_index("ix_ocr_results_scope", table_name="ocr_results")
|
||||
op.drop_index("ix_ocr_results_created_at", table_name="ocr_results")
|
||||
op.drop_table("ocr_results")
|
||||
@@ -20,6 +20,7 @@ from app.models.car import (
|
||||
AuditLog,
|
||||
Car,
|
||||
CarServiceLink,
|
||||
OCRResult,
|
||||
ServiceAppointment,
|
||||
ServiceCenter,
|
||||
ServiceCenterReview,
|
||||
@@ -251,7 +252,15 @@ DATA_SOURCES: dict[str, dict[str, Any]] = {
|
||||
"filters": {"user_id": "actor_user_id", "role": "actor_role"},
|
||||
"columns": ["id", "actor_user_id", "actor_role", "action", "target_type", "target_id", "ip", "created_at"],
|
||||
},
|
||||
"ocr_results": {"model": None, "roles": ADMIN_ROLES, "columns": []},
|
||||
"ocr_results": {
|
||||
"model": OCRResult,
|
||||
"roles": ADMIN_ROLES,
|
||||
"search": ["filename", "recognized_text", "error"],
|
||||
"filters": {"user_id": "user_id", "vehicle_id": "vehicle_id", "status": "status", "category": "scope"},
|
||||
"columns": ["id", "user_id", "vehicle_id", "scope", "filename", "status", "provider", "confidence", "created_at", "error"],
|
||||
"editable": ["status"],
|
||||
"delete": {"type": "hard"},
|
||||
},
|
||||
"imports_exports": {
|
||||
"model": AdminExportJob,
|
||||
"roles": DATA_EXPORT_ROLES,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_telegram_user
|
||||
@@ -7,6 +7,7 @@ from app.db.session import get_session
|
||||
from app.models.car import Car, VehicleAccess
|
||||
from app.models.user import User
|
||||
from app.schemas.car import CarCreate, CarRead, CarUpdate
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.odometer import add_odometer_history, validate_odometer_change
|
||||
from app.services.vehicle_identity import normalize_license_plate, validate_vin
|
||||
|
||||
@@ -42,6 +43,28 @@ async def create_car(
|
||||
source_record_id=None,
|
||||
changed_by=current_user.id,
|
||||
)
|
||||
vehicle_count = int(
|
||||
(await session.execute(select(func.count(Car.id)).where(Car.owner_id == current_user.id))).scalar_one()
|
||||
or 0
|
||||
)
|
||||
if vehicle_count == 1:
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="vehicle_created",
|
||||
title="Пользователь впервые добавил авто",
|
||||
body="\n".join(
|
||||
[
|
||||
f"User ID: {current_user.id}",
|
||||
f"Telegram ID: {current_user.telegram_id}",
|
||||
f"Авто: {car.name}",
|
||||
f"Пробег: {car.current_odometer or '-'}",
|
||||
]
|
||||
),
|
||||
entity_type="vehicle",
|
||||
entity_id=car.id,
|
||||
idempotency_key=f"vehicle_created:{current_user.id}",
|
||||
metadata={"user_id": current_user.id, "vehicle_id": car.id},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(car)
|
||||
return car
|
||||
|
||||
@@ -3,7 +3,7 @@ from io import BytesIO
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_telegram_user
|
||||
@@ -25,6 +25,7 @@ from app.schemas.expense import (
|
||||
ServiceEntryRead,
|
||||
ServiceEntryUpdate,
|
||||
)
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer
|
||||
from app.services.odometer import (
|
||||
apply_odometer_from_record,
|
||||
@@ -53,6 +54,59 @@ async def ensure_entry_owner(
|
||||
return entry
|
||||
|
||||
|
||||
async def maybe_notify_first_record(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user: User,
|
||||
car: Car,
|
||||
record_type: str,
|
||||
record_id: int,
|
||||
) -> None:
|
||||
fuel_count = int(
|
||||
(
|
||||
await session.execute(
|
||||
select(func.count(FuelEntry.id)).join(Car, FuelEntry.car_id == Car.id).where(Car.owner_id == user.id)
|
||||
)
|
||||
).scalar_one()
|
||||
or 0
|
||||
)
|
||||
service_count = int(
|
||||
(
|
||||
await session.execute(
|
||||
select(func.count(ServiceEntry.id)).join(Car, ServiceEntry.car_id == Car.id).where(Car.owner_id == user.id)
|
||||
)
|
||||
).scalar_one()
|
||||
or 0
|
||||
)
|
||||
expense_count = int(
|
||||
(
|
||||
await session.execute(
|
||||
select(func.count(ExpenseEntry.id)).join(Car, ExpenseEntry.car_id == Car.id).where(Car.owner_id == user.id)
|
||||
)
|
||||
).scalar_one()
|
||||
or 0
|
||||
)
|
||||
if fuel_count + service_count + expense_count != 1:
|
||||
return
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="first_record_created",
|
||||
title="Пользователь впервые создал запись",
|
||||
body="\n".join(
|
||||
[
|
||||
f"User ID: {user.id}",
|
||||
f"Telegram ID: {user.telegram_id}",
|
||||
f"Авто: {car.name}",
|
||||
f"Тип записи: {record_type}",
|
||||
]
|
||||
),
|
||||
entity_type="vehicle",
|
||||
entity_id=car.id,
|
||||
idempotency_key=f"first_record_created:{user.id}",
|
||||
metadata={"user_id": user.id, "vehicle_id": car.id, "record_type": record_type, "record_id": record_id},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_fuel_entry(
|
||||
payload: FuelEntryCreate,
|
||||
@@ -78,6 +132,7 @@ async def create_fuel_entry(
|
||||
changed_by=current_user.id,
|
||||
confirm_lower_odometer=payload.confirm_lower_odometer,
|
||||
)
|
||||
await maybe_notify_first_record(session, user=current_user, car=car, record_type="fuel", record_id=entry.id)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
@@ -174,6 +229,7 @@ async def create_service_entry(
|
||||
changed_by=current_user.id,
|
||||
confirm_lower_odometer=payload.confirm_lower_odometer,
|
||||
)
|
||||
await maybe_notify_first_record(session, user=current_user, car=car, record_type="service", record_id=entry.id)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
@@ -266,6 +322,7 @@ async def create_expense_entry(
|
||||
changed_by=current_user.id,
|
||||
confirm_lower_odometer=payload.confirm_lower_odometer,
|
||||
)
|
||||
await maybe_notify_first_record(session, user=current_user, car=car, record_type="expense", record_id=entry.id)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
128
app/api/ocr.py
128
app/api/ocr.py
@@ -9,9 +9,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_telegram_user
|
||||
from app.db.session import get_session
|
||||
from app.models.car import OCRResult
|
||||
from app.models.user import User
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.ocr_provider import get_ocr_provider
|
||||
from app.services.ocr_provider import OcrResult, get_ocr_provider
|
||||
from app.services.rate_limit import check_rate_limit
|
||||
from app.services.uploads import SAFE_IMAGE_TYPES, SAFE_TEXT_TYPES, validate_upload
|
||||
|
||||
@@ -42,6 +43,51 @@ class OCRResultRead(BaseModel):
|
||||
provider: str = "heuristic"
|
||||
|
||||
|
||||
def ocr_candidates_json(result: OcrResult | None) -> list[dict] | None:
|
||||
if result is None:
|
||||
return None
|
||||
return [
|
||||
{"type": candidate.type, "value": candidate.value, "confidence": candidate.confidence}
|
||||
for candidate in result.candidates
|
||||
]
|
||||
|
||||
|
||||
def ocr_confidence(result: OcrResult | None) -> Decimal | None:
|
||||
if result is None or not result.candidates:
|
||||
return None
|
||||
return Decimal(str(round(max(candidate.confidence for candidate in result.candidates), 4)))
|
||||
|
||||
|
||||
async def save_ocr_result(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
scope: str,
|
||||
filename: str | None,
|
||||
content_type: str | None,
|
||||
status: str,
|
||||
result: OcrResult | None = None,
|
||||
recognized_text: str | None = None,
|
||||
provider: str | None = None,
|
||||
error: str | None = None,
|
||||
) -> OCRResult:
|
||||
record = OCRResult(
|
||||
user_id=current_user.id,
|
||||
scope=scope,
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
status=status,
|
||||
provider=result.provider if result is not None else provider,
|
||||
confidence=ocr_confidence(result),
|
||||
recognized_text=result.recognized_text if result is not None else recognized_text,
|
||||
candidates_json=ocr_candidates_json(result),
|
||||
error=error,
|
||||
)
|
||||
session.add(record)
|
||||
await session.flush()
|
||||
return record
|
||||
|
||||
|
||||
async def validate_ocr_upload(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
@@ -59,6 +105,15 @@ async def validate_ocr_upload(
|
||||
allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="upload_validation",
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
status="blocked",
|
||||
error=str(exc.detail),
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="upload_blocked",
|
||||
@@ -93,6 +148,15 @@ async def recognize_with_alert(
|
||||
try:
|
||||
return await get_ocr_provider().recognize(content, filename)
|
||||
except Exception as exc: # noqa: BLE001 - OCR must fail gracefully and alert admins
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope=scope,
|
||||
filename=filename,
|
||||
content_type=None,
|
||||
status="failed",
|
||||
error=type(exc).__name__,
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="ocr_failed",
|
||||
@@ -134,10 +198,31 @@ async def parse_text_receipt(
|
||||
scope="parse_text_receipt",
|
||||
)
|
||||
if not result or not result.recognized_text:
|
||||
if result is not None:
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="parse_text_receipt",
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
status="preview",
|
||||
result=result,
|
||||
)
|
||||
await session.commit()
|
||||
return ReceiptSuggestion(
|
||||
confidence=0,
|
||||
message="Не удалось уверенно распознать чек. Открылся ручной ввод: проверьте дату, сумму, литры и цену.",
|
||||
)
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="parse_text_receipt",
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
status="preview",
|
||||
result=result,
|
||||
)
|
||||
await session.commit()
|
||||
return parse_receipt_text(result.recognized_text)
|
||||
text = " ".join(
|
||||
[
|
||||
@@ -145,6 +230,17 @@ async def parse_text_receipt(
|
||||
content.decode("utf-8", errors="ignore"),
|
||||
]
|
||||
)
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="parse_text_receipt",
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
status="preview",
|
||||
recognized_text=text,
|
||||
provider="text",
|
||||
)
|
||||
await session.commit()
|
||||
return parse_receipt_text(text)
|
||||
|
||||
|
||||
@@ -223,6 +319,16 @@ async def recognize_license_plate(
|
||||
)
|
||||
if result is None:
|
||||
return OCRResultRead(recognized_text="", candidates=[], provider="error")
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="license_plate",
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
status="preview",
|
||||
result=result,
|
||||
)
|
||||
await session.commit()
|
||||
return OCRResultRead(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
|
||||
@@ -255,6 +361,16 @@ async def recognize_vin(
|
||||
)
|
||||
if result is None:
|
||||
return OCRResultRead(recognized_text="", candidates=[], provider="error")
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="vin",
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
status="preview",
|
||||
result=result,
|
||||
)
|
||||
await session.commit()
|
||||
return OCRResultRead(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
|
||||
@@ -287,6 +403,16 @@ async def recognize_service_document(
|
||||
)
|
||||
if result is None:
|
||||
return OCRResultRead(recognized_text="", candidates=[], provider="error")
|
||||
await save_ocr_result(
|
||||
session,
|
||||
current_user=current_user,
|
||||
scope="service_document",
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
status="preview",
|
||||
result=result,
|
||||
)
|
||||
await session.commit()
|
||||
return OCRResultRead(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],
|
||||
|
||||
@@ -209,6 +209,22 @@ async def update_service_center_application(
|
||||
)
|
||||
)
|
||||
await log_audit(session, actor=current_user, action="service_center.update", target_type="service_center", target_id=center.id)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="sto_application_updated",
|
||||
title="СТО обновило заявку",
|
||||
body="\n".join(
|
||||
[
|
||||
f"Название: {center.display_name or center.name}",
|
||||
f"Город: {center.city or '-'}",
|
||||
f"Статус: {center.verification_status}",
|
||||
]
|
||||
),
|
||||
entity_type="service_center",
|
||||
entity_id=center.id,
|
||||
idempotency_key=f"sto_application_updated:{center.id}:{int(datetime.now(UTC).timestamp() // 60)}",
|
||||
metadata={"city": center.city, "owner_user_id": current_user.id},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(center)
|
||||
return center
|
||||
@@ -847,6 +863,18 @@ async def create_service_center_review(
|
||||
await log_audit(session, actor=current_user, action="service_review.upsert", target_type="service_center", target_id=service_center_id)
|
||||
await session.flush()
|
||||
await refresh_service_rating(session, service_center_id)
|
||||
if review.rating <= 2:
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="sto_low_review",
|
||||
title="Низкая оценка СТО",
|
||||
body=f"СТО ID: {service_center_id}\nОценка: {review.rating}\nОтзыв: {review.text or '-'}",
|
||||
entity_type="service_center",
|
||||
entity_id=service_center_id,
|
||||
severity="warning",
|
||||
idempotency_key=f"sto_low_review:{review.id}:{review.rating}",
|
||||
metadata={"review_id": review.id, "rating": review.rating, "user_id": current_user.id},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(review)
|
||||
return review
|
||||
|
||||
@@ -36,6 +36,7 @@ from app.schemas.sto_booking import (
|
||||
ServiceCenterHolidayRead,
|
||||
STODashboardRead,
|
||||
)
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.rate_limit import check_rate_limit
|
||||
from app.services.sto_booking import (
|
||||
calculate_available_slots,
|
||||
@@ -238,6 +239,28 @@ async def create_appointment(
|
||||
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
|
||||
appointment_id=appointment.id,
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="appointment_created",
|
||||
title="Новая запись в СТО",
|
||||
body="\n".join(
|
||||
[
|
||||
f"СТО ID: {appointment.service_center_id}",
|
||||
f"User ID: {current_user.id}",
|
||||
f"Авто ID: {appointment.vehicle_id}",
|
||||
f"Услуга: {appointment.service_name}",
|
||||
f"Время: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
|
||||
]
|
||||
),
|
||||
entity_type="appointment",
|
||||
entity_id=appointment.id,
|
||||
idempotency_key=f"appointment_created:{appointment.id}",
|
||||
metadata={
|
||||
"service_center_id": appointment.service_center_id,
|
||||
"vehicle_id": appointment.vehicle_id,
|
||||
"owner_id": appointment.owner_id,
|
||||
},
|
||||
)
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
@@ -554,6 +577,17 @@ async def reject_appointment(
|
||||
title="СТО отклонило запись",
|
||||
body=payload.comment,
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="appointment_cancelled",
|
||||
title="СТО отклонило запись",
|
||||
body=payload.comment,
|
||||
entity_type="appointment",
|
||||
entity_id=appointment.id,
|
||||
severity="warning",
|
||||
idempotency_key=f"appointment_rejected_by_sto:{appointment.id}",
|
||||
metadata={"service_center_id": appointment.service_center_id, "owner_id": appointment.owner_id},
|
||||
)
|
||||
await log_audit(session, actor=current_user, action="appointment.reject", target_type="service_appointment", target_id=appointment_id)
|
||||
await session.commit()
|
||||
await session.refresh(appointment)
|
||||
@@ -579,6 +613,17 @@ async def delete_appointment_by_sto(
|
||||
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
|
||||
idempotency_key=f"appointment:{appointment.id}:deleted_by_sto",
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="appointment_cancelled",
|
||||
title="СТО удалило запись",
|
||||
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
|
||||
entity_type="appointment",
|
||||
entity_id=appointment.id,
|
||||
severity="warning",
|
||||
idempotency_key=f"appointment_deleted_by_sto:{appointment.id}",
|
||||
metadata={"service_center_id": appointment.service_center_id, "owner_id": appointment.owner_id},
|
||||
)
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
@@ -677,6 +722,21 @@ async def create_work_order_from_appointment(
|
||||
body=visit.work_order_number,
|
||||
idempotency_key=f"work_order:{visit.id}:created",
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="work_order_created",
|
||||
title="Создан заказ-наряд",
|
||||
body=f"{visit.work_order_number or visit.id}: СТО {visit.service_center_id}, авто {visit.vehicle_id}",
|
||||
entity_type="work_order",
|
||||
entity_id=visit.id,
|
||||
idempotency_key=f"work_order_created:{visit.id}",
|
||||
metadata={
|
||||
"appointment_id": appointment.id,
|
||||
"service_center_id": visit.service_center_id,
|
||||
"vehicle_id": visit.vehicle_id,
|
||||
"owner_id": visit.owner_id,
|
||||
},
|
||||
)
|
||||
await log_audit(session, actor=current_user, action="appointment.create_work_order", target_type="service_appointment", target_id=appointment_id, metadata={"service_visit_id": visit.id})
|
||||
await session.commit()
|
||||
await session.refresh(visit)
|
||||
|
||||
@@ -39,6 +39,7 @@ from app.schemas.service_center import (
|
||||
WorkOrderStatusHistoryRead,
|
||||
WorkOrderUpdate,
|
||||
)
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.sto_booking import create_service_notification
|
||||
from app.services.work_orders import (
|
||||
add_labor_item,
|
||||
@@ -461,6 +462,17 @@ async def reject_work_order(
|
||||
visit.owner_comment = payload.comment
|
||||
visit.owner_resolved_at = datetime.now(UTC)
|
||||
await add_status_history(session, visit, to_status="rejected_by_owner", actor=current_user, comment=payload.comment)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="work_order_rejected_by_owner",
|
||||
title="Владелец отклонил смету",
|
||||
body=payload.comment,
|
||||
entity_type="work_order",
|
||||
entity_id=visit.id,
|
||||
severity="warning",
|
||||
idempotency_key=f"work_order_rejected_by_owner:{visit.id}",
|
||||
metadata={"service_center_id": visit.service_center_id, "vehicle_id": visit.vehicle_id, "owner_id": current_user.id},
|
||||
)
|
||||
await log_audit(session, actor=current_user, action="work_order.reject", target_type="service_visit", target_id=visit.id)
|
||||
await session.commit()
|
||||
await session.refresh(visit)
|
||||
@@ -502,6 +514,21 @@ async def complete_work_order(
|
||||
actor=current_user,
|
||||
confirm_lower_odometer=payload.confirm_lower_odometer,
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="work_order_completed",
|
||||
title="Заказ-наряд завершён",
|
||||
body=f"{visit.work_order_number or visit.id}: {visit.final_total} {visit.currency}",
|
||||
entity_type="work_order",
|
||||
entity_id=visit.id,
|
||||
idempotency_key=f"work_order_completed:{visit.id}",
|
||||
metadata={
|
||||
"service_center_id": visit.service_center_id,
|
||||
"vehicle_id": visit.vehicle_id,
|
||||
"owner_id": visit.owner_id,
|
||||
"final_total": str(visit.final_total),
|
||||
},
|
||||
)
|
||||
await log_audit(session, actor=current_user, action="work_order.complete", target_type="service_visit", target_id=visit.id)
|
||||
await session.commit()
|
||||
await session.refresh(visit)
|
||||
@@ -599,6 +626,21 @@ async def create_work_order_correction(
|
||||
web_app_url=work_order_webapp_url(visit.id),
|
||||
button_text="Открыть заказ-наряд",
|
||||
)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="work_order_correction_requested",
|
||||
title="Запрошена коррекция заказ-наряда",
|
||||
body=payload.reason,
|
||||
entity_type="work_order",
|
||||
entity_id=visit.id,
|
||||
severity="warning",
|
||||
idempotency_key=f"work_order_correction_requested:{visit.id}:{visit.version or 1}:{payload.reason[:80]}",
|
||||
metadata={
|
||||
"service_center_id": visit.service_center_id,
|
||||
"vehicle_id": visit.vehicle_id,
|
||||
"owner_approval_required": payload.owner_approval_required,
|
||||
},
|
||||
)
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
@@ -626,6 +668,16 @@ async def approve_work_order_correction(
|
||||
raise HTTPException(status_code=409, detail="Correction is already resolved")
|
||||
correction.status = "approved"
|
||||
correction.resolved_at = datetime.now(UTC)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="work_order_correction_resolved",
|
||||
title="Коррекция заказ-наряда согласована",
|
||||
body=payload.comment,
|
||||
entity_type="work_order",
|
||||
entity_id=visit.id,
|
||||
idempotency_key=f"work_order_correction_approved:{correction.id}",
|
||||
metadata={"correction_id": correction.id, "status": "approved"},
|
||||
)
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
@@ -653,6 +705,17 @@ async def reject_work_order_correction(
|
||||
raise HTTPException(status_code=409, detail="Correction is already resolved")
|
||||
correction.status = "rejected"
|
||||
correction.resolved_at = datetime.now(UTC)
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="work_order_correction_resolved",
|
||||
title="Коррекция заказ-наряда отклонена",
|
||||
body=payload.comment,
|
||||
entity_type="work_order",
|
||||
entity_id=visit.id,
|
||||
severity="warning",
|
||||
idempotency_key=f"work_order_correction_rejected:{correction.id}",
|
||||
metadata={"correction_id": correction.id, "status": "rejected"},
|
||||
)
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
|
||||
@@ -455,6 +455,24 @@ class AdminNotification(Base):
|
||||
)
|
||||
|
||||
|
||||
class OCRResult(Base):
|
||||
__tablename__ = "ocr_results"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||
vehicle_id: Mapped[int | None] = mapped_column(ForeignKey("cars.id", ondelete="SET NULL"), index=True)
|
||||
scope: Mapped[str] = mapped_column(String(80), index=True)
|
||||
filename: Mapped[str | None] = mapped_column(String(255))
|
||||
content_type: Mapped[str | None] = mapped_column(String(120))
|
||||
status: Mapped[str] = mapped_column(String(24), default="preview", server_default="preview", index=True)
|
||||
provider: Mapped[str | None] = mapped_column(String(80))
|
||||
confidence: Mapped[Decimal | None] = mapped_column(Numeric(5, 4))
|
||||
recognized_text: Mapped[str | None] = mapped_column(Text)
|
||||
candidates_json: Mapped[list | None] = mapped_column(JSON)
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
|
||||
class ServiceWorkItem(Base):
|
||||
__tablename__ = "service_work_items"
|
||||
|
||||
|
||||
@@ -20,6 +20,14 @@ ADMIN_EVENT_FLAGS = {
|
||||
"sto_application_updated": "admin_notify_sto_applications",
|
||||
"sto_approved": "admin_notify_sto_applications",
|
||||
"sto_suspended": "admin_notify_sto_applications",
|
||||
"sto_low_review": "admin_notify_sto_applications",
|
||||
"appointment_created": "admin_notify_sto_applications",
|
||||
"appointment_cancelled": "admin_notify_sto_applications",
|
||||
"work_order_created": "admin_notify_sto_applications",
|
||||
"work_order_completed": "admin_notify_sto_applications",
|
||||
"work_order_rejected_by_owner": "admin_notify_sto_applications",
|
||||
"work_order_correction_requested": "admin_notify_sto_applications",
|
||||
"work_order_correction_resolved": "admin_notify_sto_applications",
|
||||
"security_event": "admin_notify_security_events",
|
||||
"rate_limit_exceeded": "admin_notify_security_events",
|
||||
"upload_blocked": "admin_notify_security_events",
|
||||
@@ -47,6 +55,14 @@ def admin_notification_url(entity_type: str | None = None, entity_id: str | int
|
||||
return f"{base}/admin.html?section=sto-applications&entity_id={entity_id}"
|
||||
if entity_type == "user" and entity_id:
|
||||
return f"{base}/admin.html?section=users&entity_id={entity_id}"
|
||||
if entity_type == "vehicle" and entity_id:
|
||||
return f"{base}/admin.html?section=vehicles&entity_id={entity_id}"
|
||||
if entity_type == "appointment" and entity_id:
|
||||
return f"{base}/admin.html?section=appointments&entity_id={entity_id}"
|
||||
if entity_type == "work_order" and entity_id:
|
||||
return f"{base}/admin.html?section=work-orders&entity_id={entity_id}"
|
||||
if entity_type == "ocr_result" and entity_id:
|
||||
return f"{base}/admin.html?section=data&source=ocr_results&entity_id={entity_id}"
|
||||
return f"{base}/admin.html"
|
||||
|
||||
|
||||
@@ -119,6 +135,9 @@ async def send_admin_telegram_notification(notification: AdminNotification) -> N
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True,
|
||||
"reply_markup": {
|
||||
"inline_keyboard": [[{"text": "Открыть в админке", "url": link}]]
|
||||
},
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -123,6 +123,37 @@ async def test_new_sto_application_creates_admin_notification(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_vehicle_and_first_record_create_admin_notifications(
|
||||
client, auth_headers, admin_auth_headers, internal_headers
|
||||
) -> None:
|
||||
await ensure_admin(client, internal_headers)
|
||||
vehicle = (
|
||||
await client.post(
|
||||
"/api/cars",
|
||||
headers=auth_headers,
|
||||
json={"name": "First admin-visible car", "current_odometer": 1500},
|
||||
)
|
||||
).json()
|
||||
fuel = await client.post(
|
||||
"/api/fuel",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"car_id": vehicle["id"],
|
||||
"entry_date": "2026-05-19",
|
||||
"odometer": 1510,
|
||||
"liters": 30,
|
||||
"price_per_liter": 2,
|
||||
},
|
||||
)
|
||||
notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||
events = {item["event_type"] for item in notifications.json()["rows"]}
|
||||
|
||||
assert fuel.status_code == 201
|
||||
assert "vehicle_created" in events
|
||||
assert "first_record_created" in events
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_dashboard_requires_admin_role(client, auth_headers, admin_auth_headers, internal_headers) -> None:
|
||||
forbidden = await client.get("/api/admin/dashboard", headers=auth_headers)
|
||||
@@ -389,6 +420,29 @@ async def test_blocked_ocr_upload_creates_admin_notification(
|
||||
assert any(item["event_type"] == "upload_blocked" for item in notifications.json()["rows"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_preview_is_available_in_admin_data_explorer(
|
||||
client, auth_headers, admin_auth_headers, internal_headers
|
||||
) -> None:
|
||||
await ensure_admin(client, internal_headers)
|
||||
|
||||
response = await client.post(
|
||||
"/api/ocr/vin",
|
||||
headers=auth_headers,
|
||||
files={"file": ("vin.txt", b"VIN KMHCT41BAHU123456", "text/plain")},
|
||||
)
|
||||
query = await client.post(
|
||||
"/api/admin/data/query",
|
||||
headers=admin_auth_headers,
|
||||
json={"source": "ocr_results", "category": "vin", "limit": 25},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert query.status_code == 200
|
||||
assert query.json()["rows"][0]["scope"] == "vin"
|
||||
assert query.json()["rows"][0]["status"] == "preview"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_creates_admin_notification(
|
||||
client, auth_headers, admin_auth_headers, internal_headers
|
||||
|
||||
@@ -243,6 +243,9 @@ async def test_work_order_completion_creates_vehicle_records_and_updates_costs(
|
||||
assert refreshed.json()["engine_oil_type"] == "5W-30"
|
||||
assert refreshed.json()["engine_oil_volume_l"] == "4.00"
|
||||
assert stats.json()["total_cost"] == "130.00"
|
||||
admin_notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||
admin_events = {item["event_type"] for item in admin_notifications.json()["rows"]}
|
||||
assert {"work_order_completed", "work_order_correction_requested", "work_order_correction_resolved"} <= admin_events
|
||||
|
||||
cannot_edit = await client.patch(
|
||||
f"/api/work-orders/{work_order['id']}",
|
||||
|
||||
@@ -204,6 +204,9 @@ async def test_customer_booking_lifecycle_capacity_calendar_work_order_and_notif
|
||||
)
|
||||
assert work_order.status_code == 201
|
||||
assert work_order.json()["vehicle_id"] == vehicle["id"]
|
||||
admin_notifications = await client.get("/api/admin/notifications?limit=100", headers=admin_auth_headers)
|
||||
admin_events = {item["event_type"] for item in admin_notifications.json()["rows"]}
|
||||
assert {"appointment_created", "work_order_created"} <= admin_events
|
||||
|
||||
my_appointments = await client.get("/api/appointments/my", headers=other_auth_headers)
|
||||
assert my_appointments.json()[0]["linked_work_order_id"] == work_order.json()["id"]
|
||||
|
||||
@@ -53,15 +53,18 @@
|
||||
</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>
|
||||
<details class="advanced-fields compact-filter">
|
||||
<summary>Фильтры</summary>
|
||||
<label>
|
||||
Город
|
||||
<input name="city" placeholder="Seoul" />
|
||||
</label>
|
||||
<label>
|
||||
Специализация
|
||||
<input name="specialization" placeholder="BMW, масло, тормоза" />
|
||||
</label>
|
||||
<button type="submit">Найти</button>
|
||||
</details>
|
||||
</form>
|
||||
<div id="serviceList" class="stack-list"></div>
|
||||
</aside>
|
||||
@@ -89,6 +92,12 @@
|
||||
<option value="other">Другое</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="preset-row quick-service-pills wide" aria-label="Быстрый выбор услуги">
|
||||
<button type="button" data-booking-service="oil_change">Масло</button>
|
||||
<button type="button" data-booking-service="diagnostics">Диагностика</button>
|
||||
<button type="button" data-booking-service="tire_service">Шины</button>
|
||||
<button type="button" data-booking-service="brakes">Тормоза</button>
|
||||
</div>
|
||||
<label>
|
||||
Длительность
|
||||
<select name="estimated_duration_minutes" id="durationSelect">
|
||||
|
||||
134
web/index.html
134
web/index.html
@@ -220,14 +220,19 @@
|
||||
Исполнитель
|
||||
<input name="vendor" placeholder="СТО / магазин" />
|
||||
</label>
|
||||
<label>
|
||||
Следующая дата
|
||||
<input name="next_due_date" type="date" />
|
||||
</label>
|
||||
<label>
|
||||
Следующий пробег
|
||||
<input name="next_due_odometer" type="number" min="0" />
|
||||
</label>
|
||||
<details class="advanced-fields wide">
|
||||
<summary>Напоминание о следующем ТО</summary>
|
||||
<div class="grid-form drawer-form compact-inner-form">
|
||||
<label>
|
||||
Следующая дата
|
||||
<input name="next_due_date" type="date" />
|
||||
</label>
|
||||
<label>
|
||||
Следующий пробег
|
||||
<input name="next_due_odometer" type="number" min="0" />
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
<button type="submit">Сохранить запись</button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -292,6 +297,28 @@
|
||||
</div>
|
||||
|
||||
<div class="drawer-content">
|
||||
<section class="drawer-section hidden" id="quickAddSection">
|
||||
<h2>Добавить запись</h2>
|
||||
<div class="quick-entry-grid">
|
||||
<button type="button" data-quick-entry="fuelSection">
|
||||
<span>Заправка</span>
|
||||
<small>дата, пробег, литры, цена</small>
|
||||
</button>
|
||||
<button type="button" data-quick-entry="serviceSection">
|
||||
<span>ТО и ремонт</span>
|
||||
<small>работа, стоимость, следующий срок</small>
|
||||
</button>
|
||||
<button type="button" data-quick-entry="expensesSection">
|
||||
<span>Расход</span>
|
||||
<small>страховка, штраф, парковка, прочее</small>
|
||||
</button>
|
||||
<button type="button" data-quick-entry="scan">
|
||||
<span>Скан чека</span>
|
||||
<small>фото или файл</small>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section hidden" id="carsSection">
|
||||
<h2>Автомобили</h2>
|
||||
<div id="drawerCars" class="cars drawer-cars"></div>
|
||||
@@ -350,49 +377,54 @@
|
||||
Поставщик / место
|
||||
<input name="vendor" />
|
||||
</label>
|
||||
<label>
|
||||
Одометр
|
||||
<input name="odometer" type="number" min="0" />
|
||||
</label>
|
||||
<label>
|
||||
Начало периода
|
||||
<input name="period_start" type="date" />
|
||||
</label>
|
||||
<label>
|
||||
Конец периода
|
||||
<input name="period_end" type="date" />
|
||||
</label>
|
||||
<label>
|
||||
Месяцев покрытия
|
||||
<select name="period_months">
|
||||
<option value="">По датам</option>
|
||||
<option value="1">1 месяц</option>
|
||||
<option value="3">3 месяца</option>
|
||||
<option value="6">6 месяцев</option>
|
||||
<option value="12">12 месяцев</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Номер полиса / документа
|
||||
<input name="policy_number" />
|
||||
</label>
|
||||
<label>
|
||||
Тип страховки
|
||||
<select name="insurance_type">
|
||||
<option value="">Не задано</option>
|
||||
<option value="mandatory">ОСАГО / обязательная</option>
|
||||
<option value="full">КАСКО / полная</option>
|
||||
<option value="other">Другое</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Комментарий
|
||||
<input name="notes" />
|
||||
</label>
|
||||
<label class="check">
|
||||
<input name="is_recurring" type="checkbox" />
|
||||
Регулярный расход
|
||||
</label>
|
||||
<details class="advanced-fields wide">
|
||||
<summary>Дополнительно</summary>
|
||||
<div class="grid-form drawer-form compact-inner-form">
|
||||
<label>
|
||||
Одометр
|
||||
<input name="odometer" type="number" min="0" />
|
||||
</label>
|
||||
<label>
|
||||
Начало периода
|
||||
<input name="period_start" type="date" />
|
||||
</label>
|
||||
<label>
|
||||
Конец периода
|
||||
<input name="period_end" type="date" />
|
||||
</label>
|
||||
<label>
|
||||
Месяцев покрытия
|
||||
<select name="period_months">
|
||||
<option value="">По датам</option>
|
||||
<option value="1">1 месяц</option>
|
||||
<option value="3">3 месяца</option>
|
||||
<option value="6">6 месяцев</option>
|
||||
<option value="12">12 месяцев</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Номер полиса / документа
|
||||
<input name="policy_number" />
|
||||
</label>
|
||||
<label>
|
||||
Тип страховки
|
||||
<select name="insurance_type">
|
||||
<option value="">Не задано</option>
|
||||
<option value="mandatory">ОСАГО / обязательная</option>
|
||||
<option value="full">КАСКО / полная</option>
|
||||
<option value="other">Другое</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Комментарий
|
||||
<input name="notes" />
|
||||
</label>
|
||||
<label class="check">
|
||||
<input name="is_recurring" type="checkbox" />
|
||||
Регулярный расход
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
<button type="submit">Сохранить расход</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -4,6 +4,8 @@ tg?.expand();
|
||||
|
||||
const textNodes = new WeakMap();
|
||||
const attrOriginals = new WeakMap();
|
||||
let translationObserver = null;
|
||||
let translationTimer = null;
|
||||
|
||||
const i18n = {
|
||||
en: {
|
||||
@@ -83,6 +85,14 @@ const i18n = {
|
||||
"Марка": "Make",
|
||||
"Модель": "Model",
|
||||
"Добавить авто": "Add vehicle",
|
||||
"Добавить запись": "Add entry",
|
||||
"Расход": "Expense",
|
||||
"дата, пробег, литры, цена": "date, odometer, liters, price",
|
||||
"работа, стоимость, следующий срок": "work, cost, next due",
|
||||
"страховка, штраф, парковка, прочее": "insurance, fine, parking, other",
|
||||
"фото или файл": "photo or file",
|
||||
"Дополнительно": "More options",
|
||||
"Напоминание о следующем ТО": "Next maintenance reminder",
|
||||
"За весь срок": "All time",
|
||||
"За месяц": "This month",
|
||||
"За день": "Per day",
|
||||
@@ -215,6 +225,14 @@ const i18n = {
|
||||
"Марка": "브랜드",
|
||||
"Модель": "모델",
|
||||
"Добавить авто": "차량 추가",
|
||||
"Добавить запись": "기록 추가",
|
||||
"Расход": "지출",
|
||||
"дата, пробег, литры, цена": "날짜, 주행거리, 리터, 가격",
|
||||
"работа, стоимость, следующий срок": "작업, 비용, 다음 예정",
|
||||
"страховка, штраф, парковка, прочее": "보험, 벌금, 주차, 기타",
|
||||
"фото или файл": "사진 또는 파일",
|
||||
"Дополнительно": "추가 옵션",
|
||||
"Напоминание о следующем ТО": "다음 정비 알림",
|
||||
"За весь срок": "전체",
|
||||
"За месяц": "월",
|
||||
"За день": "일 평균",
|
||||
@@ -304,6 +322,15 @@ function applyTranslations(root = document.body) {
|
||||
});
|
||||
}
|
||||
|
||||
function observeTranslations(root = document.body) {
|
||||
if (translationObserver || !root) return;
|
||||
translationObserver = new MutationObserver(() => {
|
||||
window.clearTimeout(translationTimer);
|
||||
translationTimer = window.setTimeout(() => applyTranslations(root), 40);
|
||||
});
|
||||
translationObserver.observe(root, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
|
||||
const state = {
|
||||
user: null,
|
||||
@@ -582,6 +609,7 @@ async function ensureUser() {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ init_data: tg.initData }),
|
||||
});
|
||||
localStorage.setItem("carpassLocale", state.user.locale || "ru");
|
||||
hideAuthOverlay();
|
||||
updateRoleVisibility();
|
||||
return;
|
||||
@@ -590,6 +618,7 @@ async function ensureUser() {
|
||||
const devId = localStorage.getItem("driversDevTelegramId") || "1";
|
||||
localStorage.setItem("driversDevTelegramId", devId);
|
||||
state.user = await api("/users/me");
|
||||
localStorage.setItem("carpassLocale", state.user.locale || "ru");
|
||||
hideAuthOverlay();
|
||||
updateRoleVisibility();
|
||||
return;
|
||||
@@ -598,6 +627,36 @@ async function ensureUser() {
|
||||
throw new Error("Требуется вход через Telegram");
|
||||
}
|
||||
|
||||
function installLocaleSwitch() {
|
||||
const topActions = document.querySelector(".topbar .top-actions");
|
||||
if (!topActions || document.querySelector("#globalLocaleSelect")) return;
|
||||
const select = document.createElement("select");
|
||||
select.id = "globalLocaleSelect";
|
||||
select.className = "locale-switch";
|
||||
select.setAttribute("aria-label", "Язык");
|
||||
select.innerHTML = `
|
||||
<option value="ru">RU</option>
|
||||
<option value="en">EN</option>
|
||||
<option value="ko">KO</option>
|
||||
`;
|
||||
select.value = state.user?.locale || "ru";
|
||||
select.addEventListener("change", async () => {
|
||||
await runAction(select, "Сохраняю...", async () => {
|
||||
state.user = await api(`/users/${state.user.id}/preferences`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ locale: select.value, currency: state.user.currency }),
|
||||
});
|
||||
localStorage.setItem("carpassLocale", state.user.locale || "ru");
|
||||
document.querySelector("#localeSelect").value = state.user.locale || "ru";
|
||||
applyTranslations();
|
||||
renderCars();
|
||||
renderStats(state.latestStats);
|
||||
toast("Сохранено");
|
||||
});
|
||||
});
|
||||
topActions.prepend(select);
|
||||
}
|
||||
|
||||
function hideAuthOverlay() {
|
||||
document.querySelector("#authOverlay")?.classList.add("hidden");
|
||||
document.body.classList.remove("auth-required");
|
||||
@@ -2671,6 +2730,9 @@ document.querySelector("#settingsForm").addEventListener("submit", async (event)
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ locale: data.locale, currency: data.currency }),
|
||||
});
|
||||
localStorage.setItem("carpassLocale", state.user.locale || "ru");
|
||||
const globalLocale = document.querySelector("#globalLocaleSelect");
|
||||
if (globalLocale) globalLocale.value = state.user.locale || "ru";
|
||||
applyTranslations();
|
||||
initCarCatalog();
|
||||
await loadSelectedCar();
|
||||
@@ -2700,6 +2762,7 @@ document.querySelector("#fuelForm").addEventListener("submit", async (event) =>
|
||||
});
|
||||
form.reset();
|
||||
form.entry_date.value = today();
|
||||
form.is_full_tank.checked = true;
|
||||
await loadSelectedCar();
|
||||
toast("Сохранено");
|
||||
haptic("success");
|
||||
@@ -2802,6 +2865,20 @@ function mountEntryForms() {
|
||||
}
|
||||
}
|
||||
|
||||
function fillEntryDefaults(sectionId) {
|
||||
const car = selectedCar();
|
||||
const odometer = car?.current_odometer || "";
|
||||
const sections = sectionId ? [document.querySelector(`#${sectionId}`)] : [...document.querySelectorAll(".drawer-section")];
|
||||
sections.filter(Boolean).forEach((section) => {
|
||||
section.querySelectorAll('input[name="entry_date"]').forEach((input) => {
|
||||
if (!input.value) input.value = today();
|
||||
});
|
||||
section.querySelectorAll('input[name="odometer"]').forEach((input) => {
|
||||
if (!input.value && odometer) input.value = odometer;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openDrawerSection(sectionId, options = {}) {
|
||||
if (!canOpenDrawerSection(sectionId)) {
|
||||
toast("Этот раздел недоступен для вашей роли", "error");
|
||||
@@ -2817,6 +2894,7 @@ async function openDrawerSection(sectionId, options = {}) {
|
||||
button.classList.toggle("active", button.dataset.menuSection === sectionId);
|
||||
});
|
||||
mountEntryForms();
|
||||
fillEntryDefaults(sectionId);
|
||||
if (sectionId === "carProfileSection") fillCarProfileForm();
|
||||
if (sectionId === "settingsSection") {
|
||||
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
|
||||
@@ -2891,7 +2969,18 @@ document.querySelector("#addCarQuickBtn").addEventListener("click", () => {
|
||||
});
|
||||
|
||||
document.querySelector("#addRecordPrimaryBtn").addEventListener("click", () => {
|
||||
openDrawerSection("expensesSection");
|
||||
openDrawerSection("quickAddSection");
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-quick-entry]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
haptic();
|
||||
if (button.dataset.quickEntry === "scan") {
|
||||
openScanModal();
|
||||
return;
|
||||
}
|
||||
await openDrawerSection(button.dataset.quickEntry);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-menu-section]").forEach((button) => {
|
||||
@@ -3043,6 +3132,8 @@ initPwa();
|
||||
Promise.all([loadAuthConfig()])
|
||||
.then(() => Promise.all([ensureUser(), loadCatalog()]))
|
||||
.then(() => {
|
||||
installLocaleSwitch();
|
||||
observeTranslations();
|
||||
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
|
||||
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
|
||||
document.querySelector("#expenseForm").currency.value = state.user?.currency || "RUB";
|
||||
|
||||
@@ -113,6 +113,16 @@ document.querySelector("#filterForm").addEventListener("submit", async (event) =
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-booking-service]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
document.querySelector("#serviceTypeSelect").value = button.dataset.bookingService;
|
||||
document.querySelectorAll("[data-booking-service]").forEach((item) => {
|
||||
item.classList.toggle("active", item === button);
|
||||
});
|
||||
await loadSlots().catch((error) => page.toast(error.message || "Не удалось обновить окна", "error"));
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("#bookingForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const center = selectedCenter();
|
||||
|
||||
@@ -2,6 +2,238 @@ const tg = window.Telegram?.WebApp;
|
||||
tg?.ready();
|
||||
tg?.expand();
|
||||
|
||||
const CarPassI18n = (() => {
|
||||
const textNodes = new WeakMap();
|
||||
const attrOriginals = new WeakMap();
|
||||
let observer = null;
|
||||
let timer = null;
|
||||
const dictionaries = {
|
||||
en: {
|
||||
"Гараж": "Garage",
|
||||
"Автомобили": "Vehicles",
|
||||
"Автомобиль": "Vehicle",
|
||||
"Авто": "Vehicles",
|
||||
"Заправка": "Fuel",
|
||||
"Сервис": "Service",
|
||||
"Расход": "Expense",
|
||||
"Расходы": "Expenses",
|
||||
"ТО и ремонт": "Maintenance and repair",
|
||||
"Дата": "Date",
|
||||
"Одометр, км": "Odometer, km",
|
||||
"Одометр": "Odometer",
|
||||
"Литры": "Liters",
|
||||
"Цена за литр": "Price per liter",
|
||||
"АЗС": "Fuel station",
|
||||
"Полный бак": "Full tank",
|
||||
"Стоимость": "Cost",
|
||||
"Валюта": "Currency",
|
||||
"Категория": "Category",
|
||||
"Название": "Title",
|
||||
"Комментарий": "Comment",
|
||||
"Сохранить": "Save",
|
||||
"Сохранить запись": "Save entry",
|
||||
"Сохранить расход": "Save expense",
|
||||
"Сохранить заправку": "Save fuel entry",
|
||||
"Сохранить настройки": "Save settings",
|
||||
"Создать запись": "Create booking",
|
||||
"Запись в СТО": "Book service",
|
||||
"СТО": "Service centers",
|
||||
"Сервисы": "Services",
|
||||
"Каталог": "Catalog",
|
||||
"Заявка": "Request",
|
||||
"Выберите сервис": "Choose service",
|
||||
"Город": "City",
|
||||
"Специализация": "Specialization",
|
||||
"Найти": "Search",
|
||||
"Что нужно сделать": "What needs to be done",
|
||||
"Услуга": "Service",
|
||||
"Длительность": "Duration",
|
||||
"Свободное окно": "Available slot",
|
||||
"Окно записи": "Booking slot",
|
||||
"Отправить заявку": "Send request",
|
||||
"Проверить карточку авто": "Check vehicle card",
|
||||
"Меню": "Menu",
|
||||
"Проверить вход": "Check login",
|
||||
"Открыть в Telegram": "Open in Telegram",
|
||||
"Обновить": "Refresh",
|
||||
"Настройки": "Settings",
|
||||
"Язык": "Language",
|
||||
"Панель СТО": "Service workplace",
|
||||
"Записи клиентов": "Client bookings",
|
||||
"Заказ-наряды": "Work orders",
|
||||
"Сотрудники": "Staff",
|
||||
"Пригласить": "Invite",
|
||||
"Заказ-наряд": "Work order",
|
||||
"Работы": "Labor",
|
||||
"Запчасти и жидкости": "Parts and fluids",
|
||||
"Проверьте смету": "Review estimate",
|
||||
"Согласовать": "Approve",
|
||||
"Отклонить": "Reject",
|
||||
"Админ-панель": "Admin panel",
|
||||
"Операционный обзор": "Operational overview",
|
||||
"Последние события": "Latest events",
|
||||
"Быстрые переходы": "Quick links",
|
||||
"Заявки СТО": "Service applications",
|
||||
"Записи": "Bookings",
|
||||
"Фильтр": "Filter",
|
||||
"Фильтры": "Filters",
|
||||
"Запросить": "Query",
|
||||
"Показать": "Show",
|
||||
"Импорт и экспорт": "Import and export",
|
||||
"Скачать JSON": "Download JSON",
|
||||
"Проверить файл": "Preview file",
|
||||
"Импортировать": "Import",
|
||||
"Паспорт автомобиля": "Vehicle passport",
|
||||
"Выберите автомобиль": "Choose vehicle",
|
||||
"Параметры авто": "Vehicle settings",
|
||||
"Сохранить паспорт": "Save passport",
|
||||
"Удалить автомобиль": "Delete vehicle",
|
||||
"Готов к работе": "Ready",
|
||||
"Сохраняю...": "Saving...",
|
||||
"Сохранено": "Saved",
|
||||
"Ошибка": "Error",
|
||||
"Нет данных": "No data",
|
||||
"Нет доступа": "No access",
|
||||
},
|
||||
ko: {
|
||||
"Гараж": "차고",
|
||||
"Автомобили": "차량",
|
||||
"Автомобиль": "차량",
|
||||
"Авто": "차량",
|
||||
"Заправка": "주유",
|
||||
"Сервис": "정비",
|
||||
"Расход": "지출",
|
||||
"Расходы": "지출",
|
||||
"ТО и ремонт": "정비 및 수리",
|
||||
"Дата": "날짜",
|
||||
"Одометр, км": "주행거리, km",
|
||||
"Одометр": "주행거리",
|
||||
"Литры": "리터",
|
||||
"Цена за литр": "리터당 가격",
|
||||
"АЗС": "주유소",
|
||||
"Полный бак": "가득 주유",
|
||||
"Стоимость": "비용",
|
||||
"Валюта": "통화",
|
||||
"Категория": "카테고리",
|
||||
"Название": "제목",
|
||||
"Комментарий": "메모",
|
||||
"Сохранить": "저장",
|
||||
"Сохранить запись": "기록 저장",
|
||||
"Сохранить расход": "지출 저장",
|
||||
"Сохранить заправку": "주유 저장",
|
||||
"Сохранить настройки": "설정 저장",
|
||||
"Создать запись": "예약 생성",
|
||||
"Запись в СТО": "정비소 예약",
|
||||
"СТО": "정비소",
|
||||
"Сервисы": "서비스",
|
||||
"Каталог": "목록",
|
||||
"Заявка": "요청",
|
||||
"Выберите сервис": "정비소 선택",
|
||||
"Город": "도시",
|
||||
"Специализация": "전문 분야",
|
||||
"Найти": "검색",
|
||||
"Что нужно сделать": "필요한 작업",
|
||||
"Услуга": "서비스",
|
||||
"Длительность": "소요 시간",
|
||||
"Свободное окно": "예약 가능 시간",
|
||||
"Окно записи": "예약 시간",
|
||||
"Отправить заявку": "요청 보내기",
|
||||
"Проверить карточку авто": "차량 카드 확인",
|
||||
"Меню": "메뉴",
|
||||
"Проверить вход": "로그인 확인",
|
||||
"Открыть в Telegram": "텔레그램에서 열기",
|
||||
"Обновить": "새로고침",
|
||||
"Настройки": "설정",
|
||||
"Язык": "언어",
|
||||
"Панель СТО": "정비소 작업실",
|
||||
"Записи клиентов": "고객 예약",
|
||||
"Заказ-наряды": "작업지시서",
|
||||
"Сотрудники": "직원",
|
||||
"Пригласить": "초대",
|
||||
"Заказ-наряд": "작업지시서",
|
||||
"Работы": "공임",
|
||||
"Запчасти и жидкости": "부품 및 오일",
|
||||
"Проверьте смету": "견적 확인",
|
||||
"Согласовать": "승인",
|
||||
"Отклонить": "거절",
|
||||
"Админ-панель": "관리자 패널",
|
||||
"Операционный обзор": "운영 요약",
|
||||
"Последние события": "최근 이벤트",
|
||||
"Быстрые переходы": "빠른 이동",
|
||||
"Заявки СТО": "정비소 신청",
|
||||
"Записи": "예약",
|
||||
"Фильтр": "필터",
|
||||
"Фильтры": "필터",
|
||||
"Запросить": "조회",
|
||||
"Показать": "보기",
|
||||
"Импорт и экспорт": "가져오기/내보내기",
|
||||
"Скачать JSON": "JSON 다운로드",
|
||||
"Проверить файл": "파일 확인",
|
||||
"Импортировать": "가져오기",
|
||||
"Паспорт автомобиля": "차량 패스포트",
|
||||
"Выберите автомобиль": "차량 선택",
|
||||
"Параметры авто": "차량 설정",
|
||||
"Сохранить паспорт": "패스포트 저장",
|
||||
"Удалить автомобиль": "차량 삭제",
|
||||
"Готов к работе": "준비 완료",
|
||||
"Сохраняю...": "저장 중...",
|
||||
"Сохранено": "저장됨",
|
||||
"Ошибка": "오류",
|
||||
"Нет данных": "데이터 없음",
|
||||
"Нет доступа": "접근 불가",
|
||||
},
|
||||
};
|
||||
|
||||
function t(text, locale = currentLocale()) {
|
||||
return dictionaries[locale]?.[text] || text;
|
||||
}
|
||||
|
||||
function currentLocale() {
|
||||
return window.CarPassPage?.state?.user?.locale || localStorage.getItem("carpassLocale") || "ru";
|
||||
}
|
||||
|
||||
function apply(root = document.body, locale = currentLocale()) {
|
||||
document.documentElement.lang = locale;
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
const parent = node.parentElement;
|
||||
if (!parent || ["SCRIPT", "STYLE", "TEXTAREA", "INPUT", "SELECT"].includes(parent.tagName)) return NodeFilter.FILTER_REJECT;
|
||||
return node.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
||||
},
|
||||
});
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
if (!textNodes.has(node)) textNodes.set(node, node.nodeValue.trim());
|
||||
const original = textNodes.get(node);
|
||||
node.nodeValue = node.nodeValue.replace(node.nodeValue.trim(), t(original, locale));
|
||||
}
|
||||
root.querySelectorAll?.("[placeholder], [aria-label], [title]").forEach((element) => {
|
||||
["placeholder", "aria-label", "title"].forEach((attr) => {
|
||||
const value = element.getAttribute(attr);
|
||||
if (!value) return;
|
||||
let originals = attrOriginals.get(element);
|
||||
if (!originals) {
|
||||
originals = {};
|
||||
attrOriginals.set(element, originals);
|
||||
}
|
||||
originals[attr] ||= value;
|
||||
element.setAttribute(attr, t(originals[attr], locale));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function observe(root = document.body) {
|
||||
if (observer || !root) return;
|
||||
observer = new MutationObserver(() => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => apply(root), 40);
|
||||
});
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
return { apply, t, currentLocale, observe };
|
||||
})();
|
||||
|
||||
const CarPassPage = (() => {
|
||||
const state = { user: null, authConfig: null };
|
||||
|
||||
@@ -51,13 +283,14 @@ const CarPassPage = (() => {
|
||||
}
|
||||
document.body.classList.remove("auth-required");
|
||||
document.querySelector("#authOverlay")?.classList.add("hidden");
|
||||
localStorage.setItem("carpassLocale", state.user.locale || "ru");
|
||||
return state.user;
|
||||
}
|
||||
|
||||
function toast(message, tone = "success") {
|
||||
const node = document.querySelector("#toast");
|
||||
if (!node) return;
|
||||
node.textContent = message;
|
||||
node.textContent = t(message);
|
||||
node.className = `toast ${tone}`;
|
||||
window.clearTimeout(toast.timer);
|
||||
toast.timer = window.setTimeout(() => node.classList.add("hidden"), 2600);
|
||||
@@ -69,7 +302,7 @@ const CarPassPage = (() => {
|
||||
button.dataset.label = button.textContent;
|
||||
button.disabled = true;
|
||||
button.classList.add("is-busy");
|
||||
button.innerHTML = `<span class="spinner"></span><span>${label}</span>`;
|
||||
button.innerHTML = `<span class="spinner"></span><span>${t(label)}</span>`;
|
||||
} else {
|
||||
button.disabled = false;
|
||||
button.classList.remove("is-busy");
|
||||
@@ -115,18 +348,73 @@ const CarPassPage = (() => {
|
||||
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" });
|
||||
const locale = { ru: "ru-RU", en: "en-US", ko: "ko-KR" }[state.user?.locale] || "ru-RU";
|
||||
return date.toLocaleString(locale, { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function t(text) {
|
||||
return CarPassI18n.t(text, state.user?.locale || "ru");
|
||||
}
|
||||
|
||||
function applyTranslations(root = document.body) {
|
||||
CarPassI18n.apply(root, state.user?.locale || "ru");
|
||||
}
|
||||
|
||||
async function updateLocale(locale) {
|
||||
if (!state.user || state.user.locale === locale) return;
|
||||
state.user = await api(`/users/${state.user.id}/preferences`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ locale }),
|
||||
});
|
||||
localStorage.setItem("carpassLocale", locale);
|
||||
applyTranslations();
|
||||
}
|
||||
|
||||
function installLocaleSwitch() {
|
||||
const topbar = document.querySelector(".topbar");
|
||||
if (!topbar || document.querySelector("#globalLocaleSelect")) return;
|
||||
let host = topbar.querySelector(".top-actions");
|
||||
if (!host) {
|
||||
host = document.createElement("div");
|
||||
host.className = "top-actions";
|
||||
topbar.appendChild(host);
|
||||
}
|
||||
const select = document.createElement("select");
|
||||
select.id = "globalLocaleSelect";
|
||||
select.className = "locale-switch";
|
||||
select.setAttribute("aria-label", "Язык");
|
||||
select.innerHTML = `
|
||||
<option value="ru">RU</option>
|
||||
<option value="en">EN</option>
|
||||
<option value="ko">KO</option>
|
||||
`;
|
||||
select.value = state.user?.locale || "ru";
|
||||
select.addEventListener("change", async () => {
|
||||
try {
|
||||
await updateLocale(select.value);
|
||||
toast("Сохранено");
|
||||
} catch (error) {
|
||||
toast(error.message || "Ошибка", "error");
|
||||
}
|
||||
});
|
||||
const primarySelect = host.querySelector("select:not(.locale-switch)");
|
||||
if (primarySelect) primarySelect.insertAdjacentElement("afterend", select);
|
||||
else host.prepend(select);
|
||||
}
|
||||
|
||||
async function boot(init) {
|
||||
try {
|
||||
await loadAuthConfig();
|
||||
await ensureUser();
|
||||
installLocaleSwitch();
|
||||
applyTranslations();
|
||||
CarPassI18n.observe();
|
||||
await init();
|
||||
applyTranslations();
|
||||
} catch (error) {
|
||||
if (error.message === "Требуется вход через Telegram") return;
|
||||
console.error(error);
|
||||
@@ -148,5 +436,9 @@ const CarPassPage = (() => {
|
||||
csvList,
|
||||
formatDateTime,
|
||||
today,
|
||||
t,
|
||||
applyTranslations,
|
||||
installLocaleSwitch,
|
||||
updateLocale,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -2579,7 +2579,7 @@ select {
|
||||
|
||||
.sto-page .top-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 44px;
|
||||
grid-template-columns: minmax(0, 1fr) 58px 38px;
|
||||
}
|
||||
|
||||
.staff-form button {
|
||||
@@ -2597,3 +2597,265 @@ select {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Compact UX pass */
|
||||
.locale-switch {
|
||||
width: 66px;
|
||||
min-height: 34px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.shell {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding-block: 9px;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
font-size: clamp(23px, 4vw, 34px);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
button,
|
||||
.ghost-btn {
|
||||
min-height: 38px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 38px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
min-height: 38px;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
label {
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
min-height: 92px;
|
||||
padding: 13px;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
font-size: clamp(19px, 3.2vw, 27px);
|
||||
}
|
||||
|
||||
.primary-add-btn {
|
||||
min-height: 46px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.workspace,
|
||||
.chart-card {
|
||||
padding: 13px;
|
||||
}
|
||||
|
||||
.passport-panel {
|
||||
gap: 8px;
|
||||
padding: 11px;
|
||||
}
|
||||
|
||||
.passport-head h2,
|
||||
h2 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.stats,
|
||||
.hero-grid,
|
||||
.layout,
|
||||
.charts,
|
||||
.flow-layout,
|
||||
.sto-grid {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat,
|
||||
.stat-card {
|
||||
min-height: 76px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.stat strong,
|
||||
.stat-card strong {
|
||||
font-size: clamp(18px, 2.1vw, 23px);
|
||||
}
|
||||
|
||||
.grid-form,
|
||||
.entry-form,
|
||||
.flow-form {
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.entry-form {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.drawer-panel {
|
||||
width: min(560px, 100%);
|
||||
gap: 9px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.drawer-menu {
|
||||
grid-template-columns: repeat(auto-fit, minmax(145px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: min(28vh, 230px);
|
||||
}
|
||||
|
||||
.menu-group {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.menu-row {
|
||||
min-height: 40px;
|
||||
padding-inline: 11px;
|
||||
}
|
||||
|
||||
.quick-entry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.quick-entry-grid button {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-height: 82px;
|
||||
align-content: center;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 8px 20px rgba(27, 38, 34, 0.06);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.quick-entry-grid button span {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.quick-entry-grid button small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.advanced-fields {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fbfdfc;
|
||||
}
|
||||
|
||||
.advanced-fields summary {
|
||||
min-height: 38px;
|
||||
padding: 10px 12px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.advanced-fields summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.advanced-fields summary::after {
|
||||
content: "+";
|
||||
float: right;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.advanced-fields[open] summary::after {
|
||||
content: "-";
|
||||
}
|
||||
|
||||
.compact-inner-form {
|
||||
margin: 0;
|
||||
padding: 0 10px 10px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.compact-filter {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.compact-filter label,
|
||||
.compact-filter button {
|
||||
margin: 0 10px 10px;
|
||||
}
|
||||
|
||||
.quick-service-pills {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.quick-service-pills button.active {
|
||||
border-color: rgba(18, 115, 95, 0.45);
|
||||
background: #e7f4ef;
|
||||
color: #0e604f;
|
||||
}
|
||||
|
||||
.flow-hero {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.flow-hero h2 {
|
||||
font-size: clamp(20px, 2.4vw, 28px);
|
||||
}
|
||||
|
||||
.form-block {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.top-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.locale-switch {
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
.quick-entry-grid,
|
||||
.compact-inner-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.drawer-menu {
|
||||
grid-template-columns: 1fr;
|
||||
max-height: min(32vh, 260px);
|
||||
}
|
||||
|
||||
.summary-card,
|
||||
.stat {
|
||||
min-height: 74px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user