Compare commits

38 Commits
Author SHA1 Message Date
tremble7681andclaude-flow a5bd7308b5 feat(sto): activate Repair section in PWA (PR-4..PR-7)
Активация раздела «Ремонт» в Premium Мехник PWA: лента + создание ремонта,
фоновая сверка прав (У35), различающие сообщения при отказе входа (У36).

Backend для всех новых API в taxi-dashboard (отдельный commit eccaebe).
Тулится на `ENABLE_STO_MODULE=true` в backend env.

PR-4: Login + useMeSync.
- src/api/me.ts: тип Me расширен (is_admin, can_login_pwa, departments,
  support — для У35/У36).
- src/hooks/useMeSync.ts: фоновая сверка `/me` раз в 5 мин (У35). При
  403 NO_PWA_ACCESS — сброс токена, revocation info в sessionStorage.
  IndexedDB-черновики не трогаются.
- src/pages/Login.tsx: после успешного login делает getMe() и при 403
  показывает баннер «У вас нет доступа» с кликабельным TG/телефонным
  контактом (У36). Баннер также рендерится при revocation после
  фоновой сверки.
- src/App.tsx: useMeSync() подключён в корне.

PR-5: лента ремонтов.
- src/pages/ActionMenu.tsx: снят `comingSoon` с тайла «Ремонт».
- src/pages/repairs/RepairsFeedPage.tsx: лента с infinite scroll (через
  IntersectionObserver), плейсхолдер «В парке пока нет ремонтов» (У41),
  ellipsis-имена работ + плюрализация «+N работ» (У40).
- src/hooks/useRepairsFeed.ts: useInfiniteQuery + stale-while-revalidate
  + visibility refresh (У38).
- src/lib/timeFormat.ts: формат «Сегодня / Вчера / 23 мая» (У39).
- src/api/repairsFeed.ts: GET /api/v1/mechanic/repairs клиент.

PR-7: создание ремонта (online-only).
- src/api/repairsCreate.ts: searchCars, getCarContext, searchWorks,
  suggestParts, presignPhoto + uploadPhotoToTmp (PUT с правильным
  Content-Type), createRepair (POST с Idempotency-Key).
- src/pages/repairs/CarSelectPage.tsx: поиск машины с debounce 200мс.
- src/pages/repairs/CreateRepairPage.tsx: карточка создания ремонта с 5
  inline-блоками (Шапка / Пробег / Фото / Работы / Запчасти / Замена
  масла). UUID v4 как Idempotency-Key. Карандашик-разблокировка цены
  с обязательным комментарием (У19). Мягкая подписка пробега масла
  на шапочный (У24). Дефолт next_in_km=10000 (У25). Полноэкранная
  модалка поиска работ.

Routes: /repairs, /repairs/new, /repairs/new/:carId.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-24 00:16:44 +10:00
tremble7681andclaude-flow 9b4aa504ec feat(inspection): show general photo slots on приёмка (return) too
Previously slotsForType excluded all general slots for return-type
inspections and left only Домкрат, on the assumption that the car is
already familiar and приёмка should focus on damages/equipment. Feedback
from mechanics: при приёмке всё равно нужно фиксировать общее состояние
машины, иначе споры с водителем теряют доказательную базу. Now все типы
осмотров показывают одинаковый набор слотов.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 12:50:05 +10:00
tremble7681andclaude-flow 9b991a244b feat(mechanic): update search placeholder to госномер only
Backend search now matches only on plate, so reflect that in the input
placeholder instead of misleading 'Поиск по номеру или VIN'.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-23 12:10:30 +10:00
tremble7681 ed16b89353 inspection-editor: блок «Заметки механика» поднят под схему
Раньше textarea для заметок жил в самом низу страницы, после фото-сетки
и списка меток — на плановом осмотре приходилось много скроллить чтобы
добраться до основного поля ввода.

Перенёс Card 'Заметки механика' сразу под Card 'Схема'. На плановом осмотре
текстовое поле теперь второе на странице — не надо листать.
2026-05-20 15:39:01 +10:00
tremble7681 4c03f17448 inspection-editor: блок «Заметки механика» в форме нового/in-progress осмотра
Раньше textarea для заметок жил только в InspectionReview (завершённый
осмотр). При создании нового планового осмотра (через '/inspections/<id>/edit')
поля ввода не было — приходилось завершить осмотр, потом открыть карточку
и только там вводить.

Теперь блок есть в обеих страницах:
  - InspectionEditor (in_progress): textarea + кнопки Сохранить/Отменить
  - InspectionEditor (completed):  read-only вывод текста
  - InspectionReview: оставлен прежний редактор как был

Бонус: при нажатии «Завершить осмотр» текущий draft заметки коммитится
тем же PATCH вместе со status=completed — не теряется если механик
ввёл текст и сразу нажал завершение, не сохранив отдельно.
2026-05-20 15:35:05 +10:00
tremble7681 98806d1e92 vehicle list: цветной чип статуса (На линии / ДТП / Ремонт / Бронь)
VehicleSummary.status (cars_v2.element_status) рендерится как цветной
pill справа от гос.номера. Палитра по семантике:
  emerald — На линии (норма)
  amber   — Ремонтируется / Ждет ремонта
  red     — ДТП
  blue    — Бронь / Выкуп / На продаже / Подготовка на продажу / Ждет
            доставки до офиса (admin-операции)
  muted   — Простой и неизвестные статусы

Чип появляется в Home (списке машин) и в шапке VehicleCard. Backend
сторона — PremiumCRM 92d4317.
2026-05-20 15:17:15 +10:00
tremble7681 577ec37a2f vehicle-card: показ водителя под vendor states (Element history)
TtStateSummary.driver_id + driver_name добавлены в TS-тип. В строке
каждого Element-state'а под датой/пробегом теперь выводится '👤 ФИО'
если backend подтянул водителя. Логика идентична нашим осмотрам — общий
helper drivers_at_dates на backend (см. PremiumCRM aa55431).
2026-05-20 14:53:11 +10:00
tremble7681 71783f3724 vehicle-card: показ водителя под каждым прошлым осмотром
Под датой/пробегом каждого осмотра добавлена строка «👤 ФИО» если backend
вернул driver_name (рассчитан как rental, активный на момент started_at —
см. PremiumCRM 4700314). Раньше механик/менеджер не мог быстро понять, у
кого мы принимали машину — приходилось открывать VDS-карточку.

InspectionSummary.driver_id + driver_name добавлены в TS-тип.
2026-05-20 13:56:09 +10:00
tremble7681 d208838bb3 inspection-review: карточка машины + редактируемые заметки
1. Сверху на /inspections/<id> добавлена кликабельная карточка машины:
   гос.номер крупно + марка/модель/год + VIN. Использует новое поле
   ins.vehicle, которое backend теперь возвращает в InspectionDetail
   (см. PremiumCRM коммит a9c94d0). Клик ведёт на /vehicles/<id>.

2. Блок «Заметки механика» — textarea с локальным draft'ом и кнопкой
   «Сохранить» (видна только если draft отличается от ins.notes). Под
   капотом — PATCH /api/v1/mechanic/inspections/<id> с {notes}.
   TG-импортированные осмотры из «Подготовка авто» уже приезжают с
   заполненными notes (caption сообщения становится телом заметки), и
   теперь механик может дополнить/исправить их прямо в карточке.

3. Бонус: добавлен показ пробега (mileage) в первой info-карте.
2026-05-20 13:34:10 +10:00
tremble7681 c7c989d886 inspection-review: TT-фото только в секции «Метки», не дублировать в галерее
Counter «Фото (N)» по-прежнему учитывает TT carry-over (поэтому на 84-м
осмотре всё ещё 16), но карточки в гриде остаются только для наших MinIO-
фото + PWA-маркерных фото. TT-thumbnail рисуется один раз — inline под
своим маркером в списке.
2026-05-20 13:16:21 +10:00
tremble7681 b4e0dd6d65 inspection-review: TT-фото повреждений, счётчик и lightbox для всех маркеров
1. Photo counter теперь учитывает уникальные tt_image_id из markers, не только
   ins.photos + photo_id. На осмотрах, где почти все маркеры пришли из Element
   carry-over, счётчик показывал 1 вместо 15+ — теперь корректно.

2. В галерее «Фото» добавлена секция карточек для TT-image carry-over (label
   «из Element»). Картинки тянутся через ttImageUrl(hash), кликабельны.

3. Lightbox state поменялся с `number | null` на дискриминированный union
   { kind: 'photo', id } | { kind: 'tt', ttId }. Полноразмерное превью
   выбирает между AuthImg (наши MinIO-фото) и обычным <img> (Element).

4. Превью маркеров в секции «Метки» теперь кликабельны (раньше только грид
   с основными фото открывал lightbox). Работает и для photo_id, и для
   tt_image_id маркеров.

Backend-зависимость: MarkerOut.tt_image_id (Pydantic) и
MechanicDamageMarker.tt_image_id (ORM) добавлены в PremiumCRM коммитом
d405e17 на crm.pptaxi.ru — без них фронт получает tt_image_id=undefined.
2026-05-20 13:11:04 +10:00
tremble7681 7e2cf0c9c8 wip(mechanic): inflight damage-flow + vendor scheme + PhotoAnnotate + TT views
Маркер «вот где остановилась mechanic-сессия» — не разбито по логическим коммитам,
зафиксировано целиком чтобы не потерять при отвлечении/чекаутах.

Modified:
- pages/InspectionEditor.tsx — расширение damage-flow внутри редактора
- pages/VehicleCard.tsx — vendor data + расширение карточки
- pages/InspectionReview.tsx — обновлённый review
- api/{auth,inspections,photos,vehicles}.ts — новые endpoints для TT + photo flow
- components/damage-flow/DamageClassifyDialog.tsx — multi-step UI
- components/vehicle-scheme/VendorVehicleScheme.tsx — SVG zone interaction
- data/damageVocabulary.ts — обновлённый словарь повреждений
- frontend index.html/manifest/icons — PWA-метаданные

New (untracked):
- components/damage-flow/PhotoAnnotateStep.tsx — новый шаг flow'а
- components/tt/ — vendor TT-state UI
- pages/TtStateDetail.tsx — экран осмотра вендора
- recon/findings/damage_model.md + damage_vocabulary.json + scheme/ — recon docs

Cleanup: удалены bash-garbage 'Last' и 'dict[str' из frontend/.
2026-05-20 11:16:21 +10:00
tremble7681 e0083b894f fix(damage-flow): render points as SVG circles inside rotated <g> for exact tap alignment (eliminate HTML overlay letterboxing offset) 2026-05-18 00:53:06 +10:00
tremble7681 abdf0a2dea fix(damage-flow): rotation round-trip (store original-frame coords, display in rotated view) 2026-05-18 00:43:29 +10:00
tremble7681 56f1a2774b feat(damage-flow): rotate zoomed detail view to natural orientation (90/-90/180 per zone) 2026-05-18 00:25:59 +10:00
tremble7681 924dd7966a fix(damage-flow): precomputed zone bboxes (avoid getBBox timing); inline SVG viewBox in HTML so first paint already zoomed 2026-05-18 00:00:31 +10:00
tremble7681 102a2525db feat(mechanic-pwa): multi-step damage flow (confirm -> zone detail -> classify -> photo) 2026-05-17 23:45:34 +10:00
tremble7681 8893710254 feat(mechanic-pwa): action menu + vehicle list at /vehicles + mileage dialog + photo icon 2026-05-17 22:13:04 +10:00
tremble7681 ab9ddf7289 feat(mechanic-pwa): use vendor SVG scheme with zone-based markers (1:1 vendor coords for future import)
- Add public/scheme/exterior.svg (131 KB, viewBox 0 0 827 1209, 57 zones class="0"..class="56")
- Add public/scheme/salon.svg (53 KB, Phase 2 reserve)
- New VendorVehicleScheme: fetches SVG as separate asset (?url), strips vendor <script>,
  wires click/hover via event delegation, tints zones red/gray/blue by damage state
- AddMarkerDialog: side required, x/y optional (zone-level markers store NULL x/y)
- InspectionEditor/InspectionReview: swap CompositeVehicleScheme -> VendorVehicleScheme,
  pendingTap now {side} only, humanizeMarkerSide handles zone-{N} and legacy named zones
- Delete CompositeVehicleScheme (PNG grid + bumper suns)
- No backend changes; side=zone-{N} accepted by existing Optional[str] field
2026-05-17 20:18:46 +10:00
tremble7681 bd7665fb1d docs: update repo reference taxi-dashboard -> premium-crm 2026-05-17 19:48:48 +10:00
tremble7681andclaude-flow df709c58e5 feat(mechanic-pwa): composite unfolded scheme + tire wear dialog (replaces 5-tab view)
Single-screen grid layout showing all 5 PNG views + 4 bumper corner sun-zones + decorative
wheels at once. Tap any view for precise AddMarkerDialog; tap bumper corners for center-point
marker; global Tire Wear button opens new TireWearDialog (4-level radio cards).
Deletes MultiViewVehicleScheme. No backend changes. humanizeMarkerSide covers bumper-* and tires.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-17 19:41:13 +10:00
tremble7681andclaude-flow 3c6dacb29b feat(mechanic-pwa): multi-view vendor scheme, exact tap coords, per-marker photo upload
- Replace SVG sedan zone-click model with 5-view PNG scheme (top/front/back/left/right)
- New MultiViewVehicleScheme: tab selector, tap-to-exact-coord marker placement,
  touchAction:manipulation prevents iOS double-tap zoom, marker dots at normalized x/y
- New AddMarkerDialog: bottom-sheet modal with damage_type (16 opts), severity (4 opts),
  description, optional CameraCapture → uploadPhoto(side="free") → photo_id on submit
- InspectionEditor: removes addZoneMarker mutation and zone-count machinery; wires
  pendingTap state → dialog open/close → query invalidation
- InspectionReview: adds read-only MultiViewVehicleScheme; both pages show photo_id
  thumbnails via AuthImg in the marker list
