Compare commits

24 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
162 changed files with 6908 additions and 333 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ mechanic-pwa/ ← корень git репо
└── 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.
## Текущая фаза
+23 -3
View File
@@ -1,10 +1,30 @@
# Premium Mechanic PWA — Implementation State
> Last updated: 2026-05-17 (Phase 1 MVP code-complete, awaiting production deploy)
> Last updated: 2026-05-17 (Phase 1 MVP LIVE IN PRODUCTION)
## Status: **READY TO DEPLOY** (Phase J prep done, awaiting explicit user OK)
## Status: 🟢 **DEPLOYED — Phase 1 MVP LIVE on https://mechanic.pptaxi.ru**
Backend (Phases B-F) and Frontend (Phases G-I) are feature-complete and pushed to Gitea. Deployment is the only step left — it requires SSH into `root@100.64.0.12` (prod VDS), Docker compose restart, and Caddy reload. **Not executed automatically.** See "Deployment runbook" section below for the exact steps.
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
+9 -1
View File
@@ -3,8 +3,16 @@
<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="#2a2a2a" />
<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>
@@ -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": "передняя правая стойка"
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 514 B

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.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

@@ -4,10 +4,11 @@
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#f5f1ea",
"theme_color": "#2a2a2a",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
{ "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

+18 -1
View File
@@ -1,10 +1,16 @@
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);
@@ -13,13 +19,24 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
}
export default function App() {
// У35: фоновая сверка прав механика раз в 5 минут — выкидывает на /login
// с сообщением «Доступ отозван», если админ снял can_login_pwa или вывел
// пользователя из отдела механиков.
useMeSync();
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<RequireAuth><Home /></RequireAuth>} />
<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>
);
+29 -3
View File
@@ -5,26 +5,52 @@ export interface LoginInput {
password: string;
}
/** OAuth2-shaped login response, extended with backend's 2FA signaling fields. */
export interface LoginOutput {
access_token: string;
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> {
// OAuth2 password flow — form-urlencoded, NOT JSON, NOT under /api/v1/mechanic/
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() as Promise<LoginOutput>;
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 {
+28 -1
View File
@@ -18,6 +18,7 @@ export interface InspectionCreateInput {
vehicle_id: number;
type: InspectionType;
driver_id?: number;
mileage?: number;
}
export interface PhotoSummary {
@@ -26,6 +27,7 @@ export interface PhotoSummary {
slot_index: number;
storage_key: string;
thumb_key?: string | null;
annotated_key?: string | null;
width?: number | null;
height?: number | null;
status: string;
@@ -36,6 +38,7 @@ 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;
@@ -48,9 +51,22 @@ export interface MarkerSummary {
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;
@@ -62,6 +78,9 @@ export interface InspectionDetail {
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(
@@ -76,7 +95,7 @@ export async function getInspection(id: number): Promise<InspectionDetail> {
export async function patchInspection(
id: number,
body: { status?: InspectionStatus; notes?: string; finished_at?: string }
body: { status?: InspectionStatus; notes?: string; finished_at?: string; mileage?: number }
): Promise<InspectionDetail> {
return api.patch(`inspections/${id}`, { json: body }).json<InspectionDetail>();
}
@@ -101,4 +120,12 @@ export async function createMarker(
.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 };
+10
View File
@@ -1,10 +1,20 @@
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> {
+15
View File
@@ -14,12 +14,27 @@ export interface PhotoConfirmResponse {
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 }
@@ -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>();
}
+89
View File
@@ -8,10 +8,16 @@ export interface VehicleSummary {
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 {
@@ -20,6 +26,13 @@ export interface InspectionSummary {
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[]> {
@@ -34,3 +47,79 @@ export async function listVehicles(q?: string): Promise<VehicleSummary[]> {
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" : ""}`;
}
@@ -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}
/>
);
}
@@ -1,110 +0,0 @@
import { useState } from "react";
import sedanSrc from "./schemes/sedan-top.svg?raw";
interface MarkerDot {
x: number;
y: number;
severity?: string | null;
}
interface Props {
/** Normalized 0..1 dots overlaid on the scheme. */
markers?: MarkerDot[];
/** Click handler — receives the zone id like "zone-front". */
onZoneClick?: (zoneId: string) => void;
/** Per-zone count overlays (e.g. {"zone-front": 2}). Optional. */
zoneCounts?: Record<string, number>;
}
const ZONES = [
"zone-front",
"zone-rear",
"zone-left",
"zone-right",
"zone-hood",
"zone-trunk",
"zone-roof",
];
const ZONE_LABELS: Record<string, string> = {
"zone-front": "Передний бампер",
"zone-rear": "Задний бампер",
"zone-left": "Левый бок",
"zone-right": "Правый бок",
"zone-hood": "Капот",
"zone-trunk": "Багажник",
"zone-roof": "Крыша",
};
// Approximate badge position per zone (percentage of container)
const ZONE_BADGE_POSITIONS: Record<string, { x: string; y: string }> = {
"zone-front": { x: "50%", y: "10%" },
"zone-rear": { x: "50%", y: "90%" },
"zone-left": { x: "12%", y: "50%" },
"zone-right": { x: "88%", y: "50%" },
"zone-hood": { x: "50%", y: "10%" },
"zone-trunk": { x: "50%", y: "90%" },
"zone-roof": { x: "50%", y: "50%" },
};
export function VehicleScheme({ markers = [], onZoneClick, zoneCounts }: Props) {
const [hoverZone, setHoverZone] = useState<string | null>(null);
return (
<div className="relative w-full max-w-xs mx-auto">
<div
className="vehicle-scheme cursor-pointer"
dangerouslySetInnerHTML={{ __html: sedanSrc }}
onClick={(e) => {
const target = e.target as Element;
const id = target.getAttribute?.("id");
if (id && ZONES.includes(id)) onZoneClick?.(id);
}}
onMouseMove={(e) => {
const target = e.target as Element;
const id = target.getAttribute?.("id");
setHoverZone(id && ZONES.includes(id) ? id : null);
}}
onMouseLeave={() => setHoverZone(null)}
/>
{/* Point markers (red dots) overlaid on the scheme via normalized coords */}
{markers.map((m, i) => (
<div
key={i}
className="absolute w-3 h-3 rounded-full bg-destructive border border-white pointer-events-none"
style={{
left: `${m.x * 100}%`,
top: `${m.y * 100}%`,
transform: "translate(-50%, -50%)",
}}
/>
))}
{/* Zone counts as small badges */}
{zoneCounts &&
Object.entries(zoneCounts).map(([zone, count]) => {
if (count <= 0) return null;
const pos = ZONE_BADGE_POSITIONS[zone];
if (!pos) return null;
return (
<div
key={zone}
className="absolute bg-destructive text-destructive-foreground text-xs font-semibold rounded-full w-5 h-5 flex items-center justify-center pointer-events-none"
style={{
left: pos.x,
top: pos.y,
transform: "translate(-50%, -50%)",
}}
>
{count}
</div>
);
})}
<div className="text-xs text-center mt-1 text-muted-foreground min-h-[1em]">
{hoverZone ? ZONE_LABELS[hoverZone] : "Тапни по зоне, чтобы добавить метку"}
</div>
</div>
);
}
@@ -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>
);
}
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 400">
<rect x="40" y="20" width="120" height="360" rx="40" ry="40"
fill="none" stroke="hsl(0 0% 16%)" stroke-width="2"/>
<rect x="55" y="60" width="90" height="60" rx="10" fill="hsl(200 30% 88%)"/>
<rect x="55" y="280" width="90" height="50" rx="10" fill="hsl(200 30% 88%)"/>
<rect x="55" y="130" width="90" height="140" fill="hsl(40 20% 92%)" stroke="hsl(0 0% 60%)" stroke-width="1"/>
<rect id="zone-front" x="40" y="20" width="120" height="40" fill="transparent"/>
<rect id="zone-rear" x="40" y="340" width="120" height="40" fill="transparent"/>
<rect id="zone-left" x="40" y="60" width="20" height="280" fill="transparent"/>
<rect id="zone-right" x="140" y="60" width="20" height="280" fill="transparent"/>
<rect id="zone-hood" x="55" y="20" width="90" height="40" fill="transparent"/>
<rect id="zone-trunk" x="55" y="340" width="90" height="40" fill="transparent"/>
<rect id="zone-roof" x="55" y="130" width="90" height="140" fill="transparent"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -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,
};
}
@@ -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)}`;
}
@@ -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>
);
}
+60 -31
View File
@@ -1,22 +1,54 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router-dom";
import { listVehicles } from "@/api/vehicles";
import { getMe } from "@/api/me";
import { logout } from "@/api/auth";
import { Input } from "@/components/ui/input";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
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 meQuery = useQuery({
queryKey: ["me"],
queryFn: getMe,
staleTime: 60_000,
});
const action = searchParams.get("action") || undefined;
const subtitle = action ? ACTION_TITLES[action] : undefined;
const vehiclesQuery = useQuery({
queryKey: ["vehicles", q],
@@ -25,31 +57,23 @@ export default function Home() {
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 });
}}
<header className="flex items-center gap-3 p-4 border-b">
<button
onClick={() => navigate("/")}
className="text-sm text-muted-foreground hover:text-foreground"
>
Выйти
</Button>
Меню
</button>
<div className="flex-1">
<h1 className="text-lg font-semibold">
Машины{subtitle && `${subtitle}`}
</h1>
</div>
</header>
<main className="p-4">
<h2 className="text-lg font-semibold mb-3">Машины</h2>
<Input
placeholder="Поиск по номеру или VIN"
placeholder="Поиск по госномеру"
value={q}
onChange={(e) => setQ(e.target.value)}
className="mb-4"
@@ -67,9 +91,14 @@ export default function Home() {
<Card
key={v.id}
className="p-4 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => navigate(`/vehicles/${v.id}`)}
onClick={() =>
navigate(`/vehicles/${v.id}${action ? `?action=${action}` : ""}`)
}
>
<div className="font-semibold text-lg">{v.license_plate}</div>
<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>
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -5,14 +6,26 @@ import {
getInspection,
patchInspection,
createMarker,
deleteMarker,
type MarkerSummary,
} from "@/api/inspections";
import { uploadPhoto, uploadPhotoAnnotation } from "@/api/photos";
import { PhotoSlot } from "@/components/camera/PhotoSlot";
import { VehicleScheme } from "@/components/vehicle-scheme/VehicleScheme";
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: { side: string; label: string }[] = [
const SLOTS_GENERAL: { side: string; label: string }[] = [
{ side: "front", label: "Перед" },
{ side: "rear", label: "Зад" },
{ side: "left", label: "Левый бок" },
@@ -23,6 +36,16 @@ const SLOTS: { side: string; label: string }[] = [
{ 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: "лёгкое",
@@ -49,9 +72,32 @@ const DAMAGE_TYPE_LABELS: Record<string, string> = {
"not-working": "не работает",
};
function humanizeMarkerSide(side: string | null | undefined): string {
// 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 "—";
const zoneMap: Record<string, string> = {
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": "Левый бок",
@@ -60,22 +106,50 @@ function humanizeMarkerSide(side: string | null | undefined): string {
"zone-trunk": "Багажник",
"zone-roof": "Крыша",
};
return zoneMap[side] ?? side;
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: () => patchInspection(insId, { status: "completed" }),
mutationFn: () =>
// При завершении осмотра коммитим текст заметки тем же patch'ем —
// если механик что-то ввёл и сразу нажал «Завершить», не теряем.
patchInspection(insId, {
status: "completed",
notes: (notesDraft ?? "").trim() || undefined,
}),
onSuccess: () => {
toast.success("Осмотр завершён");
navigate(`/inspections/${insId}`);
@@ -83,19 +157,22 @@ export default function InspectionEditor() {
onError: () => toast.error("Не удалось завершить"),
});
const addZoneMarker = useMutation({
mutationFn: (zone: string) =>
createMarker(insId, {
side: zone,
damage_type: "damaged",
severity: "minor",
description: "Метка со схемы",
}),
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("Метка добавлена");
toast.success("Метка удалена");
},
onError: () => toast.error("Ошибка"),
onError: () => toast.error("Не удалось удалить"),
});
if (isLoading) return <div className="p-8 text-muted-foreground">Загружаю</div>;
@@ -105,26 +182,17 @@ export default function InspectionEditor() {
// 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);
// Point markers (x/y populated) drawn as dots on the scheme
const dots = ins.markers
.filter((m: MarkerSummary) => m.x != null && m.y != null)
.map((m: MarkerSummary) => ({
x: Number(m.x),
y: Number(m.y),
severity: m.severity ?? "minor",
}));
// Zone counts (markers with side="zone-*", no x/y)
const zoneCounts: Record<string, number> = {};
for (const m of ins.markers) {
if (m.side?.startsWith("zone-")) {
zoneCounts[m.side] = (zoneCounts[m.side] ?? 0) + 1;
}
}
// 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
@@ -145,20 +213,195 @@ export default function InspectionEditor() {
</div>
<Card className="p-4">
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
Схема тапни по зоне, чтобы добавить метку
<h2 className="text-sm font-semibold text-muted-foreground mb-3">
Схема тапните на зону, чтобы добавить метку
</h2>
<VehicleScheme
markers={dots}
zoneCounts={zoneCounts}
onZoneClick={editable ? (zone) => addZoneMarker.mutate(zone) : undefined}
<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">
{SLOTS.map((s) => (
{slotsForType(ins.type).map((s) => (
<PhotoSlot
key={`${s.side}-0`}
inspectionId={insId}
@@ -185,8 +428,8 @@ export default function InspectionEditor() {
<Card className="p-3 text-sm">
<div className="flex items-center justify-between">
<div>
<span className="font-medium">
{humanizeMarkerSide(m.side)}
<span className="font-medium capitalize">
{humanizeMarkerSide(m.side, zoneLabels)}
</span>
{" — "}
<span>
@@ -203,17 +446,64 @@ export default function InspectionEditor() {
</>
)}
</div>
{m.carried_over_from_id && (
<span className="text-xs text-muted-foreground">
перенесено
</span>
)}
<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>
))}
@@ -233,3 +523,35 @@ export default function InspectionEditor() {
</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>
);
}
@@ -1,8 +1,20 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getInspection, type MarkerSummary, type PhotoSummary } from "@/api/inspections";
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: "В работе",
@@ -10,6 +22,66 @@ const STATUS_LABELS: Record<string, string> = {
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);
@@ -20,10 +92,81 @@ export default function InspectionReview() {
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
@@ -35,6 +178,28 @@ export default function InspectionReview() {
<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>
@@ -44,6 +209,12 @@ export default function InspectionReview() {
<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")}
@@ -54,24 +225,78 @@ export default function InspectionReview() {
{new Date(ins.finished_at).toLocaleString("ru-RU")}
</div>
)}
{ins.notes && (
{ins.mileage != null && (
<div>
<span className="font-semibold">Заметки: </span>
{ins.notes}
<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">
Фото ({ins.photos.length})
Фото ({totalPhotoCount})
</h2>
{ins.photos.length === 0 ? (
{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">
<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}
@@ -80,6 +305,24 @@ export default function InspectionReview() {
<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>
@@ -97,14 +340,22 @@ export default function InspectionReview() {
<Card className="p-3 text-sm">
<div className="flex items-center justify-between">
<div>
<span>{m.side ?? "—"}</span>
<span className="font-medium capitalize">
{humanizeMarkerSide(m.side, zoneLabels)}
</span>
{" — "}
<span>{m.damage_type ?? "—"}</span>
<span>
{m.damage_type
? (DAMAGE_TYPE_LABELS[m.damage_type] ?? m.damage_type)
: "—"}
</span>
{m.severity && (
<span className="text-muted-foreground">
<>
{" — "}
{m.severity}
</span>
<span className="text-muted-foreground">
{SEVERITY_LABELS[m.severity] ?? m.severity}
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
@@ -125,12 +376,102 @@ export default function InspectionReview() {
{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>
);
}
+232 -42
View File
@@ -1,70 +1,260 @@
import { useState } from "react";
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 } from "@/api/auth";
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);
const m = useMutation({
mutationFn: login,
onSuccess: (data) => {
setToken(data.access_token);
// У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>
<p className="text-sm text-muted-foreground">
Войдите учётной записью TaxiDashboard.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
m.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
/>
{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>
<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={m.isPending} className="w-full">
{m.isPending ? "Входим…" : "Войти"}
</Button>
</form>
)}
{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>
);
}
+258 -60
View File
@@ -1,13 +1,16 @@
import { useParams, useNavigate } from "react-router-dom";
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 } from "@/api/vehicles";
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: "Передача",
handover: "Выдача",
return: "Приёмка",
periodic: "Плановый",
"ad-hoc": "Свободный",
@@ -24,25 +27,74 @@ const STATUS_LABELS: Record<string, string> = {
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: (type: InspectionType) =>
createInspection({ vehicle_id: vehicleId, type }),
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)
@@ -51,14 +103,36 @@ export default function VehicleCard() {
return (
<div className="min-h-screen bg-background p-4 space-y-4">
<button
onClick={() => navigate("/")}
onClick={() => navigate(`/vehicles${action ? `?action=${action}` : ""}`)}
className="text-sm text-muted-foreground hover:text-foreground"
>
К списку
</button>
<Card className="p-4">
<div className="text-xl font-semibold">{v.license_plate}</div>
<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>
@@ -73,66 +147,25 @@ export default function VehicleCard() {
</h2>
<div className="grid grid-cols-2 gap-2">
<Button
onClick={() => startInspection.mutate("handover")}
onClick={() => handleStartClick("handover")}
disabled={startInspection.isPending}
>
Передача
Выдача
</Button>
<Button
onClick={() => startInspection.mutate("return")}
onClick={() => handleStartClick("return")}
disabled={startInspection.isPending}
>
Приёмка
</Button>
<Button
variant="outline"
onClick={() => startInspection.mutate("periodic")}
onClick={() => handleStartClick("periodic")}
disabled={startInspection.isPending}
className="col-span-2"
>
Плановый
</Button>
<Button
variant="outline"
onClick={() => startInspection.mutate("ad-hoc")}
disabled={startInspection.isPending}
>
Свободный
</Button>
<Button
variant="outline"
onClick={() => startInspection.mutate("initial")}
disabled={startInspection.isPending}
>
Первичный
</Button>
<Button
variant="outline"
onClick={() => startInspection.mutate("seizure")}
disabled={startInspection.isPending}
>
Изъятие
</Button>
<Button
variant="outline"
onClick={() => startInspection.mutate("equipment-change")}
disabled={startInspection.isPending}
>
Комплектация
</Button>
<Button
variant="outline"
onClick={() => startInspection.mutate("pre-repair")}
disabled={startInspection.isPending}
>
В ремонт
</Button>
<Button
variant="outline"
onClick={() => startInspection.mutate("post-repair")}
disabled={startInspection.isPending}
>
Из ремонта
</Button>
</div>
</div>
@@ -152,22 +185,187 @@ export default function VehicleCard() {
className="p-3 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => navigate(`/inspections/${i.id}`)}
>
<div className="flex items-center justify-between">
<div className="font-medium">
{INSPECTION_TYPE_LABELS[i.type as InspectionType] ?? i.type}
<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">
<div className="text-xs text-muted-foreground shrink-0">
{STATUS_LABELS[i.status] ?? i.status}
</div>
</div>
<div className="text-xs text-muted-foreground mt-1">
{new Date(i.started_at).toLocaleString("ru-RU")}
</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>
);
}
+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"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="13" stroke = "#ffffff" fill = "#009966" d="M652.1500000000001,632.2400000000002C651.2700000000001,636.0700000000003,651.5900000000001,640.0800000000003,651.21,643.9800000000002C649.0500000000001,687.5100000000002,644.38,730.8600000000002,641.5300000000001,774.3400000000003C647.3700000000001,774.7400000000002,653.2600000000001,774.3900000000002,659.0000000000001,773.1700000000003C663.3200000000002,772.2300000000002,668.0800000000002,771.7100000000003,671.5700000000002,768.7100000000003C675.5500000000002,765.2300000000002,678.2800000000002,760.6400000000002,681.5200000000002,756.5300000000003C687.2700000000002,748.9700000000004,694.2200000000003,742.0100000000003,702.9800000000002,738.0500000000003C714.9100000000002,732.8500000000003,727.9800000000002,731.2200000000003,740.8200000000003,730.0100000000003C739.9600000000003,698.3500000000004,740.9500000000003,666.6500000000003,739.9600000000003,635.0000000000003C739.2000000000003,633.9700000000004,737.7300000000002,633.9500000000004,736.6500000000003,633.5000000000003C714.9500000000003,627.6000000000004,692.2500000000003,628.2200000000004,669.9900000000004,628.9900000000004C663.9600000000004,629.2200000000004,657.5500000000003,629.1600000000003,652.1500000000003,632.2400000000004Z"/><path onclick="clicked(evt)" class="13" stroke = "#ffffff" fill = "#009966" d="M651,633 594,646 591,726 595,760 637,775 637,765 608,752 599,726 601,651 651,641z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="16" stroke = "#ffffff" fill = "#009966" d="M381.04,565.1600000000003C362.39000000000004,566.8500000000004,343.52000000000004,569.4000000000003,325.94,576.1700000000003C328.2,587.7000000000003,329.96999999999997,599.3300000000003,330.62,611.0800000000003C334.28000000000003,651.9000000000003,335.37,692.9000000000003,336.93,733.8400000000003C337.44,743.8800000000002,336.90000000000003,753.9600000000003,337.93,763.9700000000003C360.27,770.7800000000002,383.83,771.5800000000003,407.01,771.8300000000003C432.42,772.0000000000002,458.31,771.3100000000003,482.81,763.9200000000003C483.68,747.2900000000003,483.62,730.6300000000003,484.43,714.0000000000003C485.8,680.6400000000003,486.79,647.2500000000003,489.84000000000003,613.9900000000004C490.64000000000004,601.3600000000004,491.96000000000004,588.7300000000004,494.87,576.4000000000003C485.46,572.2100000000003,475.25,570.2400000000004,465.18,568.3800000000003C437.42,563.4400000000003,409.09000000000003,563.1100000000004,381.04,565.1600000000003Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" stroke = "#ffffff" class="17" fill = "#009966" d="M725.4200000000005,371.65000000000043C708.2400000000006,375.02000000000044,694.0000000000006,389.59000000000043,691.1200000000006,406.87000000000046C688.6600000000005,420.2900000000005,692.9900000000006,434.7200000000005,702.4800000000006,444.5300000000004C710.7700000000006,453.3700000000004,722.9000000000005,458.58000000000044,735.0500000000006,458.15000000000043C750.1700000000006,458.1000000000004,764.7700000000007,449.2400000000004,772.1300000000007,436.08000000000044C776.1400000000007,429.12000000000046,778.1700000000006,421.0400000000004,777.8500000000007,413.01000000000045C777.4900000000007,397.5300000000004,768.0400000000008,382.74000000000046,754.2700000000007,375.7300000000005C745.5100000000007,371.0400000000005,735.1300000000007,369.75000000000045,725.4200000000006,371.6500000000005Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="18" stroke = "#ffffff" fill = "#009966" d="M79.47000000000048,371.64000000000044C62.27000000000048,375.00000000000045,48.01000000000048,389.57000000000045,45.12000000000048,406.86000000000047C42.70000000000048,420.06000000000046,46.85000000000048,434.2200000000005,56.00000000000048,444.0300000000005C64.31000000000049,453.1400000000005,76.64000000000048,458.5900000000005,89.0200000000005,458.1500000000005C104.1700000000005,458.1200000000005,118.8000000000005,449.2100000000005,126.1600000000005,436.0300000000005C130.1500000000005,429.0900000000005,132.1700000000005,421.0300000000005,131.8500000000005,413.0300000000005C131.5000000000005,397.5300000000005,122.02000000000051,382.7200000000005,108.2300000000005,375.7000000000005C99.4900000000005,371.0400000000005,89.1500000000005,369.7700000000005,79.4700000000005,371.6400000000005Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="19" stroke = "#ffffff" fill = "#009966" d="M723.5500000000003,753.7500000000003C706.6800000000003,757.8400000000004,693.1300000000003,772.8100000000003,690.8800000000003,790.0500000000003C688.9600000000004,803.1000000000003,693.3900000000003,816.9200000000003,702.6400000000003,826.3400000000003C710.8700000000003,835.0900000000003,722.9100000000003,840.1700000000003,734.9400000000003,839.8600000000002C749.2500000000002,839.7500000000002,763.1600000000003,831.9200000000002,770.8400000000003,819.8800000000002C775.6000000000003,812.5500000000002,778.1600000000003,803.7800000000002,777.8600000000002,795.0300000000002C777.6300000000002,779.2800000000002,767.9600000000003,764.1700000000002,753.8700000000002,757.1900000000002C744.6300000000002,752.3700000000001,733.6300000000002,751.2500000000001,723.5500000000002,753.7500000000001Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="20" stroke = "#ffffff" fill = "#009966" d="M77.59000000000026,753.7400000000004C61.45000000000026,757.6600000000003,48.30000000000026,771.4800000000004,45.250000000000256,787.8200000000004C42.55000000000025,801.2900000000004,46.720000000000255,815.9100000000004,56.19000000000025,825.8800000000003C64.47000000000025,834.9100000000003,76.72000000000025,840.2000000000004,88.99000000000025,839.8600000000004C103.56000000000026,839.7500000000003,117.67000000000024,831.6000000000004,125.27000000000025,819.2100000000004C131.06000000000026,810.0200000000003,133.18000000000026,798.6400000000003,131.11000000000024,787.9900000000004C128.60000000000025,774.6100000000004,119.44000000000024,762.6700000000003,107.13000000000024,756.8300000000004C98.07000000000023,752.3100000000004,87.39000000000024,751.3300000000004,77.59000000000023,753.7400000000004Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="21" stroke = "#ffffff" fill = "#009966" d="M307.76000000000005,1038.83C311.14000000000004,1045.8899999999999,314.6,1052.9099999999999,318.02000000000004,1059.96C320.43000000000006,1065.13,323.04,1070.8600000000001,328.23,1073.76C331.98,1075.14,336.08000000000004,1074.98,340,1075.43C374.31,1077.52,408.7,1076.6200000000001,443.06,1076.8400000000001C458.77,1076.2,474.54,1076.4700000000003,490.18,1074.6100000000001C495.5,1074.2,498.77,1069.2900000000002,501.15000000000003,1065.0500000000002C505.43,1056.1900000000003,509.90000000000003,1047.41,514,1038.4700000000003C445.25,1038.6300000000003,376.5,1038.3400000000001,307.76,1038.8300000000002Z"/><path onclick="clicked(evt)" class="21" stroke = "#ffffff" fill = "#009966" d="M303.57,1024.0600000000002C303.29,1027.0100000000002,304.86,1029.7200000000003,305.75,1032.4600000000003C375.75,1032.4900000000002,445.76,1032.4600000000003,515.76,1032.6100000000004C517.04,1029.2700000000004,519.17,1025.5800000000004,517.51,1021.9900000000004C515.9,1018.5000000000003,513.35,1014.4700000000004,509.11,1014.2000000000004C505.43,1013.6500000000004,501.7,1013.8300000000004,498,1013.8000000000004C436.98,1013.8000000000004,375.95,1013.7800000000004,314.93,1013.9300000000004C309.02,1013.4900000000004,304.74,1018.7700000000004,303.57,1024.0600000000004Z"/><path onclick ="clicked(evt)" class="21" stroke = "#ffffff" fill = "#009966" d="M316.5000000000001,841.3900000000003C316.8400000000001,860.6000000000004,319.10000000000014,879.7200000000004,320.16000000000014,898.8900000000003C330.7400000000001,908.0000000000003,343.6300000000001,915.5800000000004,357.85000000000014,916.5200000000003C376.46000000000015,918.7300000000004,395.22000000000014,919.3100000000003,413.95000000000016,919.1300000000003C432.98000000000013,919.3700000000003,452.0600000000002,918.1500000000003,470.89000000000016,915.3900000000003C481.28000000000014,912.5100000000003,490.89000000000016,907.0500000000003,499.10000000000014,900.1100000000004C500.92000000000013,899.0600000000004,500.51000000000016,896.8000000000004,500.7400000000001,895.0700000000004C501.85000000000014,877.1800000000004,503.8700000000001,859.3300000000004,504.1300000000001,841.4100000000004C496.3300000000001,847.7100000000004,486.7100000000001,851.1000000000005,477.1300000000001,853.6600000000004C453.92000000000013,859.5800000000004,429.8100000000001,860.5700000000004,405.9600000000001,860.1800000000004C384.1400000000001,860.1000000000004,362.0700000000001,858.8200000000004,340.9500000000001,852.9600000000004C332.2500000000001,850.4700000000004,323.6100000000001,847.1400000000003,316.5000000000001,841.3900000000003Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="22" stroke = "#ffffff" fill = "#009966" d="M283.93000000000006,1063.81C282.95000000000005,1065.78,281.52000000000004,1067.7,281.5400000000001,1070.01C281.4800000000001,1075.64,281.5100000000001,1081.27,281.5800000000001,1086.91C298.9500000000001,1089.1000000000001,316.5400000000001,1087.44,333.9800000000001,1088.43C389.32000000000005,1088.97,444.70000000000005,1089.19,500.0300000000001,1088.1200000000001C513.3600000000001,1087.93,526.7800000000001,1088.64,540.0300000000001,1086.91C539.69,1079.26,541.6400000000001,1071,537.8500000000001,1063.95C527.2300000000001,1065.29,516.5000000000001,1065.21,505.83000000000015,1065.91C502.3600000000001,1071.8400000000001,497.65000000000015,1078.8600000000001,489.9900000000002,1078.99C463.7400000000002,1081.6,437.33000000000015,1080.5,411.00000000000017,1081.19C384.3100000000002,1080.48,357.5300000000002,1081.65,330.9200000000002,1078.9C323.5300000000002,1078.5,319.4300000000002,1071.52,315.75000000000017,1066.0400000000002C305.1800000000002,1064.91,294.4300000000002,1065.5900000000001,283.9300000000002,1063.8100000000002Z"/><path onclick ="clicked(evt)" class="22" stroke = "#ffffff" fill = "#009966" d="M281.9700000000001,1091.1699999999998C282.5700000000001,1097.2599999999998,282.8900000000001,1103.4399999999998,284.5200000000001,1109.3799999999999C285.9000000000001,1114.52,289.3900000000001,1118.6699999999998,292.1000000000001,1123.1499999999999C295.5400000000001,1121.6699999999998,299.18000000000006,1120.6299999999999,302.9500000000001,1120.61C340.6200000000001,1118.9599999999998,378.3400000000001,1119.59,416.0400000000001,1119.4699999999998C450.3900000000001,1119.5299999999997,484.75000000000006,1119.0899999999997,519.0600000000001,1120.6299999999999C522.73,1120.6799999999998,526.2500000000001,1121.77,529.6300000000001,1123.12C532.1000000000001,1118.75,535.5200000000001,1114.86,536.9600000000002,1109.9799999999998C538.7600000000001,1103.8999999999999,539.0800000000002,1097.5199999999998,539.6400000000001,1091.2599999999998C533.1200000000001,1092.0499999999997,526.5500000000001,1092.2099999999998,519.9900000000001,1092.1699999999998C492.65000000000015,1092.0399999999997,465.33000000000015,1093.0199999999998,438.0000000000001,1092.8799999999999C401.04000000000013,1092.87,364.09000000000015,1093.06,327.1400000000001,1092.31C312.1000000000001,1091.6699999999998,296.9500000000001,1093.07,281.9700000000001,1091.1699999999998Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="0" stroke = "#ffffff" fill = "#009966" d="M279.1300000000006,119.04000000000042C278.11000000000064,124.02000000000042,278.4100000000006,129.1700000000004,278.8800000000006,134.20000000000041C283.79000000000065,131.44000000000042,289.3900000000006,130.3400000000004,294.9600000000006,129.94000000000042C302.93000000000063,129.02000000000044,310.9600000000006,128.81000000000043,318.9800000000006,128.47000000000043H489.0300000000006C502.3500000000006,128.35000000000042,515.7000000000006,128.84000000000043,528.9200000000006,130.51000000000042C532.8500000000006,131.01000000000042,536.6200000000007,132.35000000000042,540.1200000000007,134.20000000000041C540.5900000000007,129.1800000000004,540.8500000000007,124.06000000000041,539.9100000000007,119.08000000000041C536.6800000000006,118.20000000000041,533.4100000000007,117.4600000000004,530.0700000000006,117.23000000000042C511.7100000000006,115.90000000000042,493.3200000000006,115.43000000000042,474.92000000000064,115.53000000000041H400.97000000000065C368.97000000000065,115.75000000000041,336.95000000000067,114.95000000000041,304.97000000000065,116.25000000000041C296.34000000000066,116.95000000000041,287.52000000000066,116.59000000000042,279.1300000000007,119.04000000000042Z"/><path onclick="clicked(evt)" class="0" stroke = "#ffffff" fill = "#009966" d="M290.1600000000006,88.97000000000043C287.4800000000006,94.70000000000043,284.1100000000006,100.09000000000043,281.6300000000006,105.93000000000043C280.5700000000006,108.86000000000044,279.6600000000006,111.84000000000043,278.49000000000063,114.73000000000043C281.9400000000006,113.99000000000044,285.41000000000065,113.28000000000043,288.9500000000006,113.07000000000043C303.9500000000006,112.08000000000044,318.9700000000006,111.34000000000043,334.0100000000006,111.47000000000044H455.9700000000006C478.3400000000006,111.55000000000044,500.7300000000006,111.03000000000044,523.0600000000006,112.64000000000044C528.9200000000006,112.91000000000044,534.8000000000006,113.32000000000045,540.5200000000007,114.70000000000044C537.3700000000007,105.78000000000044,533.2500000000007,97.29000000000045,528.9200000000006,88.89000000000044C449.34000000000066,88.93000000000045,369.7500000000007,88.77000000000044,290.16000000000065,88.97000000000044Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="23" stroke = "#ffffff" fill = "#009966" d="M485.51 767.67c-18.23 5.96-37.52 7.47-56.56 8.45-31.79.15-64.39 1-95.06-8.74-2.21-.27-2.71 2.05-3.18 3.68-2.45 9.43-4.95 18.86-7.11 28.36-2.24 10.5-5.21 20.86-6.48 31.54-.1 1.98-.89 4.47 1.02 5.89 6.4 6.39 15.26 9.35 23.72 11.92 18.9 5.23 38.58 6.93 58.13 7.06 19.06.4 38.23.33 57.13-2.56 11.89-1.8 23.84-4.28 34.85-9.29 4.28-2.08 8.57-4.57 11.59-8.33.61-1.47-.03-3.15-.02-4.67-1.25-10.53-4.14-20.76-6.37-31.1-2.37-10.23-4.88-20.43-7.76-30.52-.29-1.79-2.42-2.38-3.9-1.69"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="24" stroke = "#ffffff" fill = "#009966" d="M191.33000000000015,526.1200000000005C189.73000000000016,527.8500000000005,188.46000000000015,530.1800000000004,185.99000000000015,530.8000000000004C180.93000000000015,531.9900000000005,174.42000000000016,532.6600000000004,171.07000000000016,527.7500000000005C172.59000000000017,557.9100000000004,173.11000000000016,588.1500000000004,174.29000000000016,618.3400000000005C191.19000000000017,622.5500000000005,208.07000000000016,626.8900000000004,224.88000000000017,631.4500000000005C224.67000000000016,617.0600000000005,222.95000000000016,602.7300000000005,220.64000000000016,588.5500000000005C219.15000000000015,578.9500000000005,216.30000000000015,569.5800000000005,212.28000000000014,560.7500000000006C206.75000000000014,548.7800000000005,200.48000000000013,537.0500000000005,192.47000000000014,526.5500000000005L191.33000000000015,526.1200000000006Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="25" stroke = "#ffffff" fill = "#009966" d="M630.2900000000002,526.0600000000005C622.2400000000002,536.3100000000005,616.1100000000002,547.9200000000005,610.5500000000002,559.6600000000005C606.6800000000002,568.0400000000005,603.7000000000002,576.8700000000006,602.1500000000002,585.9900000000006C599.5300000000002,600.9900000000006,597.7300000000002,616.1600000000005,597.4500000000002,631.3900000000006C614.3000000000002,626.8800000000006,631.2400000000001,622.6500000000005,648.1100000000001,618.2000000000005C648.9900000000001,588.1000000000005,650.0900000000001,558.0100000000004,651.0200000000001,527.9200000000005C649.3700000000001,529.6000000000005,647.6000000000001,531.6600000000005,645.0000000000001,531.5300000000005C641.4000000000001,531.6000000000006,637.4400000000002,531.7600000000006,634.2800000000001,529.7800000000005C632.6600000000001,528.8400000000005,632.2700000000001,526.3400000000005,630.2900000000001,526.0600000000005Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="26" stroke = "#ffffff" fill = "#009966" d="M175.47100000000012,644.4100000000004C176.78000000000011,672.3590000000004,179.32800000000012,700.2400000000005,181.61000000000013,728.1200000000005C194.67000000000013,728.1500000000004,207.73000000000013,727.5500000000004,220.79000000000013,727.5600000000005C226.01000000000013,703.0700000000005,224.67000000000013,677.8800000000006,225.40000000000015,653.0200000000006C217.58600000000015,651.6180000000005,209.78300000000016,650.1510000000005,201.98000000000013,648.6790000000005L198.86000000000013,648.0910000000006C191.05700000000013,646.6190000000006,183.25400000000013,645.1520000000006,175.4400000000001,643.7500000000006L175.47100000000012,644.4100000000005Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="27" stroke = "#ffffff" fill = "#009966" d="M596.9300000000001,653.0300000000004C597.5000000000001,677.8700000000005,596.5600000000001,702.9500000000004,601.3800000000001,727.4700000000005C614.5100000000001,727.6600000000005,627.6300000000001,728.1000000000005,640.7600000000001,728.1100000000005C642.9900000000001,700.0100000000004,645.5800000000002,671.9200000000005,646.9300000000001,643.7600000000004C630.24,646.7400000000005,613.61,650.0000000000005,596.9300000000001,653.0300000000004Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="30" stroke = "#ffffff" fill = "#009966" d="M602.9300000000001,734.6100000000004C605.47,743.8000000000004,609.61,752.7300000000004,616.19,759.7500000000003C621.6500000000001,765.7100000000004,629.62,768.3800000000003,637.3900000000001,769.6700000000003C638.4300000000001,758.1800000000003,639.3100000000001,746.6800000000003,640.1100000000001,735.1700000000003C627.7100000000002,735.1900000000003,615.3200000000002,734.7800000000003,602.9300000000002,734.6100000000004Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="38" fill = "#009966" d="M510.42000000000064,202.94000000000045C512.3700000000007,204.39000000000044,513.1100000000007,206.72000000000045,514.3500000000006,208.70000000000044C516.6000000000006,210.08000000000044,519.5200000000006,209.33000000000044,522.0500000000006,209.47000000000045C527.8700000000007,209.34000000000046,534.7300000000006,207.16000000000045,537.0900000000006,201.29000000000045C535.8600000000006,200.15000000000046,534.7400000000006,198.62000000000046,532.9500000000006,198.42000000000044C526.9600000000006,197.41000000000045,520.9400000000006,196.55000000000044,514.9200000000006,195.83000000000044C513.4200000000006,198.20000000000044,511.90000000000066,200.56000000000043,510.42000000000064,202.94000000000045Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="39" stroke = "#ffffff" fill = "#009966" d="M286.86000000000064,198.26000000000045C284.7500000000006,198.34000000000046,283.2500000000006,199.87000000000046,281.90000000000066,201.31000000000046C285.5600000000007,209.50000000000045,296.14000000000067,210.18000000000046,303.90000000000066,209.21000000000046C306.02000000000066,207.63000000000045,306.23000000000064,204.45000000000047,308.5700000000007,202.95000000000047C307.0700000000007,200.59000000000046,305.7300000000007,198.11000000000047,303.9800000000007,195.94000000000048C298.2300000000007,196.33000000000047,292.5600000000007,197.47000000000048,286.8600000000007,198.26000000000047Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="40" fill = "#009966" d="M81.51000000000022,767.6500000000003C94.18000000000022,764.4000000000003,108.40000000000022,771.0500000000003,114.21000000000022,782.7300000000004C117.98000000000022,790.0300000000003,118.51000000000022,798.9200000000004,115.54000000000022,806.5900000000004C112.11000000000021,815.8200000000004,103.71000000000022,822.9900000000004,94.03000000000021,824.8600000000004C84.02000000000021,827.0300000000003,73.02000000000021,823.3500000000004,66.28000000000021,815.6400000000003C58.700000000000216,807.3300000000004,56.710000000000214,794.5100000000003,61.40000000000021,784.2800000000003C65.01000000000022,776.0000000000003,72.69000000000021,769.6200000000003,81.51000000000022,767.6500000000003Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="41" fill = "#009966" 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"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="1" stroke = "#ffffff" fill = "#009966" d="M488.5400000000007,132.88000000000042C488.51000000000073,138.73000000000042,488.5500000000007,144.59000000000043,488.5000000000007,150.44000000000042C501.65000000000066,151.63000000000042,514.7800000000007,153.65000000000043,528.0100000000007,153.49000000000044C530.7700000000007,153.45000000000044,533.6300000000007,153.33000000000044,536.2300000000007,152.28000000000043C537.9800000000007,147.98000000000042,539.4200000000008,142.77000000000044,537.6800000000007,138.27000000000044C533.1300000000008,134.98000000000044,527.2600000000008,134.63000000000045,521.8500000000007,134.11000000000044C510.7800000000007,132.99000000000044,499.6500000000007,132.97000000000045,488.5400000000007,132.88000000000045Z"/>
@@ -0,0 +1 @@
<path class="42" onclick="clicked(evt)" fill = "#009966" d="M729.4400000000005,385.6000000000004C743.4900000000005,382.95000000000044,758.4300000000005,392.3500000000004,762.2300000000005,406.1100000000004C766.0100000000004,418.0000000000004,761.0200000000004,431.84000000000043,750.6500000000004,438.7300000000004C739.5500000000004,446.5600000000004,723.1200000000005,445.1100000000004,713.6200000000005,435.3700000000004C703.8000000000004,426.0700000000004,702.0400000000004,409.7900000000004,709.5800000000005,398.5800000000004C713.9900000000005,391.7100000000004,721.3700000000005,386.86000000000035,729.4400000000005,385.60000000000036Z" />
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="43" stroke="#ffffff" fill = "#009966" d="M83.49000000000046,385.59000000000043C97.50000000000047,382.9700000000004,112.41000000000047,392.3500000000004,116.21000000000046,406.07000000000045C120.02000000000046,417.9700000000004,115.03000000000046,431.84000000000043,104.65000000000046,438.7300000000005C93.53000000000046,446.57000000000045,77.06000000000046,445.1000000000005,67.57000000000046,435.32000000000045C57.770000000000465,426.00000000000045,56.050000000000466,409.71000000000043,63.61000000000046,398.52000000000044C68.04000000000046,391.6700000000004,75.42000000000046,386.84000000000043,83.49000000000046,385.59000000000043Z"/>
@@ -0,0 +1 @@
<g transform="translate(330, 580)"><path onclick="clicked(evt)" stroke="#ffffff" fill = "#009966" transform = "scale(0.15, 0.15)" class="44" ontouchstart="onstart(44)" ontouchend="onend(44)" fill = "#ffffff" d="M450.08,360.192c-9.237-8.683-19.84-12.907-29.163-15.211c1.749-7.488,3.264-15.467,4.459-23.915c2.091-14.528,1.557-29.333-1.6-44.053l-26.965-126.272c-4.608-16.299-14.315-28.437-26.432-37.675c0.491-0.619,1.131-1.109,1.6-1.771c6.891-9.899,8.832-22.421,5.355-33.899l-8.021-48c-0.213-1.323-0.555-2.624-1.024-3.883C362.635,10.24,347.957,0,331.744,0H180.235c-16.213,0-30.891,10.24-37.141,27.413l-12.629,47.68c-4.48,12.096-2.773,25.6,4.587,36.181c1.152,1.643,2.581,2.987,3.947,4.395c-11.051,9.173-19.883,20.949-24.235,36.437L88.117,277.013c-3.136,14.635-3.669,29.44-1.6,44.075c1.216,8.448,2.709,16.405,4.48,23.893c-9.301,2.325-19.883,6.549-29.099,15.211c-8.789,8.235-19.243,23.424-19.221,49.899C44.405,445.355,71.157,512,159.989,512h192c88.832,0,115.584-66.645,117.333-102.933C469.323,383.616,458.869,368.427,450.08,360.192z M138.656,467.285c-48.64-10.005-52.992-53.739-53.333-58.219c0-12.224,4.267-16.32,5.675-17.664c3.051-2.923,7.339-4.587,11.243-5.461c5.845,9.237,12.587,16.768,20.096,22.485c0.299,0.277,0.619,0.469,0.896,0.747c1.621,1.536,3.285,2.88,4.971,4.224c1.707,1.323,3.456,2.517,5.248,3.648c1.728,1.109,3.392,2.304,5.205,3.221V467.285z M330.656,469.333H181.323v-46.891c1.856-0.619,3.947-1.28,6.379-1.984c0.512-0.149,1.109-0.32,1.643-0.469c2.24-0.64,4.715-1.323,7.339-1.984c0.96-0.256,1.941-0.512,2.965-0.747c2.944-0.725,6.08-1.429,9.408-2.112c0.619-0.128,1.195-0.277,1.835-0.405c3.904-0.768,8.043-1.472,12.331-2.112c1.152-0.171,2.325-0.32,3.499-0.469c3.371-0.448,6.869-0.832,10.411-1.152c1.387-0.128,2.752-0.256,4.139-0.341c4.8-0.341,9.685-0.597,14.677-0.597c4.949,0,9.813,0.256,14.571,0.597c1.408,0.085,2.773,0.213,4.16,0.341c3.541,0.32,6.997,0.704,10.368,1.152c1.173,0.149,2.368,0.299,3.499,0.469c4.288,0.64,8.448,1.344,12.352,2.133c0.597,0.107,1.131,0.235,1.707,0.363c3.349,0.683,6.549,1.408,9.536,2.133c1.024,0.256,2.027,0.512,3.029,0.768c2.56,0.64,4.971,1.301,7.168,1.92c0.597,0.192,1.301,0.363,1.877,0.533c2.453,0.725,4.587,1.408,6.443,2.005V469.333z M270.112,85.333c-3.264-0.064-5.995-0.043-8.171,0h-11.691c-1.344-0.021-2.795-0.021-4.416,0h-74.027l11.285-42.667h145.195l7.125,42.667H270.112z M373.323,467.285v-47.04c1.813-0.917,3.499-2.155,5.227-3.243c1.685-1.088,3.328-2.219,4.949-3.456c1.728-1.365,3.456-2.709,5.099-4.288c0.171-0.149,0.363-0.277,0.533-0.427c7.744-5.76,14.635-13.419,20.608-22.891c3.755,0.853,8,2.411,11.136,5.355c1.429,1.344,5.781,5.44,5.824,16.555C426.315,413.419,421.963,457.259,373.323,467.285z" /></g>
@@ -0,0 +1 @@
<g transform="translate(412, 580)"><path onclick="clicked(evt)" stroke="#ffffff" fill = "#009966" stroke="#000" transform = "scale(0.15, 0.15)" class="45" ontouchstart="onstart(45)" ontouchend="onend(45)" fill = "#ffffff" d="M450.08,360.192c-9.237-8.683-19.84-12.907-29.163-15.211c1.749-7.488,3.264-15.467,4.459-23.915c2.091-14.528,1.557-29.333-1.6-44.053l-26.965-126.272c-4.608-16.299-14.315-28.437-26.432-37.675c0.491-0.619,1.131-1.109,1.6-1.771c6.891-9.899,8.832-22.421,5.355-33.899l-8.021-48c-0.213-1.323-0.555-2.624-1.024-3.883C362.635,10.24,347.957,0,331.744,0H180.235c-16.213,0-30.891,10.24-37.141,27.413l-12.629,47.68c-4.48,12.096-2.773,25.6,4.587,36.181c1.152,1.643,2.581,2.987,3.947,4.395c-11.051,9.173-19.883,20.949-24.235,36.437L88.117,277.013c-3.136,14.635-3.669,29.44-1.6,44.075c1.216,8.448,2.709,16.405,4.48,23.893c-9.301,2.325-19.883,6.549-29.099,15.211c-8.789,8.235-19.243,23.424-19.221,49.899C44.405,445.355,71.157,512,159.989,512h192c88.832,0,115.584-66.645,117.333-102.933C469.323,383.616,458.869,368.427,450.08,360.192z M138.656,467.285c-48.64-10.005-52.992-53.739-53.333-58.219c0-12.224,4.267-16.32,5.675-17.664c3.051-2.923,7.339-4.587,11.243-5.461c5.845,9.237,12.587,16.768,20.096,22.485c0.299,0.277,0.619,0.469,0.896,0.747c1.621,1.536,3.285,2.88,4.971,4.224c1.707,1.323,3.456,2.517,5.248,3.648c1.728,1.109,3.392,2.304,5.205,3.221V467.285z M330.656,469.333H181.323v-46.891c1.856-0.619,3.947-1.28,6.379-1.984c0.512-0.149,1.109-0.32,1.643-0.469c2.24-0.64,4.715-1.323,7.339-1.984c0.96-0.256,1.941-0.512,2.965-0.747c2.944-0.725,6.08-1.429,9.408-2.112c0.619-0.128,1.195-0.277,1.835-0.405c3.904-0.768,8.043-1.472,12.331-2.112c1.152-0.171,2.325-0.32,3.499-0.469c3.371-0.448,6.869-0.832,10.411-1.152c1.387-0.128,2.752-0.256,4.139-0.341c4.8-0.341,9.685-0.597,14.677-0.597c4.949,0,9.813,0.256,14.571,0.597c1.408,0.085,2.773,0.213,4.16,0.341c3.541,0.32,6.997,0.704,10.368,1.152c1.173,0.149,2.368,0.299,3.499,0.469c4.288,0.64,8.448,1.344,12.352,2.133c0.597,0.107,1.131,0.235,1.707,0.363c3.349,0.683,6.549,1.408,9.536,2.133c1.024,0.256,2.027,0.512,3.029,0.768c2.56,0.64,4.971,1.301,7.168,1.92c0.597,0.192,1.301,0.363,1.877,0.533c2.453,0.725,4.587,1.408,6.443,2.005V469.333z M270.112,85.333c-3.264-0.064-5.995-0.043-8.171,0h-11.691c-1.344-0.021-2.795-0.021-4.416,0h-74.027l11.285-42.667h145.195l7.125,42.667H270.112z M373.323,467.285v-47.04c1.813-0.917,3.499-2.155,5.227-3.243c1.685-1.088,3.328-2.219,4.949-3.456c1.728-1.365,3.456-2.709,5.099-4.288c0.171-0.149,0.363-0.277,0.533-0.427c7.744-5.76,14.635-13.419,20.608-22.891c3.755,0.853,8,2.411,11.136,5.355c1.429,1.344,5.781,5.44,5.824,16.555C426.315,413.419,421.963,457.259,373.323,467.285z" /></g>
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="47" stroke = "#ffffff" fill = "#009966" d="M73.89000000000013,632C74.06000000000013,642.7000000000004,73.44000000000013,658.3400000000004,73.47000000000013,724.0000000000005C73.59000000000013,729.2600000000004,72.83000000000013,734.5200000000004,73.42000000000013,739.7700000000004C74.52000000000012,739.9100000000004,75.62000000000013,740.0700000000004,76.73000000000013,740.2300000000005C78.43000000000013,724.5700000000005,76.78000000000013,708.7600000000004,77.82000000000014,693.0600000000005C77.66000000000014,654.3600000000005,78.32000000000014,615.6400000000006,77.12000000000013,632Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="49" stroke = "#ffffff" fill = "#009966" d="M110.88000000000005,848.8300000000003C103.55000000000005,851.7100000000003,95.64000000000006,853.0800000000003,87.78000000000006,853.2700000000003C87.83000000000006,858.0300000000003,88.54000000000006,862.7700000000003,89.43000000000006,867.4500000000003C93.00000000000006,886.5400000000003,97.27000000000007,905.6200000000002,104.21000000000006,923.8000000000003C105.73000000000006,924.5000000000003,107.17000000000006,925.6000000000003,108.89000000000007,925.7500000000003C113.30000000000007,925.8800000000003,117.99000000000007,926.4800000000004,121.95000000000007,924.1000000000004C126.97000000000007,924.3500000000004,132.57000000000008,922.8900000000003,135.82000000000008,918.8300000000004C138.32000000000008,916.1300000000003,137.30000000000007,912.3000000000004,137.48000000000008,909.0000000000003C137.5300000000001,881.6600000000003,137.35000000000008,854.3200000000004,137.57000000000008,826.9800000000004C131.20000000000007,836.8300000000004,121.44000000000008,844.0200000000003,110.88000000000008,848.8300000000004Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="50" stroke = "#ffffff" fill = "#009966" d="M684.9300000000001,827.2500000000002C684.83,856.5100000000002,684.8800000000001,885.7800000000002,684.9100000000001,915.0400000000002C685.96,921.3000000000002,693.34,924.2700000000002,699.08,924.0200000000002C701.62,923.7000000000002,703.4200000000001,926.2700000000002,706,925.8400000000003C710.23,925.6900000000003,715.97,926.9600000000003,718.57,922.6300000000002C723.59,909.4800000000002,726.95,895.7600000000002,730.0300000000001,882.0500000000002C731.84,872.5200000000002,734.2600000000001,863.0300000000002,734.6100000000001,853.2900000000002C715.0900000000001,853.3800000000002,695.8300000000002,843.4900000000002,684.9300000000002,827.2500000000002Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="51" stroke = "#ffffff" fill = "#009966" d="M90.69000000000051,300.70000000000044C85.06000000000051,303.2800000000004,84.14000000000051,310.11000000000047,82.6800000000005,315.43000000000046C81.0400000000005,329.94000000000045,81.6300000000005,344.6000000000005,81.55000000000051,359.18000000000046C89.39000000000051,358.77000000000044,97.36000000000051,358.61000000000047,105.05000000000051,360.42000000000047C111.25000000000051,361.63000000000045,116.9300000000005,364.50000000000045,122.44000000000051,367.4700000000005C121.75000000000051,350.1400000000005,120.09000000000052,332.87000000000046,118.0400000000005,315.6400000000005C117.44000000000051,311.2600000000005,117.3700000000005,306.8300000000005,117.5000000000005,302.4100000000005C110.1400000000005,295.7900000000005,99.18000000000049,297.3500000000005,90.6900000000005,300.7000000000005Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="52" stroke = "#ffffff" fill = "#009966" d="M704.9500000000005,302.20000000000044C704.8900000000006,306.51000000000045,704.9600000000005,310.84000000000043,704.3400000000005,315.13000000000045C702.2400000000005,332.55000000000047,700.6900000000005,350.0300000000004,699.8000000000005,367.5400000000004C704.9100000000005,364.7300000000004,710.2000000000005,362.08000000000044,715.9200000000005,360.7800000000004C724.0200000000006,358.57000000000045,732.5100000000006,358.77000000000044,740.8300000000005,359.1600000000004C740.7800000000005,345.45000000000044,741.1100000000005,331.71000000000043,740.0000000000005,318.02000000000044C739.5400000000004,313.4700000000004,738.0900000000005,309.0500000000004,736.2000000000005,304.9100000000004C734.0500000000005,300.7200000000004,729.0900000000005,299.5500000000004,724.8700000000005,298.6900000000004C718.1600000000004,296.9300000000004,710.4200000000004,297.6200000000004,704.9500000000005,302.2000000000004Z"/>
@@ -0,0 +1 @@
<path onclick="clicked(evt)" class="2" stroke = "#ffffff" fill = "#009966" d = "M298.9900000000007,133.96000000000043C292.9800000000007,134.56000000000043,286.3500000000007,134.62000000000043,281.2800000000007,138.32000000000045C279.6100000000007,142.82000000000045,281.0300000000007,147.97000000000045,282.75000000000074,152.27000000000044C287.60000000000076,154.06000000000043,292.88000000000073,153.38000000000045,297.94000000000074,153.45000000000044C308.81000000000074,152.81000000000046,319.6400000000007,151.50000000000045,330.48000000000076,150.46000000000043C330.44000000000074,144.59000000000043,330.54000000000076,138.71000000000043,330.41000000000076,132.84000000000043C319.94000000000074,133.27000000000044,309.43000000000075,132.82000000000042,298.99000000000075,133.96000000000043Z"/>

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