- humanizeMarkerSide updated for new view names; keeps zone-* legacy mapping
- Deletes VehicleScheme.tsx and sedan-top.svg (no remaining imports)
- No backend changes, no new dependencies

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-17 12:21:06 +10:00
tremble7681andclaude-flow 7775e74108 feat(mechanic-pwa): 2FA flow with trusted-device cookie support
Wire frontend to backend 2FA contract: two-step state machine in Login.tsx
(credentials → totp), new login2fa() call to POST /api/auth/2fa, credentials:include
on both fetches so the 30-day trusted_device cookie travels correctly.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-17 11:58:42 +10:00
tremble7681 da0436cee8 docs(state): Phase 1 MVP LIVE — backend+frontend deployed, smoke checks green 2026-05-17 11:34:19 +10:00
tremble7681 db938c818d docs(state): Phase 1 MVP code-complete (B-I), deployment runbook ready for Phase J 2026-05-17 10:58:11 +10:00
tremble7681andclaude-flow a428395071 feat(mechanic-pwa): VehicleScheme + InspectionEditor + InspectionReview pages (Phase I4-I6)
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-17 10:54:00 +10:00
tremble7681andclaude-flow fc4060d4d9 feat(mechanic-pwa): photo upload infra (CameraCapture + PhotoSlot + AuthImg + presigned PUT)
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-17 10:48:51 +10:00
tremble7681andclaude-flow 537548d716 feat(mechanic-pwa): Login + Home + VehicleCard pages with TanStack Query + OAuth2 login
- api/auth.ts: OAuth2 password flow via form-urlencoded POST /api/auth/login (not mechanic prefix)
- api/me.ts: GET /api/v1/mechanic/me with Me interface
- api/vehicles.ts: listVehicles (unwraps {items:[]}), getVehicle with VehicleDetail + InspectionSummary
- api/inspections.ts: createInspection returning full InspectionDetail; 9-value InspectionType union
- Login.tsx: full form with Логин/Пароль labels, useMutation, toast feedback, navigate on success
- Home.tsx: vehicle list with search, /me header with user name, logout, TanStack Query
- VehicleCard.tsx: vehicle info card, all 9 inspection type buttons (ru labels), history list

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-17 10:44:39 +10:00
tremble7681andclaude-flow e815955d46 feat(mechanic-pwa): scaffold Vite+React+TS+Tailwind+shadcn+PWA+routing (Phase G G1-G5)
- Vite 8 + React 19 + TypeScript 6 project via create-vite react-ts template
- Tailwind v3.4 with shadcn-compatible cream palette (CSS vars)
- vite-plugin-pwa v1.3 with autoUpdate SW, manifest.webmanifest, 192+512 icons
- shadcn/ui components written manually (default style): button, card, input, label, sonner
- Zustand persist auth store, ky API client with Bearer token hooks
- React Router v7 routing with RequireAuth guard
- TanStack Query v5 + Toaster wired in main.tsx
- 5 stub pages: Login, Home, VehicleCard, InspectionEditor, InspectionReview
- Build: 293 KB JS / 9.4 KB CSS, clean zero-warning tsc+vite build

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-17 10:39:27 +10:00
tremble7681 3660b80357 docs(state): Phase E complete (E1-E5), next is Phase F markers 2026-05-17 10:04:34 +10:00
tremble7681 173032c12e docs(state): Phase D complete (D1-D3 + test-fakes refactor), Phase E scoping 2026-05-17 01:08:04 +10:00
tremble7681 cbe9ae5d9c docs(state): Phase D decisions resolved + vendor recon findings, B3 contract widened 2026-05-17 00:36:13 +10:00
tremble7681 3a7bd749cc docs(state): Phase C complete (C1-C3), Phase D scoping notes 2026-05-17 00:15:17 +10:00
tremble7681 b04630cfd5 docs(state): Phase B complete (B1-B4), Phase C test-infra fork documented 2026-05-16 23:28:29 +10:00
tremble7681 dfc2e45731 docs: STATE.md with handoff for next session (Phase A done, Task B1 next) 2026-05-16 22:48:05 +10:00
tremble7681 f4e8818bf7 ops(minio): script to set global CORS allow-origin (verified working) 2026-05-16 22:37:12 +10:00
tremble7681 5370346a89 feat(ops): Caddy fragment for mechanic.pptaxi.ru 2026-05-16 22:18:36 +10:00
tremble7681 4610a50667 docs: record backend layout findings from Task A1 recon 2026-05-16 22:17:42 +10:00
193 changed files with 17289 additions and 1 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ mechanic-pwa/ ← корень git репо
└── README.md └── README.md
``` ```
> **Внимание:** backend-модуль `mechanic` (Phase B-F плана) живёт **не в этом репо**, а в виде ветки `feat/mechanic-mvp` внутри репозитория `taxi-dashboard` (Premium CRM). Этот репо — только frontend + специфика, документация и ops. > **Внимание:** backend-модуль `mechanic` (Phase B-F плана) живёт **не в этом репо**, а в виде ветки `feat/mechanic-mvp` внутри репозитория [`premium-crm`](http://100.64.0.9:3001/tremble7681/premium-crm). Этот репо — только frontend + специфика, документация и ops.
## Текущая фаза ## Текущая фаза
+50
View File
@@ -0,0 +1,50 @@
# TaxiDashboard backend — найденные пути для модуля `mechanic`
Записано по результатам Task A1 (recon существующего кода `c:\NewProject\taxi-dashboard\backend\app\`).
## Корень backend
`/opt/sites/taxi-dashboard/backend/app/` (на VDS)
`c:\NewProject\taxi-dashboard\backend\app\` (локально)
## Импорты для нового модуля `mechanic`
```python
from app.models.base import Base, TimestampMixin # SQLAlchemy declarative + created_at/updated_at
from app.db import get_db, async_session, engine # async session factory + dependency
from app.auth.deps import get_current_user # OAuth2 Bearer JWT
from app.models.user import User # User модель
```
## Auth
- **OAuth2 Password Flow + Bearer JWT** (не PIN, как я ошибочно писал в спеке).
- Логин: `POST /v1/auth/login` (или похожий, нужно найти в `app/api/auth.py`) с form-data `username` + `password` → возвращает `{ "access_token": "...", "token_type": "bearer" }`.
- Дальнейшие запросы: header `Authorization: Bearer <token>`.
## Naming clash + решение
В TaxiDashboard уже есть модуль `app.api.inspections` (читает данные **CarInspection**, импортированные от вендора NaughtySoft через VendorBridge) под префиксом `/v2/inspections`.
Чтобы не конфликтовать:
- Наш новый модуль: `app/api/mechanic.py` (или `app/mechanic/router.py` как подмодуль) — префикс `/api/v1/mechanic`
- Наши новые таблицы:
- `mechanic_inspections` (наша запись осмотров через PWA)
- `mechanic_inspection_photos`
- `mechanic_damage_markers`
- Наши новые модели:
- `app/models/mechanic_inspection.py``MechanicInspection`
- `app/models/mechanic_inspection_photo.py``MechanicInspectionPhoto`
- `app/models/mechanic_damage_marker.py``MechanicDamageMarker`
Существующие `CarInspection`, `car_inspections`, `inspection_failed_photo`**не трогаем**. Они продолжают жить read-only от VendorBridge.
## Существующий стек
- Python 3.x + FastAPI + SQLAlchemy 2.x async
- PostgreSQL (видна migration via Alembic — папку проверим в первом backend-task)
- Frontend: `c:\NewProject\taxi-dashboard\frontend\` (React, существующий — НЕ наш target)
## Vehicle model
В existing `CarInspection` авто указывается через `car_number: String(100)`. Возможно есть отдельная модель `Car` в `app/models/car_v2.py` (видел в импортах). Проверим в backend task B2 при создании FK.
+384
View File
@@ -0,0 +1,384 @@
# Premium Mechanic PWA — Implementation State
> Last updated: 2026-05-17 (Phase 1 MVP LIVE IN PRODUCTION)
## Status: 🟢 **DEPLOYED — Phase 1 MVP LIVE on https://mechanic.pptaxi.ru**
All 4 deployment phases executed successfully:
-**J1 Backend**: `git push vds feat/mechanic-mvp` → checkout on VDS → `docker build` → rolling restart blue+green → healthz 5/5 green on `https://crm.pptaxi.ru/api/v1/mechanic/healthz`
-**J2 Frontend**: `scp dist/` → VDS at `/opt/sites/mechanic-pwa/dist/` (448K) → added Caddy block + bind mount → `docker compose up -d --force-recreate caddy` → Let's Encrypt cert auto-issued → HTTPS 200 on `https://mechanic.pptaxi.ru/`
-**J3a Merge**: `feat/mechanic-mvp``main` (ff-only) in BOTH repos, pushed to Gitea + VDS
-**J3b Prod sync**: VDS now on `main` branch, healthz still green
**Smoke checks passed:**
| Check | Result |
|---|---|
| `GET https://mechanic.pptaxi.ru/` | 200 OK, text/html, served from SPA |
| `GET /manifest.webmanifest` | JSON with `"name": "Premium Механик"` |
| `GET /api/v1/mechanic/healthz` (via mechanic.pptaxi.ru) | `{"status":"ok"}` |
| `GET /api/v1/mechanic/me` (no auth) | 401 |
| `GET /api/v1/mechanic/vehicles` (no auth) | 401 |
| `GET /icons/icon-192.png` | 200 image/png, 2326 bytes |
| `GET /sw.js` (service worker) | 200 text/javascript |
| `GET /vehicles/12345` (SPA route) | 200 text/html (index.html fallback) |
| `crm.pptaxi.ru/api/v1/mechanic/healthz` x5 | 5/5 ok (both blue+green serving) |
**Remaining manual smoke (mobile, J3 step 8):** open `https://mechanic.pptaxi.ru` on Android Chrome / iOS Safari, login with test mechanic account, create inspection, snap photo, verify MinIO upload + thumbnail appears, verify "Add to Home Screen" works. *Not automated — you'll do that on a phone.*
## What is done
| Phase | Task | Status | Artifact |
|---|---|---|---|
| 0 | Git infrastructure (Gitea + repos + feature branches) | ✅ DONE | repos exist, ssh keys configured |
| A | A1: Recon TaxiDashboard backend structure | ✅ DONE | `docs/backend-layout-notes.md` |
| A | A2: Caddy fragment for `mechanic.pptaxi.ru` | ✅ DONE | `ops/Caddyfile.fragment` |
| A | A3: MinIO CORS on `pp-inspections` bucket | ✅ DONE | `ops/set-minio-cors.sh`, applied, preflight 204 |
| A | A4: SQLAlchemy models (`MechanicInspection`, `MechanicInspectionPhoto`, `MechanicDamageMarker`) | ✅ DONE | commit `be0553e` on `feat/mechanic-mvp` |
| B | B1: Mechanic module skeleton + `/healthz` | ✅ DONE | commit `d2113a0` — flat `backend/app/api/mechanic.py` with `APIRouter(prefix="/v1/mechanic")`, registered in `app/main.py` with `prefix="/api"` → final URL `/api/v1/mechanic/healthz` |
| B | B2: SQLAlchemy models registered + tests | ✅ DONE | **already shipped in A4** (`be0553e`) — 38 metadata tests in `backend/tests/test_mechanic_models.py`, no extra B2 commit |
| B | B3: Pydantic schemas | ✅ DONE | commit `2739cc8` + fix `2fb978d` — 14 schemas + 6 Literals in `backend/app/api/mechanic_schemas.py`, 15 behavioral tests in `backend/tests/test_mechanic_schemas.py`; fix added `= None` defaults to `Optional[X]` fields and tightened `MarkerOut.damage_type`/`severity` to `DamageType`/`Severity` Literals |
| B | B4: Auth + DB session dependencies | ✅ DONE | commit `f0d7e83``backend/app/api/mechanic_deps.py` re-exports `get_db`+`get_current_user`, adds `require_mechanic` guard (admin OR member of any active `dept_type='svc'` department); 9 tests in `backend/tests/test_mechanic_deps.py` use fake-DB stubs (no real session needed)
| C | C1: `GET /me` endpoint | ✅ DONE | commit `a0bdcaa` + signature fix `1e5b822` — appended to `backend/app/api/mechanic.py`; returns `MeOut` from `require_mechanic` user; `permissions` is `["admin", "mechanic:inspect"]` for admins else `["mechanic:inspect"]`. Test fixtures (`client`, `override_mechanic`, `_fake_user`) in `backend/tests/test_mechanic_endpoints.py` use FastAPI `TestClient` + `app.dependency_overrides` per Option A — no real DB / no async. 6 endpoint tests. |
| C | C2: `GET /vehicles` list with `q`+`limit` search | ✅ DONE | commit `7be8783` — new `backend/app/api/mechanic_service.py` with `search_vehicles_with_last_inspection` (single aggregated subquery + outerjoin to avoid N+1 for last completed inspection per vehicle); endpoint in `mechanic.py`; new `VehicleListResponse` schema; 8 tests (6 endpoint + 2 service-stmt) with `_FakeSession.execute()` returning canned rows. Search hits plate (`CarV2.number`) + VIN only. |
| C | C3: `GET /vehicles/{id}` detail + bound /vehicles limit | ✅ DONE | commit `9109383` + style fix `90b8c66` — new `VehicleDetail(VehicleSummary)` + `InspectionSummary(_Base)` schemas; service `get_vehicle_by_id` + `recent_mechanic_inspections`; endpoint raises 404 when missing, `last_inspection_at` derived from first completed inspection in the list; applied `Query(20, ge=1, le=200)` bounds to C2's `/vehicles` limit param. 7 new tests (5 detail + 2 bounds). Service is now FastAPI-free (404 raise lives in endpoint, matching `automations.py` convention). |
| D-pre | B3 schemas widened from vendor recon (`a187a92`) | ✅ DONE | After read-only recon of `car_inspections` (14k rows, 2yr): `InspectionType` extended 4→9 (added `initial`, `seizure`, `equipment-change`, `pre-repair`, `post-repair`); `DamageType` fully replaced 7→16 (removed `rust`/`glass`/`paint` which had 0 vendor occurrences; added real-world types from vendor distribution: `damaged`, `chip`, `scuff`, `burn`, `bent`, `puncture`, `bulge`, `not-working`, etc.). `Severity` unchanged (4 values, optional — vendor populates only 3%). 2 new positive-coverage tests. Stale comment in `mechanic_damage_marker.py:54` references old DamageType values — fix in Phase F when touching that model. |
| D | D1: `POST /inspections` with inline carry-over markers | ✅ DONE | commits `39a6579` + correctness fix `ee3d370` — service `start_inspection` + `latest_completed_inspection` (with `NULLS LAST` ordering); endpoint returns 201 with full InspectionDetail via re-query with `selectinload(photos, markers)`. Carry-over: copies all **unresolved** markers from prev completed inspection (`resolved=False` filter — closed defects don't reopen), each new marker has `carried_over_from_id=source.id`, `photo_id=None`, `resolved=False`. `_FakeSession` extended with `add`/`flush`/`commit`/`refresh` write-path methods + `rows_per_call` queue for multi-SELECT service flows. 9 new tests (6 endpoint + 3 service). |
| D | D2: `GET /inspections/{id}` with photos+markers | ✅ DONE | commit `1776a81` — service `get_inspection_by_id_with_relations` (selectinload both relationships, `scalar_one_or_none`); endpoint raises 404 with `"inspection not found"` parallel to vehicle's 404. D1's inline re-query block refactored to call the same helper (DRY). 6 new tests (4 endpoint + 2 service introspecting `stmt._with_options`). |
| D | D3: `PATCH /inspections/{id}` status/notes/finished_at | ✅ DONE | commit `aefe117` — service `get_inspection_by_id` (thin `session.get` wrapper) + `patch_inspection` (auto-sets `finished_at=now(UTC)` when status→completed AND finished_at not in payload; `setattr` loop applies all `model_dump(exclude_unset=True)` fields). Endpoint re-loads via `get_inspection_by_id_with_relations` for the response. 8 new tests (6 endpoint + 2 service). Endpoint and service both named `patch_inspection` — disambiguated by module path in code, by `as svc_patch` alias in tests. |
| D | Housekeeping: extract test fakes to `mechanic_fakes.py` | ✅ DONE | commit `eedbf23` — pure refactor. Moved `_FakeResult`, `_FakeSession`, `_fake_user`, `_fake_car`, `_fake_inspection`, `_fake_inspection_orm`, `_fake_marker_orm` from `test_mechanic_endpoints.py` (was 899 lines) to new `backend/tests/mechanic_fakes.py` (207 lines). Test file dropped to 697 lines. Pytest fixtures (`client`, `override_mechanic`, `override_db`) stayed in the test file. Phase E tests will import from `mechanic_fakes` directly. |
| E | E1: MinIO async presigned PUT URL wrapper | ✅ DONE | commit `fe38981` — added `presigned_put_url(bucket, key, *, content_type, expires_sec=300)` to `app/services/storage.py`; `PresignedUploadResponse.fields` defaults to `{}`; 3 storage tests. |
| E | E2: `POST /inspections/{id}/photos/upload-url` | ✅ DONE | commit `111661b` — service `create_pending_photo` + `_storage_key_for_photo` helper in `mechanic_service.py`; endpoint in `mechanic.py` with 415-before-404 guard; UUID4 hex storage keys at `mechanic/inspections/{id}/<uuid>.<ext>`; 9 new tests (7 endpoint + 2 service). |
| E | E3: `POST /photos/{id}/confirm` | ✅ DONE | commit `724b209` — service `get_photo_by_id` + `confirm_photo` (None-safe dim update, status→ready); endpoint verifies `storage.exists`, 409 if missing, 404 covers both photo-missing and inspection-mismatch; 9 new tests (7 endpoint + 2 service). `_fake_photo_orm` factory added to `mechanic_fakes.py`. |
| E | E4: `GET /photos/{id}` + `/thumb` (302 redirect) | ✅ DONE | commit `8361ee1` — auth-guarded 302 to presigned MinIO GET URLs (1h expiry). Thumb endpoint falls back to `storage_key` when `thumb_key is None` so galleries degrade gracefully if E5 task hasn't run yet. 8 endpoint tests. |
| E | E5: async thumbnail generation | ✅ DONE | commit `b37a068``generate_and_store_thumbnail` worker (download → Pillow resize to 480px → upload → DB update) with try/except at each step (failures logged, return None, never crashes event loop). Triggered via `asyncio.create_task` from E3 confirm endpoint. `_thumb_key_for` predictable naming (`<key-base>-thumb.jpg`). `pillow_heif.register_heif_opener()` wrapped in try/except for dev-env tolerance. 6 new tests (5 storage unit + 1 endpoint trigger). |
| F | F1+F2: markers CRUD (POST/PATCH/DELETE) | ✅ DONE | commit `7275a23` — 3 endpoints (`POST /inspections/{id}/markers`, `PATCH /markers/{id}`, `DELETE /markers/{id}`). Service helpers `get_marker_by_id`/`create_marker`/`patch_marker`/`delete_marker`. Stale comment in `mechanic_damage_marker.py:54` fixed. `_FakeSession.delete()` added; `_FakeSession.refresh()` auto-sets `created_at` for server-default fields. 15 new tests. **159 mechanic tests total. Backend Phase 1 MVP feature-complete.** |
| G | G1-G5: Vite + React + TS + Tailwind + shadcn + PWA scaffold | ✅ DONE | commit `e815955` in `PremiumDriverApp/mechanic-pwa/frontend/` — 38 files, 8973 insertions. React 19 + Vite 8 + Tailwind v3 + TS strict + shadcn/ui (manually written to bypass broken @shadcn 4.7 "base-nova" style) + vite-plugin-pwa v1.3 (auto-bumped from v0.20 for Vite 8 compat). Service worker (`sw.js` + workbox, 10 precached entries). Cream palette (CSS vars from memory `feedback_design_prefs`). API client (ky) + auth store (zustand persist). 5 stub pages. **Build: 91 KB gzip JS, 0 warnings.** |
| H | H1-H3: Login + Home + VehicleCard pages | ✅ DONE | commit `537548d` — Login uses OAuth2 password flow (form-urlencoded) against `/api/auth/login` (NOT mechanic-prefixed). Home shows vehicle list with search + logout + `/me` fetch. VehicleCard has all 9 inspection-type buttons (top 4 primary, rest outline). Russian locale dates. Semantic Tailwind tokens throughout (no hex). **Build: 110 KB gzip JS.** |
| I | I1-I3: photo capture/upload infra | ✅ DONE | commit `fc4060d``api/photos.ts` with presigned PUT pattern (not POST/FormData per plan), `confirmPhoto(inspection_id, photo_id, meta)` with both IDs in path per E3 backend. `AuthImg` component (fetches with Bearer JWT → blob URL → `<img>`) solving the `<img>`-can't-send-Authorization problem. `CameraCapture` uses `<input type="file" capture="environment">` for max compat. `PhotoSlot` combines them. |
| I | I4-I6: VehicleScheme + InspectionEditor + InspectionReview | ✅ DONE | commit `a428395``sedan-top.svg` (top-view with 7 transparent zone rects), `VehicleScheme.tsx` with click+hover handlers + point-marker overlays + zone-count badges. `inspections.ts` typed with `PhotoSummary`/`MarkerSummary`. Editor wires scheme + 8 photo slots + marker list + finalize button (gated on `status==="in_progress"`); zone tap creates marker with `damage_type="damaged"`/`severity="minor"`. Review is read-only with AuthImg thumb grid. **Build: 113 KB gzip JS. Frontend Phase 1 MVP feature-complete.** |
**Branch state:** `feat/mechanic-mvp` at `b37a068`. Full stack: A4 → B1 → B3 + fix → B4 → C1 + fix → C2 → C3 + fix → B3-vendor-align → D1 + fix → D2 → D3 → test-refactor → E1 → E2 → E3 → E4 → E5. All pushed to `origin` (Gitea). See git log for full SHAs.
**Test count on branch:** **144 mechanic tests pass** (38 models + 17 schemas + 9 deps + 9 storage + 71 endpoints) in ~2.5s, no real DB needed. Pillow image-resize tests use real PIL in-memory (verify JPEG magic header + dimensions); thumbnail task end-to-end uses mocked I/O.
**Live mechanic API surface (11 routes):**
- `GET /api/v1/mechanic/healthz`
- `GET /api/v1/mechanic/me`
- `GET /api/v1/mechanic/vehicles?q=&limit=`
- `GET /api/v1/mechanic/vehicles/{vehicle_id}`
- `POST /api/v1/mechanic/inspections` — create with carry-over for type=return
- `GET /api/v1/mechanic/inspections/{inspection_id}` — full detail with photos+markers
- `PATCH /api/v1/mechanic/inspections/{inspection_id}` — partial update + auto finished_at
- `POST /api/v1/mechanic/inspections/{inspection_id}/photos/upload-url` — create pending photo row + presigned PUT URL (5min expiry)
- `POST /api/v1/mechanic/inspections/{inspection_id}/photos/{photo_id}/confirm` — verify upload, mark ready, schedule thumbnail task
- `GET /api/v1/mechanic/photos/{photo_id}` — auth-guarded 302 to presigned GET (1h)
- `GET /api/v1/mechanic/photos/{photo_id}/thumb` — same with thumb_key, falls back to original
### Phase E architecture decisions (RESOLVED 2026-05-17)
| Decision | Choice | Rationale |
|---|---|---|
| Upload pattern | PUT (single presigned URL) not POST/FormData | Simpler PWA code: `fetch(url, {method:'PUT', body:blob})`. `PresignedUploadResponse.fields` defaults to `{}` for future POST migration. |
| Storage key naming | `mechanic/inspections/{insp_id}/<uuid4hex>.<ext>` | Human-readable path + unguessable suffix. UUID4 avoids enumeration. |
| Allowed content types | `image/{jpeg,png,heic,webp}` | Whitelist; 415 on others. |
| Confirm status semantics | `status="ready"` set immediately on confirm; thumb generated async | Frontend doesn't need to poll. Galleries fall back to original via E4 endpoint. |
| Thumbnail backend | `asyncio.create_task` + Pillow (no Celery) | Pillow + 15+ existing fire-and-forget call sites; no broker setup needed. Thumb resize is ~50ms for typical photo — acceptable on event loop. |
| Thumb size | 480px max-edge, JPEG q=85 | Matches `services/download_wazzup_media.py` precedent. |
| Auth model for read URLs | Auth-required on `/photos/{id}` endpoint; presigned URLs themselves bearer-free (1h TTL) | Same as existing `media_proxy.py` convention. |
### Vendor data reality (from recon 2026-05-17 — 14,013 inspections, 2 years)
| Metric | Value | Implication |
|---|---|---|
| Inspections with damage | 1,941 / 14,013 = 13.9% | Most inspections have empty marker list — UI optimize for that |
| Median markers per inspection | 2 | Inline carry-over copy is cheap |
| p95 markers | 10 | Confirms no async needed |
| Max markers | 34 | Worst case still ~50 inserts on commit |
| Median photos per inspection | 11 (`all_photo_ids`) | Includes 9 sides + damage photos |
| Max photos per inspection | 30 | Set MinIO presigned batch size accordingly |
| Carry-over in vendor | **None — denormalized snapshot** | Our `prev_inspection_id`+`carried_over_from_id` FK approach is net improvement |
| Vendor inspection status field | **None — write-once when completed** | Our `in_progress`/`completed`/`cancelled` is greenfield, no conflicts |
## Where things live
### Repos (Gitea: `http://100.64.0.9:3001/tremble7681`)
| Repo | Local clone | Working branch |
|---|---|---|
| `mechanic-pwa` | `c:\NewProject\PremiumDriverApp\` | `feat/mechanic-mvp` |
| `taxi-dashboard` | `c:\NewProject\taxi-dashboard\` | `feat/mechanic-mvp` |
`taxi-dashboard` clone has two remotes: `origin` → Gitea, `vds` → SSH to `/opt/sites/taxi-dashboard/` on 100.64.0.12 (for pulling back-and-forth with prod).
### Reference paths (real, verified — see `docs/backend-layout-notes.md`)
- `Base`, `TimestampMixin``app.models.base`
- `get_db``app.db` (file: `app/db/__init__.py`)
- `get_current_user``app.auth.deps`
- `User``app.models.user`
- Auth flow: **OAuth2 password + Bearer JWT** (not PIN as plan originally said)
- DB init: **`Base.metadata.create_all`** at startup (not Alembic) + inline `ALTER TABLE ADD COLUMN IF NOT EXISTS` in `app/main.py` lifespan
- Backend Docker container: `taxi-backend-green` / `taxi-backend-blue` (blue/green)
- MinIO container: `taxi-minio`
- Models registered in: `app/models/__init__.py` (imports + `__all__`)
### Naming clash decision
Existing `app/api/inspections.py` + model `CarInspection` (table `car_inspections`) hold **vendor-imported read-only** data from NaughtySoft. Our new tables use the **`mechanic_`** prefix to coexist:
- `mechanic_inspections` (model `MechanicInspection`)
- `mechanic_inspection_photos` (model `MechanicInspectionPhoto`)
- `mechanic_damage_markers` (model `MechanicDamageMarker`)
### Plan-vs-reality overrides applied in Phase B (Authoritative)
Three places where the original plan (`docs/superpowers/plans/2026-05-16-premium-mechanic-phase1.md`) was overridden during execution because Phase A1 recon showed the codebase doesn't match the plan's assumptions:
| Plan said | Actual implementation | Why |
|---|---|---|
| `backend/app/mechanic/router.py` (subpackage) | `backend/app/api/mechanic.py` (flat) | TaxiDashboard uses flat `app/api/*.py` files; no subpackages |
| `backend/app/mechanic/schemas.py` (subpackage) | `backend/app/api/mechanic_schemas.py` (flat sibling) | Same convention |
| `backend/app/mechanic/deps.py` (subpackage) | `backend/app/api/mechanic_deps.py` (flat sibling) | Same convention |
| Router prefix `/api/v1/mechanic` | Router prefix `/v1/mechanic`, with `app.include_router(..., prefix="/api")` in `main.py` | Matches existing convention (`inspections.py``/v2/inspections` + include with `/api`). Final URL is identical: `/api/v1/mechanic/...` |
| `require_mechanic` checks `user.dept.lower() in (...)` | Queries `UserDepartment ⨝ Department WHERE dept_type='svc' AND is_active`, admin shortcuts before DB hit | `User` has no `dept` attribute; departments are M2M via `user_departments`. `DEPT_TYPES=['ops','svc','adm']`, `svc` = service-type (мастерская) |
When dispatching Phase CF implementers, copy this override map into their context so they don't follow the stale subpackage / dept paths from the plan.
## Phase C testing-infrastructure decision (RESOLVED — Option A in force)
Phase C runs against `FastAPI TestClient` (sync) + `app.dependency_overrides[require_mechanic]` and `app.dependency_overrides[get_db]` to inject fake user / fake session. Fake session is a `SimpleNamespace`-flavored `_FakeSession` class in `tests/test_mechanic_endpoints.py` that supports `.execute(stmt)` (returns `_FakeResult` seeded with rows) and `.get(model, pk)` (returns seeded object or `None`).
No `conftest.py`, no `pytest-asyncio`, no real DB. SQL correctness is deliberately deferred to Phase J e2e.
If Phase D/E/F needs a more complex query shape (e.g. `.scalars().one()` / `.first()`), extend `_FakeResult.scalars()` accordingly — currently it returns `self` and supports only `.all()`. See `test_mechanic_endpoints.py:_FakeResult` docstring.
## Deployment runbook (Phase J — execute manually)
**Status:** prep done, awaiting user OK. Phase 1 MVP code is on `feat/mechanic-mvp` in both Gitea repos.
### J0 — Pre-flight (verified)
- [x] Backend `requirements.txt` already has `aiobotocore>=2.13.0`, `Pillow==10.4.0`, `pillow-heif==0.18.0`.
- [x] Backend uses `Base.metadata.create_all` in lifespan (NOT Alembic) — new mechanic tables auto-create on container restart. **No migration step needed.**
- [x] MinIO bucket `pp-inspections` exists with CORS configured (Phase A3, `ops/set-minio-cors.sh` already applied).
- [x] Caddy fragment `ops/Caddyfile.fragment` ready in PremiumDriverApp repo.
- [x] Frontend `dist/` build clean: **113 KB gzip JS**, 12 KB CSS, sw.js + workbox precaching.
- [ ] **DNS**: confirm `mechanic.pptaxi.ru` resolves to the VDS IP before Caddy reload.
- [ ] **Test mechanic user exists**: need a User in the DB with `is_admin=true` OR membership in a `Department` with `dept_type='svc'`. Verify with: `SELECT u.id, u.username, u.is_admin FROM users u LEFT JOIN user_departments ud ON ud.user_id=u.id LEFT JOIN departments d ON d.id=ud.department_id WHERE u.is_admin=true OR d.dept_type='svc';`
### J1 — Backend deploy (taxi-dashboard)
**⚠️ Production touch — affects live CRM.**
Plan said `systemctl restart taxi-dashboard-backend.service` + `alembic upgrade head`**both wrong for our setup**. We use Docker blue/green + `Base.metadata.create_all`. Correct steps:
```bash
ssh root@100.64.0.12
cd /opt/sites/taxi-dashboard
# 1. Pull the mechanic branch into prod's tracking copy
git fetch origin feat/mechanic-mvp
git checkout feat/mechanic-mvp # OR merge into main first if your release process needs that
# 2. Rebuild backend image (pulls in new requirements: Pillow, pillow-heif, aiobotocore should already be installed if it wasn't a fresh deploy, but rebuild to be safe)
docker compose build backend-blue backend-green
# 3. Rolling restart (blue first, then green — preserves availability)
docker compose up -d --no-deps backend-blue
sleep 10
docker logs taxi-backend-blue --tail 50 # check for clean startup + table-create logs
docker compose up -d --no-deps backend-green
sleep 10
docker logs taxi-backend-green --tail 50
# 4. Smoke test
curl -sS https://crm.pptaxi.ru/api/v1/mechanic/healthz
# Expected: {"status":"ok"}
```
**Rollback if broken:** `git checkout main && docker compose up -d --no-deps backend-blue backend-green`. The `Base.metadata.create_all` is additive (CREATE TABLE IF NOT EXISTS) — new tables stay, no data loss.
### J2 — Frontend deploy (mechanic-pwa)
```bash
# 1. Build locally (already done; use the fresh dist/)
cd c:/NewProject/PremiumDriverApp/mechanic-pwa/frontend
npm run build
# 2. Create target dir on VDS if first deploy
ssh root@100.64.0.12 'mkdir -p /opt/sites/mechanic-pwa/dist'
# 3. Sync the built assets
rsync -avz --delete dist/ root@100.64.0.12:/opt/sites/mechanic-pwa/dist/
# 4. Install Caddy fragment (first deploy only)
ssh root@100.64.0.12
cd /opt/sites/mechanic-pwa
# (assumes PremiumDriverApp repo is checked out at /opt/sites/mechanic-pwa/repo;
# if not, scp ops/Caddyfile.fragment instead)
cp /opt/sites/mechanic-pwa/repo/ops/Caddyfile.fragment /etc/caddy/sites-enabled/mechanic.pptaxi.ru
caddy validate --config /etc/caddy/Caddyfile # OR appropriate validation cmd
systemctl reload caddy
# 5. Smoke test
curl -I https://mechanic.pptaxi.ru
# Expected: 200 OK, Content-Type: text/html
curl -sS https://mechanic.pptaxi.ru/manifest.webmanifest | head -20
# Expected: JSON with "name": "Premium Механик"
```
**Rollback:** `rm /etc/caddy/sites-enabled/mechanic.pptaxi.ru && systemctl reload caddy` removes the route; old `dist/` can be restored from previous rsync run if you take a snapshot first.
### J3 — Smoke test (manual)
Open `https://mechanic.pptaxi.ru` on a mobile browser (Android Chrome / iOS Safari):
1. Login with the test mechanic account
2. Search for any car → open card → start "Передача"
3. Tap 2 zones on the scheme → markers appear with red badges
4. Snap 1 photo (any slot) → wait for "загружено" toast → thumb appears
5. Tap "Завершить осмотр" → redirected to review page showing photo + markers
6. Verify in DB:
```sql
SELECT id, vehicle_id, type, status, performed_by, started_at, finished_at
FROM mechanic_inspections ORDER BY id DESC LIMIT 3;
SELECT id, side, slot_index, status, thumb_key IS NOT NULL AS has_thumb
FROM mechanic_inspection_photos WHERE inspection_id=<NEW_ID>;
SELECT id, side, damage_type, carried_over_from_id, resolved
FROM mechanic_damage_markers WHERE inspection_id=<NEW_ID>;
```
7. Verify in MinIO console (`https://s3-admin.pptaxi.ru`) bucket `pp-inspections`: see the `mechanic/inspections/<NEW_ID>/<uuid>.jpg` original + `<uuid>-thumb.jpg` next to it.
8. PWA install: tap browser menu → "Add to Home Screen" → opens fullscreen.
If all 8 pass → **MVP is live**. File UX issues as Gitea issues.
## Acceptance criteria (from plan §3)
- [ ] Mechanic can log in on `mechanic.pptaxi.ru` from mobile
- [ ] Find vehicle by license plate
- [ ] Open vehicle card, see recent inspections
- [ ] Start handover / return / periodic / ad-hoc inspection
- [ ] On return: carry-over markers from prev appear with `carried_over_from_id`
- [ ] Take ≥4 photos in slots, uploaded directly to MinIO via presigned PUT
- [ ] Click zones on SVG scheme → point markers added
- [ ] Finalize inspection (status=completed, finished_at auto-set)
- [ ] View read-only review with all photos + markers
- [ ] PWA installable to home screen on Android + iOS
## Next task (post-deploy)
### **Task F1 — Markers CRUD** (Phase F starts; only 2 tasks) — DONE see table above
After successful J3 smoke test, **Phase 1 MVP is complete**. Phase 2 backlog: polygon annotation editor (Konva), offline IndexedDB queue, native APK wrap, push notifications, VendorBridge data import, admin stats dashboard, QR scanner.
(From `docs/superpowers/plans/2026-05-16-premium-mechanic-phase1.md` Phase F.)
**Working dir:** `c:\NewProject\taxi-dashboard` (branch `feat/mechanic-mvp`, HEAD `b37a068`)
Phase F adds the marker endpoints — finally exposes the carry-over data we've been carefully copying since D1. After F, backend Phase 1 MVP is feature-complete and Phase G begins frontend work.
**Two endpoints:**
- `POST /inspections/{inspection_id}/markers` — create a new damage marker (request: `MarkerCreate` from B3)
- `PATCH /markers/{marker_id}` — update marker (request: `MarkerPatch` from B3)
The plan likely also wants `DELETE /markers/{marker_id}` — verify in the plan. There may also be a "resolve marker" endpoint that's just a PATCH with `resolved=True`.
**Important context:**
- `MechanicDamageMarker` has a stale comment at line 54 (refs old DamageType values like `rust`/`glass`/`paint`) — fix in F1 while touching this model area.
- `MarkerCreate` and `MarkerOut` schemas already exist in `mechanic_schemas.py` from B3.
- The carry-over markers (`carried_over_from_id IS NOT NULL`) deserve a thought: PATCH should be allowed to update them (mechanic confirms the existing damage is still there with current photo); DELETE should probably be NOT allowed on carry-overs (only on freshly-created markers) to preserve audit trail. Verify this in the plan.
**Phase F implementer pattern:** apply the same Phase D path overrides (flat `app/api/`, `MechanicDamageMarker` not `DamageMarker`, etc.). Reuse `_FakeSession` + `_fake_marker_orm` from `mechanic_fakes.py`.
### Phase D architecture decisions (RESOLVED 2026-05-17)
1. **Carry-over: inline в POST /inspections** (atomic in the same transaction). Until carry-over copy completes, the new inspection isn't "ready" for the UI — async would create race conditions and complicate testing without a meaningful payoff for ~10-20 markers per copy.
2. **`_FakeSession` write-path extension.** Add the following to the existing `_FakeSession` in `tests/test_mechanic_endpoints.py`:
- `add(obj)` — appends to `self.added` list; auto-assigns `obj.id` if not set, popping from `_next_ids` queue if provided, else incrementing `_next_id_counter` (starts at 1000 to avoid collision with test-seeded ids in the 1-99 range)
- `async flush()` — no-op + `self.flush_count += 1` (ids already assigned in `add`)
- `async commit()` — no-op + `self.commit_count += 1`
- `async refresh(obj)` — no-op + `self.refresh_count += 1`
- **`rows_per_call: Optional[list[Sequence]]`** kwarg — if provided, each `execute()` call pops one item from this queue and returns it as the result rows. This handles multi-query service flows (e.g. `start_inspection` does `select(prev_inspection)` then `select(prev_markers)`).
- Existing `execute(stmt)` and `get(model, pk)` keep their behavior.
- All extensions backward-compatible with current 83 tests.
3. **Test infrastructure: stay on Option A, do NOT add real DB.** SQL specifics (selectinload, cascade, JSONB) will be validated in Phase J e2e. aiosqlite ≠ Postgres (JSONB mismatch), testcontainers is too heavy for 3 endpoints, two-paradigm test suite is more pain than gain. Extend the mock instead (see point 2).
### Phase D path overrides (apply to D1, D2, D3)
Same flat-file pattern as Phase B-C; map plan's stale paths:
| Plan says | Use instead |
|---|---|
| `backend/app/mechanic/router.py` | `backend/app/api/mechanic.py` (append) |
| `backend/app/mechanic/service.py` | `backend/app/api/mechanic_service.py` (append) |
| `backend/app/mechanic/schemas.py` | `backend/app/api/mechanic_schemas.py` (already has all needed shapes from B3) |
| `backend/tests/test_mechanic/test_inspections.py` | `backend/tests/test_mechanic_endpoints.py` (append) |
| `Inspection` model | `MechanicInspection` (`app/models/mechanic_inspection`) |
| `DamageMarker` model | `MechanicDamageMarker` (`app/models/mechanic_damage_marker`) |
| `InspectionPhoto` model | `MechanicInspectionPhoto` (`app/models/mechanic_inspection_photo`) |
| `service.get_vehicle_or_404` (plan) | `service.get_vehicle_by_id` + endpoint-level `None`-check + `HTTPException(404)` raise (C3 fix) |
| `from fastapi import HTTPException` inline in service | At endpoint level only; service stays FastAPI-free |
| 404 raise inside endpoint via inline `from fastapi import HTTPException` | Use top-of-file import (already there from C3) |
Pydantic schemas (already shipped in B3):
- `InspectionCreate` (vehicle_id, type, driver_id) — request body for POST
- `InspectionPatch` (status, notes, finished_at) — request body for PATCH
- `InspectionDetail` (full response with photos + markers list) — response model
Phase B3 schemas use `model_config = ConfigDict(from_attributes=True)` via `_Base`, so SQLAlchemy ORM instances serialize automatically with `response_model=InspectionDetail`.
## How to resume in a new session
In a new Claude Code chat in `c:\NewProject\`, paste this prompt:
```
Продолжаем проект Premium Mechanic PWA.
Прочитай PremiumDriverApp/docs/superpowers/STATE.md — Phase 1 MVP code-complete.
Backend (14 routes, 159 тестов) + Frontend (113 KB gzip PWA) запушены на feat/mechanic-mvp.
Deployment runbook готов в STATE.md секция "Deployment runbook". Выполни J1+J2+J3:
1. SSH в root@100.64.0.12, git pull feat/mechanic-mvp, docker compose rebuild + rolling restart blue→green
2. rsync mechanic-pwa/frontend/dist/ на VDS + caddy reload mechanic.pptaxi.ru
3. Smoke test на мобильном браузере по чек-листу из runbook'а
После успешного deploy: merge feat/mechanic-mvp → main в обоих репах (PremiumDriverApp + taxi-dashboard).
```
That prompt is the **single entry point** for the next session — Claude reads STATE.md, executes the deployment runbook, runs smoke tests, merges to main.
## Remaining roadmap (Phase 1 MVP)
- ~~**Phase B** (4 tasks)~~ ✅ done — module skeleton, models, schemas, auth deps
- ~~**Phase C** (3 tasks)~~ ✅ done — GET /me, GET /vehicles, GET /vehicles/{id}
- ~~**Phase D** (3 tasks)~~ ✅ done
- ~~**Phase E** (5 tasks)~~ ✅ done
- ~~**Phase F** (2 tasks)~~ ✅ done — backend Phase 1 MVP feature-complete (14 routes, 159 tests)
- ~~**Phase G** (5 tasks)~~ ✅ done — frontend scaffold (91 KB gzip after G; 113 KB after I)
- ~~**Phase H** (3 tasks)~~ ✅ done — Login + Home + VehicleCard
- ~~**Phase I** (6 tasks)~~ ✅ done — CameraCapture + AuthImg + PhotoSlot + VehicleScheme + Editor + Review
- **Phase J** (4 tasks): code-complete, **deployment runbook ready, awaiting user OK****HERE**
**Code work is done. Phase 1 MVP delivery is one `ssh root@100.64.0.12` + Docker compose restart + rsync away.**
≈ 25 tasks left until MVP live on `mechanic.pptaxi.ru`.
## Known external dependencies
- MinIO `pp-inspections` bucket — CORS already configured, presigned uploads will work
- Caddy on 100.64.0.12 — fragment ready, will be wired during Phase J deploy
- Docker blue/green deployment on VDS — backend re-deployment will need a touch of `docker compose` (Phase J)
- Frontend deployment target: `/opt/sites/mechanic-pwa/dist/` on VDS (Phase J)
## Conventions reminder
- All new code in `feat/mechanic-mvp` branches in both repos
- All new tables use `mechanic_` prefix
- Don't touch existing `app/api/inspections.py` or `app/models/inspection.py` (vendor-imported)
- All new mechanic source lives in **flat files under `backend/app/api/`** with `mechanic_*` naming (`mechanic.py`, `mechanic_schemas.py`, `mechanic_deps.py`). No `app/mechanic/` subpackage — see "Plan-vs-reality overrides" table above.
- Router prefix is `/v1/mechanic`; `/api` is added by `include_router(..., prefix="/api")` in `main.py`. Final URLs always `/api/v1/mechanic/*`.
- Phase C+ endpoint tests: pending the test-infrastructure decision (Option A / B / C above). Do NOT introduce `pytest-asyncio`, `conftest.py`, or a real DB without first resolving that decision.
- `require_mechanic` allows admins + members of any active `Department` with `dept_type='svc'`. Use it as the default auth dep for all mechanic endpoints.
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
<link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Mechanic" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Premium Механик</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-query": "^5.100.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"ky": "^1.14.3",
"lucide-react": "^0.400.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-hook-form": "^7.76.0",
"react-router-dom": "^7.15.1",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.1",
"vite-plugin-pwa": "^1.3.0",
"zod": "^3.25.76",
"zustand": "^5.0.13"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"postcss": "^8.5.14",
"tailwindcss": "^3.4.19",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
@@ -0,0 +1,90 @@
{
"_meta": {
"source": "Ttc.dll #US heap, contiguous block at offsets 0xc2e240xc3084",
"confidence": "HIGH — full block scanned; order is the binary storage order which matches app display order (repository pattern typically loads in definition order)",
"notes": [
"Items 012 are general exterior damage types (user-selectable for any zone)",
"Items 1318 appear to be salon-specific (Грязный салон, Запах табака, etc.) — shown only for interior zones (4446 seats, salon)",
"Установлено/Снято (19–20) and Контроль (21) are semi-system states used for equipment completeness zones (3348 area)",
"Штат (25) appears to be a system/staff marker, not user-selectable",
"Порез, Прокол, Порвано are tire-specific damage types for zones 1720",
"Есть/Нет повреждений found in binary (0x4599a/0x4597a) — likely used for salon damage yes/no toggle, not in the standard list"
]
},
"damage_types_ordered": [
"Вмятина",
"Царапина",
"Трещина",
"Повреждено",
"Скол",
"Отсутствует",
"Прожжено",
"Погнуто",
"Требуется мойка",
"Не работает",
"Порез",
"Прокол",
"Порвано",
"Пятна",
"Грязный салон",
"Запах табака",
"Повреждение салонных ковриков",
"Повреждение обшивки дверей",
"Сломанные ручки",
"Установлено",
"Снято",
"Контроль",
"Требуется химчистка",
"Затертость",
"Грыжа",
"Штат"
],
"exterior_selectable": [
"Вмятина",
"Царапина",
"Трещина",
"Повреждено",
"Скол",
"Отсутствует",
"Прожжено",
"Погнуто",
"Требуется мойка",
"Не работает",
"Порез",
"Прокол",
"Порвано",
"Пятна",
"Затертость",
"Грыжа"
],
"salon_selectable": [
"Вмятина",
"Царапина",
"Трещина",
"Повреждено",
"Скол",
"Отсутствует",
"Прожжено",
"Погнуто",
"Требуется мойка",
"Не работает",
"Пятна",
"Грязный салон",
"Запах табака",
"Повреждение салонных ковриков",
"Повреждение обшивки дверей",
"Сломанные ручки",
"Требуется химчистка",
"Затертость",
"Грыжа"
],
"severity_scale": {
"positions": 3,
"labels": ["Легкое", "Среднее", "Тяжелое"],
"values": [0, 1, 2],
"widget": "SeekBar (vehicleInspectionDegreeSelect / newDamageSeekBarDamageDegree)",
"api_field": "degree (lowercase, in JSON contract)",
"default": "Легкое (index 0)",
"source_note": "Легкое found at 0x59d79 (adjacent to 'Добавить повреждение' button label = default/initial value); Среднее+Тяжелое found together at 0xc39e80xc39f8 (loading state transitions)"
}
}
@@ -0,0 +1,401 @@
{
"0": {
"x": 278.11,
"y": 88.77,
"w": 262.74,
"h": 45.43,
"rotation": 0
},
"52": {
"x": 699.8,
"y": 296.93,
"w": 41.31,
"h": 70.61,
"rotation": -90
},
"51": {
"x": 81.04,
"y": 295.79,
"w": 41.4,
"h": 71.68,
"rotation": 90
},
"1": {
"x": 488.5,
"y": 132.88,
"w": 50.92,
"h": 20.77,
"rotation": 0
},
"2": {
"x": 279.61,
"y": 132.82,
"w": 50.93,
"h": 21.24,
"rotation": 0
},
"3": {
"x": 350.0,
"y": 130.0,
"w": 120.0,
"h": 22.0,
"rotation": 0
},
"30": {
"x": 509.22,
"y": 734.61,
"w": 130.89,
"h": 41.63,
"rotation": 0
},
"31": {
"x": 182.12,
"y": 734.5,
"w": 129.26,
"h": 41.56,
"rotation": 0
},
"28": {
"x": 170.41,
"y": 496.19,
"w": 6.35,
"h": 8.34,
"rotation": 90
},
"29": {
"x": 645.28,
"y": 496.19,
"w": 6.53,
"h": 8.58,
"rotation": -90
},
"11": {
"x": 278.5,
"y": 477.86,
"w": 465.53,
"h": 152.09,
"rotation": 0
},
"10": {
"x": 78.52,
"y": 478.98,
"w": 91.6,
"h": 150.99,
"rotation": 90
},
"4": {
"x": 300.72,
"y": 311.52,
"w": 219.3,
"h": 146.72,
"rotation": 0
},
"5": {
"x": 305.05,
"y": 442.76,
"w": 210.67,
"h": 127.13,
"rotation": 0
},
"22": {
"x": 281.48,
"y": 1063.81,
"w": 260.16,
"h": 59.34,
"rotation": 0
},
"49": {
"x": 87.78,
"y": 826.98,
"w": 50.54,
"h": 99.5,
"rotation": 90
},
"50": {
"x": 684.83,
"y": 827.25,
"w": 49.78,
"h": 99.71,
"rotation": -90
},
"33": {
"x": 284.24,
"y": 1038.88,
"w": 29.65,
"h": 22.7,
"rotation": 0
},
"32": {
"x": 507.74,
"y": 1038.78,
"w": 29.66,
"h": 22.69,
"rotation": 0
},
"21": {
"x": 303.29,
"y": 841.39,
"w": 215.88,
"h": 236.13,
"rotation": 0
},
"38": {
"x": 510.42,
"y": 195.83,
"w": 26.67,
"h": 821.6,
"rotation": -90
},
"39": {
"x": 281.9,
"y": 195.94,
"w": 26.67,
"h": 14.24,
"rotation": 0
},
"23": {
"x": 316.23,
"y": 766.98,
"w": 187.94,
"h": 89.25,
"rotation": 0
},
"6": {
"x": 78.13,
"y": 314.95,
"w": 91.98,
"h": 168.83,
"rotation": 90
},
"7": {
"x": 651.97,
"y": 314.84,
"w": 92.1,
"h": 168.93,
"rotation": -90
},
"8": {
"x": 81.05,
"y": 734.43,
"w": 104.0,
"h": 176.79,
"rotation": 90
},
"9": {
"x": 590.75,
"y": 666.97,
"w": 150.45,
"h": 244.25,
"rotation": -90
},
"12": {
"x": 81.53,
"y": 628.12,
"w": 99.38,
"h": 147.01,
"rotation": 90
},
"13": {
"x": 641.53,
"y": 627.6,
"w": 99.42,
"h": 147.14,
"rotation": -90
},
"16": {
"x": 325.94,
"y": 563.11,
"w": 168.93,
"h": 208.89,
"rotation": 90
},
"14": {
"x": 72.11,
"y": 471.33,
"w": 5.0,
"h": 161.47,
"rotation": 90
},
"47": {
"x": 72.83,
"y": 615.64,
"w": 5.6,
"h": 124.59,
"rotation": 90
},
"15": {
"x": 744.0,
"y": 461.0,
"w": 6.0,
"h": 171.5,
"rotation": -90
},
"48": {
"x": 744.0,
"y": 632.5,
"w": 6.0,
"h": 107.5,
"rotation": -90
},
"27": {
"x": 596.56,
"y": 643.76,
"w": 50.37,
"h": 84.35,
"rotation": -90
},
"26": {
"x": 175.44,
"y": 643.75,
"w": 50.57,
"h": 84.4,
"rotation": 90
},
"24": {
"x": 171.07,
"y": 526.12,
"w": 53.81,
"h": 105.33,
"rotation": 90
},
"25": {
"x": 597.45,
"y": 526.06,
"w": 53.57,
"h": 105.33,
"rotation": -90
},
"18": {
"x": 42.7,
"y": 369.77,
"w": 89.47,
"h": 88.82,
"rotation": 0
},
"43": {
"x": 56.05,
"y": 382.97,
"w": 63.97,
"h": 63.6,
"rotation": 0
},
"17": {
"x": 688.66,
"y": 369.75,
"w": 89.51,
"h": 88.83,
"rotation": 0
},
"42": {
"x": 702.04,
"y": 382.95,
"w": 63.97,
"h": 63.61,
"rotation": 0
},
"20": {
"x": 42.55,
"y": 751.33,
"w": 90.63,
"h": 88.87,
"rotation": 0
},
"40": {
"x": 56.71,
"y": 764.4,
"w": 61.8,
"h": 62.63,
"rotation": 0
},
"19": {
"x": 688.96,
"y": 751.25,
"w": 89.2,
"h": 88.92,
"rotation": 0
},
"41": {
"x": 701.6,
"y": 763.49,
"w": 64.65,
"h": 64.46,
"rotation": 0
},
"44": {
"x": 42.66,
"y": 0.0,
"w": 426.67,
"h": 512.0,
"rotation": 90
},
"45": {
"x": 42.66,
"y": 0.0,
"w": 426.67,
"h": 512.0,
"rotation": 90
},
"46": {
"x": 42.66,
"y": 0.0,
"w": 426.67,
"h": 512.0,
"rotation": 90
},
"34": {
"x": 0.0,
"y": 76.0,
"w": 512.0,
"h": 360.0,
"rotation": 0
},
"35": {
"x": 0.0,
"y": 76.0,
"w": 512.0,
"h": 360.0,
"rotation": 0
},
"36": {
"x": 0.0,
"y": 76.0,
"w": 512.0,
"h": 360.0,
"rotation": 0
},
"37": {
"x": 0.0,
"y": 76.0,
"w": 512.0,
"h": 360.0,
"rotation": 0
},
"53": {
"x": 185.83,
"y": 719.15,
"w": 42.97,
"h": 94.96,
"rotation": 90
},
"54": {
"x": 594.89,
"y": 723.2,
"w": 46.0,
"h": 101.37,
"rotation": -90
},
"55": {
"x": 170.0,
"y": 450.0,
"w": 60.0,
"h": 130.0,
"rotation": 90
},
"56": {
"x": 595.0,
"y": 450.0,
"w": 55.0,
"h": 130.0,
"rotation": -90
}
}
@@ -0,0 +1,66 @@
{
"_meta": {
"source": "Ttc.dll #US heap, offsets 0x751c70x75a81 (consecutive UTF-16 LE strings)",
"confidence": "HIGH — 57 strings appear in exact sequential order in a single contiguous block, matching the 57 class IDs (0..56) in exterior_scheme_complete.svg",
"geographic_check": "confirmed: zone 0 (front bumper) at SVG Y=119 (top), zone 22 (rear bumper) at Y=1064 (bottom); left-side zones have small X, right-side zones have large X",
"note_typo": "zone 15 has a typo in the vendor binary: 'правй порог' (missing 'ы') — preserve as-is for matching but display as 'правый порог'",
"note_zones_34_57": "zones 3437 are fog lights (противотуманки), zones 3839 mirrors, 4043 disks, 4446 salon seats, 4748 rear sill extensions, 4956 bumper sub-parts and pillars"
},
"0": "передний бампер",
"1": "передняя правая фара",
"2": "передняя левая фара",
"3": "радиатор",
"4": "капот",
"5": "лобовое стекло",
"6": "переднее левое крыло",
"7": "переднее правое крыло",
"8": "заднее левое крыло",
"9": "заднее правое крыло",
"10": "передняя левая дверь",
"11": "передняя правая дверь",
"12": "задняя левая дверь",
"13": "задняя правая дверь",
"14": "левый порог",
"15": "правый порог",
"16": "крыша",
"17": "передняя правая резина",
"18": "передняя левая резина",
"19": "задняя правая резина",
"20": "задняя левая резина",
"21": "багажник",
"22": "задний бампер",
"23": "заднее стекло",
"24": "переднее левое стекло",
"25": "переднее правое стекло",
"26": "заднее левое стекло",
"27": "заднее правое стекло",
"28": "передняя левая форточка",
"29": "передняя правая форточка",
"30": "задняя правая форточка",
"31": "задняя левая форточка",
"32": "задняя правая фара",
"33": "задняя левая фара",
"34": "передняя правая противотуманка",
"35": "передняя левая противотуманка",
"36": "задняя правая противотуманка",
"37": "задняя левая противотуманка",
"38": "правое зеркало",
"39": "левое зеркало",
"40": "задний левый диск",
"41": "задний правый диск",
"42": "передний правый диск",
"43": "передний левый диск",
"44": "водительское сидение",
"45": "пассажирское сидение",
"46": "задний диван",
"47": "задний левый порог",
"48": "задний правый порог",
"49": "задняя левая часть бампера",
"50": "задняя правая часть бампера",
"51": "передняя левая часть бампера",
"52": "передняя правая часть бампера",
"53": "задняя левая стойка",
"54": "задняя правая стойка",
"55": "передняя левая стойка",
"56": "передняя правая стойка"
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="13" fill="#000000"/>
<!-- M: широкие диагонали, плоский «дно» с двумя углами вниз -->
<path
d="M 16 16 L 21 16 L 32 35 L 43 16 L 48 16 L 48 48 L 42 48 L 42 28 L 34 41 L 30 41 L 22 28 L 22 48 L 16 48 Z"
fill="#E6E6E6"
/>
<!-- Красный акцент-черта под буквой -->
<rect x="22" y="52" width="20" height="1.5" fill="#D32F2F"/>
</svg>

After

Width:  |  Height:  |  Size: 514 B

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

@@ -0,0 +1,14 @@
{
"name": "Premium Механик",
"short_name": "Mechanic",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+43
View File
@@ -0,0 +1,43 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "@/store/auth";
import { useMeSync } from "@/hooks/useMeSync";
import Login from "@/pages/Login";
import ActionMenu from "@/pages/ActionMenu";
import Home from "@/pages/Home";
import VehicleCard from "@/pages/VehicleCard";
import InspectionEditor from "@/pages/InspectionEditor";
import InspectionReview from "@/pages/InspectionReview";
import TtStateDetail from "@/pages/TtStateDetail";
import RepairsFeedPage from "@/pages/repairs/RepairsFeedPage";
import CarSelectPage from "@/pages/repairs/CarSelectPage";
import CreateRepairPage from "@/pages/repairs/CreateRepairPage";
function RequireAuth({ children }: { children: React.ReactNode }) {
const token = useAuth((s) => s.token);
if (!token) return <Navigate to="/login" replace />;
return <>{children}</>;
}
export default function App() {
// У35: фоновая сверка прав механика раз в 5 минут — выкидывает на /login
// с сообщением «Доступ отозван», если админ снял can_login_pwa или вывел
// пользователя из отдела механиков.
useMeSync();
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<RequireAuth><ActionMenu /></RequireAuth>} />
<Route path="/vehicles" element={<RequireAuth><Home /></RequireAuth>} />
<Route path="/vehicles/:id" element={<RequireAuth><VehicleCard /></RequireAuth>} />
<Route path="/inspections/:id/edit" element={<RequireAuth><InspectionEditor /></RequireAuth>} />
<Route path="/inspections/:id" element={<RequireAuth><InspectionReview /></RequireAuth>} />
<Route path="/tt-states/:id" element={<RequireAuth><TtStateDetail /></RequireAuth>} />
{/* Раздел «Ремонт» — активируется в PR-5 (Фича 1). */}
<Route path="/repairs" element={<RequireAuth><RepairsFeedPage /></RequireAuth>} />
<Route path="/repairs/new" element={<RequireAuth><CarSelectPage /></RequireAuth>} />
<Route path="/repairs/new/:carId" element={<RequireAuth><CreateRepairPage /></RequireAuth>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { useAuth } from "@/store/auth";
export interface LoginInput {
username: string;
password: string;
}
/** OAuth2-shaped login response, extended with backend's 2FA signaling fields. */
export interface LoginOutput {
access_token?: string | null;
token_type: string;
requires_2fa?: boolean;
partial_token?: string | null;
}
export interface TwoFactorInput {
partial_token: string;
code: string;
remember_device: boolean;
}
export async function login(input: LoginInput): Promise<LoginOutput> {
const body = new URLSearchParams();
body.set("username", input.username);
body.set("password", input.password);
// PWA — длинная сессия (30 дней) чтобы механики не вводили пароль каждые 8 ч.
body.set("long_session", "true");
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
// Critical: include credentials so the 30-day trusted_device cookie is
// sent back to backend on subsequent logins (lets us skip 2FA next time).
credentials: "include",
});
if (!res.ok) {
throw new Error(`Login failed: ${res.status}`);
}
return res.json();
}
export async function login2fa(input: TwoFactorInput): Promise<LoginOutput> {
const res = await fetch("/api/auth/2fa", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...input, long_session: true }),
credentials: "include", // accept the trusted_device cookie on Set-Cookie
});
if (!res.ok) {
throw new Error(`2FA failed: ${res.status}`);
}
return res.json();
}
export function logout(): void {
useAuth.getState().setToken(null);
}
+28
View File
@@ -0,0 +1,28 @@
import ky, { type KyInstance } from "ky";
import { useAuth } from "@/store/auth";
const API_BASE = import.meta.env.VITE_API_BASE ?? "/api/v1/mechanic";
export const api: KyInstance = ky.create({
prefixUrl: API_BASE,
timeout: 30_000,
retry: { limit: 2, methods: ["get"] },
hooks: {
beforeRequest: [
(request) => {
const token = useAuth.getState().token;
if (token) {
request.headers.set("Authorization", `Bearer ${token}`);
}
},
],
beforeError: [
(error) => {
if (error.response?.status === 401) {
useAuth.getState().setToken(null);
}
return error;
},
],
},
});
@@ -0,0 +1,131 @@
import { api } from "./client";
import type { InspectionSummary } from "./vehicles";
export type InspectionType =
| "handover"
| "return"
| "periodic"
| "ad-hoc"
| "initial"
| "seizure"
| "equipment-change"
| "pre-repair"
| "post-repair";
export type InspectionStatus = "in_progress" | "completed" | "cancelled";
export interface InspectionCreateInput {
vehicle_id: number;
type: InspectionType;
driver_id?: number;
mileage?: number;
}
export interface PhotoSummary {
id: number;
side: string;
slot_index: number;
storage_key: string;
thumb_key?: string | null;
annotated_key?: string | null;
width?: number | null;
height?: number | null;
status: string;
taken_at: string;
}
export interface MarkerSummary {
id: number;
inspection_id: number;
photo_id?: number | null;
tt_image_id?: string | null;
side?: string | null;
x?: number | null;
y?: number | null;
polygon?: { x: number; y: number }[] | null;
damage_type?: string | null;
severity?: string | null;
description?: string | null;
carried_over_from_id?: number | null;
resolved: boolean;
created_at: string;
}
export interface VehicleBrief {
id: number;
license_plate: string;
vin?: string | null;
make?: string | null;
model?: string | null;
year?: number | null;
}
export interface InspectionDetail {
id: number;
vehicle_id: number;
/** Сводка по машине (гос.номер / марка / модель / год). Backend attaches её
в get_inspection_by_id_with_relations, чтобы карточка осмотра могла
показать машину без второго запроса. */
vehicle?: VehicleBrief | null;
type: InspectionType;
performed_by: number;
driver_id?: number | null;
started_at: string;
finished_at?: string | null;
status: InspectionStatus;
notes?: string | null;
prev_inspection_id?: number | null;
meta: Record<string, unknown>;
photos: PhotoSummary[];
markers: MarkerSummary[];
mileage?: number | null;
/** ФИО механика, выполнившего осмотр (lookup users.full_name по performed_by). */
inspector_name?: string | null;
}
export async function createInspection(
input: InspectionCreateInput
): Promise<InspectionDetail> {
return api.post("inspections", { json: input }).json<InspectionDetail>();
}
export async function getInspection(id: number): Promise<InspectionDetail> {
return api.get(`inspections/${id}`).json<InspectionDetail>();
}
export async function patchInspection(
id: number,
body: { status?: InspectionStatus; notes?: string; finished_at?: string; mileage?: number }
): Promise<InspectionDetail> {
return api.patch(`inspections/${id}`, { json: body }).json<InspectionDetail>();
}
export interface MarkerCreateInput {
photo_id?: number | null;
side?: string | null;
x?: number | null;
y?: number | null;
polygon?: { x: number; y: number }[] | null;
damage_type?: string | null;
severity?: string | null;
description?: string | null;
}
export async function createMarker(
inspectionId: number,
body: MarkerCreateInput
): Promise<MarkerSummary> {
return api
.post(`inspections/${inspectionId}/markers`, { json: body })
.json<MarkerSummary>();
}
export async function deleteInspection(inspectionId: number): Promise<void> {
await api.delete(`inspections/${inspectionId}`);
}
export async function deleteMarker(markerId: number): Promise<void> {
await api.delete(`markers/${markerId}`);
}
export type { InspectionSummary };
+22
View File
@@ -0,0 +1,22 @@
import { api } from "./client";
export interface PwaSupportContact {
tg_username: string | null;
phone: string | null;
}
export interface Me {
id: number;
name: string;
email?: string | null;
permissions: string[];
// Модуль СТО (У10 + У34) — расширение в backend PR-4.
is_admin: boolean;
can_login_pwa: boolean;
departments: string[];
support: PwaSupportContact;
}
export async function getMe(): Promise<Me> {
return api.get("me").json<Me>();
}
+119
View File
@@ -0,0 +1,119 @@
import { api } from "./client";
export interface PresignedUploadResponse {
photo_id: number;
presigned_url: string;
fields: Record<string, string>;
storage_key: string;
expires_at: string;
}
export interface PhotoConfirmResponse {
id: number;
side: string;
slot_index: number;
storage_key: string;
thumb_key?: string | null;
annotated_key?: string | null;
width?: number | null;
height?: number | null;
status: string;
taken_at: string;
}
export async function uploadPhotoAnnotation(
inspectionId: number,
photoId: number,
blob: Blob,
): Promise<PhotoConfirmResponse> {
const form = new FormData();
form.append("file", blob, "annotation.jpg");
return api
.post(`inspections/${inspectionId}/photos/${photoId}/annotation`, {
body: form,
})
.json<PhotoConfirmResponse>();
}
export async function requestUploadUrl(
inspectionId: number,
args: { side: string; slot_index: number; content_type: string; size: number }
): Promise<PresignedUploadResponse> {
return api
.post(`inspections/${inspectionId}/photos/upload-url`, { json: args })
.json<PresignedUploadResponse>();
}
/**
* PUT the file directly to MinIO via the presigned URL.
* Content-Type header MUST match the value the URL was signed with
* (we pass the same `args.content_type` to requestUploadUrl).
*/
export async function uploadToS3(
presigned: PresignedUploadResponse,
file: File,
contentType: string
): Promise<void> {
const r = await fetch(presigned.presigned_url, {
method: "PUT",
headers: { "Content-Type": contentType },
body: file,
});
if (!r.ok) {
throw new Error(`S3 upload failed: ${r.status} ${r.statusText}`);
}
}
export async function confirmPhoto(
inspectionId: number,
photoId: number,
meta: { width?: number; height?: number; actual_size?: number }
): Promise<PhotoConfirmResponse> {
return api
.post(`inspections/${inspectionId}/photos/${photoId}/confirm`, { json: meta })
.json<PhotoConfirmResponse>();
}
/**
* Full upload flow: request URL → PUT to S3 → measure dims → confirm.
* Returns the confirmed PhotoConfirmResponse (frontend uses .id mostly).
*/
export async function uploadPhoto(
inspectionId: number,
side: string,
slotIndex: number,
file: File
): Promise<PhotoConfirmResponse> {
const contentType = file.type || "image/jpeg";
const presign = await requestUploadUrl(inspectionId, {
side,
slot_index: slotIndex,
content_type: contentType,
size: file.size,
});
await uploadToS3(presign, file, contentType);
// Measure dimensions from a local object URL (revoked once loaded).
const dim = await new Promise<{ w: number; h: number }>((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const out = { w: img.naturalWidth, h: img.naturalHeight };
URL.revokeObjectURL(url);
resolve(out);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Could not read image dimensions"));
};
img.src = url;
});
return confirmPhoto(inspectionId, presign.photo_id, {
width: dim.w,
height: dim.h,
actual_size: file.size,
});
}
@@ -0,0 +1,136 @@
import { api } from "./client";
export interface CarBrief {
id: number;
plate: string;
brand: string | null;
model: string | null;
}
export interface CarContext {
id: number;
plate: string;
brand: string | null;
model: string | null;
mileage_starline: number | null;
driver_id: number | null;
driver_name: string | null;
}
export interface MechWork {
id: number;
name: string;
category_id: number;
price: number | string;
norma_hours: number | string | null;
}
export interface PartSuggestion {
name: string;
}
export interface PresignResponse {
photo_uuid: string;
extension: string;
content_type: string;
key: string;
upload_url: string;
expires_in: number;
}
export interface CreatePhotoPayload {
photo_uuid: string;
extension: string;
tag: "auto" | "part" | "other";
sort_order: number;
}
export interface CreateWorkPayload {
work_catalog_id: number;
price_applied: number;
manual_override: boolean;
override_comment: string | null;
}
export interface CreatePartPayload {
name: string;
qty: number;
}
export interface CreateOilChangePayload {
enabled: boolean;
mileage_at_change: number | null;
next_in_km: number;
sticker_photo_uuid: string | null;
sticker_extension: string | null;
}
export interface CreateRepairPayload {
car_id: number;
mileage: number | null;
works: CreateWorkPayload[];
parts: CreatePartPayload[];
photos: CreatePhotoPayload[];
oil_change: CreateOilChangePayload | null;
}
export interface CreateRepairResponse {
id: number;
created_at_iso: string;
tg_outbox_status: string;
}
export async function searchCars(q: string): Promise<CarBrief[]> {
const qs = q ? `?q=${encodeURIComponent(q)}` : "";
return api.get(`cars${qs}`).json<CarBrief[]>();
}
export async function getCarContext(carId: number): Promise<CarContext> {
return api.get(`cars/${carId}/context`).json<CarContext>();
}
export async function searchWorks(q: string): Promise<MechWork[]> {
const qs = q ? `?q=${encodeURIComponent(q)}` : "";
return api.get(`works${qs}`).json<MechWork[]>();
}
export async function suggestParts(q: string): Promise<PartSuggestion[]> {
return api
.get(`parts/suggest?q=${encodeURIComponent(q)}`)
.json<PartSuggestion[]>();
}
export async function presignPhoto(extension: string, photo_uuid?: string): Promise<PresignResponse> {
return api
.post("repairs/photos/presign", {
json: { extension, photo_uuid: photo_uuid ?? null },
})
.json<PresignResponse>();
}
/**
* Залить файл по presigned PUT. ВАЖНО: Content-Type ОБЯЗАН совпадать
* со значением, которое сервер вшил в подпись (см. У18 / sto_mechanic_form.py).
*/
export async function uploadPhotoToTmp(file: File | Blob, presign: PresignResponse): Promise<void> {
const res = await fetch(presign.upload_url, {
method: "PUT",
headers: { "Content-Type": presign.content_type },
body: file,
});
if (!res.ok) {
throw new Error(`Photo upload failed: ${res.status} ${res.statusText}`);
}
}
export async function createRepair(
payload: CreateRepairPayload,
idempotencyKey: string,
): Promise<CreateRepairResponse> {
return api
.post("repairs", {
json: payload,
headers: { "Idempotency-Key": idempotencyKey },
})
.json<CreateRepairResponse>();
}
@@ -0,0 +1,38 @@
import { api } from "./client";
export interface FeedWorkBrief {
name: string;
}
export interface FeedItem {
id: number;
created_at: string; // ISO
car_plate: string;
car_make: string | null;
car_model: string | null;
driver_name: string | null;
mechanic_name: string;
works_preview: FeedWorkBrief[];
works_extra: number;
photo_url: string | null;
}
export interface FeedResponse {
items: FeedItem[];
next_cursor: string | null;
}
export interface FeedParams {
limit?: number;
cursor?: string | null;
}
export async function getRepairsFeed(params: FeedParams = {}): Promise<FeedResponse> {
const search = new URLSearchParams();
if (params.limit) search.set("limit", String(params.limit));
if (params.cursor) search.set("cursor", params.cursor);
const qs = search.toString();
return api
.get(`repairs${qs ? `?${qs}` : ""}`)
.json<FeedResponse>();
}
+125
View File
@@ -0,0 +1,125 @@
import { api } from "./client";
export interface VehicleSummary {
id: number;
license_plate: string;
vin?: string | null;
make?: string | null;
model?: string | null;
year?: number | null;
last_inspection_at?: string | null;
/** Текущий статус машины из 1C Element (cars_v2.element_status):
'На линии' / 'Простой' / 'Ремонтируется' / 'Ждет ремонта' / 'ДТП' /
'Бронь' / 'Выкуп' / 'На продаже' / etc. NULL если не задан. */
status?: string | null;
}
export interface VehicleDetail extends VehicleSummary {
recent_inspections: InspectionSummary[];
starline_mileage?: number | null;
starline_mileage_at?: string | null;
}
export interface InspectionSummary {
id: number;
type: string;
status: string;
started_at: string;
finished_at?: string | null;
mileage?: number | null;
photos_count?: number;
/** Водитель, прикреплённый к машине на момент started_at (lookup в
driver_rentals_v2 по car_number + перекрывающему периоду). NULL если
на тот момент машина была свободна. */
driver_id?: number | null;
driver_name?: string | null;
}
export async function listVehicles(q?: string): Promise<VehicleSummary[]> {
const searchParams: Record<string, string> = {};
if (q && q.trim()) searchParams.q = q.trim();
const res = await api
.get("vehicles", { searchParams })
.json<{ items: VehicleSummary[] }>();
return res.items;
}
export async function getVehicle(id: number): Promise<VehicleDetail> {
return api.get(`vehicles/${id}`).json<VehicleDetail>();
}
// ── TT-Control vendor archive (Element Mechanic) ───────────────────────────
export interface TtStateSummary {
id: number;
vendor_state_id: string;
unix_time: number | null;
mechanic_name: string | null;
mileage: number | null;
photos_count: number;
damages_count: number;
/** Водитель, прикреплённый к машине на момент unix_time (lookup в
driver_rentals_v2 по car_number + перекрывающему/предыдущему периоду).
NULL если на тот момент машина была свободна. */
driver_id?: number | null;
driver_name?: string | null;
}
export interface TtHistory {
vendor_vehicle_id: string | null;
plate?: string | null;
brand?: string | null;
model?: string | null;
states: TtStateSummary[];
}
export interface TtPhotoRef {
image_id: string;
image_with_lines_id: string | null;
unix_time: number | null;
guid: string | null;
}
export interface TtDamage {
damage_type_id: number | null;
degree: number | null;
points: { x: number; y: number }[];
guid: string | null;
unix_time: number | null;
}
export interface TtSidePhoto {
image_id: string;
miniature_id: string | null;
photo_type: number | null;
}
export interface TtStateDetail {
id: number;
vendor_state_id: string;
unix_time: number | null;
mechanic_name: string | null;
mileage: number | null;
side_photos: TtSidePhoto[];
photos_by_zone: Record<string, TtPhotoRef[]>;
damages_by_zone: Record<string, TtDamage[]>;
}
export async function getTtHistory(vehicleId: number): Promise<TtHistory> {
return api.get(`vehicles/${vehicleId}/tt-history`).json<TtHistory>();
}
export async function getTtState(stateId: number): Promise<TtStateDetail> {
return api.get(`tt-states/${stateId}`).json<TtStateDetail>();
}
export async function deleteTtState(stateId: number): Promise<void> {
await api.delete(`tt-states/${stateId}`);
}
export function ttImageUrl(imageId: string, withLines: boolean = false): string {
// tt-image endpoint без auth-guard'а (vendor сам публично отдаёт эти JPEG'и),
// поэтому подходит для прямого <img src=...>.
const base = import.meta.env.VITE_API_BASE ?? "/api/v1/mechanic";
return `${base}/tt-image/${imageId}${withLines ? "?lines=true" : ""}`;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import { useAuth } from "@/store/auth";
interface Props {
/** Path relative to /api/v1/mechanic, e.g. "photos/123/thumb". */
src: string;
alt?: string;
className?: string;
/** Optional placeholder while loading. */
fallback?: React.ReactNode;
}
/**
* <img> wrapper that fetches the resource with the current Bearer token,
* turns it into a blob: URL, and renders an <img> pointing at the blob.
*
* Necessary because the mechanic photo endpoints are auth-guarded — plain
* <img src="/api/v1/mechanic/photos/123"> doesn't send the Authorization header.
*
* Cleans up the blob URL on unmount or when src changes.
*/
export function AuthImg({ src, alt = "", className, fallback }: Props) {
const token = useAuth((s) => s.token);
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<boolean>(false);
useEffect(() => {
let cancelled = false;
let createdUrl: string | null = null;
setError(false);
setBlobUrl(null);
async function load() {
if (!token) {
setError(true);
return;
}
try {
const fullUrl = `/api/v1/mechanic/${src}`;
const res = await fetch(fullUrl, {
headers: { Authorization: `Bearer ${token}` },
redirect: "follow",
});
if (!res.ok) throw new Error(`${res.status}`);
const blob = await res.blob();
createdUrl = URL.createObjectURL(blob);
if (!cancelled) setBlobUrl(createdUrl);
} catch {
if (!cancelled) setError(true);
}
}
load();
return () => {
cancelled = true;
if (createdUrl) URL.revokeObjectURL(createdUrl);
};
}, [src, token]);
if (error) {
return (
<div className={className}>
{fallback ?? (
<span className="text-xs text-muted-foreground">нет фото</span>
)}
</div>
);
}
if (!blobUrl) {
return (
<div className={className}>
{fallback ?? (
<div className="bg-muted animate-pulse h-full w-full" />
)}
</div>
);
}
return <img src={blobUrl} alt={alt} className={className} />;
}
@@ -0,0 +1,57 @@
import { useRef, useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
interface Props {
onCapture: (file: File) => void;
buttonLabel?: string;
}
export function CameraCapture({ onCapture, buttonLabel = "Сделать фото" }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const [preview, setPreview] = useState<string | null>(null);
// Cleanup preview blob URL on unmount or when preview changes
useEffect(() => {
return () => {
if (preview) URL.revokeObjectURL(preview);
};
}, [preview]);
return (
<div className="space-y-2">
<input
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
aria-hidden="true"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
if (preview) URL.revokeObjectURL(preview);
setPreview(URL.createObjectURL(file));
onCapture(file);
// Reset input value so capturing the same file twice works
e.target.value = "";
}}
/>
{preview ? (
<div className="space-y-2">
<img src={preview} alt="" className="w-full rounded-lg" />
<Button
variant="outline"
className="w-full"
onClick={() => inputRef.current?.click()}
>
Переснять
</Button>
</div>
) : (
<Button onClick={() => inputRef.current?.click()} className="w-full">
{buttonLabel}
</Button>
)}
</div>
);
}
@@ -0,0 +1,60 @@
import { useState } from "react";
import { CameraCapture } from "./CameraCapture";
import { AuthImg } from "@/components/AuthImg";
import { uploadPhoto } from "@/api/photos";
import { toast } from "sonner";
interface Props {
inspectionId: number;
side: string;
slotIndex: number;
label: string;
initialPhotoId?: number;
onUploaded?: (photoId: number) => void;
}
export function PhotoSlot({
inspectionId,
side,
slotIndex,
label,
initialPhotoId,
onUploaded,
}: Props) {
const [photoId, setPhotoId] = useState<number | undefined>(initialPhotoId);
const [uploading, setUploading] = useState(false);
async function handleCapture(file: File) {
setUploading(true);
try {
const result = await uploadPhoto(inspectionId, side, slotIndex, file);
setPhotoId(result.id);
onUploaded?.(result.id);
toast.success(`${label}: загружено`);
} catch {
toast.error(`${label}: ошибка загрузки`);
} finally {
setUploading(false);
}
}
return (
<div className="border rounded-lg p-3 bg-card space-y-2">
<div className="text-sm font-medium">{label}</div>
{photoId && (
<AuthImg
src={`photos/${photoId}/thumb`}
alt={label}
className="w-full aspect-[4/3] object-cover rounded bg-muted"
/>
)}
<CameraCapture
onCapture={handleCapture}
buttonLabel={photoId ? "Заменить" : "Снять"}
/>
{uploading && (
<div className="text-xs text-muted-foreground">Загружаю</div>
)}
</div>
);
}
@@ -0,0 +1,121 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { loadDamageVocabulary, damageTypeRuToEnum } from "@/data/damageVocabulary";
interface Props {
open: boolean;
zoneId: number;
zoneLabel: string;
pointsCount: number;
onCancel: () => void;
onSave: (data: { damageType: string; severity: 0 | 1 | 2 }) => void;
}
export function DamageClassifyDialog({ open, zoneId, zoneLabel, pointsCount, onCancel, onSave }: Props) {
const [vocab, setVocab] = useState<Awaited<ReturnType<typeof loadDamageVocabulary>> | null>(null);
const [damageType, setDamageType] = useState<string | null>(null);
const [severity, setSeverity] = useState<0 | 1 | 2>(0);
useEffect(() => {
if (!open) return;
setDamageType(null);
setSeverity(0);
loadDamageVocabulary().then(setVocab);
}, [open]);
if (!open) return null;
// Salon zones (44=водительское сидение, 45=пассажирское, 46=задний диван) use salon_selectable; else exterior
const isSalon = zoneId === 44 || zoneId === 45 || zoneId === 46;
// Фильтруем типы для которых нет соответствия в backend DamageType enum —
// иначе backend вернёт 422 на createMarker.
const list = vocab
? (isSalon ? vocab.salon_selectable : vocab.exterior_selectable).filter(
(dt) => damageTypeRuToEnum(dt) != null,
)
: [];
return (
<div
className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center p-0 sm:p-4"
onClick={onCancel}
>
<div
className="w-full max-w-md bg-card rounded-t-2xl sm:rounded-2xl shadow-xl flex flex-col max-h-[90vh]"
onClick={(e) => e.stopPropagation()}
>
<header className="p-5 border-b">
<h2 className="text-lg font-semibold capitalize">{zoneLabel}</h2>
<p className="text-xs text-muted-foreground">
Точек: {pointsCount}
</p>
</header>
<div className="p-5 border-b space-y-3">
<Label>Степень повреждения</Label>
<div className="flex items-center gap-2">
{([0, 1, 2] as const).map((idx) => {
const label = vocab?.severity_scale.labels[idx] ?? ["Лёгкое", "Среднее", "Тяжёлое"][idx];
return (
<button
key={idx}
type="button"
onClick={() => setSeverity(idx)}
className={cn(
"flex-1 py-2 px-2 rounded-md text-sm font-medium border transition-colors",
severity === idx
? "bg-primary text-primary-foreground border-primary"
: "bg-background text-foreground hover:bg-accent"
)}
>
{label}
</button>
);
})}
</div>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-1.5">
<Label>Тип повреждения</Label>
{!vocab && <div className="text-sm text-muted-foreground">Загружаю</div>}
{vocab && list.map((dt) => (
<label
key={dt}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer border transition-colors",
damageType === dt
? "bg-accent border-primary"
: "border-transparent hover:bg-accent/50"
)}
>
<input
type="radio"
name="damage_type"
value={dt}
checked={damageType === dt}
onChange={() => setDamageType(dt)}
className="shrink-0"
/>
<span className="text-sm">{dt}</span>
</label>
))}
</div>
<footer className="p-5 border-t flex gap-2">
<Button variant="outline" onClick={onCancel} className="flex-1">
Отмена
</Button>
<Button
disabled={!damageType}
onClick={() => damageType && onSave({ damageType, severity })}
className="flex-1"
>
Сохранить
</Button>
</footer>
</div>
</div>
);
}
@@ -0,0 +1,247 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
interface Props {
photoFile: File;
zoneLabel: string;
/** Called when user submits. annotated=null если "без линий". */
onSubmit: (annotated: Blob | null) => void | Promise<void>;
onCancel: () => void;
}
interface Stroke {
pts: { x: number; y: number }[]; // in image-space pixels
}
/** Pen-on-photo step: показывает только что снятое фото, даёт обвести
* пальцем красным. Flatten в JPEG при submit. Координаты strokes в native
* пикселях фото — canvas resize'нут на тот же размер. */
export function PhotoAnnotateStep({ photoFile, zoneLabel, onSubmit, onCancel }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const [imgLoaded, setImgLoaded] = useState(false);
const [strokes, setStrokes] = useState<Stroke[]>([]);
const currentStroke = useRef<Stroke | null>(null);
const [submitting, setSubmitting] = useState(false);
// Load image once
useEffect(() => {
const url = URL.createObjectURL(photoFile);
const img = new Image();
img.onload = () => {
imgRef.current = img;
const canvas = canvasRef.current;
if (canvas) {
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
}
setImgLoaded(true);
URL.revokeObjectURL(url);
redraw([]);
};
img.onerror = () => URL.revokeObjectURL(url);
img.src = url;
return () => {
URL.revokeObjectURL(url);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [photoFile]);
function redraw(allStrokes: Stroke[]) {
const canvas = canvasRef.current;
const img = imgRef.current;
if (!canvas || !img) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Linewidth scaled to image — на 4К фото 4px тонко смотрится
const lw = Math.max(6, Math.round(canvas.width / 250));
ctx.strokeStyle = "rgb(220, 38, 38)";
ctx.lineWidth = lw;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const s of allStrokes) {
if (s.pts.length < 2) {
// single tap → dot
if (s.pts.length === 1) {
ctx.fillStyle = "rgb(220, 38, 38)";
ctx.beginPath();
ctx.arc(s.pts[0].x, s.pts[0].y, lw / 2, 0, Math.PI * 2);
ctx.fill();
}
continue;
}
ctx.beginPath();
ctx.moveTo(s.pts[0].x, s.pts[0].y);
for (let i = 1; i < s.pts.length; i++) {
ctx.lineTo(s.pts[i].x, s.pts[i].y);
}
ctx.stroke();
}
}
// Re-draw whenever strokes change
useEffect(() => {
if (imgLoaded) redraw(strokes);
}, [strokes, imgLoaded]);
function pointFromEvent(e: React.PointerEvent<HTMLCanvasElement>): { x: number; y: number } | null {
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;
// CSS px → canvas px
const x = ((e.clientX - rect.left) / rect.width) * canvas.width;
const y = ((e.clientY - rect.top) / rect.height) * canvas.height;
return { x, y };
}
function onPointerDown(e: React.PointerEvent<HTMLCanvasElement>) {
e.preventDefault();
(e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);
const pt = pointFromEvent(e);
if (!pt) return;
currentStroke.current = { pts: [pt] };
// Immediate visual feedback: draw a partial stroke without setState
drawPartialStroke(currentStroke.current);
}
function onPointerMove(e: React.PointerEvent<HTMLCanvasElement>) {
if (!currentStroke.current) return;
const pt = pointFromEvent(e);
if (!pt) return;
currentStroke.current.pts.push(pt);
drawPartialStroke(currentStroke.current);
}
function onPointerUp(e: React.PointerEvent<HTMLCanvasElement>) {
if (!currentStroke.current) return;
try {
(e.target as HTMLCanvasElement).releasePointerCapture(e.pointerId);
} catch {
// ignore — pointer wasn't captured
}
const finished = currentStroke.current;
currentStroke.current = null;
if (finished.pts.length > 0) {
setStrokes((s) => [...s, finished]);
}
}
/** Avoid re-running full redraw on every move — just append last segment. */
function drawPartialStroke(s: Stroke) {
const canvas = canvasRef.current;
if (!canvas || s.pts.length < 2) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const lw = Math.max(6, Math.round(canvas.width / 250));
ctx.strokeStyle = "rgb(220, 38, 38)";
ctx.lineWidth = lw;
ctx.lineCap = "round";
ctx.lineJoin = "round";
const a = s.pts[s.pts.length - 2];
const b = s.pts[s.pts.length - 1];
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
function clearAll() {
setStrokes([]);
}
function undoLast() {
setStrokes((s) => s.slice(0, -1));
}
async function finishWithAnnotation() {
const canvas = canvasRef.current;
if (!canvas) return;
setSubmitting(true);
try {
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/jpeg", 0.85)
);
await onSubmit(blob);
} finally {
setSubmitting(false);
}
}
async function finishWithoutAnnotation() {
setSubmitting(true);
try {
await onSubmit(null);
} finally {
setSubmitting(false);
}
}
return (
<div className="fixed inset-0 z-50 bg-background flex flex-col">
<header className="p-4 border-b flex items-center justify-between">
<div>
<div className="text-xs text-muted-foreground">Обведите повреждение</div>
<h2 className="text-lg font-semibold capitalize">{zoneLabel}</h2>
</div>
<div className="flex gap-2">
<button
onClick={undoLast}
disabled={strokes.length === 0 || submitting}
className="text-sm px-3 py-1 border rounded-md disabled:opacity-40"
title="Отменить последний штрих"
>
</button>
<button
onClick={clearAll}
disabled={strokes.length === 0 || submitting}
className="text-sm px-3 py-1 border rounded-md disabled:opacity-40"
title="Очистить"
>
Очистить
</button>
</div>
</header>
<main className="flex-1 overflow-auto p-2 flex items-center justify-center bg-black">
<canvas
ref={canvasRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className="max-w-full max-h-full block"
style={{ touchAction: "none", cursor: "crosshair" }}
/>
</main>
<footer className="border-t p-3 flex gap-2 bg-card">
<Button
variant="outline"
onClick={onCancel}
disabled={submitting}
className="flex-none"
>
Назад
</Button>
<Button
variant="outline"
onClick={finishWithoutAnnotation}
disabled={submitting}
className="flex-1"
>
Без линий
</Button>
<Button
onClick={finishWithAnnotation}
disabled={submitting || strokes.length === 0}
className="flex-1"
>
{submitting ? "Сохраняю…" : "Готово"}
</Button>
</footer>
</div>
);
}
@@ -0,0 +1,38 @@
import { Button } from "@/components/ui/button";
interface Props {
open: boolean;
zoneLabel: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ZoneConfirmDialog({ open, zoneLabel, onConfirm, onCancel }: Props) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center p-0 sm:p-4"
onClick={onCancel}
>
<div
className="w-full max-w-sm bg-card rounded-t-2xl sm:rounded-2xl shadow-xl p-6 space-y-5"
onClick={(e) => e.stopPropagation()}
>
<div>
<h2 className="text-lg font-semibold">Добавить повреждение</h2>
<p className="text-sm text-muted-foreground mt-1 capitalize">
{zoneLabel}?
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onCancel} className="flex-1">
Нет
</Button>
<Button onClick={onConfirm} className="flex-1">
Да
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,307 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
interface Point {
x: number;
y: number;
}
interface ZoneBBox {
x: number;
y: number;
w: number;
h: number;
rotation?: number; // degrees, 0 / 90 / -90 / 180
}
interface Props {
open: boolean;
zoneId: number;
zoneLabel: string;
onConfirm: (points: Point[]) => void;
onCancel: () => void;
}
const PADDING = 0.1;
const COMPOSITE_W = 827;
const COMPOSITE_H = 1209;
const SVG_NS = "http://www.w3.org/2000/svg";
const POINT_RADIUS = 18; // user-space units; visible on zoomed views
let bboxCache: Record<string, ZoneBBox> | null = null;
async function loadBBoxes(): Promise<Record<string, ZoneBBox>> {
if (bboxCache) return bboxCache;
const res = await fetch("/data/zone_bboxes.json");
bboxCache = await res.json();
return bboxCache!;
}
function paddedViewBox(b: ZoneBBox): string {
const rotation = b.rotation ?? 0;
const cx = b.x + b.w / 2;
const cy = b.y + b.h / 2;
let w = b.w;
let h = b.h;
if (rotation === 90 || rotation === -90) {
w = b.h;
h = b.w;
}
const padX = w * PADDING;
const padY = h * PADDING;
const minX = cx - w / 2 - padX;
const minY = cy - h / 2 - padY;
return `${minX} ${minY} ${w + 2 * padX} ${h + 2 * padY}`;
}
function rotationTransform(b: ZoneBBox): string | null {
const rotation = b.rotation ?? 0;
if (rotation === 0) return null;
const cx = b.x + b.w / 2;
const cy = b.y + b.h / 2;
return `rotate(${rotation} ${cx} ${cy})`;
}
export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [svgText, setSvgText] = useState<string | null>(null);
const [points, setPoints] = useState<Point[]>([]);
// Load SVG + bboxes
useEffect(() => {
if (!open) return;
setPoints([]);
setSvgText(null);
let cancelled = false;
Promise.all([
fetch("/scheme/exterior.svg").then((r) => r.text()),
loadBBoxes(),
]).then(([text, bboxes]) => {
if (cancelled) return;
const b = bboxes[String(zoneId)];
const finalVB = b ? paddedViewBox(b) : `0 0 ${COMPOSITE_W} ${COMPOSITE_H}`;
const rotXform = b ? rotationTransform(b) : null;
let mutated = text;
mutated = mutated.replace(/<script[\s\S]*?<\/script>/gi, "");
mutated = mutated.replace(/(<svg\b[^>]*?)\sviewBox\s*=\s*"[^"]*"/i, `$1 viewBox="${finalVB}"`);
mutated = mutated.replace(
/(<svg\b[^>]*?)\s(?:width|height)\s*=\s*"[^"]*"/gi,
"$1"
);
mutated = mutated.replace(
/<svg\b/i,
`<svg width="100%" height="100%" preserveAspectRatio="xMidYMid meet"`
);
// Wrap inner content in rotated <g> so visible orientation is correct.
// We always wrap (even with identity rotation) so the point-overlay <g>
// append target is consistent.
const openMatch = mutated.match(/<svg\b[^>]*?>/i);
const closeIdx = mutated.lastIndexOf("</svg>");
if (openMatch && closeIdx > -1) {
const openEnd = (openMatch.index ?? 0) + openMatch[0].length;
const before = mutated.slice(0, openEnd);
const inner = mutated.slice(openEnd, closeIdx);
const after = mutated.slice(closeIdx);
const xform = rotXform ? ` transform="${rotXform}"` : "";
mutated = `${before}<g data-rot="1"${xform}>${inner}</g>${after}`;
}
setSvgText(mutated);
});
return () => { cancelled = true; };
}, [open, zoneId]);
// After SVG mounts, apply fill highlights
useEffect(() => {
if (!svgText) return;
const el = containerRef.current;
if (!el) return;
const svg = el.querySelector("svg") as SVGSVGElement | null;
if (!svg) return;
svg.querySelectorAll("path[class]").forEach((p) => {
const cls = p.getAttribute("class") ?? "";
if (cls === String(zoneId)) {
p.setAttribute("fill", "rgba(239, 68, 68, 0.18)");
p.setAttribute("stroke", "rgb(239, 68, 68)");
p.setAttribute("stroke-width", "2");
} else {
p.setAttribute("fill", "rgba(0,0,0,0.04)");
p.setAttribute("stroke", "rgba(0,0,0,0.25)");
}
(p as SVGElement).style.pointerEvents = "none";
});
svg.querySelectorAll("path:not([class])").forEach((p) => {
(p as SVGElement).style.opacity = "0.4";
(p as SVGElement).style.pointerEvents = "none";
});
}, [svgText, zoneId]);
// Imperatively render points as SVG circles inside the rotated <g>.
// This guarantees they sit at the exact same place as the SVG content,
// regardless of letterboxing / aspect-ratio.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const svg = el.querySelector("svg") as SVGSVGElement | null;
if (!svg) return;
const rotGroup = svg.querySelector("g[data-rot]") as SVGGElement | null;
if (!rotGroup) return;
// Remove existing overlay group if any
const existing = rotGroup.querySelector("g[data-points]");
if (existing) existing.remove();
// Build new overlay group
const group = document.createElementNS(SVG_NS, "g");
group.setAttribute("data-points", "1");
points.forEach((p, i) => {
const cx = p.x * COMPOSITE_W;
const cy = p.y * COMPOSITE_H;
const circle = document.createElementNS(SVG_NS, "circle");
circle.setAttribute("cx", String(cx));
circle.setAttribute("cy", String(cy));
circle.setAttribute("r", String(POINT_RADIUS));
circle.setAttribute("fill", "rgb(239, 68, 68)");
circle.setAttribute("stroke", "white");
circle.setAttribute("stroke-width", "3");
circle.setAttribute("data-point-index", String(i));
(circle as SVGElement).style.cursor = "pointer";
group.appendChild(circle);
const text = document.createElementNS(SVG_NS, "text");
text.setAttribute("x", String(cx));
text.setAttribute("y", String(cy));
text.setAttribute("text-anchor", "middle");
text.setAttribute("dominant-baseline", "central");
text.setAttribute("fill", "white");
text.setAttribute("font-size", "20");
text.setAttribute("font-weight", "bold");
text.setAttribute("data-point-label", String(i));
(text as SVGElement).style.pointerEvents = "none";
(text as SVGElement).style.userSelect = "none";
text.textContent = String(i + 1);
group.appendChild(text);
});
rotGroup.appendChild(group);
}, [svgText, points]);
function handleTap(e: React.MouseEvent<HTMLDivElement>) {
// If user clicked an existing point circle, remove it (if it's the last one)
const target = e.target as Element;
const pointIdxAttr = target.getAttribute?.("data-point-index");
if (pointIdxAttr != null) {
const idx = Number(pointIdxAttr);
if (idx === points.length - 1) {
setPoints((p) => p.slice(0, -1));
}
return;
}
const el = containerRef.current;
if (!el) return;
const svg = el.querySelector("svg") as SVGSVGElement | null;
if (!svg) return;
// getScreenCTM gives matrix from SVG root user-space to screen pixels.
// It does NOT include inner <g> transforms — so svgPt is in the rotated viewBox frame.
const pt = svg.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const ctm = svg.getScreenCTM();
if (!ctm) return;
const svgPt = pt.matrixTransform(ctm.inverse());
// Convert from the visible (rotated) frame back to the original composite frame
// so storage is in a canonical coord system.
const bboxes = bboxCache;
const b = bboxes?.[String(zoneId)] ?? null;
const original = b
? inverseRotateAroundBboxCenter({ x: svgPt.x, y: svgPt.y }, b)
: { x: svgPt.x, y: svgPt.y };
const xN = Math.max(0, Math.min(1, original.x / COMPOSITE_W));
const yN = Math.max(0, Math.min(1, original.y / COMPOSITE_H));
setPoints((prev) => [...prev, { x: xN, y: yN }]);
}
if (!open) return null;
return (
<div className="fixed inset-0 z-50 bg-background flex flex-col">
<header className="flex items-center justify-between p-4 border-b">
<div>
<div className="text-xs text-muted-foreground">Зона</div>
<h2 className="text-lg font-semibold capitalize">{zoneLabel}</h2>
</div>
<div className="text-xs text-muted-foreground">
Точек: <span className="font-semibold text-foreground">{points.length}</span>
</div>
</header>
<main className="flex-1 overflow-hidden relative bg-card">
<div
ref={containerRef}
onClick={handleTap}
className="w-full h-full"
style={{ touchAction: "manipulation" }}
dangerouslySetInnerHTML={svgText ? { __html: svgText } : undefined}
/>
{!svgText && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground pointer-events-none">
Загружаю схему
</div>
)}
</main>
<footer className="border-t p-3 flex items-center gap-3 bg-card">
<button
onClick={onCancel}
className="flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border border-destructive text-destructive hover:bg-destructive/10 active:bg-destructive/20 transition-colors font-semibold"
aria-label="Отмена"
>
<span className="text-2xl leading-none">&#x2715;</span>
<span>Отмена</span>
</button>
<button
onClick={() => onConfirm(points)}
disabled={points.length === 0}
className={cn(
"flex-1 flex items-center justify-center gap-2 py-3 rounded-lg font-semibold transition-colors",
points.length === 0
? "bg-muted text-muted-foreground"
: "bg-primary text-primary-foreground hover:opacity-90 active:opacity-80"
)}
aria-label="Готово"
>
<span className="text-2xl leading-none">&#x2713;</span>
<span>Готово ({points.length})</span>
</button>
</footer>
</div>
);
}
/** Convert tap point from rotated/visible viewBox frame back to original composite frame. */
function inverseRotateAroundBboxCenter(
pt: { x: number; y: number },
b: ZoneBBox
): { x: number; y: number } {
const rot = b.rotation ?? 0;
if (rot === 0) return pt;
const cx = b.x + b.w / 2;
const cy = b.y + b.h / 2;
const dx = pt.x - cx;
const dy = pt.y - cy;
const rad = (-rot * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
return {
x: cx + dx * cos - dy * sin,
y: cy + dx * sin + dy * cos,
};
}
@@ -0,0 +1,217 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { CameraCapture } from "@/components/camera/CameraCapture";
import { AuthImg } from "@/components/AuthImg";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { uploadPhoto } from "@/api/photos";
import { createMarker, type MarkerSummary } from "@/api/inspections";
const WEAR_LEVELS: { value: string; label: string; description: string }[] = [
{
value: "cosmetic",
label: "Новая",
description: "Протектор полный, без признаков износа",
},
{
value: "minor",
label: "Норма",
description: "Естественный износ, эксплуатация в норме",
},
{
value: "moderate",
label: "Износ",
description: "Заметный износ, требует наблюдения",
},
{
value: "severe",
label: "Менять",
description: "Критический износ, замена необходима",
},
];
interface Props {
open: boolean;
inspectionId: number;
onClose: () => void;
onCreated: (marker: MarkerSummary) => void;
}
export function TireWearDialog({
open,
inspectionId,
onClose,
onCreated,
}: Props) {
const [level, setLevel] = useState<string>("minor");
const [description, setDescription] = useState("");
const [photoId, setPhotoId] = useState<number | undefined>(undefined);
const [uploading, setUploading] = useState(false);
async function handlePhoto(file: File) {
setUploading(true);
try {
const result = await uploadPhoto(inspectionId, "free", 0, file);
setPhotoId(result.id);
toast.success("Фото загружено");
} catch {
toast.error("Ошибка загрузки фото");
} finally {
setUploading(false);
}
}
const createMut = useMutation({
mutationFn: () =>
createMarker(inspectionId, {
side: "tires",
severity: level,
// "not-working" is the closest existing damage_type enum value.
// The semantically meaningful field here is `severity` (wear level).
damage_type: "not-working",
description:
description.trim() ||
`Состояние резины: ${WEAR_LEVELS.find((w) => w.value === level)?.label ?? level}`,
photo_id: photoId,
x: null,
y: null,
}),
onSuccess: (marker) => {
onCreated(marker);
reset();
},
onError: () => toast.error("Не удалось сохранить"),
});
function reset() {
setLevel("minor");
setDescription("");
setPhotoId(undefined);
}
function handleClose() {
reset();
onClose();
}
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center p-0 sm:p-4"
onClick={handleClose}
>
<div
className="w-full max-w-md bg-card rounded-t-2xl sm:rounded-2xl shadow-xl p-5 space-y-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Состояние резины</h2>
<button
type="button"
onClick={handleClose}
className="text-muted-foreground hover:text-foreground text-2xl leading-none"
aria-label="Закрыть"
>
×
</button>
</div>
{/* Wear level — radio cards for large touch targets */}
<div className="space-y-2">
<Label>Уровень износа</Label>
<div className="space-y-2">
{WEAR_LEVELS.map((w) => (
<label
key={w.value}
className={
"flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors " +
(level === w.value
? "border-primary bg-accent"
: "border-input hover:bg-accent/50")
}
>
<input
type="radio"
name="tire_wear"
value={w.value}
checked={level === w.value}
onChange={() => setLevel(w.value)}
className="mt-0.5 shrink-0"
/>
<div>
<div className="font-medium text-sm">{w.label}</div>
<div className="text-xs text-muted-foreground">
{w.description}
</div>
</div>
</label>
))}
</div>
</div>
{/* Optional comment */}
<div className="space-y-2">
<Label htmlFor="tire_comment">Комментарий (опционально)</Label>
<Input
id="tire_comment"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Например: глубина протектора ~3 мм"
/>
</div>
{/* Optional photo */}
<div className="space-y-2">
<Label>Фото (опционально)</Label>
{photoId ? (
<div className="space-y-2">
<AuthImg
src={`photos/${photoId}/thumb`}
alt="Фото резины"
className="w-full aspect-[4/3] object-cover rounded bg-muted"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setPhotoId(undefined)}
className="w-full"
>
Удалить фото
</Button>
</div>
) : (
<CameraCapture
onCapture={handlePhoto}
buttonLabel={uploading ? "Загружаю…" : "Сделать фото"}
/>
)}
</div>
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="flex-1"
>
Отмена
</Button>
<Button
type="button"
disabled={createMut.isPending || uploading}
onClick={() => createMut.mutate()}
className="flex-1"
>
{createMut.isPending ? "Сохраняю…" : "Сохранить"}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,157 @@
import { useEffect, useRef, useState } from "react";
interface Point {
x: number;
y: number;
}
interface DamageOverlay {
zoneId: number;
points: Point[];
}
interface Props {
damagedZoneIds: number[];
/** Координаты точек повреждений в композитной SVG-системе (827×1209). */
damages?: DamageOverlay[];
onZoneClick?: (zoneId: number) => void;
}
const SVG_NS = "http://www.w3.org/2000/svg";
const POINT_RADIUS = 9;
/** Композитная SVG-схема экстерьера машины с подсветкой зон + точками
* повреждений. Координаты точек в системе 827×1209 (та же что у vendor). */
export function TtDamageMap({ damagedZoneIds, damages, onZoneClick }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [svgText, setSvgText] = useState<string | null>(null);
const damagedSet = new Set(damagedZoneIds.map(String));
useEffect(() => {
let cancelled = false;
fetch("/scheme/exterior.svg")
.then((r) => r.text())
.then((text) => {
if (cancelled) return;
let mut = text;
mut = mut.replace(/<script[\s\S]*?<\/script>/gi, "");
mut = mut.replace(
/(<svg\b[^>]*?)\s(?:width|height)\s*=\s*"[^"]*"/gi,
"$1"
);
mut = mut.replace(
/<svg\b/i,
`<svg width="100%" height="100%" preserveAspectRatio="xMidYMid meet"`
);
setSvgText(mut);
});
return () => {
cancelled = true;
};
}, []);
// Tinting + cursors
useEffect(() => {
if (!svgText) return;
const el = containerRef.current;
if (!el) return;
const svg = el.querySelector("svg") as SVGSVGElement | null;
if (!svg) return;
svg.querySelectorAll("path[class]").forEach((p) => {
const cls = p.getAttribute("class") ?? "";
if (damagedSet.has(cls)) {
p.setAttribute("fill", "rgba(239, 68, 68, 0.35)");
p.setAttribute("stroke", "rgb(239, 68, 68)");
p.setAttribute("stroke-width", "2");
(p as SVGElement).style.cursor = onZoneClick ? "pointer" : "default";
} else {
p.setAttribute("fill", "rgba(0,0,0,0.04)");
p.setAttribute("stroke", "rgba(0,0,0,0.25)");
p.setAttribute("stroke-width", "0.5");
(p as SVGElement).style.cursor = "default";
}
});
svg.querySelectorAll("path:not([class])").forEach((p) => {
(p as SVGElement).style.opacity = "0.4";
(p as SVGElement).style.pointerEvents = "none";
});
}, [svgText, damagedSet, onZoneClick]);
// Point overlay — рисуем поверх SVG в его user-space (827×1209) чтобы
// точки идеально совмещались с подсвеченными зонами при любом масштабе.
useEffect(() => {
if (!svgText) return;
const el = containerRef.current;
if (!el) return;
const svg = el.querySelector("svg") as SVGSVGElement | null;
if (!svg) return;
const existing = svg.querySelector("g[data-tt-damage-points]");
if (existing) existing.remove();
if (!damages || damages.length === 0) return;
const group = document.createElementNS(SVG_NS, "g");
group.setAttribute("data-tt-damage-points", "1");
let counter = 1;
for (const dmg of damages) {
for (const pt of dmg.points || []) {
if (
typeof pt.x !== "number" ||
typeof pt.y !== "number" ||
!Number.isFinite(pt.x) ||
!Number.isFinite(pt.y)
) {
continue;
}
const circle = document.createElementNS(SVG_NS, "circle");
circle.setAttribute("cx", String(pt.x));
circle.setAttribute("cy", String(pt.y));
circle.setAttribute("r", String(POINT_RADIUS));
circle.setAttribute("fill", "rgb(220, 38, 38)");
circle.setAttribute("stroke", "white");
circle.setAttribute("stroke-width", "2");
(circle as SVGElement).style.pointerEvents = "none";
group.appendChild(circle);
const text = document.createElementNS(SVG_NS, "text");
text.setAttribute("x", String(pt.x));
text.setAttribute("y", String(pt.y));
text.setAttribute("text-anchor", "middle");
text.setAttribute("dominant-baseline", "central");
text.setAttribute("fill", "white");
text.setAttribute("font-size", "11");
text.setAttribute("font-weight", "bold");
(text as SVGElement).style.pointerEvents = "none";
(text as SVGElement).style.userSelect = "none";
text.textContent = String(counter);
group.appendChild(text);
counter += 1;
}
}
svg.appendChild(group);
}, [svgText, damages]);
function handleClick(e: React.MouseEvent<HTMLDivElement>) {
if (!onZoneClick) return;
const target = e.target as Element;
const cls = target.getAttribute?.("class");
if (cls && damagedSet.has(cls)) {
const zoneId = Number(cls);
if (Number.isFinite(zoneId)) onZoneClick(zoneId);
}
}
return (
<div
ref={containerRef}
onClick={handleClick}
className="w-full aspect-[827/1209] max-h-[60vh] mx-auto bg-card rounded-lg overflow-hidden"
style={{ touchAction: "manipulation" }}
dangerouslySetInnerHTML={svgText ? { __html: svgText } : undefined}
/>
);
}
@@ -0,0 +1,55 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
@@ -0,0 +1,85 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};
@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };
@@ -0,0 +1,23 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
@@ -0,0 +1,25 @@
import { Toaster as SonnerToaster } from "sonner";
type ToasterProps = React.ComponentProps<typeof SonnerToaster>;
const Toaster = ({ ...props }: ToasterProps) => {
return (
<SonnerToaster
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster };
@@ -0,0 +1,263 @@
import { useEffect, useMemo, useRef, useState } from "react";
import exteriorSvgUrl from "/scheme/exterior.svg?url";
import { cn } from "@/lib/utils";
// Zone color states
const COLOR_DEFAULT_FILL = "rgba(0, 0, 0, 0)"; // transparent
const COLOR_HOVER_FILL = "rgba(59, 130, 246, 0.20)"; // soft blue
const COLOR_HAS_DAMAGE_FILL = "rgba(239, 68, 68, 0.30)"; // red tint for zones with active damage
const COLOR_RESOLVED_FILL = "rgba(156, 163, 175, 0.30)"; // gray for resolved-only zones
export interface SchemeMarker {
id: number;
side?: string | null;
damage_type?: string | null;
severity?: string | null;
resolved: boolean;
/** Координаты точек повреждения в композитной SVG-системе (827×1209). */
polygon?: { x: number; y: number }[] | null;
}
const SVG_NS = "http://www.w3.org/2000/svg";
const POINT_RADIUS = 9;
const POINT_FILL_ACTIVE = "rgb(220, 38, 38)";
const POINT_FILL_RESOLVED = "rgb(107, 114, 128)";
const COMPOSITE_W = 827;
const COMPOSITE_H = 1209;
interface Props {
markers: SchemeMarker[];
onZoneTap?: (zoneId: number) => void;
onTireWearClick?: () => void;
editable?: boolean;
}
/** Parse "zone-23" -> 23, anything else -> null */
function parseZoneId(side: string | null | undefined): number | null {
if (!side) return null;
const m = /^zone-(\d+)$/.exec(side);
if (!m) return null;
const n = Number(m[1]);
return Number.isFinite(n) ? n : null;
}
export function VendorVehicleScheme({
markers,
onZoneTap,
onTireWearClick,
editable = true,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [svgText, setSvgText] = useState<string | null>(null);
const [hoverZone, setHoverZone] = useState<number | null>(null);
// Load SVG once from public/scheme/exterior.svg as a separate asset
useEffect(() => {
let cancelled = false;
fetch(exteriorSvgUrl)
.then((r) => r.text())
.then((t) => {
if (!cancelled) setSvgText(t);
})
.catch(() => {
if (!cancelled) setSvgText("<!-- failed to load -->");
});
return () => {
cancelled = true;
};
}, []);
// Count damage per zone, separately track has-any vs has-only-resolved
const zoneStats = useMemo(() => {
const stats = new Map<number, { count: number; activeCount: number }>();
for (const m of markers) {
const z = parseZoneId(m.side);
if (z == null) continue;
const s = stats.get(z) ?? { count: 0, activeCount: 0 };
s.count += 1;
if (!m.resolved) s.activeCount += 1;
stats.set(z, s);
}
return stats;
}, [markers]);
const tireCount = markers.filter((m) => m.side === "tires").length;
// Apply zone fills after SVG renders or when hover/damage state changes
useEffect(() => {
if (!svgText) return;
const el = containerRef.current;
if (!el) return;
const svg = el.querySelector("svg");
if (!svg) return;
// Strip any inline <script> the vendor included
svg.querySelectorAll("script").forEach((s) => s.remove());
// Make the SVG fill its container
svg.setAttribute("width", "100%");
svg.setAttribute("height", "100%");
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
// Reset all numeric-class paths to appropriate fill
const all = svg.querySelectorAll("path[class]");
all.forEach((p) => {
const cls = p.getAttribute("class") ?? "";
const zid = /^\d+$/.test(cls) ? Number(cls) : null;
if (zid == null) return;
const s = zoneStats.get(zid);
let fill = COLOR_DEFAULT_FILL;
if (s) {
fill = s.activeCount > 0 ? COLOR_HAS_DAMAGE_FILL : COLOR_RESOLVED_FILL;
}
if (hoverZone === zid && editable) fill = COLOR_HOVER_FILL;
p.setAttribute("fill", fill);
(p as SVGElement).style.cursor = editable ? "pointer" : "default";
(p as SVGElement).style.transition = "fill 0.15s";
});
}, [svgText, zoneStats, hoverZone, editable]);
// Overlay точек повреждений (multi-point координаты в композитной 827×1209)
useEffect(() => {
if (!svgText) return;
const el = containerRef.current;
if (!el) return;
const svg = el.querySelector("svg") as SVGSVGElement | null;
if (!svg) return;
// Удаляем предыдущий overlay
const existing = svg.querySelector("g[data-damage-points]");
if (existing) existing.remove();
const group = document.createElementNS(SVG_NS, "g");
group.setAttribute("data-damage-points", "1");
let counter = 1;
for (const m of markers) {
const pts = m.polygon || [];
if (pts.length === 0) continue;
const fill = m.resolved ? POINT_FILL_RESOLVED : POINT_FILL_ACTIVE;
for (const pt of pts) {
if (
typeof pt.x !== "number" ||
typeof pt.y !== "number" ||
!Number.isFinite(pt.x) ||
!Number.isFinite(pt.y)
) {
continue;
}
// Polygon хранится в normalised 0..1 (см. ZoneDetailView). SVG viewBox
// в композитной 827×1209, поэтому умножаем перед отрисовкой.
const cx = pt.x * COMPOSITE_W;
const cy = pt.y * COMPOSITE_H;
const circle = document.createElementNS(SVG_NS, "circle");
circle.setAttribute("cx", String(cx));
circle.setAttribute("cy", String(cy));
circle.setAttribute("r", String(POINT_RADIUS));
circle.setAttribute("fill", fill);
circle.setAttribute("stroke", "white");
circle.setAttribute("stroke-width", "2");
(circle as SVGElement).style.pointerEvents = "none";
group.appendChild(circle);
const text = document.createElementNS(SVG_NS, "text");
text.setAttribute("x", String(cx));
text.setAttribute("y", String(cy));
text.setAttribute("text-anchor", "middle");
text.setAttribute("dominant-baseline", "central");
text.setAttribute("fill", "white");
text.setAttribute("font-size", "11");
text.setAttribute("font-weight", "bold");
(text as SVGElement).style.pointerEvents = "none";
(text as SVGElement).style.userSelect = "none";
text.textContent = String(counter);
group.appendChild(text);
counter += 1;
}
}
svg.appendChild(group);
}, [svgText, markers]);
// Walk up from event.target to find a path[class="N"] (numeric zone id)
function zoneFromEvent(e: React.MouseEvent | React.TouchEvent): number | null {
let node = e.target as Element | null;
while (node && node !== e.currentTarget) {
if (node.tagName?.toLowerCase() === "path") {
const cls = node.getAttribute("class") ?? "";
if (/^\d+$/.test(cls)) return Number(cls);
}
node = node.parentElement;
}
return null;
}
function handleClick(e: React.MouseEvent<HTMLDivElement>) {
if (!editable) return;
const z = zoneFromEvent(e);
if (z != null) onZoneTap?.(z);
}
function handleMove(e: React.MouseEvent<HTMLDivElement>) {
if (!editable) return;
setHoverZone(zoneFromEvent(e));
}
return (
<div className="space-y-3">
{/* SVG container — aspect ratio matches vendor viewBox 827×1209 */}
<div
ref={containerRef}
onClick={handleClick}
onMouseMove={handleMove}
onMouseLeave={() => setHoverZone(null)}
className={cn(
"mx-auto w-full max-w-md bg-card border rounded-lg p-2 aspect-[827/1209]",
"overflow-hidden",
)}
style={{ touchAction: "manipulation" }}
dangerouslySetInnerHTML={svgText ? { __html: svgText } : undefined}
/>
{/* Summary line: count of zones with damage */}
{zoneStats.size > 0 && (
<div className="mx-auto max-w-md text-xs text-muted-foreground text-center">
Зон с повреждениями:{" "}
<span className="font-semibold text-foreground">{zoneStats.size}</span>
{" · "}
Всего меток:{" "}
<span className="font-semibold text-foreground">
{markers.filter((m) => parseZoneId(m.side) != null).length}
</span>
</div>
)}
{/* Global tire-wear button */}
<button
type="button"
onClick={onTireWearClick}
disabled={!editable && !onTireWearClick}
className="w-full max-w-md mx-auto flex items-center justify-between gap-3 rounded-lg border bg-card px-4 py-3 hover:bg-accent transition-colors text-sm font-medium"
>
<span className="flex items-center gap-2">
<span className="text-xl">&#8857;</span>
<span>Состояние резины</span>
</span>
{tireCount > 0 && (
<span className="bg-destructive text-destructive-foreground text-xs font-bold rounded-full px-2 py-0.5">
{tireCount}
</span>
)}
</button>
{editable && (
<p className="text-xs text-center text-muted-foreground">
Тапните по зоне машины, чтобы добавить метку повреждения.
</p>
)}
</div>
);
}
@@ -0,0 +1,65 @@
export interface DamageVocabulary {
damage_types_ordered: string[];
exterior_selectable: string[];
salon_selectable: string[];
severity_scale: {
positions: number;
labels: string[];
values: number[];
default: string;
};
}
let cache: DamageVocabulary | null = null;
export async function loadDamageVocabulary(): Promise<DamageVocabulary> {
if (cache) return cache;
const res = await fetch("/data/damage_vocabulary.json");
const data = await res.json();
cache = {
damage_types_ordered: data.damage_types_ordered,
exterior_selectable: data.exterior_selectable,
salon_selectable: data.salon_selectable,
severity_scale: data.severity_scale,
};
return cache;
}
/**
* Mapping русского названия из damage_vocabulary.json в DamageType enum
* нашего backend'а (см. mechanic_schemas.DamageType Literal).
* Возвращает null для типов которым нет точного соответствия —
* вызывающий код должен такие отфильтровать из UI.
*/
export const DAMAGE_RU_TO_ENUM: Record<string, string | null> = {
"Вмятина": "dent",
"Царапина": "scratch",
"Трещина": "crack",
"Повреждено": "damaged",
"Скол": "chip",
"Отсутствует": "missing",
"Прожжено": "burn",
"Погнуто": "bent",
"Требуется мойка": null,
"Не работает": "not-working",
"Порез": "cut",
"Прокол": "puncture",
"Порвано": "torn",
"Пятна": "stain",
"Грязный салон": "stain",
"Запах табака": null,
"Повреждение салонных ковриков": "damaged",
"Повреждение обшивки дверей": "damaged",
"Сломанные ручки": "damaged",
"Установлено": "removed",
"Снято": "removed",
"Контроль": null,
"Требуется химчистка": "stain",
"Затертость": "scuff",
"Грыжа": "bulge",
"Штат": null,
};
export function damageTypeRuToEnum(ru: string): string | null {
return DAMAGE_RU_TO_ENUM[ru] ?? null;
}
@@ -0,0 +1,16 @@
let cache: Record<string, string> | null = null;
export async function loadZoneLabels(): Promise<Record<string, string>> {
if (cache) return cache;
const res = await fetch("/data/zone_labels.json");
const data = await res.json();
// The JSON has a "_meta" key + numeric string keys → filter to numeric only
cache = Object.fromEntries(
Object.entries(data).filter(([k]) => /^\d+$/.test(k))
) as Record<string, string>;
return cache;
}
export function getZoneLabelSync(zoneId: number, labels: Record<string, string> | null): string {
return labels?.[String(zoneId)] ?? `зона ${zoneId}`;
}
@@ -0,0 +1,121 @@
/**
* Фоновая сверка прав механика раз в 5 минут (У35).
*
* Если за время активной сессии админ снял `can_login_pwa` или вывел
* пользователя из отдела механиков (или потерял `is_admin`), сервер
* на ближайшем `/me` вернёт 403 с `code: "NO_PWA_ACCESS"`. В этом
* случае хук:
* 1) Сбрасывает access-токен (через `useAuth.setToken(null)`) —
* `client.ts` перехватчик 401 не сработает, но мы делаем то же
* сами явно.
* 2) Кладёт информацию о ревокации в sessionStorage, чтобы экран
* Login смог отрендерить корректное сообщение
* «Ваш доступ отозван» (с кликабельным контактом саппорта).
* 3) **Не трогает IndexedDB-черновики** — они переживают сброс
* токена (У35). Если доступ вернут — механик при следующем
* входе увидит «Продолжить незавершённый ремонт» (У18).
*
* Параметр интервала задан в файле и при необходимости подкручивается
* с одного места.
*/
import { useEffect, useRef } from "react";
import { HTTPError } from "ky";
import { useAuth } from "@/store/auth";
import { getMe, type Me } from "@/api/me";
export const SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 минут (У35)
export interface RevocationInfo {
reason: "NO_PWA_ACCESS";
support: {
tg_username: string | null;
phone: string | null;
};
at: string; // ISO timestamp
}
export const REVOCATION_STORAGE_KEY = "mechanic-pwa.revocation";
function storeRevocation(detail: unknown): void {
// backend кладёт detail = { code, support }
const d = detail as { code?: string; support?: RevocationInfo["support"] } | null | undefined;
if (!d || d.code !== "NO_PWA_ACCESS") return;
const payload: RevocationInfo = {
reason: "NO_PWA_ACCESS",
support: d.support ?? { tg_username: null, phone: null },
at: new Date().toISOString(),
};
try {
sessionStorage.setItem(REVOCATION_STORAGE_KEY, JSON.stringify(payload));
} catch {
/* sessionStorage недоступен (приватный режим?) — silent */
}
}
export function readRevocation(): RevocationInfo | null {
try {
const raw = sessionStorage.getItem(REVOCATION_STORAGE_KEY);
return raw ? (JSON.parse(raw) as RevocationInfo) : null;
} catch {
return null;
}
}
export function clearRevocation(): void {
try {
sessionStorage.removeItem(REVOCATION_STORAGE_KEY);
} catch {
/* silent */
}
}
/**
* Подключить к корневому компоненту PWA, чтобы фоновая сверка работала
* пока приложение открыто. Без зависимостей — стартует один раз
* на mount и завершается на unmount.
*/
export function useMeSync(): void {
const token = useAuth((s) => s.token);
const setToken = useAuth((s) => s.setToken);
const tickInFlight = useRef(false);
useEffect(() => {
if (!token) return; // нечего сверять
const tick = async () => {
if (tickInFlight.current) return;
tickInFlight.current = true;
try {
const _me: Me = await getMe();
void _me;
} catch (err) {
if (err instanceof HTTPError && err.response.status === 403) {
// backend FastAPI кладёт detail в JSON
let detail: unknown = null;
try {
const body = (await err.response.json()) as { detail?: unknown };
detail = body?.detail ?? null;
} catch {
/* пустое или не-JSON тело */
}
storeRevocation(detail);
setToken(null);
}
// 401 ловится в beforeError client.ts автоматически.
} finally {
tickInFlight.current = false;
}
};
const handleFocus = () => { void tick(); };
document.addEventListener("visibilitychange", handleFocus);
const id = window.setInterval(tick, SYNC_INTERVAL_MS);
return () => {
clearInterval(id);
document.removeEventListener("visibilitychange", handleFocus);
};
}, [token, setToken]);
}
/** Вспомогательная функция для use в Login.tsx — единичная проверка после login. */
export { storeRevocation };
@@ -0,0 +1,58 @@
/**
* Хук ленты ремонтов с stale-while-revalidate (У38).
*
* Использует `useInfiniteQuery` для бесконечной пагинации по 20 (У38).
* На `visibilitychange` (когда вкладка/приложение получают фокус) —
* автоматический рефреш первой страницы. Pull-to-refresh вызывается
* напрямую из компонента через `refetch()`.
*
* SWR-кэш живёт в react-query (память + localStorage persist уже
* сконфигурирован в QueryClient на уровне приложения, если такого нет —
* первая отрисовка идёт без кэша, но дальше работает).
*/
import { useEffect } from "react";
import { useInfiniteQuery, type QueryKey } from "@tanstack/react-query";
import { getRepairsFeed, type FeedItem, type FeedResponse } from "@/api/repairsFeed";
export const FEED_QUERY_KEY: QueryKey = ["repairs-feed"];
export const FEED_PAGE_SIZE = 20;
export function useRepairsFeed() {
const query = useInfiniteQuery<FeedResponse>({
queryKey: FEED_QUERY_KEY,
queryFn: ({ pageParam }) =>
getRepairsFeed({ limit: FEED_PAGE_SIZE, cursor: (pageParam as string | null) ?? null }),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined,
staleTime: 30_000, // 30 секунд считаем «свежим» — потом фон обновляет
refetchOnWindowFocus: false, // делаем свой visibilitychange-листенер ниже
});
// У38: обновление при возврате в приложение/вкладку.
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === "visible") {
// Рефрешим только первую страницу, бесконечный скролл сбрасывается.
void query.refetch();
}
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Удобный flatten для рендера в одну ленту.
const items: FeedItem[] = query.data?.pages.flatMap((p) => p.items) ?? [];
return {
items,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
fetchNextPage: query.fetchNextPage,
hasNextPage: !!query.hasNextPage,
isFetchingNextPage: query.isFetchingNextPage,
refetch: query.refetch,
};
}
+35
View File
@@ -0,0 +1,35 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 40 30% 95%; /* cream */
--foreground: 0 0% 16%; /* near-black */
--card: 0 0% 100%;
--card-foreground: 0 0% 16%;
--primary: 0 0% 16%;
--primary-foreground: 40 30% 95%;
--secondary: 40 20% 88%;
--secondary-foreground: 0 0% 16%;
--muted: 40 20% 92%;
--muted-foreground: 0 0% 40%;
--accent: 40 20% 90%;
--accent-foreground: 0 0% 16%;
--destructive: 0 70% 50%;
--destructive-foreground: 40 30% 95%;
--border: 40 15% 85%;
--input: 40 15% 85%;
--ring: 0 0% 16%;
--radius: 0.5rem;
}
}
@layer base {
* { @apply border-border; }
body {
@apply bg-background text-foreground font-sans;
}
}
@@ -0,0 +1,59 @@
/**
* Форматы времени для ленты ремонтов PWA (У39).
*
* Гибрид «Сегодня в 14:32 / Вчера в 18:00 / 23 мая, 14:32 /
* 23 мая 2025, 14:32» — для рабочего инструмента нужно точное время,
* а не относительное «2 часа назад».
*
* В детальной карточке ремонта используется всегда полный формат
* (см. `formatFullDateTime`).
*/
const HHMM = new Intl.DateTimeFormat("ru-RU", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const DAY_MONTH = new Intl.DateTimeFormat("ru-RU", {
day: "numeric",
month: "long",
});
const DAY_MONTH_YEAR = new Intl.DateTimeFormat("ru-RU", {
day: "numeric",
month: "long",
year: "numeric",
});
function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
/** У39: формат строки ленты. */
export function formatFeedTimestamp(input: string | Date, now: Date = new Date()): string {
const dt = typeof input === "string" ? new Date(input) : input;
if (Number.isNaN(dt.getTime())) return String(input);
const todayStart = startOfDay(now);
const yesterdayStart = new Date(todayStart);
yesterdayStart.setDate(yesterdayStart.getDate() - 1);
if (dt >= todayStart) {
return `Сегодня в ${HHMM.format(dt)}`;
}
if (dt >= yesterdayStart) {
return `Вчера в ${HHMM.format(dt)}`;
}
if (dt.getFullYear() === now.getFullYear()) {
return `${DAY_MONTH.format(dt)}, ${HHMM.format(dt)}`;
}
return `${DAY_MONTH_YEAR.format(dt)}, ${HHMM.format(dt)}`;
}
/** Полный формат для детальной карточки. */
export function formatFullDateTime(input: string | Date): string {
const dt = typeof input === "string" ? new Date(input) : input;
if (Number.isNaN(dt.getTime())) return String(input);
return `${DAY_MONTH_YEAR.format(dt)}, ${HHMM.format(dt)}`;
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+22
View File
@@ -0,0 +1,22 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "sonner";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 30_000 } },
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<Toaster richColors position="top-center" />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);
@@ -0,0 +1,98 @@
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getMe } from "@/api/me";
import { logout } from "@/api/auth";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
interface ActionTile {
key: string;
title: string;
description: string;
emoji: string;
onClick: () => void;
disabled?: boolean;
comingSoon?: boolean;
}
export default function ActionMenu() {
const navigate = useNavigate();
const meQuery = useQuery({ queryKey: ["me"], queryFn: getMe, staleTime: 60_000 });
const tiles: ActionTile[] = [
{
key: "inspect",
title: "Осмотр",
description: "Создать или продолжить осмотр машины",
emoji: "🔍",
onClick: () => navigate("/vehicles?action=inspect"),
},
{
key: "repair",
title: "Ремонт",
description: "Лента ремонтов парка + создать новый",
emoji: "🔧",
onClick: () => navigate("/repairs"),
},
];
return (
<div className="min-h-screen bg-background">
<header className="flex items-center justify-between p-4 border-b">
<div>
<h1 className="text-xl font-semibold">Premium Механик</h1>
{meQuery.data && (
<p className="text-xs text-muted-foreground">{meQuery.data.name}</p>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
logout();
navigate("/login", { replace: true });
}}
>
Выйти
</Button>
</header>
<main className="container max-w-2xl py-8 space-y-4">
<h2 className="text-lg font-semibold text-muted-foreground">
Что делаем?
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{tiles.map((t) => (
<Card
key={t.key}
onClick={t.disabled ? undefined : t.onClick}
className={
"p-5 transition-shadow " +
(t.disabled
? "opacity-50 cursor-not-allowed"
: "cursor-pointer hover:shadow-md active:shadow-sm")
}
>
<div className="flex items-start gap-3">
<div className="text-3xl leading-none">{t.emoji}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-base">{t.title}</h3>
{t.comingSoon && (
<span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5">
скоро
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
{t.description}
</p>
</div>
</div>
</Card>
))}
</div>
</main>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "react-router-dom";
import { listVehicles } from "@/api/vehicles";
import { Input } from "@/components/ui/input";
import { Card } from "@/components/ui/card";
const ACTION_TITLES: Record<string, string> = {
inspect: "Осмотр",
repair: "Ремонт",
};
// Группа цветов по семантике статуса. Палитра подсчитана из Tailwind с
// прозрачностью, чтобы чип не перебивал гос.номер. Tone:
// ok — на линии (зелёный)
// idle — простой (серый)
// repair — в ремонте / ждёт ремонта (янтарный)
// alert — ДТП (красный)
// admin — выкуп / на продаже / бронь / подготовка к продаже / ждёт доставки
// sold — продана (не отображается — отфильтрована из выдачи)
const STATUS_TONE: Record<string, string> = {
"На линии": "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
"Простой": "bg-muted text-muted-foreground",
"Ремонтируется": "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
"Ждет ремонта": "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
"ДТП": "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
"Бронь": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
"Выкуп": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
"На продаже": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
"Подготовка на продажу": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
"Ждет доставки до офиса": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
};
function StatusChip({ status }: { status: string }) {
const tone =
STATUS_TONE[status] ??
"bg-muted text-muted-foreground";
return (
<span className={`inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-full ${tone}`}>
{status}
</span>
);
}
export default function Home() {
const [q, setQ] = useState("");
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const action = searchParams.get("action") || undefined;
const subtitle = action ? ACTION_TITLES[action] : undefined;
const vehiclesQuery = useQuery({
queryKey: ["vehicles", q],
queryFn: () => listVehicles(q || undefined),
});
return (
<div className="min-h-screen bg-background">
<header className="flex items-center gap-3 p-4 border-b">
<button
onClick={() => navigate("/")}
className="text-sm text-muted-foreground hover:text-foreground"
>
Меню
</button>
<div className="flex-1">
<h1 className="text-lg font-semibold">
Машины{subtitle && `${subtitle}`}
</h1>
</div>
</header>
<main className="p-4">
<Input
placeholder="Поиск по госномеру"
value={q}
onChange={(e) => setQ(e.target.value)}
className="mb-4"
/>
{vehiclesQuery.isLoading ? (
<div className="text-sm text-muted-foreground">Загружаю</div>
) : vehiclesQuery.isError ? (
<div className="text-sm text-destructive">Не удалось загрузить.</div>
) : (vehiclesQuery.data ?? []).length === 0 ? (
<div className="text-sm text-muted-foreground">Ничего не найдено.</div>
) : (
<div className="space-y-2">
{(vehiclesQuery.data ?? []).map((v) => (
<Card
key={v.id}
className="p-4 cursor-pointer hover:shadow-md transition-shadow"
onClick={() =>
navigate(`/vehicles/${v.id}${action ? `?action=${action}` : ""}`)
}
>
<div className="flex items-center gap-2 flex-wrap">
<div className="font-semibold text-lg">{v.license_plate}</div>
{v.status && <StatusChip status={v.status} />}
</div>
<div className="text-sm text-muted-foreground">
{[v.make, v.model, v.year].filter(Boolean).join(" ") || "—"}
</div>
{v.last_inspection_at && (
<div className="text-xs text-muted-foreground mt-1">
Последний осмотр:{" "}
{new Date(v.last_inspection_at).toLocaleDateString("ru-RU")}
</div>
)}
</Card>
))}
</div>
)}
</main>
</div>
);
}
@@ -0,0 +1,557 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
getInspection,
patchInspection,
createMarker,
deleteMarker,
type MarkerSummary,
} from "@/api/inspections";
import { uploadPhoto, uploadPhotoAnnotation } from "@/api/photos";
import { PhotoSlot } from "@/components/camera/PhotoSlot";
import { CameraCapture } from "@/components/camera/CameraCapture";
import { VendorVehicleScheme } from "@/components/vehicle-scheme/VendorVehicleScheme";
import { TireWearDialog } from "@/components/inspection/TireWearDialog";
import { ZoneConfirmDialog } from "@/components/damage-flow/ZoneConfirmDialog";
import { ZoneDetailView } from "@/components/damage-flow/ZoneDetailView";
import { DamageClassifyDialog } from "@/components/damage-flow/DamageClassifyDialog";
import { PhotoAnnotateStep } from "@/components/damage-flow/PhotoAnnotateStep";
import { AuthImg } from "@/components/AuthImg";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { loadZoneLabels } from "@/data/zoneLabels";
import { damageTypeRuToEnum } from "@/data/damageVocabulary";
import { ttImageUrl } from "@/api/vehicles";
const SLOTS_GENERAL: { side: string; label: string }[] = [
{ side: "front", label: "Перед" },
{ side: "rear", label: "Зад" },
{ side: "left", label: "Левый бок" },
{ side: "right", label: "Правый бок" },
{ side: "vin", label: "VIN" },
{ side: "odometer", label: "Одометр" },
{ side: "interior", label: "Салон" },
{ side: "free", label: "Свободное" },
];
const SLOT_JACK = { side: "jack", label: "Домкрат" };
/** Все типы осмотров (выдача/приёмка/плановый/...) показывают одинаковый
* набор слотов общих ракурсов + домкрат. Раньше «return» исключал общие
* фото в пользу скорости приёмки, но фидбек: при приёмке всё равно надо
* фиксировать общее состояние машины, как и при выдаче/осмотре. */
function slotsForType(_type: string): typeof SLOTS_GENERAL {
return [...SLOTS_GENERAL, SLOT_JACK];
}
const SEVERITY_LABELS: Record<string, string> = {
cosmetic: "косметика",
minor: "лёгкое",
moderate: "среднее",
severe: "тяжёлое",
};
const DAMAGE_TYPE_LABELS: Record<string, string> = {
damaged: "повреждено",
scratch: "царапина",
chip: "скол",
dent: "вмятина",
crack: "трещина",
scuff: "затёртость",
burn: "прожог",
stain: "пятно",
bent: "погнуто",
torn: "порвано",
cut: "порез",
puncture: "прокол",
bulge: "грыжа",
missing: "отсутствует",
removed: "снято",
"not-working": "не работает",
};
// Vendor 3-position severity index → backend literal
const SEVERITY_MAP = ["minor", "moderate", "severe"] as const;
function humanizeMarkerSide(
side: string | null | undefined,
zoneLabels?: Record<string, string> | null,
): string {
if (!side) return "—";
if (side === "tires") return "Резина";
const zm = /^zone-(\d+)$/.exec(side);
if (zm) {
const label = zoneLabels?.[zm[1]];
if (label) return label;
return `Зона ${zm[1]}`;
}
const map: Record<string, string> = {
top: "Сверху",
front: "Перед",
back: "Зад",
left: "Левый",
right: "Правый",
"bumper-fl": "Бампер ПЛ",
"bumper-fr": "Бампер ПП",
"bumper-rl": "Бампер ЗЛ",
"bumper-rr": "Бампер ЗП",
// Legacy named zone-* support (data from earlier prod)
"zone-front": "Передний бампер",
"zone-rear": "Задний бампер",
"zone-left": "Левый бок",
"zone-right": "Правый бок",
"zone-hood": "Капот",
"zone-trunk": "Багажник",
"zone-roof": "Крыша",
};
return map[side] ?? side;
}
type DamageFlow =
| { kind: "idle" }
| { kind: "confirm"; zoneId: number; zoneLabel: string }
| { kind: "placePoints"; zoneId: number; zoneLabel: string }
| { kind: "classify"; zoneId: number; zoneLabel: string; points: { x: number; y: number }[] }
| { kind: "capture"; zoneId: number; zoneLabel: string; points: { x: number; y: number }[]; damageType: string; severity: 0 | 1 | 2 }
| { kind: "annotate"; zoneId: number; zoneLabel: string; points: { x: number; y: number }[]; damageType: string; severity: 0 | 1 | 2; photoFile: File };
export default function InspectionEditor() {
const { id } = useParams<{ id: string }>();
const insId = Number(id);
const navigate = useNavigate();
const qc = useQueryClient();
const [flow, setFlow] = useState<DamageFlow>({ kind: "idle" });
const [zoneLabels, setZoneLabels] = useState<Record<string, string> | null>(null);
const [tireWearOpen, setTireWearOpen] = useState(false);
// Local draft заметок механика — синхронизируется с ins.notes когда осмотр
// прогружается / обновляется. Кнопка «Сохранить» появляется только когда
// draft отличается от сохранённого (см. notesDirty ниже).
const [notesDraft, setNotesDraft] = useState<string>("");
useEffect(() => { loadZoneLabels().then(setZoneLabels); }, []);
const { data: ins, isLoading, isError } = useQuery({
queryKey: ["inspection", insId],
queryFn: () => getInspection(insId),
});
useEffect(() => {
if (ins?.notes !== undefined) setNotesDraft(ins.notes ?? "");
}, [ins?.notes]);
const finalize = useMutation({
mutationFn: () =>
// При завершении осмотра коммитим текст заметки тем же patch'ем —
// если механик что-то ввёл и сразу нажал «Завершить», не теряем.
patchInspection(insId, {
status: "completed",
notes: (notesDraft ?? "").trim() || undefined,
}),
onSuccess: () => {
toast.success("Осмотр завершён");
navigate(`/inspections/${insId}`);
},
onError: () => toast.error("Не удалось завершить"),
});
const notesMutation = useMutation({
mutationFn: (next: string) => patchInspection(insId, { notes: next }),
onSuccess: (updated) => {
toast.success("Заметка сохранена");
qc.setQueryData(["inspection", insId], updated);
},
onError: () => toast.error("Не удалось сохранить заметку"),
});
const removeMarker = useMutation({
mutationFn: (markerId: number) => deleteMarker(markerId),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["inspection", insId] });
toast.success("Метка удалена");
},
onError: () => toast.error("Не удалось удалить"),
});
if (isLoading) return <div className="p-8 text-muted-foreground">Загружаю</div>;
if (isError || !ins)
return <div className="p-8 text-destructive">Осмотр не найден.</div>;
// Photos lookup by side+slot for initialPhotoId
const photosBySide = new Map<string, typeof ins.photos[0]>();
for (const p of ins.photos) photosBySide.set(`${p.side}-${p.slot_index}`, p);
// Photos lookup by id — нужен для marker thumb (annotated vs original)
const photosById = new Map<number, typeof ins.photos[0]>();
for (const p of ins.photos) photosById.set(p.id, p);
const editable = ins.status === "in_progress";
const handleZoneTap = (zoneId: number) => {
const label = zoneLabels?.[String(zoneId)] ?? `зона ${zoneId}`;
setFlow({ kind: "confirm", zoneId, zoneLabel: label });
};
return (
<div className="min-h-screen bg-background p-4 space-y-4">
<button
onClick={() => navigate(-1)}
className="text-sm text-muted-foreground hover:text-foreground"
>
Назад
</button>
<div>
<h1 className="text-xl font-semibold">
Осмотр #{ins.id}{" "}
<span className="text-muted-foreground text-base">({ins.type})</span>
</h1>
<div className="text-xs text-muted-foreground">
{new Date(ins.started_at).toLocaleString("ru-RU")}
</div>
</div>
<Card className="p-4">
<h2 className="text-sm font-semibold text-muted-foreground mb-3">
Схема тапните на зону, чтобы добавить метку
</h2>
<VendorVehicleScheme
markers={ins.markers.map((m: MarkerSummary) => ({
id: m.id,
side: m.side,
damage_type: m.damage_type,
severity: m.severity,
resolved: m.resolved,
polygon: m.polygon,
}))}
onZoneTap={editable ? handleZoneTap : undefined}
onTireWearClick={editable ? () => setTireWearOpen(true) : undefined}
editable={editable}
/>
</Card>
{/* Блок «Заметки» поднят сразу под схему — главное текстовое поле для
планового осмотра, не хочется чтобы механик пролистывал фото+метки
чтобы добраться до textarea. */}
<Card className="p-4 space-y-2">
<h2 className="text-sm font-semibold text-muted-foreground">
Заметки механика
</h2>
{editable ? (
<>
<textarea
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
placeholder="Текст осмотра, замечания, рекомендации…"
rows={Math.max(3, Math.min(notesDraft.split("\n").length + 1, 12))}
className="w-full text-sm p-2 rounded border border-input bg-background resize-y focus:outline-none focus:ring-2 focus:ring-ring"
/>
{(notesDraft ?? "") !== (ins.notes ?? "") && (
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={() => setNotesDraft(ins.notes ?? "")}
className="text-xs text-muted-foreground hover:text-foreground px-3 py-1"
disabled={notesMutation.isPending}
>
Отменить
</button>
<button
type="button"
onClick={() => notesMutation.mutate(notesDraft)}
disabled={notesMutation.isPending}
className="text-xs bg-primary text-primary-foreground rounded px-3 py-1 disabled:opacity-60"
>
{notesMutation.isPending ? "Сохраняю…" : "Сохранить"}
</button>
</div>
)}
</>
) : (
<div className="text-sm whitespace-pre-wrap">{ins.notes || "—"}</div>
)}
</Card>
{/* Multi-step damage flow */}
{flow.kind === "confirm" && (
<ZoneConfirmDialog
open
zoneLabel={flow.zoneLabel}
onConfirm={() =>
setFlow({ kind: "placePoints", zoneId: flow.zoneId, zoneLabel: flow.zoneLabel })
}
onCancel={() => setFlow({ kind: "idle" })}
/>
)}
{flow.kind === "placePoints" && (
<ZoneDetailView
open
zoneId={flow.zoneId}
zoneLabel={flow.zoneLabel}
onConfirm={(points) =>
setFlow({ kind: "classify", zoneId: flow.zoneId, zoneLabel: flow.zoneLabel, points })
}
onCancel={() => setFlow({ kind: "idle" })}
/>
)}
{flow.kind === "classify" && (
<DamageClassifyDialog
open
zoneId={flow.zoneId}
zoneLabel={flow.zoneLabel}
pointsCount={flow.points.length}
onCancel={() =>
setFlow({ kind: "placePoints", zoneId: flow.zoneId, zoneLabel: flow.zoneLabel })
}
onSave={({ damageType, severity }) =>
setFlow({
kind: "capture",
zoneId: flow.zoneId,
zoneLabel: flow.zoneLabel,
points: flow.points,
damageType,
severity,
})
}
/>
)}
{flow.kind === "capture" && (
<CapturePhotoStep
zoneLabel={flow.zoneLabel}
onCancel={() => setFlow({ kind: "idle" })}
onPhotoCaptured={(file) =>
setFlow({
kind: "annotate",
zoneId: flow.zoneId,
zoneLabel: flow.zoneLabel,
points: flow.points,
damageType: flow.damageType,
severity: flow.severity,
photoFile: file,
})
}
/>
)}
{flow.kind === "annotate" && (
<PhotoAnnotateStep
photoFile={flow.photoFile}
zoneLabel={flow.zoneLabel}
onCancel={() =>
setFlow({
kind: "capture",
zoneId: flow.zoneId,
zoneLabel: flow.zoneLabel,
points: flow.points,
damageType: flow.damageType,
severity: flow.severity,
})
}
onSubmit={async (annotated) => {
try {
const damageEnum = damageTypeRuToEnum(flow.damageType);
if (!damageEnum) {
toast.error(`Не поддерживается: ${flow.damageType}`);
setFlow({ kind: "idle" });
return;
}
const result = await uploadPhoto(insId, "free", 0, flow.photoFile);
if (annotated) {
try {
await uploadPhotoAnnotation(insId, result.id, annotated);
} catch (err) {
// Аннотация — best-effort. Фото оригинал уже сохранилось.
console.warn("annotation upload failed (non-fatal)", err);
}
}
await createMarker(insId, {
side: `zone-${flow.zoneId}`,
polygon: flow.points,
damage_type: damageEnum,
severity: SEVERITY_MAP[flow.severity],
photo_id: result.id,
});
void qc.invalidateQueries({ queryKey: ["inspection", insId] });
toast.success("Повреждение записано");
} catch (err) {
console.error("save failed", err);
toast.error("Не удалось сохранить");
} finally {
setFlow({ kind: "idle" });
}
}}
/>
)}
<TireWearDialog
open={tireWearOpen}
inspectionId={insId}
onClose={() => setTireWearOpen(false)}
onCreated={() => {
setTireWearOpen(false);
void qc.invalidateQueries({ queryKey: ["inspection", insId] });
toast.success("Состояние резины записано");
}}
/>
<div>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">Фото</h2>
<div className="grid grid-cols-2 gap-2">
{slotsForType(ins.type).map((s) => (
<PhotoSlot
key={`${s.side}-0`}
inspectionId={insId}
side={s.side}
slotIndex={0}
label={s.label}
initialPhotoId={photosBySide.get(`${s.side}-0`)?.id}
onUploaded={() =>
void qc.invalidateQueries({ queryKey: ["inspection", insId] })
}
/>
))}
</div>
</div>
{ins.markers.length > 0 && (
<div>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
Метки ({ins.markers.length})
</h2>
<ul className="space-y-1">
{ins.markers.map((m: MarkerSummary) => (
<li key={m.id}>
<Card className="p-3 text-sm">
<div className="flex items-center justify-between">
<div>
<span className="font-medium capitalize">
{humanizeMarkerSide(m.side, zoneLabels)}
</span>
{" — "}
<span>
{m.damage_type
? (DAMAGE_TYPE_LABELS[m.damage_type] ?? m.damage_type)
: "—"}
</span>
{m.severity && (
<>
{" — "}
<span className="text-muted-foreground">
{SEVERITY_LABELS[m.severity] ?? m.severity}
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
{m.carried_over_from_id && (
<span className="text-xs text-muted-foreground">
перенесено
</span>
)}
{!m.carried_over_from_id &&
m.description?.startsWith("Перенесено из Element Mechanic") && (
<span className="text-xs text-muted-foreground">
из Element Mechanic
</span>
)}
{editable && (
<button
onClick={() => {
const verb = m.carried_over_from_id
? "Удалить (повреждение устранено в ремонте)?"
: "Удалить метку?";
if (window.confirm(verb)) {
removeMarker.mutate(m.id);
}
}}
disabled={removeMarker.isPending}
className="text-xs text-destructive hover:underline px-1"
title="Удалить метку"
>
</button>
)}
</div>
</div>
{m.description && (
<div className="text-xs text-muted-foreground mt-1">
{m.description}
</div>
)}
{m.photo_id ? (
<div className="mt-2 max-w-[8rem]">
<AuthImg
src={
photosById.get(m.photo_id)?.annotated_key
? `photos/${m.photo_id}/annotated`
: `photos/${m.photo_id}/thumb`
}
alt="Фото повреждения"
className="w-full aspect-[4/3] object-cover rounded bg-muted"
/>
</div>
) : m.tt_image_id ? (
<div className="mt-2 max-w-[8rem]">
<img
src={ttImageUrl(m.tt_image_id)}
alt="Фото из Element Mechanic"
loading="lazy"
className="w-full aspect-[4/3] object-cover rounded bg-muted"
/>
</div>
) : null}
</Card>
</li>
))}
</ul>
</div>
)}
{editable && (
<Button
className="w-full"
onClick={() => finalize.mutate()}
disabled={finalize.isPending}
>
{finalize.isPending ? "Завершаем…" : "Завершить осмотр"}
</Button>
)}
</div>
);
}
function CapturePhotoStep({
zoneLabel,
onCancel,
onPhotoCaptured,
}: {
zoneLabel: string;
onCancel: () => void;
onPhotoCaptured: (file: File) => void;
}) {
return (
<div className="fixed inset-0 z-50 bg-background flex flex-col">
<header className="p-4 border-b">
<div className="text-xs text-muted-foreground">Фото повреждения</div>
<h2 className="text-lg font-semibold capitalize">{zoneLabel}</h2>
</header>
<main className="flex-1 p-4 flex flex-col justify-center max-w-md w-full mx-auto">
<CameraCapture
onCapture={onPhotoCaptured}
buttonLabel="Сделать фото"
/>
<Button
variant="outline"
onClick={onCancel}
className="mt-4 w-full"
>
Отмена
</Button>
</main>
</div>
);
}
@@ -0,0 +1,477 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
getInspection,
deleteInspection,
patchInspection,
type MarkerSummary,
type PhotoSummary,
} from "@/api/inspections";
import { getMe } from "@/api/me";
import { AuthImg } from "@/components/AuthImg";
import { ttImageUrl } from "@/api/vehicles";
import { VendorVehicleScheme } from "@/components/vehicle-scheme/VendorVehicleScheme";
import { Card } from "@/components/ui/card";
import { loadZoneLabels } from "@/data/zoneLabels";
const STATUS_LABELS: Record<string, string> = {
in_progress: "В работе",
completed: "Завершён",
cancelled: "Отменён",
};
const SEVERITY_LABELS: Record<string, string> = {
cosmetic: "косметика",
minor: "лёгкое",
moderate: "среднее",
severe: "тяжёлое",
};
const DAMAGE_TYPE_LABELS: Record<string, string> = {
damaged: "повреждено",
scratch: "царапина",
chip: "скол",
dent: "вмятина",
crack: "трещина",
scuff: "затёртость",
burn: "прожог",
stain: "пятно",
bent: "погнуто",
torn: "порвано",
cut: "порез",
puncture: "прокол",
bulge: "грыжа",
missing: "отсутствует",
removed: "снято",
"not-working": "не работает",
};
function humanizeMarkerSide(
side: string | null | undefined,
zoneLabels?: Record<string, string> | null,
): string {
if (!side) return "—";
if (side === "tires") return "Резина";
const zm = /^zone-(\d+)$/.exec(side);
if (zm) {
const label = zoneLabels?.[zm[1]];
if (label) return label;
return `Зона ${zm[1]}`;
}
const map: Record<string, string> = {
top: "Сверху",
front: "Перед",
back: "Зад",
left: "Левый",
right: "Правый",
"bumper-fl": "Бампер ПЛ",
"bumper-fr": "Бампер ПП",
"bumper-rl": "Бампер ЗЛ",
"bumper-rr": "Бампер ЗП",
// Legacy named zone-* support (data from earlier prod)
"zone-front": "Передний бампер",
"zone-rear": "Задний бампер",
"zone-left": "Левый бок",
"zone-right": "Правый бок",
"zone-hood": "Капот",
"zone-trunk": "Багажник",
"zone-roof": "Крыша",
};
return map[side] ?? side;
}
export default function InspectionReview() {
const { id } = useParams<{ id: string }>();
const insId = Number(id);
const navigate = useNavigate();
const { data: ins, isLoading, isError } = useQuery({
queryKey: ["inspection", insId],
queryFn: () => getInspection(insId),
});
const { data: me } = useQuery({
queryKey: ["me"],
queryFn: getMe,
staleTime: 5 * 60_000,
});
const isAdmin = me?.permissions?.includes("admin") ?? false;
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: () => deleteInspection(insId),
onSuccess: () => {
toast.success("Осмотр удалён");
queryClient.invalidateQueries({ queryKey: ["vehicle"] });
navigate(-1);
},
onError: () => toast.error("Не удалось удалить осмотр"),
});
const { data: zoneLabels } = useQuery({
queryKey: ["zone-labels"],
queryFn: loadZoneLabels,
staleTime: Infinity,
});
// Lightbox: либо ID нашего фото в MinIO (через AuthImg), либо vendor TT
// image hash (через ttImageUrl).
const [preview, setPreview] = useState<
{ kind: "photo"; id: number } | { kind: "tt"; ttId: string } | null
>(null);
// Локальный draft заметок — синхронизируется с ins.notes при загрузке/обновлении.
// Кнопка «Сохранить» появляется только если draft отличается от сохранённого.
const [notesDraft, setNotesDraft] = useState<string>("");
useEffect(() => {
if (ins?.notes !== undefined) setNotesDraft(ins.notes ?? "");
}, [ins?.notes]);
const notesMutation = useMutation({
mutationFn: (next: string) => patchInspection(insId, { notes: next }),
onSuccess: (updated) => {
toast.success("Заметка сохранена");
queryClient.setQueryData(["inspection", insId], updated);
},
onError: () => toast.error("Не удалось сохранить заметку"),
});
if (isLoading) return <div className="p-8 text-muted-foreground">Загружаю</div>;
if (isError || !ins)
return <div className="p-8 text-destructive">Осмотр не найден.</div>;
const notesDirty = (notesDraft ?? "") !== (ins.notes ?? "");
// Lookup photo by id для определения annotated_key.
const photosById = new Map<number, typeof ins.photos[0]>();
for (const p of ins.photos) photosById.set(p.id, p);
// Все уникальные фото осмотра: основные + photo_id из маркеров (наши локальные
// фото повреждений) + tt_image_id из маркеров (carry-over из Element). Без
// tt_image_id counter недоучитывал импортированные дефекты — видно на
// осмотрах, где почти все маркеры пришли из вендора.
const markerPhotoIds = ins.markers
.map((m: MarkerSummary) => m.photo_id)
.filter((id): id is number => id != null);
const markerTtIds = ins.markers
.map((m: MarkerSummary) => m.tt_image_id)
.filter((id): id is string => !!id);
const uniquePhotoIds = Array.from(
new Set([...ins.photos.map((p: PhotoSummary) => p.id), ...markerPhotoIds])
);
const uniqueTtIds = Array.from(new Set(markerTtIds));
const totalPhotoCount = uniquePhotoIds.length + uniqueTtIds.length;
// Дополнительные фото-карточки в галерее: photo_id маркеров, которых нет в
// основном списке (`ins.photos` — это side-photos осмотра).
const extraMarkerPhotos = markerPhotoIds.filter((id) => !photosById.has(id));
return (
<div className="min-h-screen bg-background p-4 space-y-4">
<button
onClick={() => navigate(-1)}
className="text-sm text-muted-foreground hover:text-foreground"
>
Назад
</button>
<h1 className="text-xl font-semibold">Осмотр #{ins.id}</h1>
{ins.vehicle && (
<Card
className="p-4 text-sm space-y-1 cursor-pointer hover:bg-accent/40 transition-colors"
onClick={() => navigate(`/vehicles/${ins.vehicle?.id}`)}
>
<div className="text-lg font-semibold tracking-wide">
{ins.vehicle.license_plate}
</div>
<div className="text-muted-foreground">
{[ins.vehicle.make, ins.vehicle.model, ins.vehicle.year]
.filter(Boolean)
.join(" · ") || "—"}
</div>
{ins.vehicle.vin && (
<div className="text-xs text-muted-foreground">
<span className="font-semibold">VIN: </span>
{ins.vehicle.vin}
</div>
)}
</Card>
)}
<Card className="p-4 text-sm space-y-1">
<div>
<span className="font-semibold">Тип: </span>
{ins.type}
</div>
<div>
<span className="font-semibold">Статус: </span>
{STATUS_LABELS[ins.status] ?? ins.status}
</div>
{ins.inspector_name && (
<div>
<span className="font-semibold">Механик: </span>
{ins.inspector_name}
</div>
)}
<div>
<span className="font-semibold">Начат: </span>
{new Date(ins.started_at).toLocaleString("ru-RU")}
</div>
{ins.finished_at && (
<div>
<span className="font-semibold">Завершён: </span>
{new Date(ins.finished_at).toLocaleString("ru-RU")}
</div>
)}
{ins.mileage != null && (
<div>
<span className="font-semibold">Пробег: </span>
{ins.mileage.toLocaleString("ru-RU")} км
</div>
)}
</Card>
<Card className="p-4 space-y-2">
<h2 className="text-sm font-semibold text-muted-foreground">
Заметки механика
</h2>
<textarea
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
placeholder="Текст осмотра, замечания, рекомендации…"
rows={Math.max(3, Math.min(notesDraft.split("\n").length + 1, 12))}
className="w-full text-sm p-2 rounded border border-input bg-background resize-y focus:outline-none focus:ring-2 focus:ring-ring"
/>
{notesDirty && (
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={() => setNotesDraft(ins.notes ?? "")}
className="text-xs text-muted-foreground hover:text-foreground px-3 py-1"
disabled={notesMutation.isPending}
>
Отменить
</button>
<button
type="button"
onClick={() => notesMutation.mutate(notesDraft)}
disabled={notesMutation.isPending}
className="text-xs bg-primary text-primary-foreground rounded px-3 py-1 disabled:opacity-60"
>
{notesMutation.isPending ? "Сохраняю…" : "Сохранить"}
</button>
</div>
)}
</Card>
<Card className="p-4">
<h2 className="text-sm font-semibold text-muted-foreground mb-3">
Схема повреждений
</h2>
<VendorVehicleScheme
markers={ins.markers.map((m: MarkerSummary) => ({
id: m.id,
side: m.side,
damage_type: m.damage_type,
severity: m.severity,
resolved: m.resolved,
polygon: m.polygon,
}))}
editable={false}
/>
</Card>
<div>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
Фото ({totalPhotoCount})
</h2>
{totalPhotoCount === 0 ? (
<div className="text-sm text-muted-foreground">Нет фото.</div>
) : (
<div className="grid grid-cols-2 gap-2">
{ins.photos.map((p: PhotoSummary) => (
<Card
key={p.id}
className="overflow-hidden cursor-pointer"
onClick={() => setPreview({ kind: "photo", id: p.id })}
>
<AuthImg
src={`photos/${p.id}/thumb`}
alt={p.side}
className="w-full aspect-[4/3] object-cover bg-muted"
/>
<div className="text-xs text-muted-foreground p-2">{p.side}</div>
</Card>
))}
{/* Фото-маркеров, которых нет в основном списке (наши локальные) */}
{extraMarkerPhotos.map((id) => (
<Card
key={`m-${id}`}
className="overflow-hidden cursor-pointer"
onClick={() => setPreview({ kind: "photo", id })}
>
<AuthImg
src={`photos/${id}/thumb`}
alt="повреждение"
className="w-full aspect-[4/3] object-cover bg-muted"
/>
<div className="text-xs text-muted-foreground p-2">повреждение</div>
</Card>
))}
{/* TT-фото carry-over дефектов из Element живут ТОЛЬКО внутри
секции «Метки», чтобы не дублировать одну и ту же картинку
в карточке два раза. В счётчике «Фото (N)» они учтены. */}
</div>
)}
</div>
<div>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
Метки ({ins.markers.length})
</h2>
{ins.markers.length === 0 ? (
<div className="text-sm text-muted-foreground">Нет меток.</div>
) : (
<ul className="space-y-1">
{ins.markers.map((m: MarkerSummary) => (
<li key={m.id}>
<Card className="p-3 text-sm">
<div className="flex items-center justify-between">
<div>
<span className="font-medium capitalize">
{humanizeMarkerSide(m.side, zoneLabels)}
</span>
{" — "}
<span>
{m.damage_type
? (DAMAGE_TYPE_LABELS[m.damage_type] ?? m.damage_type)
: "—"}
</span>
{m.severity && (
<>
{" — "}
<span className="text-muted-foreground">
{SEVERITY_LABELS[m.severity] ?? m.severity}
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
{m.resolved && (
<span className="text-xs text-muted-foreground">
устранено
</span>
)}
{m.carried_over_from_id && (
<span className="text-xs text-muted-foreground">
перенесено
</span>
)}
</div>
</div>
{m.description && (
<div className="text-xs text-muted-foreground mt-1">
{m.description}
</div>
)}
{m.photo_id ? (
<div
className="mt-2 max-w-[8rem] cursor-pointer"
onClick={() =>
setPreview({ kind: "photo", id: m.photo_id as number })
}
>
<AuthImg
src={
photosById.get(m.photo_id)?.annotated_key
? `photos/${m.photo_id}/annotated`
: `photos/${m.photo_id}/thumb`
}
alt="Фото повреждения"
className="w-full aspect-[4/3] object-cover rounded bg-muted"
/>
</div>
) : m.tt_image_id ? (
<div
className="mt-2 max-w-[8rem] cursor-pointer"
onClick={() =>
setPreview({ kind: "tt", ttId: m.tt_image_id as string })
}
>
<img
src={ttImageUrl(m.tt_image_id)}
alt="Фото из Element Mechanic"
loading="lazy"
className="w-full aspect-[4/3] object-cover rounded bg-muted"
/>
</div>
) : null}
</Card>
</li>
))}
</ul>
)}
</div>
{isAdmin && (
<div className="pt-4">
<button
onClick={() => {
if (
window.confirm(
"Удалить этот осмотр? Все фото будут безвозвратно удалены из хранилища. Действие необратимо."
)
) {
deleteMutation.mutate();
}
}}
disabled={deleteMutation.isPending}
className="w-full py-3 rounded-lg border border-destructive text-destructive hover:bg-destructive/10 active:bg-destructive/20 transition-colors font-semibold"
>
{deleteMutation.isPending ? "Удаляю…" : "Удалить осмотр"}
</button>
</div>
)}
{/* Lightbox preview — click photo to enlarge, click background to close.
Соответствует UX в TtStateDetail (импортированные из Element осмотры). */}
{preview != null && (
<div
className="fixed inset-0 z-50 bg-black/90 flex flex-col"
onClick={() => setPreview(null)}
>
<div className="flex items-center justify-between p-3">
<button
onClick={(e) => {
e.stopPropagation();
setPreview(null);
}}
className="text-white text-2xl px-3"
>
</button>
</div>
<div
className="flex-1 flex items-center justify-center p-4"
onClick={(e) => e.stopPropagation()}
>
{preview.kind === "photo" ? (
<AuthImg
src={`photos/${preview.id}`}
alt="Просмотр"
className="max-w-full max-h-full object-contain"
/>
) : (
<img
src={ttImageUrl(preview.ttId)}
alt="Просмотр (Element)"
className="max-w-full max-h-full object-contain"
/>
)}
</div>
</div>
)}
</div>
);
}
+261
View File
@@ -0,0 +1,261 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import { HTTPError } from "ky";
import { toast } from "sonner";
import { login, login2fa } from "@/api/auth";
import { getMe } from "@/api/me";
import { useAuth } from "@/store/auth";
import {
clearRevocation,
readRevocation,
storeRevocation,
type RevocationInfo,
} from "@/hooks/useMeSync";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type Step =
| { kind: "credentials" }
| { kind: "totp"; partialToken: string };
export default function Login() {
const navigate = useNavigate();
const setToken = useAuth((s) => s.setToken);
const [step, setStep] = useState<Step>({ kind: "credentials" });
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [rememberDevice, setRememberDevice] = useState(true);
// У36: показываем явное сообщение «доступ отозван» с кликабельным контактом,
// если пользователь только что был выкинут из приложения фоновой сверкой (У35)
// или получил 403 NO_PWA_ACCESS при последней попытке логина.
const [revocation, setRevocation] = useState<RevocationInfo | null>(() => readRevocation());
// У36: после успешной аутентификации проверяем доступ к PWA через /me.
// /auth/login универсальный (используется и CRM, и PWA), поэтому
// отказ в PWA-доступе различается именно по ответу /me (403 NO_PWA_ACCESS).
const verifyPwaAccessAndNavigate = async (token: string) => {
setToken(token);
try {
await getMe();
clearRevocation();
setRevocation(null);
toast.success("Вход выполнен");
navigate("/", { replace: true });
} catch (err) {
if (err instanceof HTTPError && err.response.status === 403) {
let detail: unknown = null;
try {
const body = (await err.response.json()) as { detail?: unknown };
detail = body?.detail ?? null;
} catch {
/* not JSON */
}
storeRevocation(detail);
setRevocation(readRevocation());
setToken(null);
} else {
toast.error("Не удалось проверить доступ к приложению");
}
}
};
// Если revocation сохранён сессией — очищаем при ручной смене ввода логина.
useEffect(() => {
if (revocation && (username || password)) {
clearRevocation();
setRevocation(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [username, password]);
const loginMutation = useMutation({
mutationFn: login,
onSuccess: (data) => {
if (data.requires_2fa && data.partial_token) {
setStep({ kind: "totp", partialToken: data.partial_token });
setCode("");
toast.info("Нужен код 2FA");
return;
}
if (data.access_token) {
// У36: проверяем доступ к PWA через /me перед навигацией.
void verifyPwaAccessAndNavigate(data.access_token);
return;
}
toast.error("Неожиданный ответ сервера");
},
onError: () => toast.error("Неверный логин или пароль"),
});
const twoFaMutation = useMutation({
mutationFn: login2fa,
onSuccess: (data) => {
if (data.access_token) {
void verifyPwaAccessAndNavigate(data.access_token);
} else {
toast.error("Сервер не вернул токен");
}
},
onError: () => toast.error("Неверный код 2FA"),
});
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-sm bg-card rounded-2xl shadow-sm p-6 space-y-4">
<h1 className="text-2xl font-semibold text-foreground">Premium Механик</h1>
{revocation && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
<div className="font-medium mb-1">У вас нет доступа к PWA Premium Мехник</div>
<div>
Обратитесь к старшему механику или администратору CRM
{(() => {
const tg = revocation.support?.tg_username;
const phone = revocation.support?.phone;
if (tg) {
return (
<>
{": "}
<a
href={`https://t.me/${tg}`}
target="_blank"
rel="noreferrer"
className="underline font-medium"
>
@{tg}
</a>
{phone ? (
<>
{" · "}
<a href={`tel:${phone}`} className="underline font-medium">{phone}</a>
</>
) : null}
</>
);
}
if (phone) {
return (
<>
{": "}
<a href={`tel:${phone}`} className="underline font-medium">{phone}</a>
</>
);
}
return ".";
})()}
</div>
</div>
)}
{step.kind === "credentials" && (
<>
<p className="text-sm text-muted-foreground">
Войдите учётной записью TaxiDashboard.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
loginMutation.mutate({ username, password });
}}
className="space-y-3"
>
<div className="space-y-1">
<Label htmlFor="username">Логин</Label>
<Input
id="username"
type="text"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
/>
</div>
<div className="space-y-1">
<Label htmlFor="password">Пароль</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" disabled={loginMutation.isPending} className="w-full">
{loginMutation.isPending ? "Входим…" : "Войти"}
</Button>
</form>
</>
)}
{step.kind === "totp" && (
<>
<p className="text-sm text-muted-foreground">
Введите 6-значный код из приложения-аутентификатора (Google
Authenticator, Aegis, 1Password, и т. п.).
</p>
<form
onSubmit={(e) => {
e.preventDefault();
twoFaMutation.mutate({
partial_token: step.partialToken,
code: code.trim(),
remember_device: rememberDevice,
});
}}
className="space-y-3"
>
<div className="space-y-1">
<Label htmlFor="totp">Код 2FA</Label>
<Input
id="totp"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]{6}"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
required
autoFocus
/>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
<input
type="checkbox"
checked={rememberDevice}
onChange={(e) => setRememberDevice(e.target.checked)}
/>
Запомнить это устройство на 30 дней
</label>
<Button
type="submit"
disabled={twoFaMutation.isPending || code.length !== 6}
className="w-full"
>
{twoFaMutation.isPending ? "Проверяем…" : "Войти"}
</Button>
<button
type="button"
onClick={() => {
setStep({ kind: "credentials" });
setPassword("");
setCode("");
}}
className="w-full text-xs text-muted-foreground hover:text-foreground"
>
Назад
</button>
</form>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,394 @@
import { useState, useMemo, useRef, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
getTtState,
deleteTtState,
ttImageUrl,
type TtPhotoRef,
type TtSidePhoto,
} from "@/api/vehicles";
import { getMe } from "@/api/me";
import { Card } from "@/components/ui/card";
import { TtDamageMap } from "@/components/tt/TtDamageMap";
interface ZoneLabels {
[zoneId: string]: string;
}
interface DamageVocabulary {
damage_types_ordered: string[];
[k: string]: unknown;
}
let zoneLabelsCache: ZoneLabels | null = null;
let damageVocabCache: DamageVocabulary | null = null;
async function loadZoneLabels(): Promise<ZoneLabels> {
if (zoneLabelsCache) return zoneLabelsCache;
const res = await fetch("/data/zone_labels.json");
zoneLabelsCache = await res.json();
return zoneLabelsCache!;
}
async function loadDamageVocabulary(): Promise<DamageVocabulary> {
if (damageVocabCache) return damageVocabCache;
const res = await fetch("/data/damage_vocabulary.json");
damageVocabCache = await res.json();
return damageVocabCache!;
}
const DEGREE_LABELS: Record<number, string> = {
0: "Лёгкое",
1: "Среднее",
2: "Тяжёлое",
};
function damageTypeName(
id: number | null,
vocab: DamageVocabulary | undefined,
): string {
if (id == null) return "Без типа";
// Vendor enum is 1-based: type=1 → damage_types_ordered[0] ("Вмятина").
const idx = id - 1;
const arr = vocab?.damage_types_ordered;
if (arr && idx >= 0 && idx < arr.length) return arr[idx];
return `Тип ${id}`;
}
const SIDE_LABELS: Record<number, string> = {
1: "Перед",
2: "Зад",
3: "Левый борт",
4: "Правый борт",
5: "Сверху",
6: "Передний угол",
7: "Задний угол",
8: "Салон",
9: "Дополнительно",
};
type PreviewItem =
| { kind: "zone"; photo: TtPhotoRef }
| { kind: "side"; photo: TtSidePhoto };
export default function TtStateDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const stateId = Number(id);
const zoneRefs = useRef<Record<string, HTMLDivElement | null>>({});
const { data: state, isLoading, isError } = useQuery({
queryKey: ["tt-state", stateId],
queryFn: () => getTtState(stateId),
enabled: Number.isFinite(stateId),
});
const { data: labels } = useQuery({
queryKey: ["zone-labels"],
queryFn: loadZoneLabels,
staleTime: Infinity,
});
const { data: vocab } = useQuery({
queryKey: ["damage-vocabulary"],
queryFn: loadDamageVocabulary,
staleTime: Infinity,
});
const { data: me } = useQuery({
queryKey: ["me"],
queryFn: getMe,
staleTime: 5 * 60_000,
});
const isAdmin = me?.permissions?.includes("admin") ?? false;
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: () => deleteTtState(stateId),
onSuccess: () => {
toast.success("Осмотр удалён");
// Drop any cached tt-history for vehicle list so новый рендер не покажет удалённый.
queryClient.invalidateQueries({ queryKey: ["vehicle"] });
navigate(-1);
},
onError: () => toast.error("Не удалось удалить осмотр"),
});
const [preview, setPreview] = useState<PreviewItem | null>(null);
const [showLines, setShowLines] = useState(false);
const damagedZoneIds = useMemo(() => {
if (!state) return [];
return Object.keys(state.damages_by_zone).map((k) => Number(k));
}, [state]);
const damageOverlays = useMemo(() => {
if (!state) return [];
return Object.entries(state.damages_by_zone).flatMap(([zid, dmgs]) =>
dmgs.map((d) => ({ zoneId: Number(zid), points: d.points || [] }))
);
}, [state]);
// Reset refs when state changes
useEffect(() => {
zoneRefs.current = {};
}, [stateId]);
if (isLoading)
return <div className="p-8 text-muted-foreground">Загружаю</div>;
if (isError || !state)
return <div className="p-8 text-destructive">Осмотр не найден.</div>;
const date = state.unix_time
? new Date(state.unix_time * 1000).toLocaleString("ru-RU")
: "—";
const zoneIds = Array.from(
new Set([
...Object.keys(state.photos_by_zone),
...Object.keys(state.damages_by_zone),
])
).sort((a, b) => Number(a) - Number(b));
function scrollToZone(zoneId: number) {
const el = zoneRefs.current[String(zoneId)];
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
return (
<div className="min-h-screen bg-background p-4 space-y-4 pb-20">
<button
onClick={() => navigate(-1)}
className="text-sm text-muted-foreground hover:text-foreground"
>
Назад
</button>
<Card className="p-4">
<div className="text-xs text-muted-foreground">Element Mechanic</div>
<div className="text-lg font-semibold mt-1">
{state.mechanic_name || "Осмотр"}
</div>
<div className="text-sm text-muted-foreground mt-1">
{date}
{state.mileage != null && (
<> · {state.mileage.toLocaleString("ru-RU")} км</>
)}
</div>
</Card>
{/* Общие 9 ракурсов */}
{state.side_photos.length > 0 && (
<div>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
Общие фото
</h2>
<div className="grid grid-cols-3 gap-2">
{state.side_photos.map((p) => {
const label = p.photo_type
? SIDE_LABELS[p.photo_type] || `Ракурс ${p.photo_type}`
: "Ракурс";
const thumbSrc = p.miniature_id
? ttImageUrl(p.miniature_id)
: ttImageUrl(p.image_id);
return (
<button
key={p.image_id}
onClick={() => {
setPreview({ kind: "side", photo: p });
setShowLines(false);
}}
className="aspect-square overflow-hidden rounded-lg bg-muted hover:opacity-80 transition-opacity relative"
>
<img
src={thumbSrc}
alt={label}
loading="lazy"
className="w-full h-full object-cover"
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-1">
<div className="text-white text-[10px] font-medium truncate">
{label}
</div>
</div>
</button>
);
})}
</div>
</div>
)}
{/* Карта повреждений */}
{damagedZoneIds.length > 0 && (
<div>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
Карта повреждений
<span className="ml-2 text-xs font-normal">
{damagedZoneIds.length}{" "}
{damagedZoneIds.length === 1 ? "зона" : "зон"}
</span>
</h2>
<TtDamageMap
damagedZoneIds={damagedZoneIds}
damages={damageOverlays}
onZoneClick={scrollToZone}
/>
<div className="text-[11px] text-muted-foreground mt-1 text-center">
Тап на зону прокрутка к деталям
</div>
</div>
)}
{/* Детали по зонам с повреждениями */}
{zoneIds.length === 0 ? (
<Card className="p-4 text-sm text-muted-foreground">
В осмотре нет ни фото, ни повреждений.
</Card>
) : (
zoneIds.map((zid) => {
const photos = state.photos_by_zone[zid] || [];
const damages = state.damages_by_zone[zid] || [];
const label = labels?.[zid] || `Зона ${zid}`;
return (
<div
key={zid}
ref={(el) => {
zoneRefs.current[zid] = el;
}}
className="scroll-mt-4"
>
<h2 className="text-sm font-semibold text-muted-foreground mb-2 capitalize flex items-center justify-between">
<span>{label}</span>
{damages.length > 0 && (
<span className="text-destructive text-xs">
{damages.length}{" "}
{damages.length === 1 ? "повреждение" : "повреждений"}
</span>
)}
</h2>
{damages.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{damages.map((d, idx) => {
const tname = damageTypeName(d.damage_type_id, vocab);
const dname =
d.degree != null ? DEGREE_LABELS[d.degree] : null;
return (
<span
key={(d.guid ?? "g") + "_" + idx}
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-destructive/10 text-destructive border border-destructive/30"
>
<span className="font-medium">{tname}</span>
{dname && (
<>
<span className="opacity-50">·</span>
<span>{dname}</span>
</>
)}
</span>
);
})}
</div>
)}
{photos.length > 0 ? (
<div className="grid grid-cols-3 gap-2">
{photos.map((p, idx) => (
<button
key={p.image_id + "_" + idx}
onClick={() => {
setPreview({ kind: "zone", photo: p });
setShowLines(false);
}}
className="aspect-square overflow-hidden rounded-lg bg-muted hover:opacity-80 transition-opacity"
>
<img
src={ttImageUrl(p.image_id)}
alt={`Зона ${zid}`}
loading="lazy"
className="w-full h-full object-cover"
/>
</button>
))}
</div>
) : (
<Card className="p-3 text-xs text-muted-foreground">
Фото нет только координаты повреждений
</Card>
)}
</div>
);
})
)}
{isAdmin && (
<div className="pt-4">
<button
onClick={() => {
if (
window.confirm(
"Скрыть этот осмотр из истории? Будет помечен как игнорируемый — sync не вернёт. Действие обратимо только из БД."
)
) {
deleteMutation.mutate();
}
}}
disabled={deleteMutation.isPending}
className="w-full py-3 rounded-lg border border-destructive text-destructive hover:bg-destructive/10 active:bg-destructive/20 transition-colors font-semibold"
>
{deleteMutation.isPending ? "Удаляю…" : "Удалить осмотр"}
</button>
</div>
)}
{preview && (
<div
className="fixed inset-0 z-50 bg-black/90 flex flex-col"
onClick={() => setPreview(null)}
>
<div className="flex items-center justify-between p-3">
<button
onClick={(e) => {
e.stopPropagation();
setPreview(null);
}}
className="text-white text-2xl px-3"
>
</button>
{preview.kind === "zone" &&
preview.photo.image_with_lines_id && (
<button
onClick={(e) => {
e.stopPropagation();
setShowLines((v) => !v);
}}
className="text-white text-sm border border-white/40 px-3 py-1 rounded-lg"
>
{showLines ? "Без линий" : "С линиями"}
</button>
)}
</div>
<div
className="flex-1 flex items-center justify-center p-4"
onClick={(e) => e.stopPropagation()}
>
<img
src={ttImageUrl(
preview.kind === "zone" &&
showLines &&
preview.photo.image_with_lines_id
? preview.photo.image_with_lines_id
: preview.photo.image_id
)}
alt="Просмотр"
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,371 @@
import { useState } from "react";
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
import { useQuery, useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { getVehicle, getTtHistory } from "@/api/vehicles";
import { createInspection, type InspectionType } from "@/api/inspections";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const INSPECTION_TYPE_LABELS: Record<InspectionType, string> = {
handover: "Выдача",
return: "Приёмка",
periodic: "Плановый",
"ad-hoc": "Свободный",
initial: "Первичный",
seizure: "Изъятие",
"equipment-change": "Комплектация",
"pre-repair": "В ремонт",
"post-repair": "Из ремонта",
};
const STATUS_LABELS: Record<string, string> = {
in_progress: "В работе",
completed: "Завершён",
cancelled: "Отменён",
};
function timeAgo(iso: string): string {
const sec = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
if (sec < 60) return "только что";
const min = Math.floor(sec / 60);
if (min < 60) return `${min} мин назад`;
const h = Math.floor(min / 60);
if (h < 24) return `${h} ч назад`;
const d = Math.floor(h / 24);
if (d < 30) return `${d} дн назад`;
return new Date(iso).toLocaleDateString("ru-RU");
}
export default function VehicleCard() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const vehicleId = Number(id);
const action = searchParams.get("action") || undefined;
const [pendingType, setPendingType] = useState<InspectionType | null>(null);
const [mileageInput, setMileageInput] = useState("");
const [mileageSource, setMileageSource] = useState<
"starline" | "last-inspection" | null
>(null);
const { data: v, isLoading, isError } = useQuery({
queryKey: ["vehicle", vehicleId],
queryFn: () => getVehicle(vehicleId),
});
// История из vendor (Element Mechanic). Молча возвращает пустой список
// если машина ещё не синхронизирована — секцию просто скрываем.
const { data: tt } = useQuery({
queryKey: ["vehicle", vehicleId, "tt-history"],
queryFn: () => getTtHistory(vehicleId),
enabled: Number.isFinite(vehicleId),
staleTime: 5 * 60_000,
});
const startInspection = useMutation({
mutationFn: (input: { type: InspectionType; mileage: number | undefined }) =>
createInspection({ vehicle_id: vehicleId, type: input.type, mileage: input.mileage }),
onSuccess: (inspection) => {
navigate(`/inspections/${inspection.id}/edit`);
},
onError: () => toast.error("Не удалось создать осмотр"),
});
const handleStartClick = (type: InspectionType) => {
setPendingType(type);
// Приоритет: 1) StarLine OBD (свежий одометр), 2) пробег прошлого
// осмотра. Менеджер всегда может скорректировать.
if (v?.starline_mileage != null) {
setMileageInput(String(v.starline_mileage));
setMileageSource("starline");
return;
}
const lastWithMileage = v?.recent_inspections.find((i) => i.mileage != null);
if (lastWithMileage?.mileage != null) {
setMileageInput(String(lastWithMileage.mileage));
setMileageSource("last-inspection");
return;
}
setMileageInput("");
setMileageSource(null);
};
if (isLoading)
return <div className="p-8 text-muted-foreground">Загружаю</div>;
if (isError || !v)
return <div className="p-8 text-destructive">Машина не найдена.</div>;
return (
<div className="min-h-screen bg-background p-4 space-y-4">
<button
onClick={() => navigate(`/vehicles${action ? `?action=${action}` : ""}`)}
className="text-sm text-muted-foreground hover:text-foreground"
>
К списку
</button>
<Card className="p-4">
<div className="flex items-center gap-2 flex-wrap">
<div className="text-xl font-semibold">{v.license_plate}</div>
{v.status && (
<span
className={`inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-full ${
{
"На линии": "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
"Простой": "bg-muted text-muted-foreground",
"Ремонтируется": "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
"Ждет ремонта": "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
"ДТП": "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
"Бронь": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
"Выкуп": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
"На продаже": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
"Подготовка на продажу": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
"Ждет доставки до офиса": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
}[v.status] ?? "bg-muted text-muted-foreground"
}`}
>
{v.status}
</span>
)}
</div>
<div className="text-sm text-muted-foreground">
{[v.make, v.model, v.year].filter(Boolean).join(" ") || "—"}
</div>
{v.vin && (
<div className="text-xs text-muted-foreground mt-1">VIN: {v.vin}</div>
)}
</Card>
<div>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
Начать осмотр
</h2>
<div className="grid grid-cols-2 gap-2">
<Button
onClick={() => handleStartClick("handover")}
disabled={startInspection.isPending}
>
Выдача
</Button>
<Button
onClick={() => handleStartClick("return")}
disabled={startInspection.isPending}
>
Приёмка
</Button>
<Button
variant="outline"
onClick={() => handleStartClick("periodic")}
disabled={startInspection.isPending}
className="col-span-2"
>
Плановый
</Button>
</div>
</div>
<div>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
Прошлые осмотры
</h2>
{v.recent_inspections.length === 0 ? (
<Card className="p-4 text-sm text-muted-foreground">
Осмотров ещё не было.
</Card>
) : (
<div className="space-y-2">
{v.recent_inspections.map((i) => (
<Card
key={i.id}
className="p-3 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => navigate(`/inspections/${i.id}`)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<div className="font-medium">
{INSPECTION_TYPE_LABELS[i.type as InspectionType] ?? i.type}
</div>
{(i.photos_count ?? 0) > 0 && (
<span
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
title={`Фото: ${i.photos_count}`}
>
📷 {i.photos_count}
</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-2 flex-wrap">
<span>{new Date(i.started_at).toLocaleString("ru-RU")}</span>
{i.mileage != null && (
<>
<span className="text-muted-foreground/50">·</span>
<span>{i.mileage.toLocaleString("ru-RU")} км</span>
</>
)}
</div>
{i.driver_name && (
<div className="text-xs mt-1 text-foreground/80 truncate">
👤 {i.driver_name}
</div>
)}
</div>
<div className="text-xs text-muted-foreground shrink-0">
{STATUS_LABELS[i.status] ?? i.status}
</div>
</div>
</Card>
))}
</div>
)}
</div>
{tt && tt.states.length > 0 && (
<div>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
История осмотров (Element Mechanic)
</h2>
<div className="space-y-2">
{tt.states.map((s) => {
const date = s.unix_time
? new Date(s.unix_time * 1000).toLocaleString("ru-RU")
: "—";
return (
<Card
key={s.id}
className="p-3 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => navigate(`/tt-states/${s.id}`)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="font-medium">
{s.mechanic_name || "Осмотр"}
</div>
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-2 flex-wrap">
<span>{date}</span>
{s.mileage != null && (
<>
<span className="text-muted-foreground/50">·</span>
<span>{s.mileage.toLocaleString("ru-RU")} км</span>
</>
)}
</div>
{s.driver_name && (
<div className="text-xs mt-1 text-foreground/80 truncate">
👤 {s.driver_name}
</div>
)}
</div>
<div className="text-xs text-muted-foreground shrink-0 text-right">
<div>📷 {s.photos_count}</div>
{s.damages_count > 0 && (
<div className="text-destructive mt-0.5">
{s.damages_count}
</div>
)}
</div>
</div>
</Card>
);
})}
</div>
</div>
)}
{pendingType && (
<div
className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center p-0 sm:p-4"
onClick={() => setPendingType(null)}
>
<div
className="w-full max-w-sm bg-card rounded-t-2xl sm:rounded-2xl shadow-xl p-5 space-y-4"
onClick={(e) => e.stopPropagation()}
>
<div>
<h2 className="text-lg font-semibold">
{INSPECTION_TYPE_LABELS[pendingType]}
</h2>
<p className="text-xs text-muted-foreground mt-1">
Введите пробег машины на текущий момент.
</p>
</div>
<div className="space-y-1">
<Label htmlFor="mileage">Пробег, км</Label>
<Input
id="mileage"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={mileageInput}
onChange={(e) => {
setMileageInput(e.target.value.replace(/\D/g, ""));
setMileageSource(null);
}}
placeholder="123456"
autoFocus
/>
{mileageSource === "starline" && (
<div className="text-[11px] text-muted-foreground flex items-center gap-1.5">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500" />
<span>
Из StarLine
{v?.starline_mileage_at && (
<> · {timeAgo(v.starline_mileage_at)}</>
)}
</span>
<span className="text-muted-foreground/60">
· скорректируйте если надо
</span>
</div>
)}
{mileageSource === "last-inspection" && (
<div className="text-[11px] text-muted-foreground">
Из прошлого осмотра · скорректируйте если надо
</div>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setPendingType(null)}
className="flex-1"
>
Отмена
</Button>
<Button
disabled={startInspection.isPending}
onClick={() => {
const m = mileageInput.trim() ? Number(mileageInput) : undefined;
startInspection.mutate({
type: pendingType,
mileage: m && Number.isFinite(m) ? m : undefined,
});
}}
className="flex-1"
>
{startInspection.isPending ? "Создаём…" : "Начать"}
</Button>
</div>
<button
type="button"
onClick={() => {
startInspection.mutate({ type: pendingType, mileage: undefined });
}}
className="w-full text-xs text-muted-foreground hover:text-foreground"
>
Пропустить пробег
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,101 @@
/**
* Экран выбора машины перед созданием ремонта (Фича 3).
*
* Открывается из `/repairs` по кнопке «+ Добавить ремонт».
* Поиск по госномеру (У44 — релевантность). После тапа на строку
* переходим в `/repairs/new/:carId` (CreateRepairPage).
*/
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ChevronLeft, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { searchCars, type CarBrief } from "@/api/repairsCreate";
export default function CarSelectPage() {
const navigate = useNavigate();
const [q, setQ] = useState("");
const [cars, setCars] = useState<CarBrief[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const t = setTimeout(async () => {
try {
const list = await searchCars(q);
if (!cancelled) setCars(list);
} catch (e) {
if (!cancelled) setError((e as Error).message || "Не удалось загрузить машины");
} finally {
if (!cancelled) setLoading(false);
}
}, 200); // debounce
return () => {
cancelled = true;
clearTimeout(t);
};
}, [q]);
return (
<div className="min-h-screen bg-background flex flex-col">
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="flex items-center gap-2 p-3">
<Button variant="ghost" size="sm" onClick={() => navigate("/repairs")} className="-ml-2">
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="text-base font-semibold">Выберите машину</h1>
</div>
<div className="px-3 pb-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
autoFocus
placeholder="Госномер…"
value={q}
onChange={(e) => setQ(e.target.value)}
className="pl-8"
/>
</div>
</div>
</header>
<main className="flex-1 container max-w-2xl py-3">
{error && (
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm">
{error}
</div>
)}
{loading ? (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-12 rounded-md bg-muted animate-pulse" />
))}
</div>
) : cars.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-12">
{q ? "Ничего не найдено" : "Нет активных машин в парке"}
</div>
) : (
<ul className="space-y-1">
{cars.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => navigate(`/repairs/new/${c.id}`)}
className="w-full flex items-baseline justify-between gap-3 rounded-md border bg-card hover:bg-accent/40 transition-colors p-3 text-left"
>
<span className="font-medium">{c.plate}</span>
<span className="text-sm text-muted-foreground truncate">
{[c.brand, c.model].filter(Boolean).join(" ")}
</span>
</button>
</li>
))}
</ul>
)}
</main>
</div>
);
}
@@ -0,0 +1,675 @@
/**
* Карточка создания ремонта (Фича 4 + У19/У20/У21/У24/У25/У42/У45).
*
* Online-only flow (PR-7). Offline-first из У47/PR-8 — следующий PR.
*
* Блоки:
* - Шапка: машина (snapshot из /cars/{id}/context) + механик + водитель.
* - Пробег (предзаполняется StarLine, можно перебить вручную).
* - Фото — multi-upload через presign + PUT в `tmp/`.
* - Работы — выбор из справочника, цена редактируема через
* «карандашик» (У19); комментарий обязателен при разблокировке.
* - Запчасти — текст + qty, автодополнение (У43).
* - Замена масла — раскрывающийся блок с дефолтным интервалом 10000 (У25).
*
* Сохранение: POST /repairs с `Idempotency-Key` (UUID v4 в state).
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ChevronLeft, Plus, Trash2, Pencil, Camera } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
createRepair,
getCarContext,
presignPhoto,
searchWorks,
suggestParts,
uploadPhotoToTmp,
type CarContext,
type CreatePhotoPayload,
type MechWork,
type PresignResponse,
} from "@/api/repairsCreate";
// ── Local state types ─────────────────────────────────────────────────────
interface PhotoState {
presign: PresignResponse;
tag: "auto" | "part" | "other";
status: "uploading" | "uploaded" | "error";
errorMessage?: string;
}
interface WorkRow {
// Идентификатор строки внутри UI (для key); тождественен work_catalog_id
// + автоинкремент, чтобы дубли по У2 различались.
rowKey: string;
work_catalog_id: number;
name: string; // снапшот имени из прайса
price_catalog: number;
price_applied: number;
unlocked: boolean; // карандашик разблокировал поле
override_comment: string;
}
interface PartRow {
rowKey: string;
name: string;
qty: number;
}
interface OilChangeState {
enabled: boolean;
mileage_at_change: number | null;
mileage_is_dirty: boolean; // У24: мягкая подписка с шапочным
next_in_km: number;
sticker: PhotoState | null;
}
function uuid4(): string {
// crypto.randomUUID требует secure context; mechanic.pptaxi.ru — https.
return (crypto as any).randomUUID
? (crypto as any).randomUUID()
: `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
}
export default function CreateRepairPage() {
const navigate = useNavigate();
const { carId: carIdRaw } = useParams<{ carId: string }>();
const carId = carIdRaw ? Number(carIdRaw) : 0;
// Idempotency-Key для этой формы — стабилен между ре-сабмитами (У20).
const idemKey = useRef<string>(uuid4());
const [ctx, setCtx] = useState<CarContext | null>(null);
const [ctxError, setCtxError] = useState<string | null>(null);
const [mileage, setMileage] = useState<number | null>(null);
const [photos, setPhotos] = useState<PhotoState[]>([]);
const [works, setWorks] = useState<WorkRow[]>([]);
const [parts, setParts] = useState<PartRow[]>([]);
const [oilChange, setOilChange] = useState<OilChangeState>({
enabled: false,
mileage_at_change: null,
mileage_is_dirty: false,
next_in_km: 10000,
sticker: null,
});
const [worksPicker, setWorksPicker] = useState<{ open: boolean; q: string; results: MechWork[] }>(
{ open: false, q: "", results: [] },
);
const [submitting, setSubmitting] = useState(false);
// Initial: подгрузить контекст машины.
useEffect(() => {
if (!carId) return;
getCarContext(carId)
.then((c) => {
setCtx(c);
setMileage(c.mileage_starline);
})
.catch((e) => setCtxError((e as Error).message || "Не удалось загрузить машину"));
}, [carId]);
// У24: мягкая подписка пробега блока масла на шапочный.
useEffect(() => {
if (oilChange.enabled && !oilChange.mileage_is_dirty) {
setOilChange((oc) => ({ ...oc, mileage_at_change: mileage }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mileage, oilChange.enabled]);
// ── Photo upload ─────────────────────────────────────────────────────────
const onPhotoInput = async (files: FileList | null, target: "gallery" | "sticker") => {
if (!files || files.length === 0) return;
for (const file of Array.from(files)) {
const ext = (file.name.split(".").pop() || "jpg").toLowerCase();
const placeholder: PhotoState = {
presign: {
photo_uuid: uuid4(),
extension: ext,
content_type: file.type || "image/jpeg",
key: "",
upload_url: "",
expires_in: 0,
},
tag: "auto",
status: "uploading",
};
if (target === "gallery") setPhotos((arr) => [...arr, placeholder]);
else setOilChange((oc) => ({ ...oc, sticker: { ...placeholder, tag: "auto" } }));
try {
const presign = await presignPhoto(ext, placeholder.presign.photo_uuid);
await uploadPhotoToTmp(file, presign);
const updated: PhotoState = { presign, tag: "auto", status: "uploaded" };
if (target === "gallery") {
setPhotos((arr) =>
arr.map((p) => (p.presign.photo_uuid === presign.photo_uuid ? updated : p)),
);
} else {
setOilChange((oc) => ({ ...oc, sticker: updated }));
}
} catch (e) {
const msg = (e as Error).message || "Ошибка загрузки";
if (target === "gallery") {
setPhotos((arr) =>
arr.map((p) =>
p.presign.photo_uuid === placeholder.presign.photo_uuid
? { ...p, status: "error", errorMessage: msg }
: p,
),
);
} else {
setOilChange((oc) => ({
...oc,
sticker: oc.sticker ? { ...oc.sticker, status: "error", errorMessage: msg } : oc.sticker,
}));
}
}
}
};
const removePhoto = (photoUuid: string) =>
setPhotos((arr) => arr.filter((p) => p.presign.photo_uuid !== photoUuid));
// ── Works picker ─────────────────────────────────────────────────────────
const openWorksPicker = async () => {
setWorksPicker({ open: true, q: "", results: [] });
const list = await searchWorks("");
setWorksPicker((s) => ({ ...s, results: list }));
};
const queryWorks = async (q: string) => {
setWorksPicker((s) => ({ ...s, q }));
const list = await searchWorks(q);
setWorksPicker((s) => ({ ...s, results: list }));
};
const addWorkFromCatalog = (w: MechWork) => {
const price = Number(w.price);
setWorks((arr) => [
...arr,
{
rowKey: uuid4(),
work_catalog_id: w.id,
name: w.name,
price_catalog: price,
price_applied: price,
unlocked: false,
override_comment: "",
},
]);
setWorksPicker({ open: false, q: "", results: [] });
};
const toggleWorkUnlock = (rowKey: string) => {
setWorks((arr) =>
arr.map((w) =>
w.rowKey === rowKey
? w.unlocked
? // повторный тап = отмена правки, возврат к цене прайса (У19)
{ ...w, unlocked: false, price_applied: w.price_catalog, override_comment: "" }
: { ...w, unlocked: true }
: w,
),
);
};
const removeWork = (rowKey: string) =>
setWorks((arr) => arr.filter((w) => w.rowKey !== rowKey));
const totalSum = useMemo(
() => works.reduce((acc, w) => acc + Number(w.price_applied || 0), 0),
[works],
);
// ── Parts ────────────────────────────────────────────────────────────────
const [partsDraft, setPartsDraft] = useState<{ name: string; suggestions: string[] }>({ name: "", suggestions: [] });
const queryPartSuggestions = async (q: string) => {
setPartsDraft({ name: q, suggestions: [] });
if (!q.trim()) return;
const list = await suggestParts(q);
setPartsDraft({ name: q, suggestions: list.map((s) => s.name) });
};
const addPart = (name: string) => {
const trimmed = name.trim();
if (!trimmed) return;
setParts((arr) => [...arr, { rowKey: uuid4(), name: trimmed, qty: 1 }]);
setPartsDraft({ name: "", suggestions: [] });
};
const removePart = (rowKey: string) =>
setParts((arr) => arr.filter((p) => p.rowKey !== rowKey));
// ── Submit ───────────────────────────────────────────────────────────────
const canSubmit = () =>
photos.length > 0 &&
photos.every((p) => p.status === "uploaded") &&
works.length > 0 &&
works.every((w) => !w.unlocked || w.override_comment.trim().length > 0);
const submitWhyDisabled = (): string => {
if (photos.length === 0) return "Добавьте хотя бы одно фото";
if (photos.some((p) => p.status !== "uploaded")) return "Дождитесь окончания загрузки фото";
if (works.length === 0) return "Добавьте хотя бы одну работу";
const unlockedNoComment = works.find((w) => w.unlocked && !w.override_comment.trim());
if (unlockedNoComment) return "Укажите комментарий к ручной правке цены";
return "";
};
const onSubmit = async () => {
const reason = submitWhyDisabled();
if (reason) {
toast.error(reason);
return;
}
setSubmitting(true);
try {
const photoPayload: CreatePhotoPayload[] = photos.map((p, i) => ({
photo_uuid: p.presign.photo_uuid,
extension: p.presign.extension,
tag: p.tag,
sort_order: i,
}));
const result = await createRepair(
{
car_id: carId,
mileage,
works: works.map((w) => ({
work_catalog_id: w.work_catalog_id,
price_applied: Number(w.price_applied),
manual_override: w.unlocked,
override_comment: w.unlocked ? w.override_comment.trim() : null,
})),
parts: parts.map((p) => ({ name: p.name, qty: p.qty })),
photos: photoPayload,
oil_change: oilChange.enabled
? {
enabled: true,
mileage_at_change: oilChange.mileage_at_change,
next_in_km: oilChange.next_in_km,
sticker_photo_uuid:
oilChange.sticker?.status === "uploaded"
? oilChange.sticker.presign.photo_uuid
: null,
sticker_extension:
oilChange.sticker?.status === "uploaded"
? oilChange.sticker.presign.extension
: null,
}
: null,
},
idemKey.current,
);
toast.success(`Ремонт #${result.id} сохранён`);
navigate("/repairs", { replace: true });
} catch (e) {
toast.error((e as Error).message || "Не удалось сохранить");
} finally {
setSubmitting(false);
}
};
if (ctxError) {
return (
<div className="container max-w-2xl py-8">
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm">
{ctxError}
</div>
</div>
);
}
if (!ctx) {
return (
<div className="container max-w-2xl py-8">
<div className="text-sm text-muted-foreground">Загрузка</div>
</div>
);
}
return (
<div className="min-h-screen bg-background flex flex-col">
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="flex items-center justify-between p-3">
<Button variant="ghost" size="sm" onClick={() => navigate(-1)} className="-ml-2">
<ChevronLeft className="h-5 w-5" />
<span className="ml-1">Назад</span>
</Button>
<h1 className="text-base font-semibold">Новый ремонт</h1>
<Button
variant="default"
size="sm"
disabled={submitting || !canSubmit()}
onClick={onSubmit}
title={submitWhyDisabled() || undefined}
>
{submitting ? "Сохранение…" : "Сохранить"}
</Button>
</div>
<div className="px-3 pb-3 text-sm">
<div className="font-medium">{ctx.plate}</div>
<div className="text-muted-foreground">
{[ctx.brand, ctx.model].filter(Boolean).join(" ")}
{ctx.driver_name ? ` · ${ctx.driver_name}` : ` · Без водителя`}
</div>
</div>
</header>
<main className="flex-1 container max-w-2xl py-4 space-y-5">
{/* Пробег */}
<section>
<Label htmlFor="mileage" className="text-sm">Пробег (км)</Label>
<Input
id="mileage"
type="number"
min={0}
value={mileage ?? ""}
onChange={(e) => setMileage(e.target.value === "" ? null : Number(e.target.value))}
placeholder={ctx.mileage_starline == null ? "StarLine недоступен — введите вручную" : ""}
className="mt-1"
/>
</section>
{/* Фото */}
<section>
<div className="flex items-baseline justify-between">
<Label className="text-sm">Фото ремонта</Label>
<span className="text-xs text-muted-foreground">
{photos.length === 0 ? "Минимум одно фото" : `${photos.length} шт.`}
</span>
</div>
<div className="grid grid-cols-3 gap-2 mt-2">
{photos.map((p) => (
<div key={p.presign.photo_uuid} className="relative aspect-square border rounded-md bg-muted overflow-hidden">
{p.status === "uploading" && (
<div className="absolute inset-0 flex items-center justify-center text-xs">Загрузка</div>
)}
{p.status === "error" && (
<div className="absolute inset-0 flex items-center justify-center text-xs text-destructive p-1 text-center">
{p.errorMessage || "Ошибка"}
</div>
)}
{p.status === "uploaded" && (
<div className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground">
Загружено
</div>
)}
<button
type="button"
onClick={() => removePhoto(p.presign.photo_uuid)}
className="absolute top-1 right-1 rounded-full bg-background/80 p-1 hover:bg-background"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
<label className="aspect-square border-2 border-dashed rounded-md flex items-center justify-center cursor-pointer hover:bg-accent/40">
<Camera className="h-6 w-6 text-muted-foreground" />
<input
type="file"
accept="image/*"
capture="environment"
multiple
className="hidden"
onChange={(e) => onPhotoInput(e.target.files, "gallery")}
/>
</label>
</div>
</section>
{/* Работы */}
<section>
<div className="flex items-baseline justify-between">
<Label className="text-sm">Работы</Label>
<Button variant="outline" size="sm" onClick={openWorksPicker}>
<Plus className="h-3 w-3 mr-1" />
Добавить работу
</Button>
</div>
{works.length === 0 ? (
<div className="text-xs text-muted-foreground mt-2">Минимум одна работа</div>
) : (
<ul className="mt-2 space-y-2">
{works.map((w) => (
<li
key={w.rowKey}
className={`rounded-md border p-2 ${w.unlocked ? "bg-amber-50 border-amber-300" : ""}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{w.name}</div>
<div className="text-xs text-muted-foreground">
Прайс: {w.price_catalog}
</div>
</div>
<div className="flex items-center gap-1">
<Input
type="number"
min={0}
value={w.price_applied}
disabled={!w.unlocked}
onChange={(e) =>
setWorks((arr) =>
arr.map((row) =>
row.rowKey === w.rowKey
? { ...row, price_applied: Number(e.target.value) }
: row,
),
)
}
className="w-24"
/>
<Button
variant="ghost"
size="sm"
onClick={() => toggleWorkUnlock(w.rowKey)}
title={w.unlocked ? "Отменить ручную правку" : "Перебить цену"}
>
<Pencil className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" onClick={() => removeWork(w.rowKey)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{w.unlocked && (
<textarea
placeholder="Комментарий к ручной правке (обязателен)"
value={w.override_comment}
onChange={(e) =>
setWorks((arr) =>
arr.map((row) =>
row.rowKey === w.rowKey ? { ...row, override_comment: e.target.value } : row,
),
)
}
className="mt-2 w-full text-sm rounded-md border bg-background p-2 min-h-[60px]"
/>
)}
</li>
))}
</ul>
)}
{works.length > 0 && (
<div className="mt-3 text-right text-sm font-medium">Итого: {totalSum} </div>
)}
</section>
{/* Запчасти */}
<section>
<Label className="text-sm">Запчасти и материалы</Label>
{parts.length > 0 && (
<ul className="mt-2 space-y-1">
{parts.map((p) => (
<li key={p.rowKey} className="flex items-center gap-2 text-sm">
<span className="flex-1">{p.name}</span>
<Input
type="number"
min={1}
value={p.qty}
onChange={(e) =>
setParts((arr) =>
arr.map((row) =>
row.rowKey === p.rowKey ? { ...row, qty: Math.max(1, Number(e.target.value)) } : row,
),
)
}
className="w-20"
/>
<Button variant="ghost" size="sm" onClick={() => removePart(p.rowKey)}>
<Trash2 className="h-3 w-3" />
</Button>
</li>
))}
</ul>
)}
<div className="mt-2 flex gap-2">
<Input
value={partsDraft.name}
onChange={(e) => queryPartSuggestions(e.target.value)}
placeholder="Например, Свеча NGK"
onKeyDown={(e) => e.key === "Enter" && addPart(partsDraft.name)}
/>
<Button onClick={() => addPart(partsDraft.name)}>
<Plus className="h-4 w-4" />
</Button>
</div>
{partsDraft.suggestions.length > 0 && (
<ul className="mt-1 border rounded-md bg-popover divide-y">
{partsDraft.suggestions.map((s) => (
<li key={s}>
<button
type="button"
className="w-full text-left text-sm px-3 py-2 hover:bg-accent/40"
onClick={() => addPart(s)}
>
{s}
</button>
</li>
))}
</ul>
)}
</section>
{/* Замена масла */}
<section className="border rounded-md p-3">
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
checked={oilChange.enabled}
onChange={(e) =>
setOilChange((oc) => ({ ...oc, enabled: e.target.checked }))
}
/>
Была замена масла
</label>
{oilChange.enabled && (
<div className="mt-3 space-y-3">
<div>
<Label className="text-sm">Пробег на момент замены</Label>
<Input
type="number"
min={0}
value={oilChange.mileage_at_change ?? ""}
onChange={(e) =>
setOilChange((oc) => ({
...oc,
mileage_at_change: e.target.value === "" ? null : Number(e.target.value),
mileage_is_dirty: true,
}))
}
/>
</div>
<div>
<Label className="text-sm">Следующая замена через (км)</Label>
<Input
type="number"
min={0}
value={oilChange.next_in_km}
onChange={(e) =>
setOilChange((oc) => ({ ...oc, next_in_km: Number(e.target.value) || 0 }))
}
/>
</div>
<div>
<Label className="text-sm">Фото стикера (одно)</Label>
<label className="mt-1 block border-2 border-dashed rounded-md p-4 text-center cursor-pointer hover:bg-accent/40">
{oilChange.sticker == null ? (
<span className="text-sm text-muted-foreground">Тап чтобы загрузить</span>
) : oilChange.sticker.status === "uploading" ? (
"Загрузка…"
) : oilChange.sticker.status === "error" ? (
<span className="text-sm text-destructive">
Ошибка: {oilChange.sticker.errorMessage}
</span>
) : (
<span className="text-sm"> Загружено</span>
)}
<input
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => onPhotoInput(e.target.files, "sticker")}
/>
</label>
</div>
</div>
)}
</section>
</main>
{/* Works picker modal */}
{worksPicker.open && (
<div className="fixed inset-0 z-50 bg-background/95 flex flex-col">
<header className="border-b p-3 flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setWorksPicker({ open: false, q: "", results: [] })}>
Отмена
</Button>
<Input
autoFocus
placeholder="Поиск работы…"
value={worksPicker.q}
onChange={(e) => queryWorks(e.target.value)}
/>
</header>
<main className="flex-1 overflow-y-auto">
<ul className="divide-y">
{worksPicker.results.map((w) => (
<li key={w.id}>
<button
type="button"
onClick={() => addWorkFromCatalog(w)}
className="w-full text-left p-3 hover:bg-accent/40 flex items-baseline justify-between gap-3"
>
<span className="flex-1 min-w-0 truncate">{w.name}</span>
<span className="text-sm text-muted-foreground">{w.price} </span>
</button>
</li>
))}
{worksPicker.results.length === 0 && (
<li className="p-6 text-center text-sm text-muted-foreground">
Нет работ по запросу
</li>
)}
</ul>
</main>
</div>
)}
</div>
);
}
@@ -0,0 +1,228 @@
/**
* Лента ремонтов парка (Фича 2 + У5 + У38 + У39 + У40 + У41).
*
* Главный экран раздела «Ремонт». Все ремонты парка, сортировка
* `created_at DESC`. Бесконечный скролл по 20. Pull-to-refresh,
* обновление при возврате в приложение (через useRepairsFeed).
*
* Пустая лента — плейсхолдер «В парке пока нет ремонтов» (У41).
*/
import { useCallback, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { ChevronLeft, Plus, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useRepairsFeed } from "@/hooks/useRepairsFeed";
import { formatFeedTimestamp } from "@/lib/timeFormat";
import type { FeedItem } from "@/api/repairsFeed";
export default function RepairsFeedPage() {
const navigate = useNavigate();
const {
items,
isLoading,
isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
isFetching,
} = useRepairsFeed();
// IntersectionObserver для бесконечной прокрутки.
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = sentinelRef.current;
if (!el || !hasNextPage) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
void fetchNextPage();
}
},
{ rootMargin: "200px" },
);
io.observe(el);
return () => io.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
// Pull-to-refresh: простой UX через тач-скролл сверху.
// Делаем минимально-инвазивно: на тап-кнопку «обновить» вызываем refetch.
// Полноценный pull-to-refresh с жестом — отдельная задача.
const handleRefresh = useCallback(() => {
void refetch();
}, [refetch]);
const handleAdd = () => navigate("/repairs/new");
const handleOpen = (item: FeedItem) => navigate(`/repairs/${item.id}`);
return (
<div className="min-h-screen bg-background flex flex-col">
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="flex items-center justify-between p-3">
<Button
variant="ghost"
size="sm"
onClick={() => navigate("/")}
className="-ml-2"
aria-label="Назад"
>
<ChevronLeft className="h-5 w-5" />
<span className="ml-1">Назад</span>
</Button>
<h1 className="text-base font-semibold">Ремонт</h1>
<Button
variant="default"
size="sm"
onClick={handleAdd}
aria-label="Добавить ремонт"
>
<Plus className="h-4 w-4 mr-1" />
<span className="hidden sm:inline">Добавить</span>
</Button>
</div>
</header>
<main className="flex-1 container max-w-2xl py-3 space-y-2">
{/* Refresh tap-area для не-touch юзкейса; жест pull-to-refresh — в PR-8. */}
<div className="flex items-center justify-end">
<button
type="button"
onClick={handleRefresh}
disabled={isFetching}
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
>
{isFetching ? "Обновляем…" : "Обновить"}
</button>
</div>
{isLoading && (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-20 rounded-lg bg-muted animate-pulse" />
))}
</div>
)}
{isError && !isLoading && (
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm">
Не удалось загрузить ленту ремонтов. Проверьте сеть и попробуйте снова.
</div>
)}
{!isLoading && !isError && items.length === 0 && <EmptyState onAdd={handleAdd} />}
{items.length > 0 && (
<ul className="space-y-2">
{items.map((item) => (
<li key={item.id}>
<RepairRow item={item} onOpen={() => handleOpen(item)} />
</li>
))}
</ul>
)}
<div ref={sentinelRef} aria-hidden="true" />
{isFetchingNextPage && (
<div className="text-center text-xs text-muted-foreground py-4">
Загружаем ещё
</div>
)}
{!hasNextPage && items.length > 0 && !isFetchingNextPage && (
<div className="text-center text-xs text-muted-foreground py-4">
Это все ремонты.
</div>
)}
</main>
</div>
);
}
function RepairRow({ item, onOpen }: { item: FeedItem; onOpen: () => void }) {
const carLine = [item.car_plate, item.car_make, item.car_model]
.filter(Boolean)
.join(" · ");
return (
<button
type="button"
onClick={onOpen}
className="w-full flex items-stretch gap-3 rounded-lg border bg-card hover:bg-accent/40 transition-colors text-left p-3"
>
<div className="w-16 h-16 rounded-md bg-muted overflow-hidden flex-shrink-0 flex items-center justify-center">
{item.photo_url ? (
// Lazy-load — браузер сам решит когда грузить.
<img
src={item.photo_url}
alt=""
loading="lazy"
className="w-full h-full object-cover"
/>
) : (
<Wrench className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between gap-2">
<div className="font-medium truncate">{carLine}</div>
<div className="text-xs text-muted-foreground flex-shrink-0">
{formatFeedTimestamp(item.created_at)}
</div>
</div>
{item.driver_name && (
<div className="text-xs text-muted-foreground truncate">
Водитель: {item.driver_name}
</div>
)}
<div className="text-xs text-muted-foreground truncate">
Механик: {item.mechanic_name}
</div>
<WorksLine works={item.works_preview.map((w) => w.name)} extra={item.works_extra} />
</div>
</button>
);
}
function WorksLine({ works, extra }: { works: string[]; extra: number }) {
// У40: ellipsis в одну строку. Соединяем имена работ через «·».
if (works.length === 0) return null;
const tail = extra > 0 ? ` · +${extra} ${pluralWorks(extra)}` : "";
return (
<div className="text-sm truncate" title={works.join(", ") + (extra ? ` (+${extra})` : "")}>
{works.join(" · ")}
{tail && <span className="text-muted-foreground">{tail}</span>}
</div>
);
}
function pluralWorks(n: number): string {
// 1 → «работа», 2..4 → «работы», 5+ → «работ»
const mod10 = n % 10;
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 14) return "работ";
if (mod10 === 1) return "работа";
if (mod10 >= 2 && mod10 <= 4) return "работы";
return "работ";
}
function EmptyState({ onAdd }: { onAdd: () => void }) {
return (
<div className="flex flex-col items-center justify-center text-center py-16 px-4 space-y-4">
<div className="rounded-full bg-muted p-4">
<Wrench className="h-8 w-8 text-muted-foreground" />
</div>
<div className="space-y-1">
<h2 className="text-base font-semibold">В парке пока нет ремонтов</h2>
<p className="text-sm text-muted-foreground max-w-xs">
Создайте первый ремонт он появится здесь.
</p>
</div>
<Button onClick={onAdd}>
<Plus className="h-4 w-4 mr-1" />
Добавить ремонт
</Button>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface AuthState {
token: string | null;
setToken: (t: string | null) => void;
}
export const useAuth = create<AuthState>()(
persist(
(set) => ({
token: null,
setToken: (t) => set({ token: t }),
}),
{ name: "mechanic-auth" }
)
);
+53
View File
@@ -0,0 +1,53 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: "1rem",
},
extend: {
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
};
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Strict */
"strict": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path alias */
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
import path from "node:path";
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["icons/*.png"],
manifest: false, // we ship our own at public/manifest.webmanifest
workbox: {
globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
runtimeCaching: [
{
urlPattern: /\/api\//,
handler: "NetworkOnly",
},
],
},
}),
],
resolve: {
alias: { "@": path.resolve(__dirname, "src") },
},
server: {
port: 5173,
proxy: {
"/api": "http://localhost:8000",
},
},
});
+39
View File
@@ -0,0 +1,39 @@
# Caddy fragment for Premium Mechanic PWA on mechanic.pptaxi.ru.
# Apply on VDS 100.64.0.12 by copying/including into /etc/caddy/Caddyfile
# (or /etc/caddy/sites-enabled/ if the main Caddyfile uses imports).
#
# Assumes:
# - TaxiDashboard backend listens on 127.0.0.1:8000
# - Frontend PWA build is rsynced to /opt/sites/mechanic-pwa/dist/
mechanic.pptaxi.ru {
encode gzip zstd
# PWA: pass /api/* to the backend so the frontend can use relative URLs.
handle /api/* {
reverse_proxy 127.0.0.1:8000
}
# PWA static files with SPA fallback to index.html.
handle {
root * /opt/sites/mechanic-pwa/dist
try_files {path} /index.html
file_server
}
log {
output file /var/log/caddy/mechanic.pptaxi.ru.log {
roll_size 50mb
roll_keep 5
}
}
header {
# Basic security headers for a PWA
Referrer-Policy strict-origin-when-cross-origin
X-Content-Type-Options nosniff
X-Frame-Options DENY
# Camera permission for our origin only
Permissions-Policy "camera=(self), microphone=(self), geolocation=()"
}
}
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# Set global CORS allow-origin on MinIO instance (taxi-minio container).
# This MinIO version does not expose per-bucket PutBucketCors; CORS is global.
#
# Run on VDS 100.64.0.12 as root. Idempotent — safe to re-run after origin list changes.
set -e
export TERM=xterm
ALLOWED_ORIGINS="https://mechanic.pptaxi.ru,https://crm.pptaxi.ru,http://localhost:5173,http://localhost:8000"
docker exec taxi-minio sh -c "
mc alias set local http://localhost:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null
mc admin config set local api cors_allow_origin='$ALLOWED_ORIGINS'
echo
echo '=== verify cors_allow_origin ==='
mc admin config get local api | tr ' ' '\n' | grep '^cors_allow_origin='
"
+79
View File
@@ -0,0 +1,79 @@
# Vendor Damage Data Model — Extraction Notes
Source: `Ttc.dll` (Xamarin Android app, reverse-engineered from `assemblies.blob`)
Date: 2026-05-17
## Core Objects
### DetailDamageCoordinate
A single tap-point placed by the mechanic on the enlarged part SVG.
| Field | Type | Notes |
|-------|------|-------|
| `Id` | int/Guid | PK |
| `X` | float | Relative X on the part SVG canvas |
| `Y` | float | Relative Y on the part SVG canvas |
| `InspectionGuid` | Guid | Ties this point to an inspection session |
| `MetaId` | int | Zone ID (0..56) — which part this point belongs to |
| `GroupId` | int | Groups multiple points belonging to ONE damage annotation |
| `ImageWithLinesId` | int | FK → the photo with polyline annotation |
| `LinesId` | int | FK → the DrawingLines set |
Evidence: backing fields `<X>k__BackingField`, `<Y>k__BackingField`, `<Id>k__BackingField`, `<MetaId>k__BackingField`, `<GroupId>k__BackingField`, `<ImageWithLinesId>k__BackingField`, `<LinesId>k__BackingField` all found in contiguous block at 0x3baa00x3bd50 in Strings heap. `GetByGroupId` method confirms group-based querying.
### DetailDamageCoordinateContract (API wire format)
Same fields as above, serialized as JSON. The JSON property for severity is `"degree"` (lowercase, confirmed at 0x3d752 in Strings heap alongside `"damageDegree"` widget name).
### DetailDamageDrawPointContract
A single vertex in the finger-drawn polyline annotation on a photo.
| Field | Type | Notes |
|-------|------|-------|
| `X` | float | Point X on the photo canvas |
| `Y` | float | Point Y on the photo canvas |
The polyline is a **list** of `DetailDamageDrawPointContract` objects. The parent container is `DrawingLines` (backing field `<DrawingLines>k__BackingField` at 0x3cade). Method `DrawLine` (0x3e3c9) draws each segment. `StrokeWidth` (0x3f59a) controls line thickness.
The photo itself is stored as `Image64StringWithLines` (base64 JPEG) and `ImageBase64StringWithLines` (alternate field). The link between a damage group and its annotated photo is: `DetailDamageCoordinate.ImageWithLinesId → VehicleStatePhotoInspection.Id`.
## Multi-Point Damage Grouping
**Yes, multi-point damages are grouped via `GroupId`.** The mechanic taps N spots on the part SVG — each tap creates a `DetailDamageCoordinate` with the same `GroupId`. All points in a group share:
- One `damage_type` (the vertical list selection)
- One `degree` (severity: 0=Легкое, 1=Среднее, 2=Тяжелое)
- One `ImageWithLinesId` (one photo with optional polyline)
The `GetByGroupId` method (found in Strings heap at 0x394c0) fetches all coordinates for a group. `SaveByParts` (0x42f4f) persists the full group in one call.
## Photo + Polyline Link
```
Inspection
└─ DetailDamageCoordinate[] (grouped by GroupId)
├─ GroupId = 42 (e.g.)
├─ MetaId = 0 (front bumper)
├─ [X1,Y1], [X2,Y2], [X3,Y3] ← multiple tap points
├─ ImageWithLinesId = 7 ← FK → photo record
└─ degree = 1 (Среднее)
VehicleStatePhotoInspection (id=7)
├─ Image64StringWithLines ← base64 JPEG (the photo)
├─ DrawingLines[] ← list of DetailDamageDrawPointContract
│ └─ [{X:0.2, Y:0.4}, {X:0.5, Y:0.6}, ...] ← polyline vertices
└─ HistoryLines[] ← read-only history of old polylines
```
## Our Implementation Recommendation
1. **DamageGroup** table: `{ id, inspection_id, zone_id, damage_type, severity(0-2), photo_id, created_at }`
2. **DamagePoint** table: `{ id, group_id, x REAL, y REAL, seq_order INT }` — multiple rows per group
3. **DamagePhoto** table: `{ id, image_b64, polyline JSONB }` where `polyline` is `[[x,y], ...]`
4. Severity stored as integer 0/1/2, displayed as Легкое/Среднее/Тяжелое
This mirrors the vendor model exactly while using a standard relational schema.
## Known Unknowns
- The exact JSON shape returned by `damage/details` endpoint (API not captured live)
- Whether `GroupId` is a local UUID or server-assigned int
- Whether `DrawingLines` coordinates are normalized (0..1) or absolute pixels
+90
View File
@@ -0,0 +1,90 @@
{
"_meta": {
"source": "Ttc.dll #US heap, contiguous block at offsets 0xc2e240xc3084",
"confidence": "HIGH — full block scanned; order is the binary storage order which matches app display order (repository pattern typically loads in definition order)",
"notes": [
"Items 012 are general exterior damage types (user-selectable for any zone)",
"Items 1318 appear to be salon-specific (Грязный салон, Запах табака, etc.) — shown only for interior zones (4446 seats, salon)",
"Установлено/Снято (19–20) and Контроль (21) are semi-system states used for equipment completeness zones (3348 area)",
"Штат (25) appears to be a system/staff marker, not user-selectable",
"Порез, Прокол, Порвано are tire-specific damage types for zones 1720",
"Есть/Нет повреждений found in binary (0x4599a/0x4597a) — likely used for salon damage yes/no toggle, not in the standard list"
]
},
"damage_types_ordered": [
"Вмятина",
"Царапина",
"Трещина",
"Повреждено",
"Скол",
"Отсутствует",
"Прожжено",
"Погнуто",
"Требуется мойка",
"Не работает",
"Порез",
"Прокол",
"Порвано",
"Пятна",
"Грязный салон",
"Запах табака",
"Повреждение салонных ковриков",
"Повреждение обшивки дверей",
"Сломанные ручки",
"Установлено",
"Снято",
"Контроль",
"Требуется химчистка",
"Затертость",
"Грыжа",
"Штат"
],
"exterior_selectable": [
"Вмятина",
"Царапина",
"Трещина",
"Повреждено",
"Скол",
"Отсутствует",
"Прожжено",
"Погнуто",
"Требуется мойка",
"Не работает",
"Порез",
"Прокол",
"Порвано",
"Пятна",
"Затертость",
"Грыжа"
],
"salon_selectable": [
"Вмятина",
"Царапина",
"Трещина",
"Повреждено",
"Скол",
"Отсутствует",
"Прожжено",
"Погнуто",
"Требуется мойка",
"Не работает",
"Пятна",
"Грязный салон",
"Запах табака",
"Повреждение салонных ковриков",
"Повреждение обшивки дверей",
"Сломанные ручки",
"Требуется химчистка",
"Затертость",
"Грыжа"
],
"severity_scale": {
"positions": 3,
"labels": ["Легкое", "Среднее", "Тяжелое"],
"values": [0, 1, 2],
"widget": "SeekBar (vehicleInspectionDegreeSelect / newDamageSeekBarDamageDegree)",
"api_field": "degree (lowercase, in JSON contract)",
"default": "Легкое (index 0)",
"source_note": "Легкое found at 0x59d79 (adjacent to 'Добавить повреждение' button label = default/initial value); Среднее+Тяжелое found together at 0xc39e80xc39f8 (loading state transitions)"
}
}
+38
View File
@@ -0,0 +1,38 @@
US+1023: inspectionId
US+2393: )" id="car" xmlns = "http://www.w3.org/2000/svg" viewBox = "
US+81926: inspectionvideo/
US+83424: vehicle
US+83802: vehicle.json
US+83898: http://ttcontrol.naughtysoft.ru/api/
US+83972: vehicletechnicalcontrol
US+84020: techinspectionitem
US+84448: vehicle/vehiclelist
US+84488: vehicle/statuses
US+84522: vehiclerepair
US+84550: inspection.json
US+511737: vehiclestatephotoinspection
US+512035: vehicletechinspection
US+512311: inspection
US+512945: customersettings/salondamages
US+513773: damage/details
US+517799: vehicletechnicalcontrol/driver?search=
US+517877: vehicletechnicalcontrol/driverinspection?vehicleNumber=
US+517989: driver/state?vehicleNum={0}&reason={1}
US+518099: inspectionvideo?vehicleId=
US+518153: inspectionvideo
US+518323: vehicle?id=
US+518347: vehicle/vehiclemeta?id=
US+518395: damage/damagescard?id=
US+518441: vehicle/
US+518493: vehiclerepair/byvehicle?vehicleId=
US+518563: vehiclestate/history?id=
US+518613: vehiclestate/mileage
US+518655: vehiclestate/new
US+518689: vehiclestate/newpart
US+518731: vehiclestate/new?id=
US+518773: vehiclestatephotoinspection/photohistory?vehicleId={0}&type={1}
US+518901: vehiclestatephotoinspection/photoinspection?vehicleId=
US+519011: vehicletechinspection/byvehicle?vehicleId=
US+519097: inspection/byvehicle?id=
US+519147: inspection/photohistory?vehicleId={0}&type={1}
US+519253: damage
+6
View File
@@ -0,0 +1,6 @@
US+391601 (2929 chars): <line x1="540" y1="515" x2="610" y2="280" stroke="#000" stroke-width="2" /><circle class="38" ontouchstart="onstart(38)" ontouchend="onend(38)" cx = "610" cy = "280" r = "30" stroke = "black" stroke-width = "3" fill = "white" /><path fill="#C0C0C0" d="M640.7200000000004,512.7300000000006C638.7400000
US+397462 (2866 chars): <line x1="280" y1="515" x2="220" y2="280" stroke="#000" stroke-width="2" /><circle class="39" ontouchstart="onstart(39)" ontouchend="onend(39)" cx = "220" cy = "280" r = "30" stroke = "black" stroke-width = "3" fill = "white" /><path fill="#C0C0C0" d="M286.35,1012.9200000000002C286.43,1017.160000000
US+513813 (12 chars): <circle cx="
@@ -0,0 +1,30 @@
[529] US+513681: 'Контроль'
[530] US+513699: 'Требуется химчистка'
[531] US+513739: 'Затертость'
[532] US+513761: 'Грыжа'
[533] US+513773: 'damage/details'
[534] US+513803: 'Штат'
[535] US+513813: '<circle cx="'
[536] US+513839: '" cy="'
[537] US+513853: '" r="10" stroke-width="1" fill = "'
[538] US+513923: '" />'
[539] US+513933: '<text x="{0}" y = "{1}" stroke="#fff" class="small">{2}</text>'
[540] US+514059: '<rect x="'
[541] US+514079: '" y="'
[542] US+514091: '" width="20" height="20" fill = "'
[543] US+514159: '<polygon points="'
@@ -0,0 +1,76 @@
# Per-Part Detail SVG: API-Fetched, Not Embedded
## Verdict: API-FETCHED
No per-part SVG `<path d="...">` data is embedded in the DLL binaries. The vendor app fetches detail part SVGs from the server at runtime.
## How It Works
### 1. Vehicle Load: `vehicle/vehiclemeta?id=<vehicleId>`
When the mechanic selects a vehicle, the app calls:
```
GET {baseUrl}/vehicle/vehiclemeta?id={vehicleId}
Authorization: Bearer {token}
```
The response includes a list of `DetailScheme` objects — one per tappable zone. Each `DetailScheme` has at minimum:
- `Id` — database PK
- `MetaId` — maps to the SVG `class="N"` zone ID (0..56)
- `Scheme` — full SVG string for the enlarged part view
- `ViewBox` — the viewBox string for that part's SVG (e.g. `"0 0 400 300"`)
### 2. Zone Tap → Detail View
When user taps zone N on the composite SVG:
1. `Foo.TouchEnd(N)` fires (JavaScript in WebView)
2. App calls `GetDamagedDetailScheme(metaId: N)` (C# method in `Ttc.dll`)
3. The cached `DetailScheme` for that MetaId is retrieved
4. Its `Scheme` SVG is rendered in a new WebView/Activity (`DetailSchemeActivity`)
5. The scheme has the same `<script>` click handler so mechanic can tap damage spots
### 3. Damage Point Overlay: `damage/details` + `damage/damagescard?id=`
Existing damage points are overlaid on the detail scheme via:
```
GET {baseUrl}/damage/details # returns damage list for the part
GET {baseUrl}/damage/damagescard?id={damageId} # returns single damage card
```
## Key C# Symbols
```
DetailScheme — per-part SVG wrapper (field: Scheme, ViewBox, MetaId)
DetailSchemeWithStack — same with pre-rendered damage circle stack
DetailSchemeWithDamages — ViewModel binding damage points to scheme
GetDamagedDetailScheme(metaId) — retrieves scheme by zone ID
GetDamagedDetailSchemeWithStack(...) — retrieves scheme + overlaid damage marks
GenerateDamagesFromStack — draws circle markers from DamageCoordinates
DetailSchemeActivity — the Android Activity that hosts the per-part WebView
```
## Base URL
```
http://ttcontrol.naughtysoft.ru/api/
```
(Found at US heap 0x83898 in Ttc.dll — hard-coded base URL)
## Why No Embedded SVGs
The composite exterior and salon SVGs are embedded (they are static templates).
Per-part detail SVGs are vehicle-model-specific and change when the fleet changes
vehicle types, so they are served from the API to stay updatable without an app release.
## Our Implementation Options
1. **Mirror the API contract**: Implement our own `GET /vehicle/vehiclemeta?id=` that returns `DetailScheme[]` with our custom per-part SVGs.
2. **Embed static SVGs**: For each of the 57 zone IDs we use, design our own per-part SVG and bundle it in the frontend. Simpler but requires a frontend release for changes.
3. **Hybrid**: Embed SVGs for the top-N most common damage zones (bumpers, doors, hood, roof = zones 0,4,10,11,12,13,16,22) and fetch others from API.
Option 2 (embed static) is recommended for Phase 1 — we control all 57 shapes.
@@ -0,0 +1,15 @@
[63] US+1763: 'Комплектность'
[64] US+1791: '«Я, '
[65] US+1801: ', ознакомлен с результатами осмотра автомобиля.Выявленные повреждения автомобиля подтверждаю»'
[66] US+1990: '«Я, ознакомлен с результатами осмотра автомобиля.Выявленные повреждения автомобиля подтверждаю»'
[67] US+2183: 'gasAvailable'
[68] US+2209: 'Detail not found'
[69] US+2243: 'utf-8'
[70] US+2255: 'webInterface'
[71] US+2281: 'text/html; charset=UTF-8'
[72] US+2331: 'base64'
[73] US+2345: '<svg transform="rotate('
[74] US+2393: ')" id="car" xmlns = "http://www.w3.org/2000/svg" viewBox = "'
[75] US+2515: '" stroke = "black"><script>var pt = document.getElementById("car").createSVGPoint();function clicked(evt){var e = evt.target;var dim = e.getBoundingClientRect();pt.x = evt.clientX;pt.y = evt.clientY;v'
[76] US+81152: ' </svg>'
[77] US+81168: ' '
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 129 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 52 KiB

@@ -0,0 +1,795 @@
+ 88: __StaticArrayInitTypeSize=40
+ 158: <>c__DisplayClass10_0
+ 233: <_vehicleStatusHistoryRepository_CarChanged>b__11_0
+ 326: <TrySave>b__11_0
+ 343: <>c__DisplayClass11_0
+ 395: <GetSalonDamages>b__1_0
+ 419: <>c__DisplayClass1_0
+ 481: <>c__DisplayClass12_0
+ 503: <>c__DisplayClass72_0
+ 606: <>c__DisplayClass2_0
+ 650: <>c__DisplayClass13_0
+ 789: <GetHistoryPhotos>b__3_0
+ 814: <>c__DisplayClass3_0
+ 887: <>c__DisplayClass14_0
+ 1114: <>c__DisplayClass4_0
+ 1168: <GetHistory>b__65_0
+ 1188: <>c__DisplayClass65_0
+ 1271: <GoNextButton_Click>b__5_0
+ 1312: <GetHistoryPhotos>b__5_0
+ 1337: <>c__DisplayClass5_0
+ 1452: <>c__DisplayClass66_0
+ 1483: <GetByVehicle>b__6_0
+ 1521: <GetForSalon>b__6_0
+ 1541: <>c__DisplayClass6_0
+ 1605: <>c__DisplayClass17_0
+ 1656: <>c__DisplayClass67_0
+ 1678: <>c__DisplayClass77_0
+ 1739: <>c__DisplayClass7_0
+ 1760: <>c__DisplayClass68_0
+ 1782: <>c__DisplayClass78_0
+ 1826: <>c__DisplayClass8_0
+ 1945: <>c__DisplayClass9_0
+ 1966: <GetByGroupId>b__0
+ 1985: <GetMetaById>b__0
+ 2003: <GetImageById>b__0
+ 2022: <GetImagePathById>b__0
+ 2045: <GetById>b__0
+ 2059: <SearchText_TextChanged>b__0
+ 2103: <GetByVehicle>b__0
+ 2357: <GetDamages>b__0
+ 2391: <SaveByParts>b__0
+ 2464: <GetItemBy>b__0
+ 2480: <GetStateFromHistory>b__0
+ 2653: <TrySave>d__11
+ 2835: <TrySave>b__11_1
+ 2852: <>c__DisplayClass11_1
+ 2955: <>c__DisplayClass72_1
+ 3003: <>c__DisplayClass2_1
+ 3226: <>c__DisplayClass14_1
+ 3430: <GoNextButton_Click>b__5_1
+ 3807: <GetByVehicle>b__1
+ 3940: <GetHistoryPhotos>b__1
+ 3963: <SaveByParts>b__1
+ 4014: <GetHistory>b__1
+ 4031: <GetById>d__1
+ 4063: <GetSalonDamages>d__1
+ 4132: <Retry>d__11`1
+ 4251: AsyncTaskMethodBuilder`1
+ 4319: ArrayAdapter`1
+ 4425: __StaticArrayInitTypeSize=12
+ 4524: <SaveByParts>d__72
+ 4689: <>c__DisplayClass14_2
+ 4763: <GoNextButton_Click>b__5_2
+ 4987: <exceptions>5__2
+ 5071: <isReadOnly>5__2
+ 5188: <GetMetaById>d__2
+ 5378: KeyValuePair`2
+ 5393: Dictionary`2
+ 5406: TextAppearance_Compat_Notification_Line2
+ 5479: text2
+ 5594: <>c__DisplayClass14_3
+ 5642: <GoNextButton_Click>b__5_3
+ 5908: <GetImageById>d__3
+ 5927: <GetByVehicle>d__3
+ 5995: <GetDamages>d__3
+ 6034: <GetHistoryPhotos>d__3
+ 6153: __StaticArrayInitTypeSize=24
+ 6381: <GetImagePathById>d__4
+ 6512: <GetHistory>d__65
+ 6777: <GetByVehicle>d__5
+ 6822: <GoNextButton_Click>d__5
+ 6847: <GetHistoryPhotos>d__5
+ 7066: <GetByVehicle>d__6
+ 7366: <OnActivityResult>d__7
+ 7392: __StaticArrayInitTypeSize=28
+ 7421: __StaticArrayInitTypeSize=48
+ 7747: <GoToNextButton_Click>d__9
+ 7992: PointF
+ 8071: System.IO
+ 8104: get_X
+ 8110: set_X
+ 8116: GradientColor_android_endX
+ 8143: GradientColor_android_centerX
+ 8173: GetX
+ 8178: GradientColor_android_startX
+ 8207: _viewX
+ 8214: get_Y
+ 8220: set_Y
+ 8226: GradientColor_android_endY
+ 8253: GradientColor_android_centerY
+ 8283: GetY
+ 8288: GradientColor_android_startY
+ 8423: TextAppearance_Compat_Notification_Line2_Media
+ 8470: TextAppearance_Compat_Notification_Title_Media
+ 8517: TextAppearance_Compat_Notification_Time_Media
+ 8563: TextAppearance_Compat_Notification_Media
+ 8604: TextAppearance_Compat_Notification_Info_Media
+ 8792: newDamageTextureCamera
+ 8815: inspectionTextureCamera
+ 8847: GetStringExtra
+ 8862: GetBooleanExtra
+ 8878: GetIntExtra
+ 8890: PutExtra
+ 9059: System.Collections.Generic
+ 9086: GetResponseAsync
+ 9103: DownloadDataTaskAsync
+ 9125: GetRequestStreamAsync
+ 9147: async
+ 9157: <<TrySave>b__11_0>d
+ 9448: textViewResourceId
+ 9613: get_PhotoTypeId
+ 9629: set_PhotoTypeId
+ 10003: GetByGroupId
+ 10221: GetMetaById
+ 10233: GetImageById
+ 10246: GetImagePathById
+ 10263: GetById
+ 10271: FindViewById
+ 10347: detailhistoryadd
+ 10427: _damageAdded
+ 10614: _vehicleStatusHistoryRepository_CarChanged
+ 10694: DamageDegreeOnProgressChanged
+ 10791: _vehicleStatusHistoryRepository_ApprovedListChanged
+ 10872: add_TextChanged
+ 10888: SearchText_TextChanged
+ 10950: get_SalonChecked
+ 10967: set_SalonChecked
+ 11026: get_ComplecityChecked
+ 11048: set_ComplecityChecked
+ 11601: System.Collections.Specialized
+ 11770: <X>k__BackingField
+ 11789: <Y>k__BackingField
+ 12000: <PhotoTypeId>k__BackingField
+ 12516: <SalonChecked>k__BackingField
+ 12580: <ComplecityChecked>k__BackingField
+ 13005: <DamageDegree>k__BackingField
+ 13282: <Damage>k__BackingField
+ 13306: <ImageDamage>k__BackingField
+ 13335: <SalonGotDamage>k__BackingField
+ 13367: <GotNewDamage>k__BackingField
+ 13397: <ReadyForStatusChange>k__BackingField
+ 13435: <ReadyForDischarge>k__BackingField
+ 13631: <TypeName>k__BackingField
+ 13657: <TiTypeName>k__BackingField
+ 13829: <Scheme>k__BackingField
+ 13853: <DetailScheme>k__BackingField
+ 13883: <UnixTime>k__BackingField
+ 13909: <LastServiceUnixTime>k__BackingField
+ 13946: <LastServiceGasUnixTime>k__BackingField
+ 14009: <Type>k__BackingField
+ 14031: <SubType>k__BackingField
+ 14056: <VehicleRepairMetalWorkType>k__BackingField
+ 14100: <DetailType>k__BackingField
+ 14128: <PhotoType>k__BackingField
+ 14155: <VehicleRepairType>k__BackingField
+ 14190: <AccountType>k__BackingField
+ 14241: <ExpectedDate>k__BackingField
+ 15546: <ApprovedBySecondDriver>k__BackingField
+ 15586: <ApprovedByDriver>k__BackingField
+ 15770: <Damages>k__BackingField
+ 15795: <SalonDamages>k__BackingField
+ 15939: <HistoryLines>k__BackingField
+ 15969: <DamageTypes>k__BackingField
+ 15998: <TireTypes>k__BackingField
+ 16025: <DiskTypes>k__BackingField
+ 16143: <DamageCoordinates>k__BackingField
+ 16779: <ViewBox>k__BackingField
+ 16804: <ImageRepository>k__BackingField
+ 16837: <NewDamageRepository>k__BackingField
+ 16874: <VehicleRepository>k__BackingField
+ 16909: <VehicleRepairReferenceTypeRepository>k__BackingField
+ 16963: <DamageTypeRepository>k__BackingField
+ 17001: <InspectionRepairMetalWorkTypeRepository>k__BackingField
+ 17058: <DetailTypeRepository>k__BackingField
+ 17096: <SetItemTypeRepository>k__BackingField
+ 17135: <VehicleTechInspectionTypeRepository>k__BackingField
+ 17188: <InspectionReasonTypeRepository>k__BackingField
+ 17236: <PhotoTypeRepository>k__BackingField
+ 17273: <RepairTypeRepository>k__BackingField
+ 17311: <VehicleTechControlRepository>k__BackingField
+ 17357: <TechInspectionItemRepository>k__BackingField
+ 17403: <InspectionRepository>k__BackingField
+ 17441: <TechInspectionRepository>k__BackingField
+ 17483: <VehicleStatePhotoInspectionRepository>k__BackingField
+ 17538: <InspectionReasonRepository>k__BackingField
+ 17582: <InspectionVideoRepository>k__BackingField
+ 17625: <DriverRepository>k__BackingField
+ 17659: <VehicleRepairRepository>k__BackingField
+ 17700: <CustomerSettingsRepository>k__BackingField
+ 17744: <VehicleStateDraftRepository>k__BackingField
+ 17789: <AccountRepository>k__BackingField
+ 17824: <SparePartRepository>k__BackingField
+ 17861: <VehicleStatusHistoryRepository>k__BackingField
+ 17909: <AccidentGuilty>k__BackingField
+ 17941: textField
+ 18071: CoordinatorLayout_statusBarBackground
+ 18392: GetSystemService
+ 18875: get_DamageDegree
+ 18892: set_DamageDegree
+ 18909: newDamageSeekBarDamageDegree
+ 18938: _newDamageTextViewDamageDegree
+ 18969: _damageDegree
+ 18983: vehicleInspectionDamagesDegree
+ 19235: techinspectionHistoryTiMileage
+ 19266: CustomHistoryInspectionItemMileage
+ 19587: FontFamily_fontProviderPackage
+ 19862: get_Damage
+ 19873: set_Damage
+ 19884: detailPreviewAddDamage
+ 19907: SelectedDamage
+ 19922: get_ImageDamage
+ 19938: set_ImageDamage
+ 19954: DrawingImageDamage
+ 19973: DetailDamage
+ 19986: currentDamage
+ 20000: get_SalonGotDamage
+ 20019: set_SalonGotDamage
+ 20038: get_GotNewDamage
+ 20055: set_GotNewDamage
+ 20072: damage
+ 20144: CoordinatorLayout_Layout_layout_insetEdge
+ 20206: get_ReadyForStatusChange
+ 20231: set_ReadyForStatusChange
+ 20256: CompareExchange
+ 20272: get_ReadyForDischarge
+ 20294: set_ReadyForDischarge
+ 20454: Styleable
+ 20507: add_SurfaceTextureAvailable
+ 20535: TextureViewOnSurfaceTextureAvailable
+ 20807: GetByVehicle
+ 20847: RuntimeTypeHandle
+ 20865: GetTypeFromHandle
+ 21038: TextAppearance_Compat_Notification_Title
+ 21295: ProgressDialogStyle
+ 21315: SetProgressStyle
+ 21332: FontFamilyFont_android_fontStyle
+ 21365: FontFamilyFont_fontStyle
+ 21390: coordinatorLayoutStyle
+ 21507: _contextFileName
+ 21534: get_TypeName
+ 21547: set_TypeName
+ 21560: get_TiTypeName
+ 21575: set_TiTypeName
+ 21590: set_DefaultTextEncodingName
+ 21845: library_name
+ 21882: get_Scheme
+ 21893: set_Scheme
+ 21904: get_DetailScheme
+ 21921: set_DetailScheme
+ 21938: GetDamagedDetailScheme
+ 21961: TextAppearance_Compat_Notification_Time
+ 22010: get_UnixTime
+ 22023: set_UnixTime
+ 22036: get_LastServiceUnixTime
+ 22060: set_LastServiceUnixTime
+ 22084: get_LastServiceGasUnixTime
+ 22111: set_LastServiceGasUnixTime
+ 22222: IAsyncStateMachine
+ 22304: CoordinatorLayout_Layout_layout_keyline
+ 22410: get_Type
+ 22419: set_Type
+ 22428: get_SubType
+ 22440: set_SubType
+ 22452: methodType
+ 22463: VehicleType
+ 22475: _vehicleType
+ 22488: ValueType
+ 22498: get_VehicleRepairMetalWorkType
+ 22529: set_VehicleRepairMetalWorkType
+ 22560: ChangeDiskType
+ 22575: get_DetailType
+ 22590: set_DetailType
+ 22605: ApprovedCustomItemType
+ 22628: get_PhotoType
+ 22642: set_PhotoType
+ 22656: get_VehicleRepairType
+ 22678: set_VehicleRepairType
+ 22700: set_ContentType
+ 22716: get_AccountType
+ 22732: set_AccountType
+ 22748: InspectionPartType
+ 22767: GenerateDiskByType
+ 22786: GradientColor_android_type
+ 22854: System.Core
+ 22969: get_SurfaceTexture
+ 22988: completenessPhotoTexture
+ 23013: SetPreviewTexture
+ 23381: InspectionVideoHistoryResponse
+ 23420: TryParse
+ 23455: get_ExpectedDate
+ 23472: set_ExpectedDate
+ 23623: techinspectionHistoryTiDate
+ 23691: CustomHistoryInspectionItemDate
+ 23976: DetailDamageCoordinate
+ 24064: GetCurrentSalonDamageState
+ 24579: AssemblyTitleAttribute
+ 24602: AsyncStateMachineAttribute
+ 24629: AssemblyTrademarkAttribute
+ 24728: AssemblyFileVersionAttribute
+ 24757: AssemblyConfigurationAttribute
+ 24788: AssemblyDescriptionAttribute
+ 24866: CompilationRelaxationsAttribute
+ 24898: AssemblyProductAttribute
+ 24923: AssemblyCopyrightAttribute
+ 24950: ExportAttribute
+ 24966: AssemblyCompanyAttribute
+ 24991: RuntimeCompatibilityAttribute
+ 25021: ActivityAttribute
+ 25039: Byte
+ 25123: TrySave
+ 25173: newDamageRemove
+ 25189: vehicleInspectionDamageAprove
+ 25369: notification_action_text_size
+ 25399: notification_subtext_size
+ 25425: IndexOf
+ 25433: Exif
+ 25505: notify_panel_notification_icon_bg
+ 25737: System.Threading
+ 25844: ThenByDescending
+ 25861: OrderByDescending
+ 25946: System.Runtime.Versioning
+ 26831: compat_notification_large_icon_max_width
+ 26901: dayOfMonth
+ 26939: taxi
+ 26993: damageTypeGoBack
+ 27010: metalWorkTypeGoBack
+ 27030: detailTypeGoBack
+ 27047: repairTypeGoBack
+ 27141: vehicleRepairTypeSelectGoBack
+ 27229: damageViewGoBack
+ 27320: AsyncCallback
+ 27444: inspectionSalonGoback
+ 27604: videoplaybackgoback
+ 27784: techinspectiontypesgoback
+ 28100: inspectionHistorygoback
+ 28124: inspectionTakePhotoHistorygoback
+ 28157: detailhistorygoback
+ 28177: detailhistoryhistorygoback
+ 28245: GetDamagedDetailSchemeWithStack
+ 28277: GenerateDamagesFromStack
+ 28433: GoNextButton_Click
+ 28452: GoToNextButton_Click
+ 28650: customDamageItemCheckMark
+ 28676: customSalonItemCheckMark
+ 28701: secondary_text_default_material_dark
+ 28738: primary_text_default_material_dark
+ 29728: MemoryStream
+ 29947: InspectionVideoHistoryResponseItem
+ 30246: CustomDamageListViewItem
+ 30271: CustomSalonDamageListViewItem
+ 30569: CustomInspectionHistoryListViewItem
+ 30667: System
+ 30980: status_bar_notification_info_maxnum
+ 31258: notification_big_circle_margin
+ 31466: TextAppearance_Compat_Notification
+ 31608: SetCameraDisplayOrientation
+ 31636: SetDisplayOrientation
+ 31693: System.Globalization
+ 31861: System.Reflection
+ 32120: WebException
+ 32133: AggregateException
+ 32152: CustomApiException
+ 32171: InvalidOperationException
+ 32197: SetException
+ 32210: ContextException
+ 32227: IdsForSalon
+ 32239: GetForSalon
+ 32478: newDamageButton
+ 32546: damageTypeButton
+ 32925: salonApproveButton
+ 33279: newDamagePhotoButton
+ 33356: inspectionNextPhotoButton
+ 33607: tiGoNextButton
+ 33622: repairGoNextButton
+ 33641: sparePartGoNextButton
+ 33663: inspectionHistoryGoNextButton
+ 33693: _goToNextButton
+ 33709: inspectionPhotoHistoryButton
+ 33742: CopyTo
+ 33787: TextAppearance_Compat_Notification_Info
+ 33943: detailHistoryMakePhoto
+ 33966: newDamageTakePhoto
+ 34032: newDamageRetakePhoto
+ 34128: newDamageApprovePhoto
+ 34314: HistoryPhoto
+ 34327: detailhistoryPhoto
+ 34765: System.Linq
+ 34889: get_Year
+ 34898: monthOfYear
+ 34916: year
+ 35188: CustomDamageItemHeader
+ 35367: CustomSalonItemHeader
+ 35479: TextReader
+ 35617: inspectionHistoryheader
+ 35641: detailhistoryheader
+ 35733: ContextProvider
+ 35749: _contextProvider
+ 35775: AsyncVoidMethodBuilder
+ 36058: tag_unhandled_key_event_manager
+ 36234: System.CodeDom.Compiler
+ 36510: videoplaybackOpenInBrowser
+ 36558: get_LayoutInflater
+ 36666: TextWriter
+ 36886: CustomSalonDamageListViewAdapter
+ 37174: CustomTechInspectionHistoryListViewAdapter
+ 37217: CustomInspectionHistoryListViewAdapter
+ 37256: CustomArrayAdapter
+ 37401: get_ApprovedBySecondDriver
+ 37428: set_ApprovedBySecondDriver
+ 37527: get_ApprovedByDriver
+ 37548: set_ApprovedByDriver
+ 37621: CamelCasePropertyNamesContractResolver
+ 37688: get_MediaPlayer
+ 37704: set_MediaPlayer
+ 37720: _mediaPlayer
+ 37890: CoordinatorLayout_Layout_layout_anchor
+ 37929: CoordinatorLayout_Layout_layout_behavior
+ 38128: SetTextColor
+ 38506: get_Extras
+ 38517: extras
+ 38574: System.Diagnostics
+ 38604: FromUnixTimeSeconds
+ 38744: System.Runtime.InteropServices
+ 38775: System.Runtime.CompilerServices
+ 38914: get_Damages
+ 38926: set_Damages
+ 38938: GenerateDamages
+ 38954: DetailSchemeWithDamages
+ 38978: vehicleInspectionDamages
+ 39003: get_SalonDamages
+ 39020: set_SalonDamages
+ 39037: GetSalonDamages
+ 39053: GetDamages
+ 39064: _damages
+ 39073: CoordinatorLayout_Layout_layout_dodgeInsetEdges
+ 39147: QueryIntentActivities
+ 39221: damageNames
+ 39233: DetailSchemes
+ 39397: get_HistoryLines
+ 39414: set_HistoryLines
+ 39431: CoordinatorLayout_keylines
+ 39458: get_DamageTypes
+ 39474: set_DamageTypes
+ 39490: get_TireTypes
+ 39504: set_TireTypes
+ 39518: GetTiTypes
+ 39529: get_DiskTypes
+ 39543: set_DiskTypes
+ 39557: GetPhotoControlTypes
+ 39578: GetInspectionTypes
+ 39777: get_DamageCoordinates
+ 39799: set_DamageCoordinates
+ 39832: GetBytes
+ 39841: bytes
+ 39979: FontFamilyFont_android_fontVariationSettings
+ 40024: FontFamilyFont_fontVariationSettings
+ 40134: TextChangedEventArgs
+ 40180: SurfaceTextureAvailableEventArgs
+ 40213: surfaceTextureAvailableEventArgs
+ 40367: Microsoft.CodeAnalysis
+ 40390: System.Threading.Tasks
+ 40519: LayoutParams
+ 40865: System.Collections
+ 41022: GetHistoryPhotos
+ 41092: tag_unhandled_key_listeners
+ 41276: _damageColors
+ 41539: SaveByParts
+ 41572: FontFamily_fontProviderCerts
+ 41601: CheckDetailExists
+ 41755: ChangeSalonDamageStatus
+ 41950: AddDays
+ 42021: Xamarin.Android.Support.Compat
+ 42052: ContextCompat
+ 42066: ActivityCompat
+ 42146: DamageContract
+ 42219: DetailDamageCoordinateContract
+ 42426: DetailDamageDrawPointContract
+ 42632: System.Net
+ 42753: KeySet
+ 42904: FontFamilyFont_android_fontWeight
+ 42938: FontFamilyFont_fontWeight
+ 42995: compat_notification_large_icon_max_height
+ 43059: secondary_text_default_material_light
+ 43144: _commentTextEdit
+ 43161: textEdit
+ 43196: Exit
+ 43201: exit
+ 43261: IAsyncResult
+ 43274: StartActivityForResult
+ 43317: OnActivityResult
+ 43468: newDamageComment
+ 43485: _damageComment
+ 43795: DrawingPoint
+ 43808: FontFamilyFont
+ 43823: FontFamilyFont_android_font
+ 43851: FontFamilyFont_font
+ 43896: maxAttemptCount
+ 43937: SyncRoot
+ 44176: newDamageCoast
+ 44191: _damageCoast
+ 44236: salonDamageList
+ 44252: damageTypeList
+ 44452: detailhistoryDamagesList
+ 44477: techinspectiontypesList
+ 44581: inspectionHistoryList
+ 44603: videoHistoryList
+ 44620: inspectionTakePhotoHistoryList
+ 44651: detailhistoryhistorylist
+ 44730: FontFamily_fontProviderFetchTimeout
+ 44766: CoordinatorLayout_Layout
+ 44791: SignatureSecondLayout
+ 44813: VehicleInspectionDetailDamageLayout
+ 44849: InspectionRepairMetalWorkTypeLayout
+ 44885: InspectionRepairTypeLayout
+ 44912: InspectionReasonsMoreLayout
+ 44940: SignatureLayout
+ 44956: VehicleTechControlApproveLayout
+ 44988: VehicleInspectionDetailDrawingLayout
+ 45025: LoadingDialogLayout
+ 45045: VehicleInspectionVideoPlaybackLayout
+ 45082: InspectionRepairAdditionalLayout
+ 45115: VehicleInspectionCustomFuelLayout
+ 45149: VehicleInspectionFuelLayout
+ 45177: VehicleInspectionTierLevelLayout
+ 45210: VehicleTechControlLayout
+ 45235: vehicleTechControlLayout
+ 45260: VehicleInspectionLayout
+ 45284: VehicleInspectionSalonLayout
+ 45313: VehicleInspectionVideoLayout
+ 45342: VehicleInspectionCompletenessTakePhotoLayout
+ 45387: LinearLayout
+ 45400: Widget_Support_CoordinatorLayout
+ 45433: VehicleTechControlVehiclesLayout
+ 45466: VehicleStatusesLayout
+ 45488: DriverDischargeReasonsLayout
+ 45517: InspectionReasonsLayout
+ 45541: SpareGroupsLayout
+ 45559: VehicleTechControlDriversLayout
+ 45591: VehicleInspectionCompletenessLayout
+ 45627: VehicleInspectionDraftsLayout
+ 45657: VehicleInspectionDocumentsLayout
+ 45690: VehicleForRejectLayout
+ 45713: InspectionReasonsAfterAccidentLayout
+ 45750: VehicleInspectionCommentLayout
+ 45781: VehicleRepairCommentLayout
+ 45808: SparePartLayout
+ 45824: VehicleTechInspectionTypeListLayout
+ 45860: VehicleTechInspectionWorkListLayout
+ 45896: ImageViewLayout
+ 45912: imageViewLayout
+ 45928: ParkViewLayout
+ 45943: SignaturePreviewLayout
+ 45966: VehicleInspectionDetailPreviewLayout
+ 46003: VehicleInspectionDetailPhotoPreviewLayout
+ 46045: VehicleInspectionChangeHistoryLayout
+ 46082: VehicleInspectionDetailHistoryLayout
+ 46119: VehicleInspectionHistoryLayout
+ 46150: VehicleTechInspectionHistoryLayout
+ 46185: InspectionTakePhotoHistoryLayout
+ 46218: VehicleInspectionDetailHistoryHistoryLayout
+ 46262: videolayout
+ 46284: MoveNext
+ 46293: repairAditionaGoNext
+ 46314: inspectionDetailGoNext
+ 46337: detailPreviewGoNext
+ 46357: newDamageGoToNext
+ 46375: isNext
+ 46382: Android.Text
+ 46395: System.Text
+ 46407: get_Text
+ 46416: set_Text
+ 46425: vehicleTechControlMileageText
+ 46455: inspectionChangeNewMileageText
+ 46486: inspectionHistoryMileageText
+ 46515: _mileageText
+ 46528: MakeText
+ 46537: _titleText
+ 46548: vehicleRepairTypeText
+ 46570: _searchText
+ 46582: vehicleInspectionCustomFuelText
+ 46614: tierLevelText
+ 46628: Widget_Compat_NotificationActionText
+ 46665: customApprovedHeaderText
+ 46690: _headerText
+ 46702: _passwordEditText
+ 46720: _mileagEditText
+ 46736: driverSecondSearchEditText
+ 46763: vehicleSearchEditText
+ 46785: techcotroldriverSearchEditText
+ 46816: sparePartSearchEditText
+ 46840: _loginEditText
+ 46855: _stoEditText
+ 46868: vehicleInspectionCommentText
+ 46897: vehicleRepairCommentText
+ 46922: vehicleRejectCommentText
+ 46947: detailHistoryCommentText
+ 46972: progressViewText
+ 46989: next
+ 46994: notification_top_pad_large_text
+ 47026: action_text
+ 47038: inspectionsecondsignaturetext
+ 47068: inspectionsignaturetext
+ 47092: get_Context
+ 47104: SaveVehicleContext
+ 47123: GetVehicleContext
+ 47141: SaveAccountContext
+ 47160: GetAccountContext
+ 47178: context
+ 47186: txt
+ 47196: NewDamageMenu
+ 47210: NewDamagePhotoMenu
+ 47364: inspectionHistoryImageView
+ 47391: NewDamageView
+ 47416: DamageTypeView
+ 47431: DetailTypeView
+ 47446: TextureView
+ 47458: _textureView
+ 47602: VehicleRepairTypeSelectView
+ 47775: metalWorkTypeListView
+ 47797: detailTypeListView
+ 47816: vehicleRepairTypeListView
+ 47842: repairTypeListView
+ 48005: _headerTextView
+ 48021: textView
+ 48158: newDamagePhotoPreview
+ 48481: Max
+ 48485: FontFamilyFont_android_ttcIndex
+ 48517: FontFamilyFont_ttcIndex
+ 48541: Matrix
+ 48548: CheckBox
+ 48557: get_ViewBox
+ 48569: set_ViewBox
+ 48581: viewBox
+ 48589: GetItemBy
+ 48599: get_Day
+ 48607: get_Today
+ 48617: Play
+ 48622: get_DefaultDisplay
+ 48641: cm_gray
+ 48649: DecodeByteArray
+ 48665: InitializeArray
+ 48681: ToArray
+ 48689: set_Accuracy
+ 48702: _changeStatusReady
+ 48721: body
+ 48726: get_Key
+ 48734: ContainsKey
+ 48746: FontFamily_fontProviderFetchStrategy
+ 48783: FontFamily
+ 48794: IsReadOnly
+ 48805: _isReadOnly
+ 48817: Any
+ 48821: OnDestroy
+ 48831: Copy
+ 48836: _buttonInfoDictionary
+ 48858: detailPreviewGallery
+ 48879: gallery
+ 48887: FontFamily_fontProviderQuery
+ 48916: BitmapFactory
+ 48930: get_ExternalStorageDirectory
+ 48959: Ttc.Data.Repository
+ 48979: get_ImageRepository
+ 48999: get_NewDamageRepository
+ 49023: _newDamageRepository
+ 49044: _imageRepository
+ 49061: get_VehicleRepository
+ 49083: _vehicleRepository
+ 49102: get_VehicleRepairReferenceTypeRepository
+ 49143: get_DamageTypeRepository
+ 49168: _vehicleDamageTypeRepository
+ 49197: _damageTypeRepository
+ 49219: get_InspectionRepairMetalWorkTypeRepository
+ 49263: get_DetailTypeRepository
+ 49288: get_SetItemTypeRepository
+ 49314: get_VehicleTechInspectionTypeRepository
+ 49354: set_VehicleTechInspectionTypeRepository
+ 49394: _vehicleTechInspectionTypeRepository
+ 49431: get_InspectionReasonTypeRepository
+ 49466: get_PhotoTypeRepository
+ 49490: _photoTypeRepository
+ 49511: get_RepairTypeRepository
+ 49536: _repairTypeRepository
+ 49558: _vehicleDetailStateRepository
+ 49588: get_VehicleTechControlRepository
+ 49621: _vehicleTechControlRepository
+ 49651: get_TechInspectionItemRepository
+ 49684: _techInspectionItemRepository
+ 49714: get_InspectionRepository
+ 49739: get_TechInspectionRepository
+ 49768: _techInspectionRepository
+ 49794: get_VehicleStatePhotoInspectionRepository
+ 49836: _vehicleStatePhotoInspectionRepository
+ 49875: _inspectionRepository
+ 49897: get_InspectionReasonRepository
+ 49928: _inspectionReasonRepository
+ 49956: get_InspectionVideoRepository
+ 49986: _inspectionVideoRepository
+ 50013: get_DriverRepository
+ 50034: get_VehicleRepairRepository
+ 50062: _vehicleRepairRepository
+ 50087: get_CustomerSettingsRepository
+ 50118: _customerSettingsRepository
+ 50146: get_VehicleStateDraftRepository
+ 50178: _vehicleStateDraftRepository
+ 50207: get_AccountRepository
+ 50229: _accountRepository
+ 50248: get_SparePartRepository
+ 50272: RestRepository
+ 50287: get_VehicleStatusHistoryRepository
+ 50322: _vehicleStatusHistoryRepository
+ 50354: GetStateFromHistory
+ 50374: CachedCarHistory
+ 50391: IsHistory
+ 50401: GetHistory
+ 50412: photocarhistory
+ 50428: detailhistoryhistory
+ 50449: Retry
+ 50455: Entry
+ 50461: set_JpegQuality
+ 50477: op_Equality
+ 50489: Availability
+ 50502: get_Visibility
+ 50517: set_Visibility
+ 50532: _extraVisibility
+ 50549: FontFamily_fontProviderAuthority
+ 50582: CoordinatorLayout_Layout_layout_anchorGravity
+ 50628: CoordinatorLayout_Layout_android_layout_gravity
+ 50676: get_Activity
+ 50689: SignatureSecondActivity
+ 50713: VehicleInspectionDetailDamageActivity
+ 50751: VehicleInspectionDetailSchemeActivity
+ 50789: InspectionReasonsMoreActivity
+ 50819: SignatureActivity
+ 50837: BaseActivity
+ 50850: VehicleInspectionVideoPlaybackActivity
+ 50889: InspectionRepairAdditionalActivity
+ 50924: VehicleInspectionCustomFuelActivity
+ 50960: VehicleInspectionFuelActivity
+ 50990: VehicleInspectionCustomGasFuelActivity
+ 51029: VehicleInspectionGasFuelActivity
+ 51062: VehicleInspectionTierLevelActivity
+ 51097: VehicleTechControlActivity
+ 51124: VehicleDriverTechControlActivity
+ 51157: SplashScreenActivity
+ 51178: ManagerMainActivity
+ 51198: VehicleInspectionActivity
+ 51224: VehicleInspectionSalonActivity
+ 51255: VehicleInspectionVideoActivity
+ 51286: NewDamagePhotoActivity
+ 51309: VehicleInspectionDetailTakePhotoActivity
+ 51350: InspectionTakePhotoActivity
+ 51378: VehicleInspectionCompletenessTakePhotoActivity
+ 51425: VehicleRepairActivity
+ 51447: VehicleTechControlVehiclesActivity
+ 51482: VehicleStatusesActivity
+ 51506: DriverDischargeReasonsActivity
+ 51537: InspectionReasonsActivity
+ 51563: VehicleTechControlDriversActivity
+ 51597: VehicleRepairsActivity
+ 51620: VehicleInspectionCompletenessActivity
+ 51658: VehicleInspectionDraftsActivity
+ 51690: VehicleInspectionDocumentsActivity
+ 51725: VehicleForRejectActivity
+ 51750: VehiclesForRejectActivity
+ 51776: VehicleRepairTypeSelectActivity
+ 51808: InspectionReasonsAfterAccidentActivity
+ 51847: VehicleInspectionCommentActivity
+ 51880: VehicleRepairCommentActivity
+ 51909: StartActivity
+ 51923: VehicleTechInspectionTypeListActivity
+ 51961: VehicleTechInspectionWorkListActivity
+ 51999: ImageViewActivity
+ 52017: ParkViewActivity
+ 52034: SignaturePreviewActivity
+ 52059: VehicleInspectionDetailPhotoPreviewActivity
+ 52103: VehicleInspectionChangeHistoryActivity
+ 52142: VehicleTechInspectionHistoryActivity
+ 52179: InspectionTakePhotoHistoryActivity
+ 52214: InspectionPhotoHistoryActivity
+ 52245: activity
+ 52254: get_AccidentGuilty
+ 52273: set_AccidentGuilty
+ 52292: GetAccidentGuilty
+ 52310: IsNullOrEmpty
+ 52324: isEmpty
+37
View File
@@ -0,0 +1,37 @@
=== Strings BEFORE exterior SVG (idx 391) ===
[376] US+201813: 'Левый передний диск '
[377] US+201855: 'Правый передний диск '
[378] US+201899: 'Правый задний диск '
[379] US+201939: 'Левый задний диск '
[380] US+201977: 'штампованный'
[381] US+202003: 'штампованный с колпаком'
[382] US+202051: 'литой'
[383] US+202063: '<path onclick="Foo.ChangeRezina(0)" transform="scale(0.2, 0.2)" fill="#006DF0" d="M501.961,245.961h-42.423l10.335-10.335c3.92-3.92,3.92-10.277,0-14.198c-3.921-3.919-10.276-3.919-14.198,0 l-24.532,2'
[384] US+210660: '<path ontouchstart="Foo.ChangeRezinaOnStart(0)" ontouchend="Foo.ChangeRezinaOnEnd(0)" transform="scale(0.2, 0.2)" fill="#FFDA44" d="M497.78,326.334l-51.395-70.808l51.395-70.804c1.711-2.475,2.088-5.232'
[385] US+212935: '<path onclick="Foo.ChangeRezina(1)" transform="scale(0.2, 0.2)" fill="#006DF0" d="M501.961,245.961h-42.423l10.335-10.335c3.92-3.92,3.92-10.277,0-14.198c-3.921-3.919-10.276-3.919-14.198,0 l-24.532,2'
[386] US+221532: '<path ontouchstart="Foo.ChangeRezinaOnStart(1)" ontouchend="Foo.ChangeRezinaOnEnd(1)" transform="scale(0.2, 0.2)" fill="#FFDA44" d="M497.78,326.334l-51.395-70.808l51.395-70.804c1.711-2.475,2.088-5.232'
[387] US+223807: '<path onclick="Foo.ChangeRezina(2)" transform="scale(0.2, 0.2)" fill="#006DF0" d="M501.961,245.961h-42.423l10.335-10.335c3.92-3.92,3.92-10.277,0-14.198c-3.921-3.919-10.276-3.919-14.198,0 l-24.532,2'
[388] US+232404: '<path ontouchstart="Foo.ChangeRezinaOnStart(2)" ontouchend="Foo.ChangeRezinaOnEnd(2)" transform="scale(0.2, 0.2)" fill="#FFDA44" d="M497.78,326.334l-51.395-70.808l51.395-70.804c1.711-2.475,2.088-5.232'
[389] US+234679: '<path onclick="Foo.ChangeRezina(3)" transform="scale(0.2, 0.2)" fill="#006DF0" d="M501.961,245.961h-42.423l10.335-10.335c3.92-3.92,3.92-10.277,0-14.198c-3.921-3.919-10.276-3.919-14.198,0 l-24.532,2'
[390] US+243276: '<path ontouchstart="Foo.ChangeRezinaOnStart(3)" ontouchend="Foo.ChangeRezinaOnEnd(3)" transform="scale(0.2, 0.2)" fill="#FFDA44" d="M497.78,326.334l-51.395-70.808l51.395-70.804c1.711-2.475,2.088-5.232'
=== Strings AFTER exterior SVG ===
[392] US+324296: '</g><g transform ="translate(660, 150)">'
[393] US+324378: '</g><g transform ="translate(60, 950)">'
[394] US+324458: '</g><g transform ="translate(660, 950)">'
[395] US+324540: '</g><path fill = "#C0C0C0" d="M352.2200000000006,295.44000000000045C352.1500000000006,299.89000000000044,352.1700000000006,304.3500000000005,352.1900000000006,308.81000000000046C390.92000000000064,308'
[396] US+330189: '<path class="52" ontouchstart="onstart(52)" ontouchend="onend(52)" fill = "#ffffff" d="M704.9500000000005,302.20000000000044C704.8900000000006,306.51000000000045,704.9600000000005,310.84000000000043,7'
[397] US+332198: '<path class="51" ontouchstart="onstart(51)" ontouchend="onend(51)" fill = "#ffffff" d="M90.69000000000051,300.70000000000044C85.06000000000051,303.2800000000004,84.14000000000051,310.11000000000047,82'
[398] US+333995: '<path fill = "#C0C0C0" d="M684.1800000000005,309.9200000000004C684.0400000000005,315.9500000000004,684.7600000000006,321.96000000000043,684.5100000000006,327.9900000000004C684.6600000000005,330.130000'
[399] US+338054: '<path fill = "#C0C0C0" d="M122.05000000000052,309.27000000000044C122.98000000000053,318.9100000000004,123.96000000000052,328.5500000000004,125.32000000000052,338.14000000000044C129.62000000000052,337.'
[400] US+341951: '<path class="3" ontouchstart="onstart(3)" ontouchend="onend(3)" fill = "#ffffff" d="M350,130L470,130,470,152,350,152Z" />'
[401] US+342196: '<path ontouchstart="onstart(57)" ontouchend="onend(57)" fill = "#ffffff" d = "M360,130L390,130,400,152,350,152Z" />'
[402] US+342429: '<path class="30" fill = "#C0C0C0" ontouchstart="onstart(30)" ontouchend="onend(30)" d="M509.22000000000014,746.4400000000003C512.6800000000002,756.4900000000002,515.8800000000001,766.7000000000003,520'
[403] US+344372: '<path fill = "#C0C0C0" class="31" ontouchstart="onstart(31)" ontouchend="onend(31)" d="M298.04000000000013,743.8400000000003C298.5500000000001,754.5800000000003,298.91000000000014,765.3500000000003,29'
[404] US+346287: '<path class="28" ontouchstart="onstart(28)" ontouchend="onend(28)" fill="№ffffff" d="M170.45000000000044,496.19000000000057C170.49000000000044,498.31000000000057,170.41000000000045,500.45000000000056,'
[405] US+347230: '<path class="29" ontouchstart="onstart(29)" ontouchend="onend(29)" fill="#ffffff" d="M645.2800000000004,504.77000000000055C647.3600000000005,503.95000000000056,649.5200000000004,503.39000000000055,651'
[406] US+348151: '<path fill = "#C0C0C0" d="M531.9600000000002,557.0300000000004C532.1600000000002,583.7900000000004,531.5300000000002,610.5500000000004,531.8000000000002,637.3000000000004C534.7500000000002,635.7400000'
[407] US+353102: '<path d="M664.2900000000003,581.3900000000006C667.1000000000003,580.3300000000006,671.5300000000003,581.3400000000006,671.4800000000004,585.0100000000006C671.5500000000004,593.9800000000006,671.600000'
[408] US+358861: '<path d="M153.3000000000003,581.3800000000006C156.1100000000003,580.3300000000006,160.5200000000003,581.3300000000006,160.4800000000003,585.0000000000006C160.56000000000031,593.9600000000006,160.59000'
[409] US+363706: '<path fill = "#C0C0C0" d="M172.47000000000034,456.1500000000006C171.80000000000035,465.5700000000006,171.77000000000035,475.5100000000006,175.78000000000034,484.2800000000006C178.87000000000035,489.95'
[410] US+369021: '<path class="22" ontouchstart="onstart(22)" ontouchend="onend(22)" fill = "#ffffff" d="M283.93000000000006,1063.81C282.95000000000005,1065.78,281.52000000000004,1067.7,281.5400000000001,1070.01C281.48'
@@ -0,0 +1,58 @@
=== Strings mentioning TouchEnd, TouchStart, Foo, Circle, cx, cy ===
US+150958: <g transform="translate(490, 85)"><path onclick="clicked(evt)" stroke-width="20" fill = "#009966" stroke="#000" transform = "scale(0.06, 0.06)" class="34" ontouchstart="onstart(34)" ontouchend="onend(34)" fill = "#ffffff" d = "M407,76H105C47.109,76,0,123.109,0,181v150c0,57.891,47.109,105,105,105h302c57.891,0,105-47.109,105-105V181C512,123.109,464.891,76,407,76z"/></g>
US+151701: <g transform="translate(300, 85)"><path stroke-width="20" onclick="clicked(evt)" stroke="#000" fill = "#009966" transform = "scale(0.06, 0.06)" class="35" ontouchstart="onstart(35)" ontouchend="onend(35)" fill = "#ffffff" d = "M407,76H105C47.109,76,0,123.109,0,181v150c0,57.891,47.109,105,105,105h302c57.891,0,105-47.109,105-105V181C512,123.109,464.891,76,407,76z"/></g>
US+152444: <g transform="translate(490, 1090)"><path onclick="clicked(evt)" stroke-width="20" stroke="#000" fill = "#009966" transform = "scale(0.06, 0.06)" class="36" ontouchstart="onstart(36)" ontouchend="onend(36)" d = "M407,76H105C47.109,76,0,123.109,0,181v150c0,57.891,47.109,105,105,105h302c57.891,0,105-47.109,105-105V181C512,123.109,464.891,76,407,76z"/></g>
US+153157: <g transform="translate(300, 1090)"><path onclick="clicked(evt)" stroke-width="20" fill = "#009966" stroke="#000" transform = "scale(0.06, 0.06)" class="37" ontouchstart="onstart(37)" ontouchend="onend(37)" d = "M407,76H105C47.109,76,0,123.109,0,181v150c0,57.891,47.109,105,105,105h302c57.891,0,105-47.109,105-105V181C512,123.109,464.891,76,407,76z"/></g>
US+198789: " /><path onclick="Foo.ChangeWash()" d="M290.909,277.721c-12.853,0-23.273,10.42-23.273,23.273v31.03c0,12.853,10.42,23.273,23.273,23.273 c12.853,0,23.273-10.42,23.273-23.273v-31.03C314.182,288.141,303.762,277.721,290.909,277.721z" fill="
US+199270: " /><path onclick="Foo.ChangeWash()" d="M290.909,401.842c-12.853,0-23.273,10.42-23.273,23.273v31.03c0,12.853,10.42,23.273,23.273,23.273 c12.853,0,23.273-10.42,23.273-23.273v-31.03C314.182,412.262,303.762,401.842,290.909,401.842z" fill="
US+199751: " /><path onclick="Foo.ChangeWash()" d="M197.818,277.721c-12.853,0-23.273,10.42-23.273,23.273v31.03c0,12.853,10.42,23.273,23.273,23.273 c12.853,0,23.273-10.42,23.273-23.273v-31.03C221.091,288.141,210.671,277.721,197.818,277.721z" fill="
US+200232: " /><path onclick="Foo.ChangeWash()" d="M197.818,401.842c-12.853,0-23.273,10.42-23.273,23.273v31.03c0,12.853,10.42,23.273,23.273,23.273 c12.853,0,23.273-10.42,23.273-23.273v-31.03C221.091,412.262,210.671,401.842,197.818,401.842z" fill="
US+200713: " /><path onclick="Foo.ChangeWash()" d="M104.727,277.721c-12.853,0-23.273,10.42-23.273,23.273v31.03c0,12.853,10.42,23.273,23.273,23.273 c12.853,0,23.273-10.42,23.273-23.273v-31.03C128,288.141,117.58,277.721,104.727,277.721z" fill="
US+201184: " /><path onclick="Foo.ChangeWash()" d="M104.727,401.842c-12.853,0-23.273,10.42-23.273,23.273v31.03c0,12.853,10.42,23.273,23.273,23.273 c12.853,0,23.273-10.42,23.273-23.273v-31.03C128,412.262,117.58,401.842,104.727,401.842z" fill="
US+341951: <path class="3" ontouchstart="onstart(3)" ontouchend="onend(3)" fill = "#ffffff" d="M350,130L470,130,470,152,350,152Z" />
US+342196: <path ontouchstart="onstart(57)" ontouchend="onend(57)" fill = "#ffffff" d = "M360,130L390,130,400,152,350,152Z" />
US+346287: <path class="28" ontouchstart="onstart(28)" ontouchend="onend(28)" fill="№ffffff" d="M170.45000000000044,496.19000000000057C170.49000000000044,498.31000000000057,170.41000000000045,500.45000000000056,170.59000000000043,502.58000000000055C172.44000000000042,503.66000000000054,174.74000000000044,503.81000000000057,176.76000000000042,504.53000000000054C174.73000000000042,501.69000000000057,172.54000000000042,498.97000000000054,170.45000000000041,496.19000000000057Z" />
US+347230: <path class="29" ontouchstart="onstart(29)" ontouchend="onend(29)" fill="#ffffff" d="M645.2800000000004,504.77000000000055C647.3600000000005,503.95000000000056,649.5200000000004,503.39000000000055,651.7400000000005,503.08000000000055C651.8000000000004,500.78000000000054,651.8100000000005,498.48000000000053,651.7900000000004,496.19000000000057C649.6800000000004,499.1000000000006,647.5100000000004,501.95000000000056,645.2800000000004,504.77000000000055Z" />
US+451703: <path class="14" ontouchstart="onstart(14)" ontouchend="onend(14)" fill = "#ffffff" d="M72.11000000000013,471.3300000000005L72.96000000000012,632.8000000000005L77.00000000000013,632.4500000000005L77.11000000000013,471.3300000000005Z" />
US+453461: <path class="15" ontouchstart="onstart(15)" ontouchend="onend(15)" fill = "#ffffff" d="M748,470L744,632.5L748.19,632.5L750,461Z" />
US+453726: <path class="48" ontouchstart="onstart(48)" ontouchend="onend(48)" fill = "#ffffff" d="M745,740L744,632.5L748.19,632.5L750,739Z" />
US+482566: <path class="41" ontouchstart="onstart(41)" ontouchend="onend(41)" fill = "#ffffff" d="M727.5600000000002,767.6400000000003C743.2000000000002,763.4900000000004,760.6000000000001,774.9700000000004,762.9700000000001,790.9700000000004C766.2500000000001,806.3900000000003,754.6800000000002,822.8200000000004,739.0900000000001,825.0200000000003C724.4000000000001,827.9500000000003,708.7500000000001,817.5600000000003,705.6800000000002,802.9100000000003C701.6000000000001,787.7300000000004,712.1800000000002,770.7700000000003,727.5600000000002,767.6400000000003Z" />
US+504522: <g transform="translate(490, 85)"><path stroke-width="20" stroke="#000" transform = "scale(0.06, 0.06)" class="34" ontouchstart="onstart(34)" ontouchend="onend(34)" fill = "#ffffff" d = "M407,76H105C47.109,76,0,123.109,0,181v150c0,57.891,47.109,105,105,105h302c57.891,0,105-47.109,105-105V181C512,123.109,464.891,76,407,76z"/></g>
US+505185: <g transform="translate(300, 85)"><path stroke-width="20" stroke="#000" transform = "scale(0.06, 0.06)" class="35" ontouchstart="onstart(35)" ontouchend="onend(35)" fill = "#ffffff" d = "M407,76H105C47.109,76,0,123.109,0,181v150c0,57.891,47.109,105,105,105h302c57.891,0,105-47.109,105-105V181C512,123.109,464.891,76,407,76z"/></g>
US+505848: <g transform="translate(490, 1090)"><path stroke-width="20" stroke="#000" transform = "scale(0.06, 0.06)" class="36" ontouchstart="onstart(36)" ontouchend="onend(36)" fill = "#ffffff" d = "M407,76H105C47.109,76,0,123.109,0,181v150c0,57.891,47.109,105,105,105h302c57.891,0,105-47.109,105-105V181C512,123.109,464.891,76,407,76z"/></g>
US+506515: <g transform="translate(300, 1090)"><path stroke-width="20" stroke="#000" transform = "scale(0.06, 0.06)" class="37" ontouchstart="onstart(37)" ontouchend="onend(37)" fill = "#ffffff" d = "M407,76H105C47.109,76,0,123.109,0,181v150c0,57.891,47.109,105,105,105h302c57.891,0,105-47.109,105-105V181C512,123.109,464.891,76,407,76z"/></g>
US+507182: <path class="53" ontouchstart="onstart(53)" ontouchend="onend(53)" fill = "#ffffff" d="M228.80000000000018,719.1500000000003L218.80000000000018,748.1500000000003,185.83000000000015,780.5999999999999,188.41000000000017,814.1099999999999,190.55000000000015,811.5099999999999L201.85000000000016,798.2499999999999,210.37000000000015,782.9599999999999,218.77000000000015,767.7799999999999C221.21000000000015,763.5799999999998,222.51000000000016,758.3399999999998,226.96000000000015,755.7199999999999Z" />
US+508183: <path class="54" ontouchstart="onstart(54)" ontouchend="onend(54)" fill = "#ffffff" d="M634.3400000000001,824.57L634.3400000000001,814.57,640.8900000000001,778.2000000000002,605.8900000000001,745.2000000000002,594.8900000000001,723.2000000000002,594.8900000000001,756.2000000000002Z" />
US+508758: <path class="55" ontouchstart="onstart(55)" ontouchend="onend(55)" fill = "#ffffff" d="M170,490L170,450,230,560,230,580Z" />
US+509009: <path class="56" ontouchstart="onstart(56)" ontouchend="onend(56)" fill = "#ffffff" d="M650,490L650,450,595,560,595,580Z" />
US+511008: <path ontouchstart ="Foo.ChangeDiskOnStart({0})" ontouchend="Foo.ChangeDiskOnEnd({1})" fill = "{2}" transform = "scale(0.2, 0.2)" d = "M256,0C114.615,0,0,114.615,0,256s114.615,256,256,256s256-114.615,256-256S397.385,0,256,0z M256,428.5c-95.117,0-172.5-77.383-172.5-172.5S160.883,83.5,256,83.5S428.5,160.883,428.5,256S351.117,428.5,256,428.5z" />
US+513813: <circle cx="
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="11" stroke = "#ffffff" fill = "#009966" d="M662.0900000000003,483.2500000000005C659.7200000000003,483.9300000000005,657.9700000000003,485.72000000000054,656.2100000000003,487.3200000000005C655.5700000000003,534.0300000000005,653.3100000000003,580.7100000000005,652.1600000000003,627.4100000000005C657.7600000000003,624.9000000000005,664.0000000000003,625.0500000000005,670.0100000000003,624.7000000000005C679.9700000000004,624.2900000000005,689.9500000000004,623.7400000000005,699.9200000000003,624.4500000000005C713.5100000000003,624.2400000000005,727.0300000000003,626.4000000000005,740.1200000000003,629.9500000000005C740.0200000000003,608.9800000000005,740.9200000000003,588.0200000000006,741.1300000000003,567.0500000000005C742.0200000000003,540.3700000000006,742.1800000000003,513.6400000000006,743.4700000000004,486.97000000000054C744.0300000000003,484.47000000000054,741.1900000000004,484.07000000000056,739.5200000000003,483.3600000000005C732.5100000000003,481.40000000000055,725.2200000000004,480.6200000000005,718.0000000000003,479.9900000000005C699.3500000000004,479.8300000000005,680.2500000000003,477.8600000000005,662.0900000000004,483.2500000000005Z"/><path onclick="clicked(evt)" class="11" stroke = "#ffffff" fill = "#009966" d="M656,489 600,555 595,605 590,642 652,627 652,614 600,627 610,555 656,500Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="12" stroke = "#ffffff" fill = "#009966" d="M82.14000000000013,634.7700000000002C82.17000000000013,639.8500000000003,82.24000000000012,644.9400000000002,82.12000000000013,650.0200000000002C81.53000000000013,676.7000000000002,82.32000000000014,703.4100000000002,81.57000000000014,730.0800000000002C94.31000000000013,731.1200000000001,107.23000000000013,732.8800000000001,119.07000000000014,737.9200000000002C128.33000000000013,742.0100000000002,135.56000000000014,749.4400000000002,141.51000000000013,757.4500000000002C145.66000000000014,762.6800000000002,149.15000000000012,769.4900000000001,156.02000000000012,771.5100000000001C164.11000000000013,773.6200000000001,172.52000000000012,775.1300000000001,180.91000000000014,774.2400000000001C178.95000000000013,750.0100000000001,177.24000000000015,725.7500000000001,175.06000000000014,701.5300000000001C173.04000000000013,678.4800000000001,171.78000000000014,655.3800000000001,170.45000000000013,632.2900000000001C163.23000000000013,628.4900000000001,154.87000000000012,629.2,147.00000000000014,628.85C133.97000000000014,628.12,120.92000000000014,628.61,107.91000000000014,629.37C99.24000000000014,630.66,90.41000000000014,631.71,82.14000000000014,634.77Z"/><path onclick="clicked(evt)" class="12" stroke = "#ffffff" fill = "#009966" d="M171,633 228,646 225,726 211,760 182,775 182,765 205,752 215,726 218,651 171,641z"/>

Some files were not shown because too many files have changed in this diff Show More