Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5bd7308b5 | ||
|
|
9b4aa504ec | ||
|
|
9b991a244b | ||
|
|
ed16b89353 | ||
|
|
4c03f17448 | ||
|
|
98806d1e92 | ||
|
|
577ec37a2f | ||
|
|
71783f3724 | ||
|
|
d208838bb3 | ||
|
|
c7c989d886 | ||
|
|
b4e0dd6d65 | ||
|
|
7e2cf0c9c8 | ||
|
|
e0083b894f | ||
|
|
abdf0a2dea | ||
|
|
56f1a2774b | ||
|
|
924dd7966a | ||
|
|
102a2525db | ||
|
|
8893710254 | ||
|
|
ab9ddf7289 | ||
|
|
bd7665fb1d | ||
|
|
df709c58e5 | ||
|
|
3c6dacb29b | ||
|
|
7775e74108 | ||
|
|
da0436cee8 | ||
|
|
db938c818d | ||
|
|
a428395071 | ||
|
|
fc4060d4d9 | ||
|
|
537548d716 | ||
|
|
e815955d46 | ||
|
|
3660b80357 | ||
|
|
173032c12e | ||
|
|
cbe9ae5d9c | ||
|
|
3a7bd749cc | ||
|
|
b04630cfd5 | ||
|
|
dfc2e45731 | ||
|
|
f4e8818bf7 | ||
|
|
5370346a89 | ||
|
|
4610a50667 |
@@ -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.
|
||||
|
||||
## Текущая фаза
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# TaxiDashboard backend — найденные пути для модуля `mechanic`
|
||||
|
||||
Записано по результатам Task A1 (recon существующего кода `c:\NewProject\taxi-dashboard\backend\app\`).
|
||||
|
||||
## Корень backend
|
||||
|
||||
`/opt/sites/taxi-dashboard/backend/app/` (на VDS)
|
||||
`c:\NewProject\taxi-dashboard\backend\app\` (локально)
|
||||
|
||||
## Импорты для нового модуля `mechanic`
|
||||
|
||||
```python
|
||||
from app.models.base import Base, TimestampMixin # SQLAlchemy declarative + created_at/updated_at
|
||||
from app.db import get_db, async_session, engine # async session factory + dependency
|
||||
from app.auth.deps import get_current_user # OAuth2 Bearer JWT
|
||||
from app.models.user import User # User модель
|
||||
```
|
||||
|
||||
## Auth
|
||||
|
||||
- **OAuth2 Password Flow + Bearer JWT** (не PIN, как я ошибочно писал в спеке).
|
||||
- Логин: `POST /v1/auth/login` (или похожий, нужно найти в `app/api/auth.py`) с form-data `username` + `password` → возвращает `{ "access_token": "...", "token_type": "bearer" }`.
|
||||
- Дальнейшие запросы: header `Authorization: Bearer <token>`.
|
||||
|
||||
## Naming clash + решение
|
||||
|
||||
В TaxiDashboard уже есть модуль `app.api.inspections` (читает данные **CarInspection**, импортированные от вендора NaughtySoft через VendorBridge) под префиксом `/v2/inspections`.
|
||||
|
||||
Чтобы не конфликтовать:
|
||||
- Наш новый модуль: `app/api/mechanic.py` (или `app/mechanic/router.py` как подмодуль) — префикс `/api/v1/mechanic`
|
||||
- Наши новые таблицы:
|
||||
- `mechanic_inspections` (наша запись осмотров через PWA)
|
||||
- `mechanic_inspection_photos`
|
||||
- `mechanic_damage_markers`
|
||||
- Наши новые модели:
|
||||
- `app/models/mechanic_inspection.py` — `MechanicInspection`
|
||||
- `app/models/mechanic_inspection_photo.py` — `MechanicInspectionPhoto`
|
||||
- `app/models/mechanic_damage_marker.py` — `MechanicDamageMarker`
|
||||
|
||||
Существующие `CarInspection`, `car_inspections`, `inspection_failed_photo` — **не трогаем**. Они продолжают жить read-only от VendorBridge.
|
||||
|
||||
## Существующий стек
|
||||
|
||||
- Python 3.x + FastAPI + SQLAlchemy 2.x async
|
||||
- PostgreSQL (видна migration via Alembic — папку проверим в первом backend-task)
|
||||
- Frontend: `c:\NewProject\taxi-dashboard\frontend\` (React, существующий — НЕ наш target)
|
||||
|
||||
## Vehicle model
|
||||
|
||||
В existing `CarInspection` авто указывается через `car_number: String(100)`. Возможно есть отдельная модель `Car` в `app/models/car_v2.py` (видел в импортах). Проверим в backend task B2 при создании FK.
|
||||
@@ -0,0 +1,384 @@
|
||||
# Premium Mechanic PWA — Implementation State
|
||||
|
||||
> Last updated: 2026-05-17 (Phase 1 MVP LIVE IN PRODUCTION)
|
||||
|
||||
## Status: 🟢 **DEPLOYED — Phase 1 MVP LIVE on https://mechanic.pptaxi.ru**
|
||||
|
||||
All 4 deployment phases executed successfully:
|
||||
|
||||
- ✅ **J1 Backend**: `git push vds feat/mechanic-mvp` → checkout on VDS → `docker build` → rolling restart blue+green → healthz 5/5 green on `https://crm.pptaxi.ru/api/v1/mechanic/healthz`
|
||||
- ✅ **J2 Frontend**: `scp dist/` → VDS at `/opt/sites/mechanic-pwa/dist/` (448K) → added Caddy block + bind mount → `docker compose up -d --force-recreate caddy` → Let's Encrypt cert auto-issued → HTTPS 200 on `https://mechanic.pptaxi.ru/`
|
||||
- ✅ **J3a Merge**: `feat/mechanic-mvp` → `main` (ff-only) in BOTH repos, pushed to Gitea + VDS
|
||||
- ✅ **J3b Prod sync**: VDS now on `main` branch, healthz still green
|
||||
|
||||
**Smoke checks passed:**
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `GET https://mechanic.pptaxi.ru/` | 200 OK, text/html, served from SPA |
|
||||
| `GET /manifest.webmanifest` | JSON with `"name": "Premium Механик"` |
|
||||
| `GET /api/v1/mechanic/healthz` (via mechanic.pptaxi.ru) | `{"status":"ok"}` |
|
||||
| `GET /api/v1/mechanic/me` (no auth) | 401 |
|
||||
| `GET /api/v1/mechanic/vehicles` (no auth) | 401 |
|
||||
| `GET /icons/icon-192.png` | 200 image/png, 2326 bytes |
|
||||
| `GET /sw.js` (service worker) | 200 text/javascript |
|
||||
| `GET /vehicles/12345` (SPA route) | 200 text/html (index.html fallback) |
|
||||
| `crm.pptaxi.ru/api/v1/mechanic/healthz` x5 | 5/5 ok (both blue+green serving) |
|
||||
|
||||
**Remaining manual smoke (mobile, J3 step 8):** open `https://mechanic.pptaxi.ru` on Android Chrome / iOS Safari, login with test mechanic account, create inspection, snap photo, verify MinIO upload + thumbnail appears, verify "Add to Home Screen" works. *Not automated — you'll do that on a phone.*
|
||||
|
||||
## What is done
|
||||
|
||||
| Phase | Task | Status | Artifact |
|
||||
|---|---|---|---|
|
||||
| 0 | Git infrastructure (Gitea + repos + feature branches) | ✅ DONE | repos exist, ssh keys configured |
|
||||
| A | A1: Recon TaxiDashboard backend structure | ✅ DONE | `docs/backend-layout-notes.md` |
|
||||
| A | A2: Caddy fragment for `mechanic.pptaxi.ru` | ✅ DONE | `ops/Caddyfile.fragment` |
|
||||
| A | A3: MinIO CORS on `pp-inspections` bucket | ✅ DONE | `ops/set-minio-cors.sh`, applied, preflight 204 |
|
||||
| A | A4: SQLAlchemy models (`MechanicInspection`, `MechanicInspectionPhoto`, `MechanicDamageMarker`) | ✅ DONE | commit `be0553e` on `feat/mechanic-mvp` |
|
||||
| B | B1: Mechanic module skeleton + `/healthz` | ✅ DONE | commit `d2113a0` — flat `backend/app/api/mechanic.py` with `APIRouter(prefix="/v1/mechanic")`, registered in `app/main.py` with `prefix="/api"` → final URL `/api/v1/mechanic/healthz` |
|
||||
| B | B2: SQLAlchemy models registered + tests | ✅ DONE | **already shipped in A4** (`be0553e`) — 38 metadata tests in `backend/tests/test_mechanic_models.py`, no extra B2 commit |
|
||||
| B | B3: Pydantic schemas | ✅ DONE | commit `2739cc8` + fix `2fb978d` — 14 schemas + 6 Literals in `backend/app/api/mechanic_schemas.py`, 15 behavioral tests in `backend/tests/test_mechanic_schemas.py`; fix added `= None` defaults to `Optional[X]` fields and tightened `MarkerOut.damage_type`/`severity` to `DamageType`/`Severity` Literals |
|
||||
| B | B4: Auth + DB session dependencies | ✅ DONE | commit `f0d7e83` — `backend/app/api/mechanic_deps.py` re-exports `get_db`+`get_current_user`, adds `require_mechanic` guard (admin OR member of any active `dept_type='svc'` department); 9 tests in `backend/tests/test_mechanic_deps.py` use fake-DB stubs (no real session needed)
|
||||
| C | C1: `GET /me` endpoint | ✅ DONE | commit `a0bdcaa` + signature fix `1e5b822` — appended to `backend/app/api/mechanic.py`; returns `MeOut` from `require_mechanic` user; `permissions` is `["admin", "mechanic:inspect"]` for admins else `["mechanic:inspect"]`. Test fixtures (`client`, `override_mechanic`, `_fake_user`) in `backend/tests/test_mechanic_endpoints.py` use FastAPI `TestClient` + `app.dependency_overrides` per Option A — no real DB / no async. 6 endpoint tests. |
|
||||
| C | C2: `GET /vehicles` list with `q`+`limit` search | ✅ DONE | commit `7be8783` — new `backend/app/api/mechanic_service.py` with `search_vehicles_with_last_inspection` (single aggregated subquery + outerjoin to avoid N+1 for last completed inspection per vehicle); endpoint in `mechanic.py`; new `VehicleListResponse` schema; 8 tests (6 endpoint + 2 service-stmt) with `_FakeSession.execute()` returning canned rows. Search hits plate (`CarV2.number`) + VIN only. |
|
||||
| C | C3: `GET /vehicles/{id}` detail + bound /vehicles limit | ✅ DONE | commit `9109383` + style fix `90b8c66` — new `VehicleDetail(VehicleSummary)` + `InspectionSummary(_Base)` schemas; service `get_vehicle_by_id` + `recent_mechanic_inspections`; endpoint raises 404 when missing, `last_inspection_at` derived from first completed inspection in the list; applied `Query(20, ge=1, le=200)` bounds to C2's `/vehicles` limit param. 7 new tests (5 detail + 2 bounds). Service is now FastAPI-free (404 raise lives in endpoint, matching `automations.py` convention). |
|
||||
| D-pre | B3 schemas widened from vendor recon (`a187a92`) | ✅ DONE | After read-only recon of `car_inspections` (14k rows, 2yr): `InspectionType` extended 4→9 (added `initial`, `seizure`, `equipment-change`, `pre-repair`, `post-repair`); `DamageType` fully replaced 7→16 (removed `rust`/`glass`/`paint` which had 0 vendor occurrences; added real-world types from vendor distribution: `damaged`, `chip`, `scuff`, `burn`, `bent`, `puncture`, `bulge`, `not-working`, etc.). `Severity` unchanged (4 values, optional — vendor populates only 3%). 2 new positive-coverage tests. Stale comment in `mechanic_damage_marker.py:54` references old DamageType values — fix in Phase F when touching that model. |
|
||||
| D | D1: `POST /inspections` with inline carry-over markers | ✅ DONE | commits `39a6579` + correctness fix `ee3d370` — service `start_inspection` + `latest_completed_inspection` (with `NULLS LAST` ordering); endpoint returns 201 with full InspectionDetail via re-query with `selectinload(photos, markers)`. Carry-over: copies all **unresolved** markers from prev completed inspection (`resolved=False` filter — closed defects don't reopen), each new marker has `carried_over_from_id=source.id`, `photo_id=None`, `resolved=False`. `_FakeSession` extended with `add`/`flush`/`commit`/`refresh` write-path methods + `rows_per_call` queue for multi-SELECT service flows. 9 new tests (6 endpoint + 3 service). |
|
||||
| D | D2: `GET /inspections/{id}` with photos+markers | ✅ DONE | commit `1776a81` — service `get_inspection_by_id_with_relations` (selectinload both relationships, `scalar_one_or_none`); endpoint raises 404 with `"inspection not found"` parallel to vehicle's 404. D1's inline re-query block refactored to call the same helper (DRY). 6 new tests (4 endpoint + 2 service introspecting `stmt._with_options`). |
|
||||
| D | D3: `PATCH /inspections/{id}` status/notes/finished_at | ✅ DONE | commit `aefe117` — service `get_inspection_by_id` (thin `session.get` wrapper) + `patch_inspection` (auto-sets `finished_at=now(UTC)` when status→completed AND finished_at not in payload; `setattr` loop applies all `model_dump(exclude_unset=True)` fields). Endpoint re-loads via `get_inspection_by_id_with_relations` for the response. 8 new tests (6 endpoint + 2 service). Endpoint and service both named `patch_inspection` — disambiguated by module path in code, by `as svc_patch` alias in tests. |
|
||||
| D | Housekeeping: extract test fakes to `mechanic_fakes.py` | ✅ DONE | commit `eedbf23` — pure refactor. Moved `_FakeResult`, `_FakeSession`, `_fake_user`, `_fake_car`, `_fake_inspection`, `_fake_inspection_orm`, `_fake_marker_orm` from `test_mechanic_endpoints.py` (was 899 lines) to new `backend/tests/mechanic_fakes.py` (207 lines). Test file dropped to 697 lines. Pytest fixtures (`client`, `override_mechanic`, `override_db`) stayed in the test file. Phase E tests will import from `mechanic_fakes` directly. |
|
||||
| E | E1: MinIO async presigned PUT URL wrapper | ✅ DONE | commit `fe38981` — added `presigned_put_url(bucket, key, *, content_type, expires_sec=300)` to `app/services/storage.py`; `PresignedUploadResponse.fields` defaults to `{}`; 3 storage tests. |
|
||||
| E | E2: `POST /inspections/{id}/photos/upload-url` | ✅ DONE | commit `111661b` — service `create_pending_photo` + `_storage_key_for_photo` helper in `mechanic_service.py`; endpoint in `mechanic.py` with 415-before-404 guard; UUID4 hex storage keys at `mechanic/inspections/{id}/<uuid>.<ext>`; 9 new tests (7 endpoint + 2 service). |
|
||||
| E | E3: `POST /photos/{id}/confirm` | ✅ DONE | commit `724b209` — service `get_photo_by_id` + `confirm_photo` (None-safe dim update, status→ready); endpoint verifies `storage.exists`, 409 if missing, 404 covers both photo-missing and inspection-mismatch; 9 new tests (7 endpoint + 2 service). `_fake_photo_orm` factory added to `mechanic_fakes.py`. |
|
||||
| E | E4: `GET /photos/{id}` + `/thumb` (302 redirect) | ✅ DONE | commit `8361ee1` — auth-guarded 302 to presigned MinIO GET URLs (1h expiry). Thumb endpoint falls back to `storage_key` when `thumb_key is None` so galleries degrade gracefully if E5 task hasn't run yet. 8 endpoint tests. |
|
||||
| E | E5: async thumbnail generation | ✅ DONE | commit `b37a068` — `generate_and_store_thumbnail` worker (download → Pillow resize to 480px → upload → DB update) with try/except at each step (failures logged, return None, never crashes event loop). Triggered via `asyncio.create_task` from E3 confirm endpoint. `_thumb_key_for` predictable naming (`<key-base>-thumb.jpg`). `pillow_heif.register_heif_opener()` wrapped in try/except for dev-env tolerance. 6 new tests (5 storage unit + 1 endpoint trigger). |
|
||||
| F | F1+F2: markers CRUD (POST/PATCH/DELETE) | ✅ DONE | commit `7275a23` — 3 endpoints (`POST /inspections/{id}/markers`, `PATCH /markers/{id}`, `DELETE /markers/{id}`). Service helpers `get_marker_by_id`/`create_marker`/`patch_marker`/`delete_marker`. Stale comment in `mechanic_damage_marker.py:54` fixed. `_FakeSession.delete()` added; `_FakeSession.refresh()` auto-sets `created_at` for server-default fields. 15 new tests. **159 mechanic tests total. Backend Phase 1 MVP feature-complete.** |
|
||||
| G | G1-G5: Vite + React + TS + Tailwind + shadcn + PWA scaffold | ✅ DONE | commit `e815955` in `PremiumDriverApp/mechanic-pwa/frontend/` — 38 files, 8973 insertions. React 19 + Vite 8 + Tailwind v3 + TS strict + shadcn/ui (manually written to bypass broken @shadcn 4.7 "base-nova" style) + vite-plugin-pwa v1.3 (auto-bumped from v0.20 for Vite 8 compat). Service worker (`sw.js` + workbox, 10 precached entries). Cream palette (CSS vars from memory `feedback_design_prefs`). API client (ky) + auth store (zustand persist). 5 stub pages. **Build: 91 KB gzip JS, 0 warnings.** |
|
||||
| H | H1-H3: Login + Home + VehicleCard pages | ✅ DONE | commit `537548d` — Login uses OAuth2 password flow (form-urlencoded) against `/api/auth/login` (NOT mechanic-prefixed). Home shows vehicle list with search + logout + `/me` fetch. VehicleCard has all 9 inspection-type buttons (top 4 primary, rest outline). Russian locale dates. Semantic Tailwind tokens throughout (no hex). **Build: 110 KB gzip JS.** |
|
||||
| I | I1-I3: photo capture/upload infra | ✅ DONE | commit `fc4060d` — `api/photos.ts` with presigned PUT pattern (not POST/FormData per plan), `confirmPhoto(inspection_id, photo_id, meta)` with both IDs in path per E3 backend. `AuthImg` component (fetches with Bearer JWT → blob URL → `<img>`) solving the `<img>`-can't-send-Authorization problem. `CameraCapture` uses `<input type="file" capture="environment">` for max compat. `PhotoSlot` combines them. |
|
||||
| I | I4-I6: VehicleScheme + InspectionEditor + InspectionReview | ✅ DONE | commit `a428395` — `sedan-top.svg` (top-view with 7 transparent zone rects), `VehicleScheme.tsx` with click+hover handlers + point-marker overlays + zone-count badges. `inspections.ts` typed with `PhotoSummary`/`MarkerSummary`. Editor wires scheme + 8 photo slots + marker list + finalize button (gated on `status==="in_progress"`); zone tap creates marker with `damage_type="damaged"`/`severity="minor"`. Review is read-only with AuthImg thumb grid. **Build: 113 KB gzip JS. Frontend Phase 1 MVP feature-complete.** |
|
||||
|
||||
**Branch state:** `feat/mechanic-mvp` at `b37a068`. Full stack: A4 → B1 → B3 + fix → B4 → C1 + fix → C2 → C3 + fix → B3-vendor-align → D1 + fix → D2 → D3 → test-refactor → E1 → E2 → E3 → E4 → E5. All pushed to `origin` (Gitea). See git log for full SHAs.
|
||||
|
||||
**Test count on branch:** **144 mechanic tests pass** (38 models + 17 schemas + 9 deps + 9 storage + 71 endpoints) in ~2.5s, no real DB needed. Pillow image-resize tests use real PIL in-memory (verify JPEG magic header + dimensions); thumbnail task end-to-end uses mocked I/O.
|
||||
|
||||
**Live mechanic API surface (11 routes):**
|
||||
- `GET /api/v1/mechanic/healthz`
|
||||
- `GET /api/v1/mechanic/me`
|
||||
- `GET /api/v1/mechanic/vehicles?q=&limit=`
|
||||
- `GET /api/v1/mechanic/vehicles/{vehicle_id}`
|
||||
- `POST /api/v1/mechanic/inspections` — create with carry-over for type=return
|
||||
- `GET /api/v1/mechanic/inspections/{inspection_id}` — full detail with photos+markers
|
||||
- `PATCH /api/v1/mechanic/inspections/{inspection_id}` — partial update + auto finished_at
|
||||
- `POST /api/v1/mechanic/inspections/{inspection_id}/photos/upload-url` — create pending photo row + presigned PUT URL (5min expiry)
|
||||
- `POST /api/v1/mechanic/inspections/{inspection_id}/photos/{photo_id}/confirm` — verify upload, mark ready, schedule thumbnail task
|
||||
- `GET /api/v1/mechanic/photos/{photo_id}` — auth-guarded 302 to presigned GET (1h)
|
||||
- `GET /api/v1/mechanic/photos/{photo_id}/thumb` — same with thumb_key, falls back to original
|
||||
|
||||
### Phase E architecture decisions (RESOLVED 2026-05-17)
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Upload pattern | PUT (single presigned URL) not POST/FormData | Simpler PWA code: `fetch(url, {method:'PUT', body:blob})`. `PresignedUploadResponse.fields` defaults to `{}` for future POST migration. |
|
||||
| Storage key naming | `mechanic/inspections/{insp_id}/<uuid4hex>.<ext>` | Human-readable path + unguessable suffix. UUID4 avoids enumeration. |
|
||||
| Allowed content types | `image/{jpeg,png,heic,webp}` | Whitelist; 415 on others. |
|
||||
| Confirm status semantics | `status="ready"` set immediately on confirm; thumb generated async | Frontend doesn't need to poll. Galleries fall back to original via E4 endpoint. |
|
||||
| Thumbnail backend | `asyncio.create_task` + Pillow (no Celery) | Pillow + 15+ existing fire-and-forget call sites; no broker setup needed. Thumb resize is ~50ms for typical photo — acceptable on event loop. |
|
||||
| Thumb size | 480px max-edge, JPEG q=85 | Matches `services/download_wazzup_media.py` precedent. |
|
||||
| Auth model for read URLs | Auth-required on `/photos/{id}` endpoint; presigned URLs themselves bearer-free (1h TTL) | Same as existing `media_proxy.py` convention. |
|
||||
|
||||
### Vendor data reality (from recon 2026-05-17 — 14,013 inspections, 2 years)
|
||||
|
||||
| Metric | Value | Implication |
|
||||
|---|---|---|
|
||||
| Inspections with damage | 1,941 / 14,013 = 13.9% | Most inspections have empty marker list — UI optimize for that |
|
||||
| Median markers per inspection | 2 | Inline carry-over copy is cheap |
|
||||
| p95 markers | 10 | Confirms no async needed |
|
||||
| Max markers | 34 | Worst case still ~50 inserts on commit |
|
||||
| Median photos per inspection | 11 (`all_photo_ids`) | Includes 9 sides + damage photos |
|
||||
| Max photos per inspection | 30 | Set MinIO presigned batch size accordingly |
|
||||
| Carry-over in vendor | **None — denormalized snapshot** | Our `prev_inspection_id`+`carried_over_from_id` FK approach is net improvement |
|
||||
| Vendor inspection status field | **None — write-once when completed** | Our `in_progress`/`completed`/`cancelled` is greenfield, no conflicts |
|
||||
|
||||
## Where things live
|
||||
|
||||
### Repos (Gitea: `http://100.64.0.9:3001/tremble7681`)
|
||||
|
||||
| Repo | Local clone | Working branch |
|
||||
|---|---|---|
|
||||
| `mechanic-pwa` | `c:\NewProject\PremiumDriverApp\` | `feat/mechanic-mvp` |
|
||||
| `taxi-dashboard` | `c:\NewProject\taxi-dashboard\` | `feat/mechanic-mvp` |
|
||||
|
||||
`taxi-dashboard` clone has two remotes: `origin` → Gitea, `vds` → SSH to `/opt/sites/taxi-dashboard/` on 100.64.0.12 (for pulling back-and-forth with prod).
|
||||
|
||||
### Reference paths (real, verified — see `docs/backend-layout-notes.md`)
|
||||
|
||||
- `Base`, `TimestampMixin` → `app.models.base`
|
||||
- `get_db` → `app.db` (file: `app/db/__init__.py`)
|
||||
- `get_current_user` → `app.auth.deps`
|
||||
- `User` → `app.models.user`
|
||||
- Auth flow: **OAuth2 password + Bearer JWT** (not PIN as plan originally said)
|
||||
- DB init: **`Base.metadata.create_all`** at startup (not Alembic) + inline `ALTER TABLE ADD COLUMN IF NOT EXISTS` in `app/main.py` lifespan
|
||||
- Backend Docker container: `taxi-backend-green` / `taxi-backend-blue` (blue/green)
|
||||
- MinIO container: `taxi-minio`
|
||||
- Models registered in: `app/models/__init__.py` (imports + `__all__`)
|
||||
|
||||
### Naming clash decision
|
||||
|
||||
Existing `app/api/inspections.py` + model `CarInspection` (table `car_inspections`) hold **vendor-imported read-only** data from NaughtySoft. Our new tables use the **`mechanic_`** prefix to coexist:
|
||||
|
||||
- `mechanic_inspections` (model `MechanicInspection`)
|
||||
- `mechanic_inspection_photos` (model `MechanicInspectionPhoto`)
|
||||
- `mechanic_damage_markers` (model `MechanicDamageMarker`)
|
||||
|
||||
### Plan-vs-reality overrides applied in Phase B (Authoritative)
|
||||
|
||||
Three places where the original plan (`docs/superpowers/plans/2026-05-16-premium-mechanic-phase1.md`) was overridden during execution because Phase A1 recon showed the codebase doesn't match the plan's assumptions:
|
||||
|
||||
| Plan said | Actual implementation | Why |
|
||||
|---|---|---|
|
||||
| `backend/app/mechanic/router.py` (subpackage) | `backend/app/api/mechanic.py` (flat) | TaxiDashboard uses flat `app/api/*.py` files; no subpackages |
|
||||
| `backend/app/mechanic/schemas.py` (subpackage) | `backend/app/api/mechanic_schemas.py` (flat sibling) | Same convention |
|
||||
| `backend/app/mechanic/deps.py` (subpackage) | `backend/app/api/mechanic_deps.py` (flat sibling) | Same convention |
|
||||
| Router prefix `/api/v1/mechanic` | Router prefix `/v1/mechanic`, with `app.include_router(..., prefix="/api")` in `main.py` | Matches existing convention (`inspections.py` → `/v2/inspections` + include with `/api`). Final URL is identical: `/api/v1/mechanic/...` |
|
||||
| `require_mechanic` checks `user.dept.lower() in (...)` | Queries `UserDepartment ⨝ Department WHERE dept_type='svc' AND is_active`, admin shortcuts before DB hit | `User` has no `dept` attribute; departments are M2M via `user_departments`. `DEPT_TYPES=['ops','svc','adm']`, `svc` = service-type (мастерская) |
|
||||
|
||||
When dispatching Phase C–F implementers, copy this override map into their context so they don't follow the stale subpackage / dept paths from the plan.
|
||||
|
||||
## Phase C testing-infrastructure decision (RESOLVED — Option A in force)
|
||||
|
||||
Phase C runs against `FastAPI TestClient` (sync) + `app.dependency_overrides[require_mechanic]` and `app.dependency_overrides[get_db]` to inject fake user / fake session. Fake session is a `SimpleNamespace`-flavored `_FakeSession` class in `tests/test_mechanic_endpoints.py` that supports `.execute(stmt)` (returns `_FakeResult` seeded with rows) and `.get(model, pk)` (returns seeded object or `None`).
|
||||
|
||||
No `conftest.py`, no `pytest-asyncio`, no real DB. SQL correctness is deliberately deferred to Phase J e2e.
|
||||
|
||||
If Phase D/E/F needs a more complex query shape (e.g. `.scalars().one()` / `.first()`), extend `_FakeResult.scalars()` accordingly — currently it returns `self` and supports only `.all()`. See `test_mechanic_endpoints.py:_FakeResult` docstring.
|
||||
|
||||
## Deployment runbook (Phase J — execute manually)
|
||||
|
||||
**Status:** prep done, awaiting user OK. Phase 1 MVP code is on `feat/mechanic-mvp` in both Gitea repos.
|
||||
|
||||
### J0 — Pre-flight (verified)
|
||||
|
||||
- [x] Backend `requirements.txt` already has `aiobotocore>=2.13.0`, `Pillow==10.4.0`, `pillow-heif==0.18.0`.
|
||||
- [x] Backend uses `Base.metadata.create_all` in lifespan (NOT Alembic) — new mechanic tables auto-create on container restart. **No migration step needed.**
|
||||
- [x] MinIO bucket `pp-inspections` exists with CORS configured (Phase A3, `ops/set-minio-cors.sh` already applied).
|
||||
- [x] Caddy fragment `ops/Caddyfile.fragment` ready in PremiumDriverApp repo.
|
||||
- [x] Frontend `dist/` build clean: **113 KB gzip JS**, 12 KB CSS, sw.js + workbox precaching.
|
||||
- [ ] **DNS**: confirm `mechanic.pptaxi.ru` resolves to the VDS IP before Caddy reload.
|
||||
- [ ] **Test mechanic user exists**: need a User in the DB with `is_admin=true` OR membership in a `Department` with `dept_type='svc'`. Verify with: `SELECT u.id, u.username, u.is_admin FROM users u LEFT JOIN user_departments ud ON ud.user_id=u.id LEFT JOIN departments d ON d.id=ud.department_id WHERE u.is_admin=true OR d.dept_type='svc';`
|
||||
|
||||
### J1 — Backend deploy (taxi-dashboard)
|
||||
|
||||
**⚠️ Production touch — affects live CRM.**
|
||||
|
||||
Plan said `systemctl restart taxi-dashboard-backend.service` + `alembic upgrade head` — **both wrong for our setup**. We use Docker blue/green + `Base.metadata.create_all`. Correct steps:
|
||||
|
||||
```bash
|
||||
ssh root@100.64.0.12
|
||||
cd /opt/sites/taxi-dashboard
|
||||
|
||||
# 1. Pull the mechanic branch into prod's tracking copy
|
||||
git fetch origin feat/mechanic-mvp
|
||||
git checkout feat/mechanic-mvp # OR merge into main first if your release process needs that
|
||||
|
||||
# 2. Rebuild backend image (pulls in new requirements: Pillow, pillow-heif, aiobotocore should already be installed if it wasn't a fresh deploy, but rebuild to be safe)
|
||||
docker compose build backend-blue backend-green
|
||||
|
||||
# 3. Rolling restart (blue first, then green — preserves availability)
|
||||
docker compose up -d --no-deps backend-blue
|
||||
sleep 10
|
||||
docker logs taxi-backend-blue --tail 50 # check for clean startup + table-create logs
|
||||
docker compose up -d --no-deps backend-green
|
||||
sleep 10
|
||||
docker logs taxi-backend-green --tail 50
|
||||
|
||||
# 4. Smoke test
|
||||
curl -sS https://crm.pptaxi.ru/api/v1/mechanic/healthz
|
||||
# Expected: {"status":"ok"}
|
||||
```
|
||||
|
||||
**Rollback if broken:** `git checkout main && docker compose up -d --no-deps backend-blue backend-green`. The `Base.metadata.create_all` is additive (CREATE TABLE IF NOT EXISTS) — new tables stay, no data loss.
|
||||
|
||||
### J2 — Frontend deploy (mechanic-pwa)
|
||||
|
||||
```bash
|
||||
# 1. Build locally (already done; use the fresh dist/)
|
||||
cd c:/NewProject/PremiumDriverApp/mechanic-pwa/frontend
|
||||
npm run build
|
||||
|
||||
# 2. Create target dir on VDS if first deploy
|
||||
ssh root@100.64.0.12 'mkdir -p /opt/sites/mechanic-pwa/dist'
|
||||
|
||||
# 3. Sync the built assets
|
||||
rsync -avz --delete dist/ root@100.64.0.12:/opt/sites/mechanic-pwa/dist/
|
||||
|
||||
# 4. Install Caddy fragment (first deploy only)
|
||||
ssh root@100.64.0.12
|
||||
cd /opt/sites/mechanic-pwa
|
||||
# (assumes PremiumDriverApp repo is checked out at /opt/sites/mechanic-pwa/repo;
|
||||
# if not, scp ops/Caddyfile.fragment instead)
|
||||
cp /opt/sites/mechanic-pwa/repo/ops/Caddyfile.fragment /etc/caddy/sites-enabled/mechanic.pptaxi.ru
|
||||
caddy validate --config /etc/caddy/Caddyfile # OR appropriate validation cmd
|
||||
systemctl reload caddy
|
||||
|
||||
# 5. Smoke test
|
||||
curl -I https://mechanic.pptaxi.ru
|
||||
# Expected: 200 OK, Content-Type: text/html
|
||||
curl -sS https://mechanic.pptaxi.ru/manifest.webmanifest | head -20
|
||||
# Expected: JSON with "name": "Premium Механик"
|
||||
```
|
||||
|
||||
**Rollback:** `rm /etc/caddy/sites-enabled/mechanic.pptaxi.ru && systemctl reload caddy` removes the route; old `dist/` can be restored from previous rsync run if you take a snapshot first.
|
||||
|
||||
### J3 — Smoke test (manual)
|
||||
|
||||
Open `https://mechanic.pptaxi.ru` on a mobile browser (Android Chrome / iOS Safari):
|
||||
|
||||
1. Login with the test mechanic account
|
||||
2. Search for any car → open card → start "Передача"
|
||||
3. Tap 2 zones on the scheme → markers appear with red badges
|
||||
4. Snap 1 photo (any slot) → wait for "загружено" toast → thumb appears
|
||||
5. Tap "Завершить осмотр" → redirected to review page showing photo + markers
|
||||
6. Verify in DB:
|
||||
```sql
|
||||
SELECT id, vehicle_id, type, status, performed_by, started_at, finished_at
|
||||
FROM mechanic_inspections ORDER BY id DESC LIMIT 3;
|
||||
SELECT id, side, slot_index, status, thumb_key IS NOT NULL AS has_thumb
|
||||
FROM mechanic_inspection_photos WHERE inspection_id=<NEW_ID>;
|
||||
SELECT id, side, damage_type, carried_over_from_id, resolved
|
||||
FROM mechanic_damage_markers WHERE inspection_id=<NEW_ID>;
|
||||
```
|
||||
7. Verify in MinIO console (`https://s3-admin.pptaxi.ru`) bucket `pp-inspections`: see the `mechanic/inspections/<NEW_ID>/<uuid>.jpg` original + `<uuid>-thumb.jpg` next to it.
|
||||
8. PWA install: tap browser menu → "Add to Home Screen" → opens fullscreen.
|
||||
|
||||
If all 8 pass → **MVP is live**. File UX issues as Gitea issues.
|
||||
|
||||
## Acceptance criteria (from plan §3)
|
||||
|
||||
- [ ] Mechanic can log in on `mechanic.pptaxi.ru` from mobile
|
||||
- [ ] Find vehicle by license plate
|
||||
- [ ] Open vehicle card, see recent inspections
|
||||
- [ ] Start handover / return / periodic / ad-hoc inspection
|
||||
- [ ] On return: carry-over markers from prev appear with `carried_over_from_id`
|
||||
- [ ] Take ≥4 photos in slots, uploaded directly to MinIO via presigned PUT
|
||||
- [ ] Click zones on SVG scheme → point markers added
|
||||
- [ ] Finalize inspection (status=completed, finished_at auto-set)
|
||||
- [ ] View read-only review with all photos + markers
|
||||
- [ ] PWA installable to home screen on Android + iOS
|
||||
|
||||
## Next task (post-deploy)
|
||||
|
||||
### **Task F1 — Markers CRUD** (Phase F starts; only 2 tasks) — DONE see table above
|
||||
|
||||
After successful J3 smoke test, **Phase 1 MVP is complete**. Phase 2 backlog: polygon annotation editor (Konva), offline IndexedDB queue, native APK wrap, push notifications, VendorBridge data import, admin stats dashboard, QR scanner.
|
||||
|
||||
(From `docs/superpowers/plans/2026-05-16-premium-mechanic-phase1.md` Phase F.)
|
||||
|
||||
**Working dir:** `c:\NewProject\taxi-dashboard` (branch `feat/mechanic-mvp`, HEAD `b37a068`)
|
||||
|
||||
Phase F adds the marker endpoints — finally exposes the carry-over data we've been carefully copying since D1. After F, backend Phase 1 MVP is feature-complete and Phase G begins frontend work.
|
||||
|
||||
**Two endpoints:**
|
||||
- `POST /inspections/{inspection_id}/markers` — create a new damage marker (request: `MarkerCreate` from B3)
|
||||
- `PATCH /markers/{marker_id}` — update marker (request: `MarkerPatch` from B3)
|
||||
|
||||
The plan likely also wants `DELETE /markers/{marker_id}` — verify in the plan. There may also be a "resolve marker" endpoint that's just a PATCH with `resolved=True`.
|
||||
|
||||
**Important context:**
|
||||
- `MechanicDamageMarker` has a stale comment at line 54 (refs old DamageType values like `rust`/`glass`/`paint`) — fix in F1 while touching this model area.
|
||||
- `MarkerCreate` and `MarkerOut` schemas already exist in `mechanic_schemas.py` from B3.
|
||||
- The carry-over markers (`carried_over_from_id IS NOT NULL`) deserve a thought: PATCH should be allowed to update them (mechanic confirms the existing damage is still there with current photo); DELETE should probably be NOT allowed on carry-overs (only on freshly-created markers) to preserve audit trail. Verify this in the plan.
|
||||
|
||||
**Phase F implementer pattern:** apply the same Phase D path overrides (flat `app/api/`, `MechanicDamageMarker` not `DamageMarker`, etc.). Reuse `_FakeSession` + `_fake_marker_orm` from `mechanic_fakes.py`.
|
||||
|
||||
### Phase D architecture decisions (RESOLVED 2026-05-17)
|
||||
|
||||
1. **Carry-over: inline в POST /inspections** (atomic in the same transaction). Until carry-over copy completes, the new inspection isn't "ready" for the UI — async would create race conditions and complicate testing without a meaningful payoff for ~10-20 markers per copy.
|
||||
|
||||
2. **`_FakeSession` write-path extension.** Add the following to the existing `_FakeSession` in `tests/test_mechanic_endpoints.py`:
|
||||
- `add(obj)` — appends to `self.added` list; auto-assigns `obj.id` if not set, popping from `_next_ids` queue if provided, else incrementing `_next_id_counter` (starts at 1000 to avoid collision with test-seeded ids in the 1-99 range)
|
||||
- `async flush()` — no-op + `self.flush_count += 1` (ids already assigned in `add`)
|
||||
- `async commit()` — no-op + `self.commit_count += 1`
|
||||
- `async refresh(obj)` — no-op + `self.refresh_count += 1`
|
||||
- **`rows_per_call: Optional[list[Sequence]]`** kwarg — if provided, each `execute()` call pops one item from this queue and returns it as the result rows. This handles multi-query service flows (e.g. `start_inspection` does `select(prev_inspection)` then `select(prev_markers)`).
|
||||
- Existing `execute(stmt)` and `get(model, pk)` keep their behavior.
|
||||
- All extensions backward-compatible with current 83 tests.
|
||||
|
||||
3. **Test infrastructure: stay on Option A, do NOT add real DB.** SQL specifics (selectinload, cascade, JSONB) will be validated in Phase J e2e. aiosqlite ≠ Postgres (JSONB mismatch), testcontainers is too heavy for 3 endpoints, two-paradigm test suite is more pain than gain. Extend the mock instead (see point 2).
|
||||
|
||||
### Phase D path overrides (apply to D1, D2, D3)
|
||||
|
||||
Same flat-file pattern as Phase B-C; map plan's stale paths:
|
||||
|
||||
| Plan says | Use instead |
|
||||
|---|---|
|
||||
| `backend/app/mechanic/router.py` | `backend/app/api/mechanic.py` (append) |
|
||||
| `backend/app/mechanic/service.py` | `backend/app/api/mechanic_service.py` (append) |
|
||||
| `backend/app/mechanic/schemas.py` | `backend/app/api/mechanic_schemas.py` (already has all needed shapes from B3) |
|
||||
| `backend/tests/test_mechanic/test_inspections.py` | `backend/tests/test_mechanic_endpoints.py` (append) |
|
||||
| `Inspection` model | `MechanicInspection` (`app/models/mechanic_inspection`) |
|
||||
| `DamageMarker` model | `MechanicDamageMarker` (`app/models/mechanic_damage_marker`) |
|
||||
| `InspectionPhoto` model | `MechanicInspectionPhoto` (`app/models/mechanic_inspection_photo`) |
|
||||
| `service.get_vehicle_or_404` (plan) | `service.get_vehicle_by_id` + endpoint-level `None`-check + `HTTPException(404)` raise (C3 fix) |
|
||||
| `from fastapi import HTTPException` inline in service | At endpoint level only; service stays FastAPI-free |
|
||||
| 404 raise inside endpoint via inline `from fastapi import HTTPException` | Use top-of-file import (already there from C3) |
|
||||
|
||||
Pydantic schemas (already shipped in B3):
|
||||
- `InspectionCreate` (vehicle_id, type, driver_id) — request body for POST
|
||||
- `InspectionPatch` (status, notes, finished_at) — request body for PATCH
|
||||
- `InspectionDetail` (full response with photos + markers list) — response model
|
||||
|
||||
Phase B3 schemas use `model_config = ConfigDict(from_attributes=True)` via `_Base`, so SQLAlchemy ORM instances serialize automatically with `response_model=InspectionDetail`.
|
||||
|
||||
## How to resume in a new session
|
||||
|
||||
In a new Claude Code chat in `c:\NewProject\`, paste this prompt:
|
||||
|
||||
```
|
||||
Продолжаем проект Premium Mechanic PWA.
|
||||
|
||||
Прочитай PremiumDriverApp/docs/superpowers/STATE.md — Phase 1 MVP code-complete.
|
||||
Backend (14 routes, 159 тестов) + Frontend (113 KB gzip PWA) запушены на feat/mechanic-mvp.
|
||||
|
||||
Deployment runbook готов в STATE.md секция "Deployment runbook". Выполни J1+J2+J3:
|
||||
1. SSH в root@100.64.0.12, git pull feat/mechanic-mvp, docker compose rebuild + rolling restart blue→green
|
||||
2. rsync mechanic-pwa/frontend/dist/ на VDS + caddy reload mechanic.pptaxi.ru
|
||||
3. Smoke test на мобильном браузере по чек-листу из runbook'а
|
||||
|
||||
После успешного deploy: merge feat/mechanic-mvp → main в обоих репах (PremiumDriverApp + taxi-dashboard).
|
||||
```
|
||||
|
||||
That prompt is the **single entry point** for the next session — Claude reads STATE.md, executes the deployment runbook, runs smoke tests, merges to main.
|
||||
|
||||
## Remaining roadmap (Phase 1 MVP)
|
||||
|
||||
- ~~**Phase B** (4 tasks)~~ ✅ done — module skeleton, models, schemas, auth deps
|
||||
- ~~**Phase C** (3 tasks)~~ ✅ done — GET /me, GET /vehicles, GET /vehicles/{id}
|
||||
- ~~**Phase D** (3 tasks)~~ ✅ done
|
||||
- ~~**Phase E** (5 tasks)~~ ✅ done
|
||||
- ~~**Phase F** (2 tasks)~~ ✅ done — backend Phase 1 MVP feature-complete (14 routes, 159 tests)
|
||||
- ~~**Phase G** (5 tasks)~~ ✅ done — frontend scaffold (91 KB gzip after G; 113 KB after I)
|
||||
- ~~**Phase H** (3 tasks)~~ ✅ done — Login + Home + VehicleCard
|
||||
- ~~**Phase I** (6 tasks)~~ ✅ done — CameraCapture + AuthImg + PhotoSlot + VehicleScheme + Editor + Review
|
||||
- **Phase J** (4 tasks): code-complete, **deployment runbook ready, awaiting user OK** ← **HERE**
|
||||
|
||||
**Code work is done. Phase 1 MVP delivery is one `ssh root@100.64.0.12` + Docker compose restart + rsync away.**
|
||||
|
||||
≈ 25 tasks left until MVP live on `mechanic.pptaxi.ru`.
|
||||
|
||||
## Known external dependencies
|
||||
|
||||
- MinIO `pp-inspections` bucket — CORS already configured, presigned uploads will work
|
||||
- Caddy on 100.64.0.12 — fragment ready, will be wired during Phase J deploy
|
||||
- Docker blue/green deployment on VDS — backend re-deployment will need a touch of `docker compose` (Phase J)
|
||||
- Frontend deployment target: `/opt/sites/mechanic-pwa/dist/` on VDS (Phase J)
|
||||
|
||||
## Conventions reminder
|
||||
|
||||
- All new code in `feat/mechanic-mvp` branches in both repos
|
||||
- All new tables use `mechanic_` prefix
|
||||
- Don't touch existing `app/api/inspections.py` or `app/models/inspection.py` (vendor-imported)
|
||||
- All new mechanic source lives in **flat files under `backend/app/api/`** with `mechanic_*` naming (`mechanic.py`, `mechanic_schemas.py`, `mechanic_deps.py`). No `app/mechanic/` subpackage — see "Plan-vs-reality overrides" table above.
|
||||
- Router prefix is `/v1/mechanic`; `/api` is added by `include_router(..., prefix="/api")` in `main.py`. Final URLs always `/api/v1/mechanic/*`.
|
||||
- Phase C+ endpoint tests: pending the test-infrastructure decision (Option A / B / C above). Do NOT introduce `pytest-asyncio`, `conftest.py`, or a real DB without first resolving that decision.
|
||||
- `require_mechanic` allows admins + members of any active `Department` with `dept_type='svc'`. Use it as the default auth dep for all mechanic endpoints.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Mechanic" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Premium Механик</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"ky": "^1.14.3",
|
||||
"lucide-react": "^0.400.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.76.0",
|
||||
"react-router-dom": "^7.15.1",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^2.6.1",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"zod": "^3.25.76",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"_meta": {
|
||||
"source": "Ttc.dll #US heap, contiguous block at offsets 0xc2e24–0xc3084",
|
||||
"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 0–12 are general exterior damage types (user-selectable for any zone)",
|
||||
"Items 13–18 appear to be salon-specific (Грязный салон, Запах табака, etc.) — shown only for interior zones (44–46 seats, salon)",
|
||||
"Установлено/Снято (19–20) and Контроль (21) are semi-system states used for equipment completeness zones (33–48 area)",
|
||||
"Штат (25) appears to be a system/staff marker, not user-selectable",
|
||||
"Порез, Прокол, Порвано are tire-specific damage types for zones 17–20",
|
||||
"Есть/Нет повреждений 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 0xc39e8–0xc39f8 (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 0x751c7–0x75a81 (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 34–37 are fog lights (противотуманки), zones 38–39 mirrors, 40–43 disks, 44–46 salon seats, 47–48 rear sill extensions, 49–56 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": "передняя правая стойка"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="13" fill="#000000"/>
|
||||
<!-- M: широкие диагонали, плоский «дно» с двумя углами вниз -->
|
||||
<path
|
||||
d="M 16 16 L 21 16 L 32 35 L 43 16 L 48 16 L 48 48 L 42 48 L 42 28 L 34 41 L 30 41 L 22 28 L 22 48 L 16 48 Z"
|
||||
fill="#E6E6E6"
|
||||
/>
|
||||
<!-- Красный акцент-черта под буквой -->
|
||||
<rect x="22" y="52" width="20" height="1.5" fill="#D32F2F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 514 B |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 410 B |
|
After Width: | Height: | Size: 889 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Premium Механик",
|
||||
"short_name": "Mechanic",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#000000",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,184 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useMeSync } from "@/hooks/useMeSync";
|
||||
import Login from "@/pages/Login";
|
||||
import ActionMenu from "@/pages/ActionMenu";
|
||||
import Home from "@/pages/Home";
|
||||
import VehicleCard from "@/pages/VehicleCard";
|
||||
import InspectionEditor from "@/pages/InspectionEditor";
|
||||
import InspectionReview from "@/pages/InspectionReview";
|
||||
import TtStateDetail from "@/pages/TtStateDetail";
|
||||
import RepairsFeedPage from "@/pages/repairs/RepairsFeedPage";
|
||||
import CarSelectPage from "@/pages/repairs/CarSelectPage";
|
||||
import CreateRepairPage from "@/pages/repairs/CreateRepairPage";
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuth((s) => s.token);
|
||||
if (!token) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// У35: фоновая сверка прав механика раз в 5 минут — выкидывает на /login
|
||||
// с сообщением «Доступ отозван», если админ снял can_login_pwa или вывел
|
||||
// пользователя из отдела механиков.
|
||||
useMeSync();
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<RequireAuth><ActionMenu /></RequireAuth>} />
|
||||
<Route path="/vehicles" element={<RequireAuth><Home /></RequireAuth>} />
|
||||
<Route path="/vehicles/:id" element={<RequireAuth><VehicleCard /></RequireAuth>} />
|
||||
<Route path="/inspections/:id/edit" element={<RequireAuth><InspectionEditor /></RequireAuth>} />
|
||||
<Route path="/inspections/:id" element={<RequireAuth><InspectionReview /></RequireAuth>} />
|
||||
<Route path="/tt-states/:id" element={<RequireAuth><TtStateDetail /></RequireAuth>} />
|
||||
{/* Раздел «Ремонт» — активируется в PR-5 (Фича 1). */}
|
||||
<Route path="/repairs" element={<RequireAuth><RepairsFeedPage /></RequireAuth>} />
|
||||
<Route path="/repairs/new" element={<RequireAuth><CarSelectPage /></RequireAuth>} />
|
||||
<Route path="/repairs/new/:carId" element={<RequireAuth><CreateRepairPage /></RequireAuth>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useAuth } from "@/store/auth";
|
||||
|
||||
export interface LoginInput {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** OAuth2-shaped login response, extended with backend's 2FA signaling fields. */
|
||||
export interface LoginOutput {
|
||||
access_token?: string | null;
|
||||
token_type: string;
|
||||
requires_2fa?: boolean;
|
||||
partial_token?: string | null;
|
||||
}
|
||||
|
||||
export interface TwoFactorInput {
|
||||
partial_token: string;
|
||||
code: string;
|
||||
remember_device: boolean;
|
||||
}
|
||||
|
||||
export async function login(input: LoginInput): Promise<LoginOutput> {
|
||||
const body = new URLSearchParams();
|
||||
body.set("username", input.username);
|
||||
body.set("password", input.password);
|
||||
// PWA — длинная сессия (30 дней) чтобы механики не вводили пароль каждые 8 ч.
|
||||
body.set("long_session", "true");
|
||||
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
// Critical: include credentials so the 30-day trusted_device cookie is
|
||||
// sent back to backend on subsequent logins (lets us skip 2FA next time).
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Login failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function login2fa(input: TwoFactorInput): Promise<LoginOutput> {
|
||||
const res = await fetch("/api/auth/2fa", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...input, long_session: true }),
|
||||
credentials: "include", // accept the trusted_device cookie on Set-Cookie
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`2FA failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function logout(): void {
|
||||
useAuth.getState().setToken(null);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import ky, { type KyInstance } from "ky";
|
||||
import { useAuth } from "@/store/auth";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? "/api/v1/mechanic";
|
||||
|
||||
export const api: KyInstance = ky.create({
|
||||
prefixUrl: API_BASE,
|
||||
timeout: 30_000,
|
||||
retry: { limit: 2, methods: ["get"] },
|
||||
hooks: {
|
||||
beforeRequest: [
|
||||
(request) => {
|
||||
const token = useAuth.getState().token;
|
||||
if (token) {
|
||||
request.headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
},
|
||||
],
|
||||
beforeError: [
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
useAuth.getState().setToken(null);
|
||||
}
|
||||
return error;
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { api } from "./client";
|
||||
import type { InspectionSummary } from "./vehicles";
|
||||
|
||||
export type InspectionType =
|
||||
| "handover"
|
||||
| "return"
|
||||
| "periodic"
|
||||
| "ad-hoc"
|
||||
| "initial"
|
||||
| "seizure"
|
||||
| "equipment-change"
|
||||
| "pre-repair"
|
||||
| "post-repair";
|
||||
|
||||
export type InspectionStatus = "in_progress" | "completed" | "cancelled";
|
||||
|
||||
export interface InspectionCreateInput {
|
||||
vehicle_id: number;
|
||||
type: InspectionType;
|
||||
driver_id?: number;
|
||||
mileage?: number;
|
||||
}
|
||||
|
||||
export interface PhotoSummary {
|
||||
id: number;
|
||||
side: string;
|
||||
slot_index: number;
|
||||
storage_key: string;
|
||||
thumb_key?: string | null;
|
||||
annotated_key?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
status: string;
|
||||
taken_at: string;
|
||||
}
|
||||
|
||||
export interface MarkerSummary {
|
||||
id: number;
|
||||
inspection_id: number;
|
||||
photo_id?: number | null;
|
||||
tt_image_id?: string | null;
|
||||
side?: string | null;
|
||||
x?: number | null;
|
||||
y?: number | null;
|
||||
polygon?: { x: number; y: number }[] | null;
|
||||
damage_type?: string | null;
|
||||
severity?: string | null;
|
||||
description?: string | null;
|
||||
carried_over_from_id?: number | null;
|
||||
resolved: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface VehicleBrief {
|
||||
id: number;
|
||||
license_plate: string;
|
||||
vin?: string | null;
|
||||
make?: string | null;
|
||||
model?: string | null;
|
||||
year?: number | null;
|
||||
}
|
||||
|
||||
export interface InspectionDetail {
|
||||
id: number;
|
||||
vehicle_id: number;
|
||||
/** Сводка по машине (гос.номер / марка / модель / год). Backend attaches её
|
||||
в get_inspection_by_id_with_relations, чтобы карточка осмотра могла
|
||||
показать машину без второго запроса. */
|
||||
vehicle?: VehicleBrief | null;
|
||||
type: InspectionType;
|
||||
performed_by: number;
|
||||
driver_id?: number | null;
|
||||
started_at: string;
|
||||
finished_at?: string | null;
|
||||
status: InspectionStatus;
|
||||
notes?: string | null;
|
||||
prev_inspection_id?: number | null;
|
||||
meta: Record<string, unknown>;
|
||||
photos: PhotoSummary[];
|
||||
markers: MarkerSummary[];
|
||||
mileage?: number | null;
|
||||
/** ФИО механика, выполнившего осмотр (lookup users.full_name по performed_by). */
|
||||
inspector_name?: string | null;
|
||||
}
|
||||
|
||||
export async function createInspection(
|
||||
input: InspectionCreateInput
|
||||
): Promise<InspectionDetail> {
|
||||
return api.post("inspections", { json: input }).json<InspectionDetail>();
|
||||
}
|
||||
|
||||
export async function getInspection(id: number): Promise<InspectionDetail> {
|
||||
return api.get(`inspections/${id}`).json<InspectionDetail>();
|
||||
}
|
||||
|
||||
export async function patchInspection(
|
||||
id: number,
|
||||
body: { status?: InspectionStatus; notes?: string; finished_at?: string; mileage?: number }
|
||||
): Promise<InspectionDetail> {
|
||||
return api.patch(`inspections/${id}`, { json: body }).json<InspectionDetail>();
|
||||
}
|
||||
|
||||
export interface MarkerCreateInput {
|
||||
photo_id?: number | null;
|
||||
side?: string | null;
|
||||
x?: number | null;
|
||||
y?: number | null;
|
||||
polygon?: { x: number; y: number }[] | null;
|
||||
damage_type?: string | null;
|
||||
severity?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export async function createMarker(
|
||||
inspectionId: number,
|
||||
body: MarkerCreateInput
|
||||
): Promise<MarkerSummary> {
|
||||
return api
|
||||
.post(`inspections/${inspectionId}/markers`, { json: body })
|
||||
.json<MarkerSummary>();
|
||||
}
|
||||
|
||||
export async function deleteInspection(inspectionId: number): Promise<void> {
|
||||
await api.delete(`inspections/${inspectionId}`);
|
||||
}
|
||||
|
||||
export async function deleteMarker(markerId: number): Promise<void> {
|
||||
await api.delete(`markers/${markerId}`);
|
||||
}
|
||||
|
||||
export type { InspectionSummary };
|
||||
@@ -0,0 +1,22 @@
|
||||
import { api } from "./client";
|
||||
|
||||
export interface PwaSupportContact {
|
||||
tg_username: string | null;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
export interface Me {
|
||||
id: number;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
permissions: string[];
|
||||
// Модуль СТО (У10 + У34) — расширение в backend PR-4.
|
||||
is_admin: boolean;
|
||||
can_login_pwa: boolean;
|
||||
departments: string[];
|
||||
support: PwaSupportContact;
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<Me> {
|
||||
return api.get("me").json<Me>();
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { api } from "./client";
|
||||
|
||||
export interface PresignedUploadResponse {
|
||||
photo_id: number;
|
||||
presigned_url: string;
|
||||
fields: Record<string, string>;
|
||||
storage_key: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export interface PhotoConfirmResponse {
|
||||
id: number;
|
||||
side: string;
|
||||
slot_index: number;
|
||||
storage_key: string;
|
||||
thumb_key?: string | null;
|
||||
annotated_key?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
status: string;
|
||||
taken_at: string;
|
||||
}
|
||||
|
||||
export async function uploadPhotoAnnotation(
|
||||
inspectionId: number,
|
||||
photoId: number,
|
||||
blob: Blob,
|
||||
): Promise<PhotoConfirmResponse> {
|
||||
const form = new FormData();
|
||||
form.append("file", blob, "annotation.jpg");
|
||||
return api
|
||||
.post(`inspections/${inspectionId}/photos/${photoId}/annotation`, {
|
||||
body: form,
|
||||
})
|
||||
.json<PhotoConfirmResponse>();
|
||||
}
|
||||
|
||||
export async function requestUploadUrl(
|
||||
inspectionId: number,
|
||||
args: { side: string; slot_index: number; content_type: string; size: number }
|
||||
): Promise<PresignedUploadResponse> {
|
||||
return api
|
||||
.post(`inspections/${inspectionId}/photos/upload-url`, { json: args })
|
||||
.json<PresignedUploadResponse>();
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT the file directly to MinIO via the presigned URL.
|
||||
* Content-Type header MUST match the value the URL was signed with
|
||||
* (we pass the same `args.content_type` to requestUploadUrl).
|
||||
*/
|
||||
export async function uploadToS3(
|
||||
presigned: PresignedUploadResponse,
|
||||
file: File,
|
||||
contentType: string
|
||||
): Promise<void> {
|
||||
const r = await fetch(presigned.presigned_url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": contentType },
|
||||
body: file,
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(`S3 upload failed: ${r.status} ${r.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmPhoto(
|
||||
inspectionId: number,
|
||||
photoId: number,
|
||||
meta: { width?: number; height?: number; actual_size?: number }
|
||||
): Promise<PhotoConfirmResponse> {
|
||||
return api
|
||||
.post(`inspections/${inspectionId}/photos/${photoId}/confirm`, { json: meta })
|
||||
.json<PhotoConfirmResponse>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Full upload flow: request URL → PUT to S3 → measure dims → confirm.
|
||||
* Returns the confirmed PhotoConfirmResponse (frontend uses .id mostly).
|
||||
*/
|
||||
export async function uploadPhoto(
|
||||
inspectionId: number,
|
||||
side: string,
|
||||
slotIndex: number,
|
||||
file: File
|
||||
): Promise<PhotoConfirmResponse> {
|
||||
const contentType = file.type || "image/jpeg";
|
||||
|
||||
const presign = await requestUploadUrl(inspectionId, {
|
||||
side,
|
||||
slot_index: slotIndex,
|
||||
content_type: contentType,
|
||||
size: file.size,
|
||||
});
|
||||
|
||||
await uploadToS3(presign, file, contentType);
|
||||
|
||||
// Measure dimensions from a local object URL (revoked once loaded).
|
||||
const dim = await new Promise<{ w: number; h: number }>((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const out = { w: img.naturalWidth, h: img.naturalHeight };
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(out);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error("Could not read image dimensions"));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
return confirmPhoto(inspectionId, presign.photo_id, {
|
||||
width: dim.w,
|
||||
height: dim.h,
|
||||
actual_size: file.size,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { api } from "./client";
|
||||
|
||||
export interface CarBrief {
|
||||
id: number;
|
||||
plate: string;
|
||||
brand: string | null;
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export interface CarContext {
|
||||
id: number;
|
||||
plate: string;
|
||||
brand: string | null;
|
||||
model: string | null;
|
||||
mileage_starline: number | null;
|
||||
driver_id: number | null;
|
||||
driver_name: string | null;
|
||||
}
|
||||
|
||||
export interface MechWork {
|
||||
id: number;
|
||||
name: string;
|
||||
category_id: number;
|
||||
price: number | string;
|
||||
norma_hours: number | string | null;
|
||||
}
|
||||
|
||||
export interface PartSuggestion {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PresignResponse {
|
||||
photo_uuid: string;
|
||||
extension: string;
|
||||
content_type: string;
|
||||
key: string;
|
||||
upload_url: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export interface CreatePhotoPayload {
|
||||
photo_uuid: string;
|
||||
extension: string;
|
||||
tag: "auto" | "part" | "other";
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface CreateWorkPayload {
|
||||
work_catalog_id: number;
|
||||
price_applied: number;
|
||||
manual_override: boolean;
|
||||
override_comment: string | null;
|
||||
}
|
||||
|
||||
export interface CreatePartPayload {
|
||||
name: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
export interface CreateOilChangePayload {
|
||||
enabled: boolean;
|
||||
mileage_at_change: number | null;
|
||||
next_in_km: number;
|
||||
sticker_photo_uuid: string | null;
|
||||
sticker_extension: string | null;
|
||||
}
|
||||
|
||||
export interface CreateRepairPayload {
|
||||
car_id: number;
|
||||
mileage: number | null;
|
||||
works: CreateWorkPayload[];
|
||||
parts: CreatePartPayload[];
|
||||
photos: CreatePhotoPayload[];
|
||||
oil_change: CreateOilChangePayload | null;
|
||||
}
|
||||
|
||||
export interface CreateRepairResponse {
|
||||
id: number;
|
||||
created_at_iso: string;
|
||||
tg_outbox_status: string;
|
||||
}
|
||||
|
||||
export async function searchCars(q: string): Promise<CarBrief[]> {
|
||||
const qs = q ? `?q=${encodeURIComponent(q)}` : "";
|
||||
return api.get(`cars${qs}`).json<CarBrief[]>();
|
||||
}
|
||||
|
||||
export async function getCarContext(carId: number): Promise<CarContext> {
|
||||
return api.get(`cars/${carId}/context`).json<CarContext>();
|
||||
}
|
||||
|
||||
export async function searchWorks(q: string): Promise<MechWork[]> {
|
||||
const qs = q ? `?q=${encodeURIComponent(q)}` : "";
|
||||
return api.get(`works${qs}`).json<MechWork[]>();
|
||||
}
|
||||
|
||||
export async function suggestParts(q: string): Promise<PartSuggestion[]> {
|
||||
return api
|
||||
.get(`parts/suggest?q=${encodeURIComponent(q)}`)
|
||||
.json<PartSuggestion[]>();
|
||||
}
|
||||
|
||||
export async function presignPhoto(extension: string, photo_uuid?: string): Promise<PresignResponse> {
|
||||
return api
|
||||
.post("repairs/photos/presign", {
|
||||
json: { extension, photo_uuid: photo_uuid ?? null },
|
||||
})
|
||||
.json<PresignResponse>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Залить файл по presigned PUT. ВАЖНО: Content-Type ОБЯЗАН совпадать
|
||||
* со значением, которое сервер вшил в подпись (см. У18 / sto_mechanic_form.py).
|
||||
*/
|
||||
export async function uploadPhotoToTmp(file: File | Blob, presign: PresignResponse): Promise<void> {
|
||||
const res = await fetch(presign.upload_url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": presign.content_type },
|
||||
body: file,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Photo upload failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRepair(
|
||||
payload: CreateRepairPayload,
|
||||
idempotencyKey: string,
|
||||
): Promise<CreateRepairResponse> {
|
||||
return api
|
||||
.post("repairs", {
|
||||
json: payload,
|
||||
headers: { "Idempotency-Key": idempotencyKey },
|
||||
})
|
||||
.json<CreateRepairResponse>();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { api } from "./client";
|
||||
|
||||
export interface FeedWorkBrief {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FeedItem {
|
||||
id: number;
|
||||
created_at: string; // ISO
|
||||
car_plate: string;
|
||||
car_make: string | null;
|
||||
car_model: string | null;
|
||||
driver_name: string | null;
|
||||
mechanic_name: string;
|
||||
works_preview: FeedWorkBrief[];
|
||||
works_extra: number;
|
||||
photo_url: string | null;
|
||||
}
|
||||
|
||||
export interface FeedResponse {
|
||||
items: FeedItem[];
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
export interface FeedParams {
|
||||
limit?: number;
|
||||
cursor?: string | null;
|
||||
}
|
||||
|
||||
export async function getRepairsFeed(params: FeedParams = {}): Promise<FeedResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.limit) search.set("limit", String(params.limit));
|
||||
if (params.cursor) search.set("cursor", params.cursor);
|
||||
const qs = search.toString();
|
||||
return api
|
||||
.get(`repairs${qs ? `?${qs}` : ""}`)
|
||||
.json<FeedResponse>();
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { api } from "./client";
|
||||
|
||||
export interface VehicleSummary {
|
||||
id: number;
|
||||
license_plate: string;
|
||||
vin?: string | null;
|
||||
make?: string | null;
|
||||
model?: string | null;
|
||||
year?: number | null;
|
||||
last_inspection_at?: string | null;
|
||||
/** Текущий статус машины из 1C Element (cars_v2.element_status):
|
||||
'На линии' / 'Простой' / 'Ремонтируется' / 'Ждет ремонта' / 'ДТП' /
|
||||
'Бронь' / 'Выкуп' / 'На продаже' / etc. NULL если не задан. */
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
export interface VehicleDetail extends VehicleSummary {
|
||||
recent_inspections: InspectionSummary[];
|
||||
starline_mileage?: number | null;
|
||||
starline_mileage_at?: string | null;
|
||||
}
|
||||
|
||||
export interface InspectionSummary {
|
||||
id: number;
|
||||
type: string;
|
||||
status: string;
|
||||
started_at: string;
|
||||
finished_at?: string | null;
|
||||
mileage?: number | null;
|
||||
photos_count?: number;
|
||||
/** Водитель, прикреплённый к машине на момент started_at (lookup в
|
||||
driver_rentals_v2 по car_number + перекрывающему периоду). NULL если
|
||||
на тот момент машина была свободна. */
|
||||
driver_id?: number | null;
|
||||
driver_name?: string | null;
|
||||
}
|
||||
|
||||
export async function listVehicles(q?: string): Promise<VehicleSummary[]> {
|
||||
const searchParams: Record<string, string> = {};
|
||||
if (q && q.trim()) searchParams.q = q.trim();
|
||||
const res = await api
|
||||
.get("vehicles", { searchParams })
|
||||
.json<{ items: VehicleSummary[] }>();
|
||||
return res.items;
|
||||
}
|
||||
|
||||
export async function getVehicle(id: number): Promise<VehicleDetail> {
|
||||
return api.get(`vehicles/${id}`).json<VehicleDetail>();
|
||||
}
|
||||
|
||||
// ── TT-Control vendor archive (Element Mechanic) ───────────────────────────
|
||||
|
||||
export interface TtStateSummary {
|
||||
id: number;
|
||||
vendor_state_id: string;
|
||||
unix_time: number | null;
|
||||
mechanic_name: string | null;
|
||||
mileage: number | null;
|
||||
photos_count: number;
|
||||
damages_count: number;
|
||||
/** Водитель, прикреплённый к машине на момент unix_time (lookup в
|
||||
driver_rentals_v2 по car_number + перекрывающему/предыдущему периоду).
|
||||
NULL если на тот момент машина была свободна. */
|
||||
driver_id?: number | null;
|
||||
driver_name?: string | null;
|
||||
}
|
||||
|
||||
export interface TtHistory {
|
||||
vendor_vehicle_id: string | null;
|
||||
plate?: string | null;
|
||||
brand?: string | null;
|
||||
model?: string | null;
|
||||
states: TtStateSummary[];
|
||||
}
|
||||
|
||||
export interface TtPhotoRef {
|
||||
image_id: string;
|
||||
image_with_lines_id: string | null;
|
||||
unix_time: number | null;
|
||||
guid: string | null;
|
||||
}
|
||||
|
||||
export interface TtDamage {
|
||||
damage_type_id: number | null;
|
||||
degree: number | null;
|
||||
points: { x: number; y: number }[];
|
||||
guid: string | null;
|
||||
unix_time: number | null;
|
||||
}
|
||||
|
||||
export interface TtSidePhoto {
|
||||
image_id: string;
|
||||
miniature_id: string | null;
|
||||
photo_type: number | null;
|
||||
}
|
||||
|
||||
export interface TtStateDetail {
|
||||
id: number;
|
||||
vendor_state_id: string;
|
||||
unix_time: number | null;
|
||||
mechanic_name: string | null;
|
||||
mileage: number | null;
|
||||
side_photos: TtSidePhoto[];
|
||||
photos_by_zone: Record<string, TtPhotoRef[]>;
|
||||
damages_by_zone: Record<string, TtDamage[]>;
|
||||
}
|
||||
|
||||
export async function getTtHistory(vehicleId: number): Promise<TtHistory> {
|
||||
return api.get(`vehicles/${vehicleId}/tt-history`).json<TtHistory>();
|
||||
}
|
||||
|
||||
export async function getTtState(stateId: number): Promise<TtStateDetail> {
|
||||
return api.get(`tt-states/${stateId}`).json<TtStateDetail>();
|
||||
}
|
||||
|
||||
export async function deleteTtState(stateId: number): Promise<void> {
|
||||
await api.delete(`tt-states/${stateId}`);
|
||||
}
|
||||
|
||||
export function ttImageUrl(imageId: string, withLines: boolean = false): string {
|
||||
// tt-image endpoint без auth-guard'а (vendor сам публично отдаёт эти JPEG'и),
|
||||
// поэтому подходит для прямого <img src=...>.
|
||||
const base = import.meta.env.VITE_API_BASE ?? "/api/v1/mechanic";
|
||||
return `${base}/tt-image/${imageId}${withLines ? "?lines=true" : ""}`;
|
||||
}
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/store/auth";
|
||||
|
||||
interface Props {
|
||||
/** Path relative to /api/v1/mechanic, e.g. "photos/123/thumb". */
|
||||
src: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
/** Optional placeholder while loading. */
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* <img> wrapper that fetches the resource with the current Bearer token,
|
||||
* turns it into a blob: URL, and renders an <img> pointing at the blob.
|
||||
*
|
||||
* Necessary because the mechanic photo endpoints are auth-guarded — plain
|
||||
* <img src="/api/v1/mechanic/photos/123"> doesn't send the Authorization header.
|
||||
*
|
||||
* Cleans up the blob URL on unmount or when src changes.
|
||||
*/
|
||||
export function AuthImg({ src, alt = "", className, fallback }: Props) {
|
||||
const token = useAuth((s) => s.token);
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let createdUrl: string | null = null;
|
||||
setError(false);
|
||||
setBlobUrl(null);
|
||||
|
||||
async function load() {
|
||||
if (!token) {
|
||||
setError(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const fullUrl = `/api/v1/mechanic/${src}`;
|
||||
const res = await fetch(fullUrl, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
redirect: "follow",
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const blob = await res.blob();
|
||||
createdUrl = URL.createObjectURL(blob);
|
||||
if (!cancelled) setBlobUrl(createdUrl);
|
||||
} catch {
|
||||
if (!cancelled) setError(true);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (createdUrl) URL.revokeObjectURL(createdUrl);
|
||||
};
|
||||
}, [src, token]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{fallback ?? (
|
||||
<span className="text-xs text-muted-foreground">нет фото</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!blobUrl) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{fallback ?? (
|
||||
<div className="bg-muted animate-pulse h-full w-full" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <img src={blobUrl} alt={alt} className={className} />;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
onCapture: (file: File) => void;
|
||||
buttonLabel?: string;
|
||||
}
|
||||
|
||||
export function CameraCapture({ onCapture, buttonLabel = "Сделать фото" }: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
|
||||
// Cleanup preview blob URL on unmount or when preview changes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (preview) URL.revokeObjectURL(preview);
|
||||
};
|
||||
}, [preview]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
aria-hidden="true"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (preview) URL.revokeObjectURL(preview);
|
||||
setPreview(URL.createObjectURL(file));
|
||||
onCapture(file);
|
||||
// Reset input value so capturing the same file twice works
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{preview ? (
|
||||
<div className="space-y-2">
|
||||
<img src={preview} alt="" className="w-full rounded-lg" />
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
Переснять
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => inputRef.current?.click()} className="w-full">
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from "react";
|
||||
import { CameraCapture } from "./CameraCapture";
|
||||
import { AuthImg } from "@/components/AuthImg";
|
||||
import { uploadPhoto } from "@/api/photos";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
inspectionId: number;
|
||||
side: string;
|
||||
slotIndex: number;
|
||||
label: string;
|
||||
initialPhotoId?: number;
|
||||
onUploaded?: (photoId: number) => void;
|
||||
}
|
||||
|
||||
export function PhotoSlot({
|
||||
inspectionId,
|
||||
side,
|
||||
slotIndex,
|
||||
label,
|
||||
initialPhotoId,
|
||||
onUploaded,
|
||||
}: Props) {
|
||||
const [photoId, setPhotoId] = useState<number | undefined>(initialPhotoId);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
async function handleCapture(file: File) {
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await uploadPhoto(inspectionId, side, slotIndex, file);
|
||||
setPhotoId(result.id);
|
||||
onUploaded?.(result.id);
|
||||
toast.success(`${label}: загружено`);
|
||||
} catch {
|
||||
toast.error(`${label}: ошибка загрузки`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-3 bg-card space-y-2">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
{photoId && (
|
||||
<AuthImg
|
||||
src={`photos/${photoId}/thumb`}
|
||||
alt={label}
|
||||
className="w-full aspect-[4/3] object-cover rounded bg-muted"
|
||||
/>
|
||||
)}
|
||||
<CameraCapture
|
||||
onCapture={handleCapture}
|
||||
buttonLabel={photoId ? "Заменить" : "Снять"}
|
||||
/>
|
||||
{uploading && (
|
||||
<div className="text-xs text-muted-foreground">Загружаю…</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { loadDamageVocabulary, damageTypeRuToEnum } from "@/data/damageVocabulary";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
zoneId: number;
|
||||
zoneLabel: string;
|
||||
pointsCount: number;
|
||||
onCancel: () => void;
|
||||
onSave: (data: { damageType: string; severity: 0 | 1 | 2 }) => void;
|
||||
}
|
||||
|
||||
export function DamageClassifyDialog({ open, zoneId, zoneLabel, pointsCount, onCancel, onSave }: Props) {
|
||||
const [vocab, setVocab] = useState<Awaited<ReturnType<typeof loadDamageVocabulary>> | null>(null);
|
||||
const [damageType, setDamageType] = useState<string | null>(null);
|
||||
const [severity, setSeverity] = useState<0 | 1 | 2>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDamageType(null);
|
||||
setSeverity(0);
|
||||
loadDamageVocabulary().then(setVocab);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Salon zones (44=водительское сидение, 45=пассажирское, 46=задний диван) use salon_selectable; else exterior
|
||||
const isSalon = zoneId === 44 || zoneId === 45 || zoneId === 46;
|
||||
// Фильтруем типы для которых нет соответствия в backend DamageType enum —
|
||||
// иначе backend вернёт 422 на createMarker.
|
||||
const list = vocab
|
||||
? (isSalon ? vocab.salon_selectable : vocab.exterior_selectable).filter(
|
||||
(dt) => damageTypeRuToEnum(dt) != null,
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center p-0 sm:p-4"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md bg-card rounded-t-2xl sm:rounded-2xl shadow-xl flex flex-col max-h-[90vh]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="p-5 border-b">
|
||||
<h2 className="text-lg font-semibold capitalize">{zoneLabel}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Точек: {pointsCount}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="p-5 border-b space-y-3">
|
||||
<Label>Степень повреждения</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{([0, 1, 2] as const).map((idx) => {
|
||||
const label = vocab?.severity_scale.labels[idx] ?? ["Лёгкое", "Среднее", "Тяжёлое"][idx];
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => setSeverity(idx)}
|
||||
className={cn(
|
||||
"flex-1 py-2 px-2 rounded-md text-sm font-medium border transition-colors",
|
||||
severity === idx
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-background text-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-1.5">
|
||||
<Label>Тип повреждения</Label>
|
||||
{!vocab && <div className="text-sm text-muted-foreground">Загружаю…</div>}
|
||||
{vocab && list.map((dt) => (
|
||||
<label
|
||||
key={dt}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer border transition-colors",
|
||||
damageType === dt
|
||||
? "bg-accent border-primary"
|
||||
: "border-transparent hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="damage_type"
|
||||
value={dt}
|
||||
checked={damageType === dt}
|
||||
onChange={() => setDamageType(dt)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="text-sm">{dt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="p-5 border-t flex gap-2">
|
||||
<Button variant="outline" onClick={onCancel} className="flex-1">
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!damageType}
|
||||
onClick={() => damageType && onSave({ damageType, severity })}
|
||||
className="flex-1"
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
photoFile: File;
|
||||
zoneLabel: string;
|
||||
/** Called when user submits. annotated=null если "без линий". */
|
||||
onSubmit: (annotated: Blob | null) => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface Stroke {
|
||||
pts: { x: number; y: number }[]; // in image-space pixels
|
||||
}
|
||||
|
||||
/** Pen-on-photo step: показывает только что снятое фото, даёт обвести
|
||||
* пальцем красным. Flatten в JPEG при submit. Координаты strokes в native
|
||||
* пикселях фото — canvas resize'нут на тот же размер. */
|
||||
export function PhotoAnnotateStep({ photoFile, zoneLabel, onSubmit, onCancel }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
const [strokes, setStrokes] = useState<Stroke[]>([]);
|
||||
const currentStroke = useRef<Stroke | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Load image once
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(photoFile);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
imgRef.current = img;
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
}
|
||||
setImgLoaded(true);
|
||||
URL.revokeObjectURL(url);
|
||||
redraw([]);
|
||||
};
|
||||
img.onerror = () => URL.revokeObjectURL(url);
|
||||
img.src = url;
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [photoFile]);
|
||||
|
||||
function redraw(allStrokes: Stroke[]) {
|
||||
const canvas = canvasRef.current;
|
||||
const img = imgRef.current;
|
||||
if (!canvas || !img) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
// Linewidth scaled to image — на 4К фото 4px тонко смотрится
|
||||
const lw = Math.max(6, Math.round(canvas.width / 250));
|
||||
ctx.strokeStyle = "rgb(220, 38, 38)";
|
||||
ctx.lineWidth = lw;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
for (const s of allStrokes) {
|
||||
if (s.pts.length < 2) {
|
||||
// single tap → dot
|
||||
if (s.pts.length === 1) {
|
||||
ctx.fillStyle = "rgb(220, 38, 38)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.pts[0].x, s.pts[0].y, lw / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s.pts[0].x, s.pts[0].y);
|
||||
for (let i = 1; i < s.pts.length; i++) {
|
||||
ctx.lineTo(s.pts[i].x, s.pts[i].y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-draw whenever strokes change
|
||||
useEffect(() => {
|
||||
if (imgLoaded) redraw(strokes);
|
||||
}, [strokes, imgLoaded]);
|
||||
|
||||
function pointFromEvent(e: React.PointerEvent<HTMLCanvasElement>): { x: number; y: number } | null {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return null;
|
||||
// CSS px → canvas px
|
||||
const x = ((e.clientX - rect.left) / rect.width) * canvas.width;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * canvas.height;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function onPointerDown(e: React.PointerEvent<HTMLCanvasElement>) {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);
|
||||
const pt = pointFromEvent(e);
|
||||
if (!pt) return;
|
||||
currentStroke.current = { pts: [pt] };
|
||||
// Immediate visual feedback: draw a partial stroke without setState
|
||||
drawPartialStroke(currentStroke.current);
|
||||
}
|
||||
|
||||
function onPointerMove(e: React.PointerEvent<HTMLCanvasElement>) {
|
||||
if (!currentStroke.current) return;
|
||||
const pt = pointFromEvent(e);
|
||||
if (!pt) return;
|
||||
currentStroke.current.pts.push(pt);
|
||||
drawPartialStroke(currentStroke.current);
|
||||
}
|
||||
|
||||
function onPointerUp(e: React.PointerEvent<HTMLCanvasElement>) {
|
||||
if (!currentStroke.current) return;
|
||||
try {
|
||||
(e.target as HTMLCanvasElement).releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// ignore — pointer wasn't captured
|
||||
}
|
||||
const finished = currentStroke.current;
|
||||
currentStroke.current = null;
|
||||
if (finished.pts.length > 0) {
|
||||
setStrokes((s) => [...s, finished]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Avoid re-running full redraw on every move — just append last segment. */
|
||||
function drawPartialStroke(s: Stroke) {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || s.pts.length < 2) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const lw = Math.max(6, Math.round(canvas.width / 250));
|
||||
ctx.strokeStyle = "rgb(220, 38, 38)";
|
||||
ctx.lineWidth = lw;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
const a = s.pts[s.pts.length - 2];
|
||||
const b = s.pts[s.pts.length - 1];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setStrokes([]);
|
||||
}
|
||||
|
||||
function undoLast() {
|
||||
setStrokes((s) => s.slice(0, -1));
|
||||
}
|
||||
|
||||
async function finishWithAnnotation() {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, "image/jpeg", 0.85)
|
||||
);
|
||||
await onSubmit(blob);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function finishWithoutAnnotation() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(null);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-background flex flex-col">
|
||||
<header className="p-4 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Обведите повреждение</div>
|
||||
<h2 className="text-lg font-semibold capitalize">{zoneLabel}</h2>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={undoLast}
|
||||
disabled={strokes.length === 0 || submitting}
|
||||
className="text-sm px-3 py-1 border rounded-md disabled:opacity-40"
|
||||
title="Отменить последний штрих"
|
||||
>
|
||||
↶
|
||||
</button>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
disabled={strokes.length === 0 || submitting}
|
||||
className="text-sm px-3 py-1 border rounded-md disabled:opacity-40"
|
||||
title="Очистить"
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-auto p-2 flex items-center justify-center bg-black">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
className="max-w-full max-h-full block"
|
||||
style={{ touchAction: "none", cursor: "crosshair" }}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<footer className="border-t p-3 flex gap-2 bg-card">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className="flex-none"
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={finishWithoutAnnotation}
|
||||
disabled={submitting}
|
||||
className="flex-1"
|
||||
>
|
||||
Без линий
|
||||
</Button>
|
||||
<Button
|
||||
onClick={finishWithAnnotation}
|
||||
disabled={submitting || strokes.length === 0}
|
||||
className="flex-1"
|
||||
>
|
||||
{submitting ? "Сохраняю…" : "Готово"}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
zoneLabel: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ZoneConfirmDialog({ open, zoneLabel, onConfirm, onCancel }: Props) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center p-0 sm:p-4"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm bg-card rounded-t-2xl sm:rounded-2xl shadow-xl p-6 space-y-5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Добавить повреждение</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1 capitalize">
|
||||
{zoneLabel}?
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onCancel} className="flex-1">
|
||||
Нет
|
||||
</Button>
|
||||
<Button onClick={onConfirm} className="flex-1">
|
||||
Да
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ZoneBBox {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
rotation?: number; // degrees, 0 / 90 / -90 / 180
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
zoneId: number;
|
||||
zoneLabel: string;
|
||||
onConfirm: (points: Point[]) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const PADDING = 0.1;
|
||||
const COMPOSITE_W = 827;
|
||||
const COMPOSITE_H = 1209;
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const POINT_RADIUS = 18; // user-space units; visible on zoomed views
|
||||
|
||||
let bboxCache: Record<string, ZoneBBox> | null = null;
|
||||
|
||||
async function loadBBoxes(): Promise<Record<string, ZoneBBox>> {
|
||||
if (bboxCache) return bboxCache;
|
||||
const res = await fetch("/data/zone_bboxes.json");
|
||||
bboxCache = await res.json();
|
||||
return bboxCache!;
|
||||
}
|
||||
|
||||
function paddedViewBox(b: ZoneBBox): string {
|
||||
const rotation = b.rotation ?? 0;
|
||||
const cx = b.x + b.w / 2;
|
||||
const cy = b.y + b.h / 2;
|
||||
let w = b.w;
|
||||
let h = b.h;
|
||||
if (rotation === 90 || rotation === -90) {
|
||||
w = b.h;
|
||||
h = b.w;
|
||||
}
|
||||
const padX = w * PADDING;
|
||||
const padY = h * PADDING;
|
||||
const minX = cx - w / 2 - padX;
|
||||
const minY = cy - h / 2 - padY;
|
||||
return `${minX} ${minY} ${w + 2 * padX} ${h + 2 * padY}`;
|
||||
}
|
||||
|
||||
function rotationTransform(b: ZoneBBox): string | null {
|
||||
const rotation = b.rotation ?? 0;
|
||||
if (rotation === 0) return null;
|
||||
const cx = b.x + b.w / 2;
|
||||
const cy = b.y + b.h / 2;
|
||||
return `rotate(${rotation} ${cx} ${cy})`;
|
||||
}
|
||||
|
||||
export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [svgText, setSvgText] = useState<string | null>(null);
|
||||
const [points, setPoints] = useState<Point[]>([]);
|
||||
|
||||
// Load SVG + bboxes
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPoints([]);
|
||||
setSvgText(null);
|
||||
let cancelled = false;
|
||||
|
||||
Promise.all([
|
||||
fetch("/scheme/exterior.svg").then((r) => r.text()),
|
||||
loadBBoxes(),
|
||||
]).then(([text, bboxes]) => {
|
||||
if (cancelled) return;
|
||||
const b = bboxes[String(zoneId)];
|
||||
const finalVB = b ? paddedViewBox(b) : `0 0 ${COMPOSITE_W} ${COMPOSITE_H}`;
|
||||
const rotXform = b ? rotationTransform(b) : null;
|
||||
|
||||
let mutated = text;
|
||||
mutated = mutated.replace(/<script[\s\S]*?<\/script>/gi, "");
|
||||
mutated = mutated.replace(/(<svg\b[^>]*?)\sviewBox\s*=\s*"[^"]*"/i, `$1 viewBox="${finalVB}"`);
|
||||
mutated = mutated.replace(
|
||||
/(<svg\b[^>]*?)\s(?:width|height)\s*=\s*"[^"]*"/gi,
|
||||
"$1"
|
||||
);
|
||||
mutated = mutated.replace(
|
||||
/<svg\b/i,
|
||||
`<svg width="100%" height="100%" preserveAspectRatio="xMidYMid meet"`
|
||||
);
|
||||
// Wrap inner content in rotated <g> so visible orientation is correct.
|
||||
// We always wrap (even with identity rotation) so the point-overlay <g>
|
||||
// append target is consistent.
|
||||
const openMatch = mutated.match(/<svg\b[^>]*?>/i);
|
||||
const closeIdx = mutated.lastIndexOf("</svg>");
|
||||
if (openMatch && closeIdx > -1) {
|
||||
const openEnd = (openMatch.index ?? 0) + openMatch[0].length;
|
||||
const before = mutated.slice(0, openEnd);
|
||||
const inner = mutated.slice(openEnd, closeIdx);
|
||||
const after = mutated.slice(closeIdx);
|
||||
const xform = rotXform ? ` transform="${rotXform}"` : "";
|
||||
mutated = `${before}<g data-rot="1"${xform}>${inner}</g>${after}`;
|
||||
}
|
||||
|
||||
setSvgText(mutated);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [open, zoneId]);
|
||||
|
||||
// After SVG mounts, apply fill highlights
|
||||
useEffect(() => {
|
||||
if (!svgText) return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
||||
if (!svg) return;
|
||||
|
||||
svg.querySelectorAll("path[class]").forEach((p) => {
|
||||
const cls = p.getAttribute("class") ?? "";
|
||||
if (cls === String(zoneId)) {
|
||||
p.setAttribute("fill", "rgba(239, 68, 68, 0.18)");
|
||||
p.setAttribute("stroke", "rgb(239, 68, 68)");
|
||||
p.setAttribute("stroke-width", "2");
|
||||
} else {
|
||||
p.setAttribute("fill", "rgba(0,0,0,0.04)");
|
||||
p.setAttribute("stroke", "rgba(0,0,0,0.25)");
|
||||
}
|
||||
(p as SVGElement).style.pointerEvents = "none";
|
||||
});
|
||||
svg.querySelectorAll("path:not([class])").forEach((p) => {
|
||||
(p as SVGElement).style.opacity = "0.4";
|
||||
(p as SVGElement).style.pointerEvents = "none";
|
||||
});
|
||||
}, [svgText, zoneId]);
|
||||
|
||||
// Imperatively render points as SVG circles inside the rotated <g>.
|
||||
// This guarantees they sit at the exact same place as the SVG content,
|
||||
// regardless of letterboxing / aspect-ratio.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
||||
if (!svg) return;
|
||||
const rotGroup = svg.querySelector("g[data-rot]") as SVGGElement | null;
|
||||
if (!rotGroup) return;
|
||||
|
||||
// Remove existing overlay group if any
|
||||
const existing = rotGroup.querySelector("g[data-points]");
|
||||
if (existing) existing.remove();
|
||||
|
||||
// Build new overlay group
|
||||
const group = document.createElementNS(SVG_NS, "g");
|
||||
group.setAttribute("data-points", "1");
|
||||
|
||||
points.forEach((p, i) => {
|
||||
const cx = p.x * COMPOSITE_W;
|
||||
const cy = p.y * COMPOSITE_H;
|
||||
const circle = document.createElementNS(SVG_NS, "circle");
|
||||
circle.setAttribute("cx", String(cx));
|
||||
circle.setAttribute("cy", String(cy));
|
||||
circle.setAttribute("r", String(POINT_RADIUS));
|
||||
circle.setAttribute("fill", "rgb(239, 68, 68)");
|
||||
circle.setAttribute("stroke", "white");
|
||||
circle.setAttribute("stroke-width", "3");
|
||||
circle.setAttribute("data-point-index", String(i));
|
||||
(circle as SVGElement).style.cursor = "pointer";
|
||||
group.appendChild(circle);
|
||||
|
||||
const text = document.createElementNS(SVG_NS, "text");
|
||||
text.setAttribute("x", String(cx));
|
||||
text.setAttribute("y", String(cy));
|
||||
text.setAttribute("text-anchor", "middle");
|
||||
text.setAttribute("dominant-baseline", "central");
|
||||
text.setAttribute("fill", "white");
|
||||
text.setAttribute("font-size", "20");
|
||||
text.setAttribute("font-weight", "bold");
|
||||
text.setAttribute("data-point-label", String(i));
|
||||
(text as SVGElement).style.pointerEvents = "none";
|
||||
(text as SVGElement).style.userSelect = "none";
|
||||
text.textContent = String(i + 1);
|
||||
group.appendChild(text);
|
||||
});
|
||||
|
||||
rotGroup.appendChild(group);
|
||||
}, [svgText, points]);
|
||||
|
||||
function handleTap(e: React.MouseEvent<HTMLDivElement>) {
|
||||
// If user clicked an existing point circle, remove it (if it's the last one)
|
||||
const target = e.target as Element;
|
||||
const pointIdxAttr = target.getAttribute?.("data-point-index");
|
||||
if (pointIdxAttr != null) {
|
||||
const idx = Number(pointIdxAttr);
|
||||
if (idx === points.length - 1) {
|
||||
setPoints((p) => p.slice(0, -1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
||||
if (!svg) return;
|
||||
|
||||
// getScreenCTM gives matrix from SVG root user-space to screen pixels.
|
||||
// It does NOT include inner <g> transforms — so svgPt is in the rotated viewBox frame.
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = e.clientX;
|
||||
pt.y = e.clientY;
|
||||
const ctm = svg.getScreenCTM();
|
||||
if (!ctm) return;
|
||||
const svgPt = pt.matrixTransform(ctm.inverse());
|
||||
|
||||
// Convert from the visible (rotated) frame back to the original composite frame
|
||||
// so storage is in a canonical coord system.
|
||||
const bboxes = bboxCache;
|
||||
const b = bboxes?.[String(zoneId)] ?? null;
|
||||
const original = b
|
||||
? inverseRotateAroundBboxCenter({ x: svgPt.x, y: svgPt.y }, b)
|
||||
: { x: svgPt.x, y: svgPt.y };
|
||||
|
||||
const xN = Math.max(0, Math.min(1, original.x / COMPOSITE_W));
|
||||
const yN = Math.max(0, Math.min(1, original.y / COMPOSITE_H));
|
||||
setPoints((prev) => [...prev, { x: xN, y: yN }]);
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-background flex flex-col">
|
||||
<header className="flex items-center justify-between p-4 border-b">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Зона</div>
|
||||
<h2 className="text-lg font-semibold capitalize">{zoneLabel}</h2>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Точек: <span className="font-semibold text-foreground">{points.length}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-hidden relative bg-card">
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={handleTap}
|
||||
className="w-full h-full"
|
||||
style={{ touchAction: "manipulation" }}
|
||||
dangerouslySetInnerHTML={svgText ? { __html: svgText } : undefined}
|
||||
/>
|
||||
{!svgText && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground pointer-events-none">
|
||||
Загружаю схему…
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="border-t p-3 flex items-center gap-3 bg-card">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border border-destructive text-destructive hover:bg-destructive/10 active:bg-destructive/20 transition-colors font-semibold"
|
||||
aria-label="Отмена"
|
||||
>
|
||||
<span className="text-2xl leading-none">✕</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">✓</span>
|
||||
<span>Готово ({points.length})</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Convert tap point from rotated/visible viewBox frame back to original composite frame. */
|
||||
function inverseRotateAroundBboxCenter(
|
||||
pt: { x: number; y: number },
|
||||
b: ZoneBBox
|
||||
): { x: number; y: number } {
|
||||
const rot = b.rotation ?? 0;
|
||||
if (rot === 0) return pt;
|
||||
const cx = b.x + b.w / 2;
|
||||
const cy = b.y + b.h / 2;
|
||||
const dx = pt.x - cx;
|
||||
const dy = pt.y - cy;
|
||||
const rad = (-rot * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
return {
|
||||
x: cx + dx * cos - dy * sin,
|
||||
y: cy + dx * sin + dy * cos,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { CameraCapture } from "@/components/camera/CameraCapture";
|
||||
import { AuthImg } from "@/components/AuthImg";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { uploadPhoto } from "@/api/photos";
|
||||
import { createMarker, type MarkerSummary } from "@/api/inspections";
|
||||
|
||||
const WEAR_LEVELS: { value: string; label: string; description: string }[] = [
|
||||
{
|
||||
value: "cosmetic",
|
||||
label: "Новая",
|
||||
description: "Протектор полный, без признаков износа",
|
||||
},
|
||||
{
|
||||
value: "minor",
|
||||
label: "Норма",
|
||||
description: "Естественный износ, эксплуатация в норме",
|
||||
},
|
||||
{
|
||||
value: "moderate",
|
||||
label: "Износ",
|
||||
description: "Заметный износ, требует наблюдения",
|
||||
},
|
||||
{
|
||||
value: "severe",
|
||||
label: "Менять",
|
||||
description: "Критический износ, замена необходима",
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
inspectionId: number;
|
||||
onClose: () => void;
|
||||
onCreated: (marker: MarkerSummary) => void;
|
||||
}
|
||||
|
||||
export function TireWearDialog({
|
||||
open,
|
||||
inspectionId,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: Props) {
|
||||
const [level, setLevel] = useState<string>("minor");
|
||||
const [description, setDescription] = useState("");
|
||||
const [photoId, setPhotoId] = useState<number | undefined>(undefined);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
async function handlePhoto(file: File) {
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await uploadPhoto(inspectionId, "free", 0, file);
|
||||
setPhotoId(result.id);
|
||||
toast.success("Фото загружено");
|
||||
} catch {
|
||||
toast.error("Ошибка загрузки фото");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () =>
|
||||
createMarker(inspectionId, {
|
||||
side: "tires",
|
||||
severity: level,
|
||||
// "not-working" is the closest existing damage_type enum value.
|
||||
// The semantically meaningful field here is `severity` (wear level).
|
||||
damage_type: "not-working",
|
||||
description:
|
||||
description.trim() ||
|
||||
`Состояние резины: ${WEAR_LEVELS.find((w) => w.value === level)?.label ?? level}`,
|
||||
photo_id: photoId,
|
||||
x: null,
|
||||
y: null,
|
||||
}),
|
||||
onSuccess: (marker) => {
|
||||
onCreated(marker);
|
||||
reset();
|
||||
},
|
||||
onError: () => toast.error("Не удалось сохранить"),
|
||||
});
|
||||
|
||||
function reset() {
|
||||
setLevel("minor");
|
||||
setDescription("");
|
||||
setPhotoId(undefined);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center p-0 sm:p-4"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md bg-card rounded-t-2xl sm:rounded-2xl shadow-xl p-5 space-y-4 max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Состояние резины</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="text-muted-foreground hover:text-foreground text-2xl leading-none"
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Wear level — radio cards for large touch targets */}
|
||||
<div className="space-y-2">
|
||||
<Label>Уровень износа</Label>
|
||||
<div className="space-y-2">
|
||||
{WEAR_LEVELS.map((w) => (
|
||||
<label
|
||||
key={w.value}
|
||||
className={
|
||||
"flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors " +
|
||||
(level === w.value
|
||||
? "border-primary bg-accent"
|
||||
: "border-input hover:bg-accent/50")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="tire_wear"
|
||||
value={w.value}
|
||||
checked={level === w.value}
|
||||
onChange={() => setLevel(w.value)}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-sm">{w.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{w.description}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional comment */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tire_comment">Комментарий (опционально)</Label>
|
||||
<Input
|
||||
id="tire_comment"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Например: глубина протектора ~3 мм"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Optional photo */}
|
||||
<div className="space-y-2">
|
||||
<Label>Фото (опционально)</Label>
|
||||
{photoId ? (
|
||||
<div className="space-y-2">
|
||||
<AuthImg
|
||||
src={`photos/${photoId}/thumb`}
|
||||
alt="Фото резины"
|
||||
className="w-full aspect-[4/3] object-cover rounded bg-muted"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPhotoId(undefined)}
|
||||
className="w-full"
|
||||
>
|
||||
Удалить фото
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<CameraCapture
|
||||
onCapture={handlePhoto}
|
||||
buttonLabel={uploading ? "Загружаю…" : "Сделать фото"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="flex-1"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={createMut.isPending || uploading}
|
||||
onClick={() => createMut.mutate()}
|
||||
className="flex-1"
|
||||
>
|
||||
{createMut.isPending ? "Сохраняю…" : "Сохранить"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface DamageOverlay {
|
||||
zoneId: number;
|
||||
points: Point[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
damagedZoneIds: number[];
|
||||
/** Координаты точек повреждений в композитной SVG-системе (827×1209). */
|
||||
damages?: DamageOverlay[];
|
||||
onZoneClick?: (zoneId: number) => void;
|
||||
}
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const POINT_RADIUS = 9;
|
||||
|
||||
/** Композитная SVG-схема экстерьера машины с подсветкой зон + точками
|
||||
* повреждений. Координаты точек в системе 827×1209 (та же что у vendor). */
|
||||
export function TtDamageMap({ damagedZoneIds, damages, onZoneClick }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [svgText, setSvgText] = useState<string | null>(null);
|
||||
const damagedSet = new Set(damagedZoneIds.map(String));
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch("/scheme/exterior.svg")
|
||||
.then((r) => r.text())
|
||||
.then((text) => {
|
||||
if (cancelled) return;
|
||||
let mut = text;
|
||||
mut = mut.replace(/<script[\s\S]*?<\/script>/gi, "");
|
||||
mut = mut.replace(
|
||||
/(<svg\b[^>]*?)\s(?:width|height)\s*=\s*"[^"]*"/gi,
|
||||
"$1"
|
||||
);
|
||||
mut = mut.replace(
|
||||
/<svg\b/i,
|
||||
`<svg width="100%" height="100%" preserveAspectRatio="xMidYMid meet"`
|
||||
);
|
||||
setSvgText(mut);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Tinting + cursors
|
||||
useEffect(() => {
|
||||
if (!svgText) return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
||||
if (!svg) return;
|
||||
|
||||
svg.querySelectorAll("path[class]").forEach((p) => {
|
||||
const cls = p.getAttribute("class") ?? "";
|
||||
if (damagedSet.has(cls)) {
|
||||
p.setAttribute("fill", "rgba(239, 68, 68, 0.35)");
|
||||
p.setAttribute("stroke", "rgb(239, 68, 68)");
|
||||
p.setAttribute("stroke-width", "2");
|
||||
(p as SVGElement).style.cursor = onZoneClick ? "pointer" : "default";
|
||||
} else {
|
||||
p.setAttribute("fill", "rgba(0,0,0,0.04)");
|
||||
p.setAttribute("stroke", "rgba(0,0,0,0.25)");
|
||||
p.setAttribute("stroke-width", "0.5");
|
||||
(p as SVGElement).style.cursor = "default";
|
||||
}
|
||||
});
|
||||
svg.querySelectorAll("path:not([class])").forEach((p) => {
|
||||
(p as SVGElement).style.opacity = "0.4";
|
||||
(p as SVGElement).style.pointerEvents = "none";
|
||||
});
|
||||
}, [svgText, damagedSet, onZoneClick]);
|
||||
|
||||
// Point overlay — рисуем поверх SVG в его user-space (827×1209) чтобы
|
||||
// точки идеально совмещались с подсвеченными зонами при любом масштабе.
|
||||
useEffect(() => {
|
||||
if (!svgText) return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
||||
if (!svg) return;
|
||||
|
||||
const existing = svg.querySelector("g[data-tt-damage-points]");
|
||||
if (existing) existing.remove();
|
||||
|
||||
if (!damages || damages.length === 0) return;
|
||||
|
||||
const group = document.createElementNS(SVG_NS, "g");
|
||||
group.setAttribute("data-tt-damage-points", "1");
|
||||
|
||||
let counter = 1;
|
||||
for (const dmg of damages) {
|
||||
for (const pt of dmg.points || []) {
|
||||
if (
|
||||
typeof pt.x !== "number" ||
|
||||
typeof pt.y !== "number" ||
|
||||
!Number.isFinite(pt.x) ||
|
||||
!Number.isFinite(pt.y)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const circle = document.createElementNS(SVG_NS, "circle");
|
||||
circle.setAttribute("cx", String(pt.x));
|
||||
circle.setAttribute("cy", String(pt.y));
|
||||
circle.setAttribute("r", String(POINT_RADIUS));
|
||||
circle.setAttribute("fill", "rgb(220, 38, 38)");
|
||||
circle.setAttribute("stroke", "white");
|
||||
circle.setAttribute("stroke-width", "2");
|
||||
(circle as SVGElement).style.pointerEvents = "none";
|
||||
group.appendChild(circle);
|
||||
|
||||
const text = document.createElementNS(SVG_NS, "text");
|
||||
text.setAttribute("x", String(pt.x));
|
||||
text.setAttribute("y", String(pt.y));
|
||||
text.setAttribute("text-anchor", "middle");
|
||||
text.setAttribute("dominant-baseline", "central");
|
||||
text.setAttribute("fill", "white");
|
||||
text.setAttribute("font-size", "11");
|
||||
text.setAttribute("font-weight", "bold");
|
||||
(text as SVGElement).style.pointerEvents = "none";
|
||||
(text as SVGElement).style.userSelect = "none";
|
||||
text.textContent = String(counter);
|
||||
group.appendChild(text);
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
svg.appendChild(group);
|
||||
}, [svgText, damages]);
|
||||
|
||||
function handleClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||
if (!onZoneClick) return;
|
||||
const target = e.target as Element;
|
||||
const cls = target.getAttribute?.("class");
|
||||
if (cls && damagedSet.has(cls)) {
|
||||
const zoneId = Number(cls);
|
||||
if (Number.isFinite(zoneId)) onZoneClick(zoneId);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={handleClick}
|
||||
className="w-full aspect-[827/1209] max-h-[60vh] mx-auto bg-card rounded-lg overflow-hidden"
|
||||
style={{ touchAction: "manipulation" }}
|
||||
dangerouslySetInnerHTML={svgText ? { __html: svgText } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Toaster as SonnerToaster } from "sonner";
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof SonnerToaster>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<SonnerToaster
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import exteriorSvgUrl from "/scheme/exterior.svg?url";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Zone color states
|
||||
const COLOR_DEFAULT_FILL = "rgba(0, 0, 0, 0)"; // transparent
|
||||
const COLOR_HOVER_FILL = "rgba(59, 130, 246, 0.20)"; // soft blue
|
||||
const COLOR_HAS_DAMAGE_FILL = "rgba(239, 68, 68, 0.30)"; // red tint for zones with active damage
|
||||
const COLOR_RESOLVED_FILL = "rgba(156, 163, 175, 0.30)"; // gray for resolved-only zones
|
||||
|
||||
export interface SchemeMarker {
|
||||
id: number;
|
||||
side?: string | null;
|
||||
damage_type?: string | null;
|
||||
severity?: string | null;
|
||||
resolved: boolean;
|
||||
/** Координаты точек повреждения в композитной SVG-системе (827×1209). */
|
||||
polygon?: { x: number; y: number }[] | null;
|
||||
}
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const POINT_RADIUS = 9;
|
||||
const POINT_FILL_ACTIVE = "rgb(220, 38, 38)";
|
||||
const POINT_FILL_RESOLVED = "rgb(107, 114, 128)";
|
||||
const COMPOSITE_W = 827;
|
||||
const COMPOSITE_H = 1209;
|
||||
|
||||
interface Props {
|
||||
markers: SchemeMarker[];
|
||||
onZoneTap?: (zoneId: number) => void;
|
||||
onTireWearClick?: () => void;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
/** Parse "zone-23" -> 23, anything else -> null */
|
||||
function parseZoneId(side: string | null | undefined): number | null {
|
||||
if (!side) return null;
|
||||
const m = /^zone-(\d+)$/.exec(side);
|
||||
if (!m) return null;
|
||||
const n = Number(m[1]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export function VendorVehicleScheme({
|
||||
markers,
|
||||
onZoneTap,
|
||||
onTireWearClick,
|
||||
editable = true,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [svgText, setSvgText] = useState<string | null>(null);
|
||||
const [hoverZone, setHoverZone] = useState<number | null>(null);
|
||||
|
||||
// Load SVG once from public/scheme/exterior.svg as a separate asset
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch(exteriorSvgUrl)
|
||||
.then((r) => r.text())
|
||||
.then((t) => {
|
||||
if (!cancelled) setSvgText(t);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSvgText("<!-- failed to load -->");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Count damage per zone, separately track has-any vs has-only-resolved
|
||||
const zoneStats = useMemo(() => {
|
||||
const stats = new Map<number, { count: number; activeCount: number }>();
|
||||
for (const m of markers) {
|
||||
const z = parseZoneId(m.side);
|
||||
if (z == null) continue;
|
||||
const s = stats.get(z) ?? { count: 0, activeCount: 0 };
|
||||
s.count += 1;
|
||||
if (!m.resolved) s.activeCount += 1;
|
||||
stats.set(z, s);
|
||||
}
|
||||
return stats;
|
||||
}, [markers]);
|
||||
|
||||
const tireCount = markers.filter((m) => m.side === "tires").length;
|
||||
|
||||
// Apply zone fills after SVG renders or when hover/damage state changes
|
||||
useEffect(() => {
|
||||
if (!svgText) return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const svg = el.querySelector("svg");
|
||||
if (!svg) return;
|
||||
|
||||
// Strip any inline <script> the vendor included
|
||||
svg.querySelectorAll("script").forEach((s) => s.remove());
|
||||
|
||||
// Make the SVG fill its container
|
||||
svg.setAttribute("width", "100%");
|
||||
svg.setAttribute("height", "100%");
|
||||
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||||
|
||||
// Reset all numeric-class paths to appropriate fill
|
||||
const all = svg.querySelectorAll("path[class]");
|
||||
all.forEach((p) => {
|
||||
const cls = p.getAttribute("class") ?? "";
|
||||
const zid = /^\d+$/.test(cls) ? Number(cls) : null;
|
||||
if (zid == null) return;
|
||||
|
||||
const s = zoneStats.get(zid);
|
||||
let fill = COLOR_DEFAULT_FILL;
|
||||
if (s) {
|
||||
fill = s.activeCount > 0 ? COLOR_HAS_DAMAGE_FILL : COLOR_RESOLVED_FILL;
|
||||
}
|
||||
if (hoverZone === zid && editable) fill = COLOR_HOVER_FILL;
|
||||
|
||||
p.setAttribute("fill", fill);
|
||||
(p as SVGElement).style.cursor = editable ? "pointer" : "default";
|
||||
(p as SVGElement).style.transition = "fill 0.15s";
|
||||
});
|
||||
}, [svgText, zoneStats, hoverZone, editable]);
|
||||
|
||||
// Overlay точек повреждений (multi-point координаты в композитной 827×1209)
|
||||
useEffect(() => {
|
||||
if (!svgText) return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
||||
if (!svg) return;
|
||||
|
||||
// Удаляем предыдущий overlay
|
||||
const existing = svg.querySelector("g[data-damage-points]");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const group = document.createElementNS(SVG_NS, "g");
|
||||
group.setAttribute("data-damage-points", "1");
|
||||
|
||||
let counter = 1;
|
||||
for (const m of markers) {
|
||||
const pts = m.polygon || [];
|
||||
if (pts.length === 0) continue;
|
||||
const fill = m.resolved ? POINT_FILL_RESOLVED : POINT_FILL_ACTIVE;
|
||||
|
||||
for (const pt of pts) {
|
||||
if (
|
||||
typeof pt.x !== "number" ||
|
||||
typeof pt.y !== "number" ||
|
||||
!Number.isFinite(pt.x) ||
|
||||
!Number.isFinite(pt.y)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// Polygon хранится в normalised 0..1 (см. ZoneDetailView). SVG viewBox
|
||||
// в композитной 827×1209, поэтому умножаем перед отрисовкой.
|
||||
const cx = pt.x * COMPOSITE_W;
|
||||
const cy = pt.y * COMPOSITE_H;
|
||||
const circle = document.createElementNS(SVG_NS, "circle");
|
||||
circle.setAttribute("cx", String(cx));
|
||||
circle.setAttribute("cy", String(cy));
|
||||
circle.setAttribute("r", String(POINT_RADIUS));
|
||||
circle.setAttribute("fill", fill);
|
||||
circle.setAttribute("stroke", "white");
|
||||
circle.setAttribute("stroke-width", "2");
|
||||
(circle as SVGElement).style.pointerEvents = "none";
|
||||
group.appendChild(circle);
|
||||
|
||||
const text = document.createElementNS(SVG_NS, "text");
|
||||
text.setAttribute("x", String(cx));
|
||||
text.setAttribute("y", String(cy));
|
||||
text.setAttribute("text-anchor", "middle");
|
||||
text.setAttribute("dominant-baseline", "central");
|
||||
text.setAttribute("fill", "white");
|
||||
text.setAttribute("font-size", "11");
|
||||
text.setAttribute("font-weight", "bold");
|
||||
(text as SVGElement).style.pointerEvents = "none";
|
||||
(text as SVGElement).style.userSelect = "none";
|
||||
text.textContent = String(counter);
|
||||
group.appendChild(text);
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
svg.appendChild(group);
|
||||
}, [svgText, markers]);
|
||||
|
||||
// Walk up from event.target to find a path[class="N"] (numeric zone id)
|
||||
function zoneFromEvent(e: React.MouseEvent | React.TouchEvent): number | null {
|
||||
let node = e.target as Element | null;
|
||||
while (node && node !== e.currentTarget) {
|
||||
if (node.tagName?.toLowerCase() === "path") {
|
||||
const cls = node.getAttribute("class") ?? "";
|
||||
if (/^\d+$/.test(cls)) return Number(cls);
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||
if (!editable) return;
|
||||
const z = zoneFromEvent(e);
|
||||
if (z != null) onZoneTap?.(z);
|
||||
}
|
||||
|
||||
function handleMove(e: React.MouseEvent<HTMLDivElement>) {
|
||||
if (!editable) return;
|
||||
setHoverZone(zoneFromEvent(e));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* SVG container — aspect ratio matches vendor viewBox 827×1209 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={handleClick}
|
||||
onMouseMove={handleMove}
|
||||
onMouseLeave={() => setHoverZone(null)}
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-md bg-card border rounded-lg p-2 aspect-[827/1209]",
|
||||
"overflow-hidden",
|
||||
)}
|
||||
style={{ touchAction: "manipulation" }}
|
||||
dangerouslySetInnerHTML={svgText ? { __html: svgText } : undefined}
|
||||
/>
|
||||
|
||||
{/* Summary line: count of zones with damage */}
|
||||
{zoneStats.size > 0 && (
|
||||
<div className="mx-auto max-w-md text-xs text-muted-foreground text-center">
|
||||
Зон с повреждениями:{" "}
|
||||
<span className="font-semibold text-foreground">{zoneStats.size}</span>
|
||||
{" · "}
|
||||
Всего меток:{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{markers.filter((m) => parseZoneId(m.side) != null).length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global tire-wear button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTireWearClick}
|
||||
disabled={!editable && !onTireWearClick}
|
||||
className="w-full max-w-md mx-auto flex items-center justify-between gap-3 rounded-lg border bg-card px-4 py-3 hover:bg-accent transition-colors text-sm font-medium"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xl">⊙</span>
|
||||
<span>Состояние резины</span>
|
||||
</span>
|
||||
{tireCount > 0 && (
|
||||
<span className="bg-destructive text-destructive-foreground text-xs font-bold rounded-full px-2 py-0.5">
|
||||
{tireCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{editable && (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Тапните по зоне машины, чтобы добавить метку повреждения.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export interface DamageVocabulary {
|
||||
damage_types_ordered: string[];
|
||||
exterior_selectable: string[];
|
||||
salon_selectable: string[];
|
||||
severity_scale: {
|
||||
positions: number;
|
||||
labels: string[];
|
||||
values: number[];
|
||||
default: string;
|
||||
};
|
||||
}
|
||||
|
||||
let cache: DamageVocabulary | null = null;
|
||||
|
||||
export async function loadDamageVocabulary(): Promise<DamageVocabulary> {
|
||||
if (cache) return cache;
|
||||
const res = await fetch("/data/damage_vocabulary.json");
|
||||
const data = await res.json();
|
||||
cache = {
|
||||
damage_types_ordered: data.damage_types_ordered,
|
||||
exterior_selectable: data.exterior_selectable,
|
||||
salon_selectable: data.salon_selectable,
|
||||
severity_scale: data.severity_scale,
|
||||
};
|
||||
return cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping русского названия из damage_vocabulary.json в DamageType enum
|
||||
* нашего backend'а (см. mechanic_schemas.DamageType Literal).
|
||||
* Возвращает null для типов которым нет точного соответствия —
|
||||
* вызывающий код должен такие отфильтровать из UI.
|
||||
*/
|
||||
export const DAMAGE_RU_TO_ENUM: Record<string, string | null> = {
|
||||
"Вмятина": "dent",
|
||||
"Царапина": "scratch",
|
||||
"Трещина": "crack",
|
||||
"Повреждено": "damaged",
|
||||
"Скол": "chip",
|
||||
"Отсутствует": "missing",
|
||||
"Прожжено": "burn",
|
||||
"Погнуто": "bent",
|
||||
"Требуется мойка": null,
|
||||
"Не работает": "not-working",
|
||||
"Порез": "cut",
|
||||
"Прокол": "puncture",
|
||||
"Порвано": "torn",
|
||||
"Пятна": "stain",
|
||||
"Грязный салон": "stain",
|
||||
"Запах табака": null,
|
||||
"Повреждение салонных ковриков": "damaged",
|
||||
"Повреждение обшивки дверей": "damaged",
|
||||
"Сломанные ручки": "damaged",
|
||||
"Установлено": "removed",
|
||||
"Снято": "removed",
|
||||
"Контроль": null,
|
||||
"Требуется химчистка": "stain",
|
||||
"Затертость": "scuff",
|
||||
"Грыжа": "bulge",
|
||||
"Штат": null,
|
||||
};
|
||||
|
||||
export function damageTypeRuToEnum(ru: string): string | null {
|
||||
return DAMAGE_RU_TO_ENUM[ru] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
let cache: Record<string, string> | null = null;
|
||||
|
||||
export async function loadZoneLabels(): Promise<Record<string, string>> {
|
||||
if (cache) return cache;
|
||||
const res = await fetch("/data/zone_labels.json");
|
||||
const data = await res.json();
|
||||
// The JSON has a "_meta" key + numeric string keys → filter to numeric only
|
||||
cache = Object.fromEntries(
|
||||
Object.entries(data).filter(([k]) => /^\d+$/.test(k))
|
||||
) as Record<string, string>;
|
||||
return cache;
|
||||
}
|
||||
|
||||
export function getZoneLabelSync(zoneId: number, labels: Record<string, string> | null): string {
|
||||
return labels?.[String(zoneId)] ?? `зона ${zoneId}`;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Фоновая сверка прав механика раз в 5 минут (У35).
|
||||
*
|
||||
* Если за время активной сессии админ снял `can_login_pwa` или вывел
|
||||
* пользователя из отдела механиков (или потерял `is_admin`), сервер
|
||||
* на ближайшем `/me` вернёт 403 с `code: "NO_PWA_ACCESS"`. В этом
|
||||
* случае хук:
|
||||
* 1) Сбрасывает access-токен (через `useAuth.setToken(null)`) —
|
||||
* `client.ts` перехватчик 401 не сработает, но мы делаем то же
|
||||
* сами явно.
|
||||
* 2) Кладёт информацию о ревокации в sessionStorage, чтобы экран
|
||||
* Login смог отрендерить корректное сообщение
|
||||
* «Ваш доступ отозван» (с кликабельным контактом саппорта).
|
||||
* 3) **Не трогает IndexedDB-черновики** — они переживают сброс
|
||||
* токена (У35). Если доступ вернут — механик при следующем
|
||||
* входе увидит «Продолжить незавершённый ремонт» (У18).
|
||||
*
|
||||
* Параметр интервала задан в файле и при необходимости подкручивается
|
||||
* с одного места.
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { HTTPError } from "ky";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { getMe, type Me } from "@/api/me";
|
||||
|
||||
export const SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 минут (У35)
|
||||
|
||||
export interface RevocationInfo {
|
||||
reason: "NO_PWA_ACCESS";
|
||||
support: {
|
||||
tg_username: string | null;
|
||||
phone: string | null;
|
||||
};
|
||||
at: string; // ISO timestamp
|
||||
}
|
||||
|
||||
export const REVOCATION_STORAGE_KEY = "mechanic-pwa.revocation";
|
||||
|
||||
function storeRevocation(detail: unknown): void {
|
||||
// backend кладёт detail = { code, support }
|
||||
const d = detail as { code?: string; support?: RevocationInfo["support"] } | null | undefined;
|
||||
if (!d || d.code !== "NO_PWA_ACCESS") return;
|
||||
const payload: RevocationInfo = {
|
||||
reason: "NO_PWA_ACCESS",
|
||||
support: d.support ?? { tg_username: null, phone: null },
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
sessionStorage.setItem(REVOCATION_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch {
|
||||
/* sessionStorage недоступен (приватный режим?) — silent */
|
||||
}
|
||||
}
|
||||
|
||||
export function readRevocation(): RevocationInfo | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(REVOCATION_STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as RevocationInfo) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearRevocation(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(REVOCATION_STORAGE_KEY);
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключить к корневому компоненту PWA, чтобы фоновая сверка работала
|
||||
* пока приложение открыто. Без зависимостей — стартует один раз
|
||||
* на mount и завершается на unmount.
|
||||
*/
|
||||
export function useMeSync(): void {
|
||||
const token = useAuth((s) => s.token);
|
||||
const setToken = useAuth((s) => s.setToken);
|
||||
const tickInFlight = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return; // нечего сверять
|
||||
|
||||
const tick = async () => {
|
||||
if (tickInFlight.current) return;
|
||||
tickInFlight.current = true;
|
||||
try {
|
||||
const _me: Me = await getMe();
|
||||
void _me;
|
||||
} catch (err) {
|
||||
if (err instanceof HTTPError && err.response.status === 403) {
|
||||
// backend FastAPI кладёт detail в JSON
|
||||
let detail: unknown = null;
|
||||
try {
|
||||
const body = (await err.response.json()) as { detail?: unknown };
|
||||
detail = body?.detail ?? null;
|
||||
} catch {
|
||||
/* пустое или не-JSON тело */
|
||||
}
|
||||
storeRevocation(detail);
|
||||
setToken(null);
|
||||
}
|
||||
// 401 ловится в beforeError client.ts автоматически.
|
||||
} finally {
|
||||
tickInFlight.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => { void tick(); };
|
||||
document.addEventListener("visibilitychange", handleFocus);
|
||||
const id = window.setInterval(tick, SYNC_INTERVAL_MS);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", handleFocus);
|
||||
};
|
||||
}, [token, setToken]);
|
||||
}
|
||||
|
||||
/** Вспомогательная функция для use в Login.tsx — единичная проверка после login. */
|
||||
export { storeRevocation };
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Хук ленты ремонтов с stale-while-revalidate (У38).
|
||||
*
|
||||
* Использует `useInfiniteQuery` для бесконечной пагинации по 20 (У38).
|
||||
* На `visibilitychange` (когда вкладка/приложение получают фокус) —
|
||||
* автоматический рефреш первой страницы. Pull-to-refresh вызывается
|
||||
* напрямую из компонента через `refetch()`.
|
||||
*
|
||||
* SWR-кэш живёт в react-query (память + localStorage persist уже
|
||||
* сконфигурирован в QueryClient на уровне приложения, если такого нет —
|
||||
* первая отрисовка идёт без кэша, но дальше работает).
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { useInfiniteQuery, type QueryKey } from "@tanstack/react-query";
|
||||
import { getRepairsFeed, type FeedItem, type FeedResponse } from "@/api/repairsFeed";
|
||||
|
||||
export const FEED_QUERY_KEY: QueryKey = ["repairs-feed"];
|
||||
export const FEED_PAGE_SIZE = 20;
|
||||
|
||||
export function useRepairsFeed() {
|
||||
const query = useInfiniteQuery<FeedResponse>({
|
||||
queryKey: FEED_QUERY_KEY,
|
||||
queryFn: ({ pageParam }) =>
|
||||
getRepairsFeed({ limit: FEED_PAGE_SIZE, cursor: (pageParam as string | null) ?? null }),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
staleTime: 30_000, // 30 секунд считаем «свежим» — потом фон обновляет
|
||||
refetchOnWindowFocus: false, // делаем свой visibilitychange-листенер ниже
|
||||
});
|
||||
|
||||
// У38: обновление при возврате в приложение/вкладку.
|
||||
useEffect(() => {
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
// Рефрешим только первую страницу, бесконечный скролл сбрасывается.
|
||||
void query.refetch();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => document.removeEventListener("visibilitychange", onVisible);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Удобный flatten для рендера в одну ленту.
|
||||
const items: FeedItem[] = query.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
return {
|
||||
items,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
hasNextPage: !!query.hasNextPage,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 40 30% 95%; /* cream */
|
||||
--foreground: 0 0% 16%; /* near-black */
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 16%;
|
||||
--primary: 0 0% 16%;
|
||||
--primary-foreground: 40 30% 95%;
|
||||
--secondary: 40 20% 88%;
|
||||
--secondary-foreground: 0 0% 16%;
|
||||
--muted: 40 20% 92%;
|
||||
--muted-foreground: 0 0% 40%;
|
||||
--accent: 40 20% 90%;
|
||||
--accent-foreground: 0 0% 16%;
|
||||
--destructive: 0 70% 50%;
|
||||
--destructive-foreground: 40 30% 95%;
|
||||
--border: 40 15% 85%;
|
||||
--input: 40 15% 85%;
|
||||
--ring: 0 0% 16%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* { @apply border-border; }
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Форматы времени для ленты ремонтов PWA (У39).
|
||||
*
|
||||
* Гибрид «Сегодня в 14:32 / Вчера в 18:00 / 23 мая, 14:32 /
|
||||
* 23 мая 2025, 14:32» — для рабочего инструмента нужно точное время,
|
||||
* а не относительное «2 часа назад».
|
||||
*
|
||||
* В детальной карточке ремонта используется всегда полный формат
|
||||
* (см. `formatFullDateTime`).
|
||||
*/
|
||||
|
||||
const HHMM = new Intl.DateTimeFormat("ru-RU", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const DAY_MONTH = new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
|
||||
const DAY_MONTH_YEAR = new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
function startOfDay(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
|
||||
/** У39: формат строки ленты. */
|
||||
export function formatFeedTimestamp(input: string | Date, now: Date = new Date()): string {
|
||||
const dt = typeof input === "string" ? new Date(input) : input;
|
||||
if (Number.isNaN(dt.getTime())) return String(input);
|
||||
|
||||
const todayStart = startOfDay(now);
|
||||
const yesterdayStart = new Date(todayStart);
|
||||
yesterdayStart.setDate(yesterdayStart.getDate() - 1);
|
||||
|
||||
if (dt >= todayStart) {
|
||||
return `Сегодня в ${HHMM.format(dt)}`;
|
||||
}
|
||||
if (dt >= yesterdayStart) {
|
||||
return `Вчера в ${HHMM.format(dt)}`;
|
||||
}
|
||||
if (dt.getFullYear() === now.getFullYear()) {
|
||||
return `${DAY_MONTH.format(dt)}, ${HHMM.format(dt)}`;
|
||||
}
|
||||
return `${DAY_MONTH_YEAR.format(dt)}, ${HHMM.format(dt)}`;
|
||||
}
|
||||
|
||||
/** Полный формат для детальной карточки. */
|
||||
export function formatFullDateTime(input: string | Date): string {
|
||||
const dt = typeof input === "string" ? new Date(input) : input;
|
||||
if (Number.isNaN(dt.getTime())) return String(input);
|
||||
return `${DAY_MONTH_YEAR.format(dt)}, ${HHMM.format(dt)}`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "sonner";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 1, staleTime: 30_000 } },
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<Toaster richColors position="top-center" />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getMe } from "@/api/me";
|
||||
import { logout } from "@/api/auth";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ActionTile {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
comingSoon?: boolean;
|
||||
}
|
||||
|
||||
export default function ActionMenu() {
|
||||
const navigate = useNavigate();
|
||||
const meQuery = useQuery({ queryKey: ["me"], queryFn: getMe, staleTime: 60_000 });
|
||||
|
||||
const tiles: ActionTile[] = [
|
||||
{
|
||||
key: "inspect",
|
||||
title: "Осмотр",
|
||||
description: "Создать или продолжить осмотр машины",
|
||||
emoji: "🔍",
|
||||
onClick: () => navigate("/vehicles?action=inspect"),
|
||||
},
|
||||
{
|
||||
key: "repair",
|
||||
title: "Ремонт",
|
||||
description: "Лента ремонтов парка + создать новый",
|
||||
emoji: "🔧",
|
||||
onClick: () => navigate("/repairs"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="flex items-center justify-between p-4 border-b">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Premium Механик</h1>
|
||||
{meQuery.data && (
|
||||
<p className="text-xs text-muted-foreground">{meQuery.data.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
logout();
|
||||
navigate("/login", { replace: true });
|
||||
}}
|
||||
>
|
||||
Выйти
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<main className="container max-w-2xl py-8 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-muted-foreground">
|
||||
Что делаем?
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{tiles.map((t) => (
|
||||
<Card
|
||||
key={t.key}
|
||||
onClick={t.disabled ? undefined : t.onClick}
|
||||
className={
|
||||
"p-5 transition-shadow " +
|
||||
(t.disabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "cursor-pointer hover:shadow-md active:shadow-sm")
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-3xl leading-none">{t.emoji}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-base">{t.title}</h3>
|
||||
{t.comingSoon && (
|
||||
<span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5">
|
||||
скоро
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { listVehicles } from "@/api/vehicles";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
const ACTION_TITLES: Record<string, string> = {
|
||||
inspect: "Осмотр",
|
||||
repair: "Ремонт",
|
||||
};
|
||||
|
||||
// Группа цветов по семантике статуса. Палитра подсчитана из Tailwind с
|
||||
// прозрачностью, чтобы чип не перебивал гос.номер. Tone:
|
||||
// ok — на линии (зелёный)
|
||||
// idle — простой (серый)
|
||||
// repair — в ремонте / ждёт ремонта (янтарный)
|
||||
// alert — ДТП (красный)
|
||||
// admin — выкуп / на продаже / бронь / подготовка к продаже / ждёт доставки
|
||||
// sold — продана (не отображается — отфильтрована из выдачи)
|
||||
const STATUS_TONE: Record<string, string> = {
|
||||
"На линии": "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||
"Простой": "bg-muted text-muted-foreground",
|
||||
"Ремонтируется": "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
|
||||
"Ждет ремонта": "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
|
||||
"ДТП": "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
|
||||
"Бронь": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
"Выкуп": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
"На продаже": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
"Подготовка на продажу": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
"Ждет доставки до офиса": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
};
|
||||
|
||||
function StatusChip({ status }: { status: string }) {
|
||||
const tone =
|
||||
STATUS_TONE[status] ??
|
||||
"bg-muted text-muted-foreground";
|
||||
return (
|
||||
<span className={`inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-full ${tone}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [q, setQ] = useState("");
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const action = searchParams.get("action") || undefined;
|
||||
const subtitle = action ? ACTION_TITLES[action] : undefined;
|
||||
|
||||
const vehiclesQuery = useQuery({
|
||||
queryKey: ["vehicles", q],
|
||||
queryFn: () => listVehicles(q || undefined),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="flex items-center gap-3 p-4 border-b">
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Меню
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-lg font-semibold">
|
||||
Машины{subtitle && ` — ${subtitle}`}
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="p-4">
|
||||
<Input
|
||||
placeholder="Поиск по госномеру"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{vehiclesQuery.isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Загружаю…</div>
|
||||
) : vehiclesQuery.isError ? (
|
||||
<div className="text-sm text-destructive">Не удалось загрузить.</div>
|
||||
) : (vehiclesQuery.data ?? []).length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">Ничего не найдено.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(vehiclesQuery.data ?? []).map((v) => (
|
||||
<Card
|
||||
key={v.id}
|
||||
className="p-4 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() =>
|
||||
navigate(`/vehicles/${v.id}${action ? `?action=${action}` : ""}`)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="font-semibold text-lg">{v.license_plate}</div>
|
||||
{v.status && <StatusChip status={v.status} />}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{[v.make, v.model, v.year].filter(Boolean).join(" ") || "—"}
|
||||
</div>
|
||||
{v.last_inspection_at && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Последний осмотр:{" "}
|
||||
{new Date(v.last_inspection_at).toLocaleDateString("ru-RU")}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getInspection,
|
||||
patchInspection,
|
||||
createMarker,
|
||||
deleteMarker,
|
||||
type MarkerSummary,
|
||||
} from "@/api/inspections";
|
||||
import { uploadPhoto, uploadPhotoAnnotation } from "@/api/photos";
|
||||
import { PhotoSlot } from "@/components/camera/PhotoSlot";
|
||||
import { CameraCapture } from "@/components/camera/CameraCapture";
|
||||
import { VendorVehicleScheme } from "@/components/vehicle-scheme/VendorVehicleScheme";
|
||||
import { TireWearDialog } from "@/components/inspection/TireWearDialog";
|
||||
import { ZoneConfirmDialog } from "@/components/damage-flow/ZoneConfirmDialog";
|
||||
import { ZoneDetailView } from "@/components/damage-flow/ZoneDetailView";
|
||||
import { DamageClassifyDialog } from "@/components/damage-flow/DamageClassifyDialog";
|
||||
import { PhotoAnnotateStep } from "@/components/damage-flow/PhotoAnnotateStep";
|
||||
import { AuthImg } from "@/components/AuthImg";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { loadZoneLabels } from "@/data/zoneLabels";
|
||||
import { damageTypeRuToEnum } from "@/data/damageVocabulary";
|
||||
import { ttImageUrl } from "@/api/vehicles";
|
||||
|
||||
const SLOTS_GENERAL: { side: string; label: string }[] = [
|
||||
{ side: "front", label: "Перед" },
|
||||
{ side: "rear", label: "Зад" },
|
||||
{ side: "left", label: "Левый бок" },
|
||||
{ side: "right", label: "Правый бок" },
|
||||
{ side: "vin", label: "VIN" },
|
||||
{ side: "odometer", label: "Одометр" },
|
||||
{ side: "interior", label: "Салон" },
|
||||
{ side: "free", label: "Свободное" },
|
||||
];
|
||||
|
||||
const SLOT_JACK = { side: "jack", label: "Домкрат" };
|
||||
|
||||
/** Все типы осмотров (выдача/приёмка/плановый/...) показывают одинаковый
|
||||
* набор слотов общих ракурсов + домкрат. Раньше «return» исключал общие
|
||||
* фото в пользу скорости приёмки, но фидбек: при приёмке всё равно надо
|
||||
* фиксировать общее состояние машины, как и при выдаче/осмотре. */
|
||||
function slotsForType(_type: string): typeof SLOTS_GENERAL {
|
||||
return [...SLOTS_GENERAL, SLOT_JACK];
|
||||
}
|
||||
|
||||
const SEVERITY_LABELS: Record<string, string> = {
|
||||
cosmetic: "косметика",
|
||||
minor: "лёгкое",
|
||||
moderate: "среднее",
|
||||
severe: "тяжёлое",
|
||||
};
|
||||
|
||||
const DAMAGE_TYPE_LABELS: Record<string, string> = {
|
||||
damaged: "повреждено",
|
||||
scratch: "царапина",
|
||||
chip: "скол",
|
||||
dent: "вмятина",
|
||||
crack: "трещина",
|
||||
scuff: "затёртость",
|
||||
burn: "прожог",
|
||||
stain: "пятно",
|
||||
bent: "погнуто",
|
||||
torn: "порвано",
|
||||
cut: "порез",
|
||||
puncture: "прокол",
|
||||
bulge: "грыжа",
|
||||
missing: "отсутствует",
|
||||
removed: "снято",
|
||||
"not-working": "не работает",
|
||||
};
|
||||
|
||||
// Vendor 3-position severity index → backend literal
|
||||
const SEVERITY_MAP = ["minor", "moderate", "severe"] as const;
|
||||
|
||||
function humanizeMarkerSide(
|
||||
side: string | null | undefined,
|
||||
zoneLabels?: Record<string, string> | null,
|
||||
): string {
|
||||
if (!side) return "—";
|
||||
if (side === "tires") return "Резина";
|
||||
const zm = /^zone-(\d+)$/.exec(side);
|
||||
if (zm) {
|
||||
const label = zoneLabels?.[zm[1]];
|
||||
if (label) return label;
|
||||
return `Зона ${zm[1]}`;
|
||||
}
|
||||
const map: Record<string, string> = {
|
||||
top: "Сверху",
|
||||
front: "Перед",
|
||||
back: "Зад",
|
||||
left: "Левый",
|
||||
right: "Правый",
|
||||
"bumper-fl": "Бампер ПЛ",
|
||||
"bumper-fr": "Бампер ПП",
|
||||
"bumper-rl": "Бампер ЗЛ",
|
||||
"bumper-rr": "Бампер ЗП",
|
||||
// Legacy named zone-* support (data from earlier prod)
|
||||
"zone-front": "Передний бампер",
|
||||
"zone-rear": "Задний бампер",
|
||||
"zone-left": "Левый бок",
|
||||
"zone-right": "Правый бок",
|
||||
"zone-hood": "Капот",
|
||||
"zone-trunk": "Багажник",
|
||||
"zone-roof": "Крыша",
|
||||
};
|
||||
return map[side] ?? side;
|
||||
}
|
||||
|
||||
type DamageFlow =
|
||||
| { kind: "idle" }
|
||||
| { kind: "confirm"; zoneId: number; zoneLabel: string }
|
||||
| { kind: "placePoints"; zoneId: number; zoneLabel: string }
|
||||
| { kind: "classify"; zoneId: number; zoneLabel: string; points: { x: number; y: number }[] }
|
||||
| { kind: "capture"; zoneId: number; zoneLabel: string; points: { x: number; y: number }[]; damageType: string; severity: 0 | 1 | 2 }
|
||||
| { kind: "annotate"; zoneId: number; zoneLabel: string; points: { x: number; y: number }[]; damageType: string; severity: 0 | 1 | 2; photoFile: File };
|
||||
|
||||
export default function InspectionEditor() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const insId = Number(id);
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [flow, setFlow] = useState<DamageFlow>({ kind: "idle" });
|
||||
const [zoneLabels, setZoneLabels] = useState<Record<string, string> | null>(null);
|
||||
const [tireWearOpen, setTireWearOpen] = useState(false);
|
||||
// Local draft заметок механика — синхронизируется с ins.notes когда осмотр
|
||||
// прогружается / обновляется. Кнопка «Сохранить» появляется только когда
|
||||
// draft отличается от сохранённого (см. notesDirty ниже).
|
||||
const [notesDraft, setNotesDraft] = useState<string>("");
|
||||
|
||||
useEffect(() => { loadZoneLabels().then(setZoneLabels); }, []);
|
||||
|
||||
const { data: ins, isLoading, isError } = useQuery({
|
||||
queryKey: ["inspection", insId],
|
||||
queryFn: () => getInspection(insId),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (ins?.notes !== undefined) setNotesDraft(ins.notes ?? "");
|
||||
}, [ins?.notes]);
|
||||
|
||||
const finalize = useMutation({
|
||||
mutationFn: () =>
|
||||
// При завершении осмотра коммитим текст заметки тем же patch'ем —
|
||||
// если механик что-то ввёл и сразу нажал «Завершить», не теряем.
|
||||
patchInspection(insId, {
|
||||
status: "completed",
|
||||
notes: (notesDraft ?? "").trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success("Осмотр завершён");
|
||||
navigate(`/inspections/${insId}`);
|
||||
},
|
||||
onError: () => toast.error("Не удалось завершить"),
|
||||
});
|
||||
|
||||
const notesMutation = useMutation({
|
||||
mutationFn: (next: string) => patchInspection(insId, { notes: next }),
|
||||
onSuccess: (updated) => {
|
||||
toast.success("Заметка сохранена");
|
||||
qc.setQueryData(["inspection", insId], updated);
|
||||
},
|
||||
onError: () => toast.error("Не удалось сохранить заметку"),
|
||||
});
|
||||
|
||||
const removeMarker = useMutation({
|
||||
mutationFn: (markerId: number) => deleteMarker(markerId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["inspection", insId] });
|
||||
toast.success("Метка удалена");
|
||||
},
|
||||
onError: () => toast.error("Не удалось удалить"),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="p-8 text-muted-foreground">Загружаю…</div>;
|
||||
if (isError || !ins)
|
||||
return <div className="p-8 text-destructive">Осмотр не найден.</div>;
|
||||
|
||||
// Photos lookup by side+slot for initialPhotoId
|
||||
const photosBySide = new Map<string, typeof ins.photos[0]>();
|
||||
for (const p of ins.photos) photosBySide.set(`${p.side}-${p.slot_index}`, p);
|
||||
// Photos lookup by id — нужен для marker thumb (annotated vs original)
|
||||
const photosById = new Map<number, typeof ins.photos[0]>();
|
||||
for (const p of ins.photos) photosById.set(p.id, p);
|
||||
|
||||
const editable = ins.status === "in_progress";
|
||||
|
||||
const handleZoneTap = (zoneId: number) => {
|
||||
const label = zoneLabels?.[String(zoneId)] ?? `зона ${zoneId}`;
|
||||
setFlow({ kind: "confirm", zoneId, zoneLabel: label });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-4 space-y-4">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Назад
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">
|
||||
Осмотр #{ins.id}{" "}
|
||||
<span className="text-muted-foreground text-base">({ins.type})</span>
|
||||
</h1>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(ins.started_at).toLocaleString("ru-RU")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="p-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||
Схема — тапните на зону, чтобы добавить метку
|
||||
</h2>
|
||||
<VendorVehicleScheme
|
||||
markers={ins.markers.map((m: MarkerSummary) => ({
|
||||
id: m.id,
|
||||
side: m.side,
|
||||
damage_type: m.damage_type,
|
||||
severity: m.severity,
|
||||
resolved: m.resolved,
|
||||
polygon: m.polygon,
|
||||
}))}
|
||||
onZoneTap={editable ? handleZoneTap : undefined}
|
||||
onTireWearClick={editable ? () => setTireWearOpen(true) : undefined}
|
||||
editable={editable}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Блок «Заметки» поднят сразу под схему — главное текстовое поле для
|
||||
планового осмотра, не хочется чтобы механик пролистывал фото+метки
|
||||
чтобы добраться до textarea. */}
|
||||
<Card className="p-4 space-y-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">
|
||||
Заметки механика
|
||||
</h2>
|
||||
{editable ? (
|
||||
<>
|
||||
<textarea
|
||||
value={notesDraft}
|
||||
onChange={(e) => setNotesDraft(e.target.value)}
|
||||
placeholder="Текст осмотра, замечания, рекомендации…"
|
||||
rows={Math.max(3, Math.min(notesDraft.split("\n").length + 1, 12))}
|
||||
className="w-full text-sm p-2 rounded border border-input bg-background resize-y focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
{(notesDraft ?? "") !== (ins.notes ?? "") && (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNotesDraft(ins.notes ?? "")}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-3 py-1"
|
||||
disabled={notesMutation.isPending}
|
||||
>
|
||||
Отменить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => notesMutation.mutate(notesDraft)}
|
||||
disabled={notesMutation.isPending}
|
||||
className="text-xs bg-primary text-primary-foreground rounded px-3 py-1 disabled:opacity-60"
|
||||
>
|
||||
{notesMutation.isPending ? "Сохраняю…" : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm whitespace-pre-wrap">{ins.notes || "—"}</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Multi-step damage flow */}
|
||||
{flow.kind === "confirm" && (
|
||||
<ZoneConfirmDialog
|
||||
open
|
||||
zoneLabel={flow.zoneLabel}
|
||||
onConfirm={() =>
|
||||
setFlow({ kind: "placePoints", zoneId: flow.zoneId, zoneLabel: flow.zoneLabel })
|
||||
}
|
||||
onCancel={() => setFlow({ kind: "idle" })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{flow.kind === "placePoints" && (
|
||||
<ZoneDetailView
|
||||
open
|
||||
zoneId={flow.zoneId}
|
||||
zoneLabel={flow.zoneLabel}
|
||||
onConfirm={(points) =>
|
||||
setFlow({ kind: "classify", zoneId: flow.zoneId, zoneLabel: flow.zoneLabel, points })
|
||||
}
|
||||
onCancel={() => setFlow({ kind: "idle" })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{flow.kind === "classify" && (
|
||||
<DamageClassifyDialog
|
||||
open
|
||||
zoneId={flow.zoneId}
|
||||
zoneLabel={flow.zoneLabel}
|
||||
pointsCount={flow.points.length}
|
||||
onCancel={() =>
|
||||
setFlow({ kind: "placePoints", zoneId: flow.zoneId, zoneLabel: flow.zoneLabel })
|
||||
}
|
||||
onSave={({ damageType, severity }) =>
|
||||
setFlow({
|
||||
kind: "capture",
|
||||
zoneId: flow.zoneId,
|
||||
zoneLabel: flow.zoneLabel,
|
||||
points: flow.points,
|
||||
damageType,
|
||||
severity,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{flow.kind === "capture" && (
|
||||
<CapturePhotoStep
|
||||
zoneLabel={flow.zoneLabel}
|
||||
onCancel={() => setFlow({ kind: "idle" })}
|
||||
onPhotoCaptured={(file) =>
|
||||
setFlow({
|
||||
kind: "annotate",
|
||||
zoneId: flow.zoneId,
|
||||
zoneLabel: flow.zoneLabel,
|
||||
points: flow.points,
|
||||
damageType: flow.damageType,
|
||||
severity: flow.severity,
|
||||
photoFile: file,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{flow.kind === "annotate" && (
|
||||
<PhotoAnnotateStep
|
||||
photoFile={flow.photoFile}
|
||||
zoneLabel={flow.zoneLabel}
|
||||
onCancel={() =>
|
||||
setFlow({
|
||||
kind: "capture",
|
||||
zoneId: flow.zoneId,
|
||||
zoneLabel: flow.zoneLabel,
|
||||
points: flow.points,
|
||||
damageType: flow.damageType,
|
||||
severity: flow.severity,
|
||||
})
|
||||
}
|
||||
onSubmit={async (annotated) => {
|
||||
try {
|
||||
const damageEnum = damageTypeRuToEnum(flow.damageType);
|
||||
if (!damageEnum) {
|
||||
toast.error(`Не поддерживается: ${flow.damageType}`);
|
||||
setFlow({ kind: "idle" });
|
||||
return;
|
||||
}
|
||||
const result = await uploadPhoto(insId, "free", 0, flow.photoFile);
|
||||
if (annotated) {
|
||||
try {
|
||||
await uploadPhotoAnnotation(insId, result.id, annotated);
|
||||
} catch (err) {
|
||||
// Аннотация — best-effort. Фото оригинал уже сохранилось.
|
||||
console.warn("annotation upload failed (non-fatal)", err);
|
||||
}
|
||||
}
|
||||
await createMarker(insId, {
|
||||
side: `zone-${flow.zoneId}`,
|
||||
polygon: flow.points,
|
||||
damage_type: damageEnum,
|
||||
severity: SEVERITY_MAP[flow.severity],
|
||||
photo_id: result.id,
|
||||
});
|
||||
void qc.invalidateQueries({ queryKey: ["inspection", insId] });
|
||||
toast.success("Повреждение записано");
|
||||
} catch (err) {
|
||||
console.error("save failed", err);
|
||||
toast.error("Не удалось сохранить");
|
||||
} finally {
|
||||
setFlow({ kind: "idle" });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TireWearDialog
|
||||
open={tireWearOpen}
|
||||
inspectionId={insId}
|
||||
onClose={() => setTireWearOpen(false)}
|
||||
onCreated={() => {
|
||||
setTireWearOpen(false);
|
||||
void qc.invalidateQueries({ queryKey: ["inspection", insId] });
|
||||
toast.success("Состояние резины записано");
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">Фото</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{slotsForType(ins.type).map((s) => (
|
||||
<PhotoSlot
|
||||
key={`${s.side}-0`}
|
||||
inspectionId={insId}
|
||||
side={s.side}
|
||||
slotIndex={0}
|
||||
label={s.label}
|
||||
initialPhotoId={photosBySide.get(`${s.side}-0`)?.id}
|
||||
onUploaded={() =>
|
||||
void qc.invalidateQueries({ queryKey: ["inspection", insId] })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ins.markers.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
Метки ({ins.markers.length})
|
||||
</h2>
|
||||
<ul className="space-y-1">
|
||||
{ins.markers.map((m: MarkerSummary) => (
|
||||
<li key={m.id}>
|
||||
<Card className="p-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium capitalize">
|
||||
{humanizeMarkerSide(m.side, zoneLabels)}
|
||||
</span>
|
||||
{" — "}
|
||||
<span>
|
||||
{m.damage_type
|
||||
? (DAMAGE_TYPE_LABELS[m.damage_type] ?? m.damage_type)
|
||||
: "—"}
|
||||
</span>
|
||||
{m.severity && (
|
||||
<>
|
||||
{" — "}
|
||||
<span className="text-muted-foreground">
|
||||
{SEVERITY_LABELS[m.severity] ?? m.severity}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{m.carried_over_from_id && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
перенесено
|
||||
</span>
|
||||
)}
|
||||
{!m.carried_over_from_id &&
|
||||
m.description?.startsWith("Перенесено из Element Mechanic") && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
из Element Mechanic
|
||||
</span>
|
||||
)}
|
||||
{editable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const verb = m.carried_over_from_id
|
||||
? "Удалить (повреждение устранено в ремонте)?"
|
||||
: "Удалить метку?";
|
||||
if (window.confirm(verb)) {
|
||||
removeMarker.mutate(m.id);
|
||||
}
|
||||
}}
|
||||
disabled={removeMarker.isPending}
|
||||
className="text-xs text-destructive hover:underline px-1"
|
||||
title="Удалить метку"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{m.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{m.description}
|
||||
</div>
|
||||
)}
|
||||
{m.photo_id ? (
|
||||
<div className="mt-2 max-w-[8rem]">
|
||||
<AuthImg
|
||||
src={
|
||||
photosById.get(m.photo_id)?.annotated_key
|
||||
? `photos/${m.photo_id}/annotated`
|
||||
: `photos/${m.photo_id}/thumb`
|
||||
}
|
||||
alt="Фото повреждения"
|
||||
className="w-full aspect-[4/3] object-cover rounded bg-muted"
|
||||
/>
|
||||
</div>
|
||||
) : m.tt_image_id ? (
|
||||
<div className="mt-2 max-w-[8rem]">
|
||||
<img
|
||||
src={ttImageUrl(m.tt_image_id)}
|
||||
alt="Фото из Element Mechanic"
|
||||
loading="lazy"
|
||||
className="w-full aspect-[4/3] object-cover rounded bg-muted"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editable && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => finalize.mutate()}
|
||||
disabled={finalize.isPending}
|
||||
>
|
||||
{finalize.isPending ? "Завершаем…" : "Завершить осмотр"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapturePhotoStep({
|
||||
zoneLabel,
|
||||
onCancel,
|
||||
onPhotoCaptured,
|
||||
}: {
|
||||
zoneLabel: string;
|
||||
onCancel: () => void;
|
||||
onPhotoCaptured: (file: File) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-background flex flex-col">
|
||||
<header className="p-4 border-b">
|
||||
<div className="text-xs text-muted-foreground">Фото повреждения</div>
|
||||
<h2 className="text-lg font-semibold capitalize">{zoneLabel}</h2>
|
||||
</header>
|
||||
<main className="flex-1 p-4 flex flex-col justify-center max-w-md w-full mx-auto">
|
||||
<CameraCapture
|
||||
onCapture={onPhotoCaptured}
|
||||
buttonLabel="Сделать фото"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="mt-4 w-full"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getInspection,
|
||||
deleteInspection,
|
||||
patchInspection,
|
||||
type MarkerSummary,
|
||||
type PhotoSummary,
|
||||
} from "@/api/inspections";
|
||||
import { getMe } from "@/api/me";
|
||||
import { AuthImg } from "@/components/AuthImg";
|
||||
import { ttImageUrl } from "@/api/vehicles";
|
||||
import { VendorVehicleScheme } from "@/components/vehicle-scheme/VendorVehicleScheme";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { loadZoneLabels } from "@/data/zoneLabels";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
in_progress: "В работе",
|
||||
completed: "Завершён",
|
||||
cancelled: "Отменён",
|
||||
};
|
||||
|
||||
const SEVERITY_LABELS: Record<string, string> = {
|
||||
cosmetic: "косметика",
|
||||
minor: "лёгкое",
|
||||
moderate: "среднее",
|
||||
severe: "тяжёлое",
|
||||
};
|
||||
|
||||
const DAMAGE_TYPE_LABELS: Record<string, string> = {
|
||||
damaged: "повреждено",
|
||||
scratch: "царапина",
|
||||
chip: "скол",
|
||||
dent: "вмятина",
|
||||
crack: "трещина",
|
||||
scuff: "затёртость",
|
||||
burn: "прожог",
|
||||
stain: "пятно",
|
||||
bent: "погнуто",
|
||||
torn: "порвано",
|
||||
cut: "порез",
|
||||
puncture: "прокол",
|
||||
bulge: "грыжа",
|
||||
missing: "отсутствует",
|
||||
removed: "снято",
|
||||
"not-working": "не работает",
|
||||
};
|
||||
|
||||
function humanizeMarkerSide(
|
||||
side: string | null | undefined,
|
||||
zoneLabels?: Record<string, string> | null,
|
||||
): string {
|
||||
if (!side) return "—";
|
||||
if (side === "tires") return "Резина";
|
||||
const zm = /^zone-(\d+)$/.exec(side);
|
||||
if (zm) {
|
||||
const label = zoneLabels?.[zm[1]];
|
||||
if (label) return label;
|
||||
return `Зона ${zm[1]}`;
|
||||
}
|
||||
const map: Record<string, string> = {
|
||||
top: "Сверху",
|
||||
front: "Перед",
|
||||
back: "Зад",
|
||||
left: "Левый",
|
||||
right: "Правый",
|
||||
"bumper-fl": "Бампер ПЛ",
|
||||
"bumper-fr": "Бампер ПП",
|
||||
"bumper-rl": "Бампер ЗЛ",
|
||||
"bumper-rr": "Бампер ЗП",
|
||||
// Legacy named zone-* support (data from earlier prod)
|
||||
"zone-front": "Передний бампер",
|
||||
"zone-rear": "Задний бампер",
|
||||
"zone-left": "Левый бок",
|
||||
"zone-right": "Правый бок",
|
||||
"zone-hood": "Капот",
|
||||
"zone-trunk": "Багажник",
|
||||
"zone-roof": "Крыша",
|
||||
};
|
||||
return map[side] ?? side;
|
||||
}
|
||||
|
||||
export default function InspectionReview() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const insId = Number(id);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: ins, isLoading, isError } = useQuery({
|
||||
queryKey: ["inspection", insId],
|
||||
queryFn: () => getInspection(insId),
|
||||
});
|
||||
|
||||
const { data: me } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: getMe,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const isAdmin = me?.permissions?.includes("admin") ?? false;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteInspection(insId),
|
||||
onSuccess: () => {
|
||||
toast.success("Осмотр удалён");
|
||||
queryClient.invalidateQueries({ queryKey: ["vehicle"] });
|
||||
navigate(-1);
|
||||
},
|
||||
onError: () => toast.error("Не удалось удалить осмотр"),
|
||||
});
|
||||
|
||||
const { data: zoneLabels } = useQuery({
|
||||
queryKey: ["zone-labels"],
|
||||
queryFn: loadZoneLabels,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
// Lightbox: либо ID нашего фото в MinIO (через AuthImg), либо vendor TT
|
||||
// image hash (через ttImageUrl).
|
||||
const [preview, setPreview] = useState<
|
||||
{ kind: "photo"; id: number } | { kind: "tt"; ttId: string } | null
|
||||
>(null);
|
||||
|
||||
// Локальный draft заметок — синхронизируется с ins.notes при загрузке/обновлении.
|
||||
// Кнопка «Сохранить» появляется только если draft отличается от сохранённого.
|
||||
const [notesDraft, setNotesDraft] = useState<string>("");
|
||||
useEffect(() => {
|
||||
if (ins?.notes !== undefined) setNotesDraft(ins.notes ?? "");
|
||||
}, [ins?.notes]);
|
||||
|
||||
const notesMutation = useMutation({
|
||||
mutationFn: (next: string) => patchInspection(insId, { notes: next }),
|
||||
onSuccess: (updated) => {
|
||||
toast.success("Заметка сохранена");
|
||||
queryClient.setQueryData(["inspection", insId], updated);
|
||||
},
|
||||
onError: () => toast.error("Не удалось сохранить заметку"),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="p-8 text-muted-foreground">Загружаю…</div>;
|
||||
if (isError || !ins)
|
||||
return <div className="p-8 text-destructive">Осмотр не найден.</div>;
|
||||
|
||||
const notesDirty = (notesDraft ?? "") !== (ins.notes ?? "");
|
||||
|
||||
// Lookup photo by id для определения annotated_key.
|
||||
const photosById = new Map<number, typeof ins.photos[0]>();
|
||||
for (const p of ins.photos) photosById.set(p.id, p);
|
||||
|
||||
// Все уникальные фото осмотра: основные + photo_id из маркеров (наши локальные
|
||||
// фото повреждений) + tt_image_id из маркеров (carry-over из Element). Без
|
||||
// tt_image_id counter недоучитывал импортированные дефекты — видно на
|
||||
// осмотрах, где почти все маркеры пришли из вендора.
|
||||
const markerPhotoIds = ins.markers
|
||||
.map((m: MarkerSummary) => m.photo_id)
|
||||
.filter((id): id is number => id != null);
|
||||
const markerTtIds = ins.markers
|
||||
.map((m: MarkerSummary) => m.tt_image_id)
|
||||
.filter((id): id is string => !!id);
|
||||
const uniquePhotoIds = Array.from(
|
||||
new Set([...ins.photos.map((p: PhotoSummary) => p.id), ...markerPhotoIds])
|
||||
);
|
||||
const uniqueTtIds = Array.from(new Set(markerTtIds));
|
||||
const totalPhotoCount = uniquePhotoIds.length + uniqueTtIds.length;
|
||||
// Дополнительные фото-карточки в галерее: photo_id маркеров, которых нет в
|
||||
// основном списке (`ins.photos` — это side-photos осмотра).
|
||||
const extraMarkerPhotos = markerPhotoIds.filter((id) => !photosById.has(id));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-4 space-y-4">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Назад
|
||||
</button>
|
||||
|
||||
<h1 className="text-xl font-semibold">Осмотр #{ins.id}</h1>
|
||||
|
||||
{ins.vehicle && (
|
||||
<Card
|
||||
className="p-4 text-sm space-y-1 cursor-pointer hover:bg-accent/40 transition-colors"
|
||||
onClick={() => navigate(`/vehicles/${ins.vehicle?.id}`)}
|
||||
>
|
||||
<div className="text-lg font-semibold tracking-wide">
|
||||
{ins.vehicle.license_plate}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{[ins.vehicle.make, ins.vehicle.model, ins.vehicle.year]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "—"}
|
||||
</div>
|
||||
{ins.vehicle.vin && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-semibold">VIN: </span>
|
||||
{ins.vehicle.vin}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="p-4 text-sm space-y-1">
|
||||
<div>
|
||||
<span className="font-semibold">Тип: </span>
|
||||
{ins.type}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Статус: </span>
|
||||
{STATUS_LABELS[ins.status] ?? ins.status}
|
||||
</div>
|
||||
{ins.inspector_name && (
|
||||
<div>
|
||||
<span className="font-semibold">Механик: </span>
|
||||
{ins.inspector_name}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold">Начат: </span>
|
||||
{new Date(ins.started_at).toLocaleString("ru-RU")}
|
||||
</div>
|
||||
{ins.finished_at && (
|
||||
<div>
|
||||
<span className="font-semibold">Завершён: </span>
|
||||
{new Date(ins.finished_at).toLocaleString("ru-RU")}
|
||||
</div>
|
||||
)}
|
||||
{ins.mileage != null && (
|
||||
<div>
|
||||
<span className="font-semibold">Пробег: </span>
|
||||
{ins.mileage.toLocaleString("ru-RU")} км
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 space-y-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">
|
||||
Заметки механика
|
||||
</h2>
|
||||
<textarea
|
||||
value={notesDraft}
|
||||
onChange={(e) => setNotesDraft(e.target.value)}
|
||||
placeholder="Текст осмотра, замечания, рекомендации…"
|
||||
rows={Math.max(3, Math.min(notesDraft.split("\n").length + 1, 12))}
|
||||
className="w-full text-sm p-2 rounded border border-input bg-background resize-y focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
{notesDirty && (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNotesDraft(ins.notes ?? "")}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-3 py-1"
|
||||
disabled={notesMutation.isPending}
|
||||
>
|
||||
Отменить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => notesMutation.mutate(notesDraft)}
|
||||
disabled={notesMutation.isPending}
|
||||
className="text-xs bg-primary text-primary-foreground rounded px-3 py-1 disabled:opacity-60"
|
||||
>
|
||||
{notesMutation.isPending ? "Сохраняю…" : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||
Схема повреждений
|
||||
</h2>
|
||||
<VendorVehicleScheme
|
||||
markers={ins.markers.map((m: MarkerSummary) => ({
|
||||
id: m.id,
|
||||
side: m.side,
|
||||
damage_type: m.damage_type,
|
||||
severity: m.severity,
|
||||
resolved: m.resolved,
|
||||
polygon: m.polygon,
|
||||
}))}
|
||||
editable={false}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
Фото ({totalPhotoCount})
|
||||
</h2>
|
||||
{totalPhotoCount === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">Нет фото.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ins.photos.map((p: PhotoSummary) => (
|
||||
<Card
|
||||
key={p.id}
|
||||
className="overflow-hidden cursor-pointer"
|
||||
onClick={() => setPreview({ kind: "photo", id: p.id })}
|
||||
>
|
||||
<AuthImg
|
||||
src={`photos/${p.id}/thumb`}
|
||||
alt={p.side}
|
||||
className="w-full aspect-[4/3] object-cover bg-muted"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground p-2">{p.side}</div>
|
||||
</Card>
|
||||
))}
|
||||
{/* Фото-маркеров, которых нет в основном списке (наши локальные) */}
|
||||
{extraMarkerPhotos.map((id) => (
|
||||
<Card
|
||||
key={`m-${id}`}
|
||||
className="overflow-hidden cursor-pointer"
|
||||
onClick={() => setPreview({ kind: "photo", id })}
|
||||
>
|
||||
<AuthImg
|
||||
src={`photos/${id}/thumb`}
|
||||
alt="повреждение"
|
||||
className="w-full aspect-[4/3] object-cover bg-muted"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground p-2">повреждение</div>
|
||||
</Card>
|
||||
))}
|
||||
{/* TT-фото carry-over дефектов из Element живут ТОЛЬКО внутри
|
||||
секции «Метки», чтобы не дублировать одну и ту же картинку
|
||||
в карточке два раза. В счётчике «Фото (N)» они учтены. */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
Метки ({ins.markers.length})
|
||||
</h2>
|
||||
{ins.markers.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">Нет меток.</div>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{ins.markers.map((m: MarkerSummary) => (
|
||||
<li key={m.id}>
|
||||
<Card className="p-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium capitalize">
|
||||
{humanizeMarkerSide(m.side, zoneLabels)}
|
||||
</span>
|
||||
{" — "}
|
||||
<span>
|
||||
{m.damage_type
|
||||
? (DAMAGE_TYPE_LABELS[m.damage_type] ?? m.damage_type)
|
||||
: "—"}
|
||||
</span>
|
||||
{m.severity && (
|
||||
<>
|
||||
{" — "}
|
||||
<span className="text-muted-foreground">
|
||||
{SEVERITY_LABELS[m.severity] ?? m.severity}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{m.resolved && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
устранено
|
||||
</span>
|
||||
)}
|
||||
{m.carried_over_from_id && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
перенесено
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{m.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{m.description}
|
||||
</div>
|
||||
)}
|
||||
{m.photo_id ? (
|
||||
<div
|
||||
className="mt-2 max-w-[8rem] cursor-pointer"
|
||||
onClick={() =>
|
||||
setPreview({ kind: "photo", id: m.photo_id as number })
|
||||
}
|
||||
>
|
||||
<AuthImg
|
||||
src={
|
||||
photosById.get(m.photo_id)?.annotated_key
|
||||
? `photos/${m.photo_id}/annotated`
|
||||
: `photos/${m.photo_id}/thumb`
|
||||
}
|
||||
alt="Фото повреждения"
|
||||
className="w-full aspect-[4/3] object-cover rounded bg-muted"
|
||||
/>
|
||||
</div>
|
||||
) : m.tt_image_id ? (
|
||||
<div
|
||||
className="mt-2 max-w-[8rem] cursor-pointer"
|
||||
onClick={() =>
|
||||
setPreview({ kind: "tt", ttId: m.tt_image_id as string })
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={ttImageUrl(m.tt_image_id)}
|
||||
alt="Фото из Element Mechanic"
|
||||
loading="lazy"
|
||||
className="w-full aspect-[4/3] object-cover rounded bg-muted"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
"Удалить этот осмотр? Все фото будут безвозвратно удалены из хранилища. Действие необратимо."
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="w-full py-3 rounded-lg border border-destructive text-destructive hover:bg-destructive/10 active:bg-destructive/20 transition-colors font-semibold"
|
||||
>
|
||||
{deleteMutation.isPending ? "Удаляю…" : "Удалить осмотр"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lightbox preview — click photo to enlarge, click background to close.
|
||||
Соответствует UX в TtStateDetail (импортированные из Element осмотры). */}
|
||||
{preview != null && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/90 flex flex-col"
|
||||
onClick={() => setPreview(null)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreview(null);
|
||||
}}
|
||||
className="text-white text-2xl px-3"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{preview.kind === "photo" ? (
|
||||
<AuthImg
|
||||
src={`photos/${preview.id}`}
|
||||
alt="Просмотр"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={ttImageUrl(preview.ttId)}
|
||||
alt="Просмотр (Element)"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { HTTPError } from "ky";
|
||||
import { toast } from "sonner";
|
||||
import { login, login2fa } from "@/api/auth";
|
||||
import { getMe } from "@/api/me";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import {
|
||||
clearRevocation,
|
||||
readRevocation,
|
||||
storeRevocation,
|
||||
type RevocationInfo,
|
||||
} from "@/hooks/useMeSync";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type Step =
|
||||
| { kind: "credentials" }
|
||||
| { kind: "totp"; partialToken: string };
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const setToken = useAuth((s) => s.setToken);
|
||||
|
||||
const [step, setStep] = useState<Step>({ kind: "credentials" });
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [rememberDevice, setRememberDevice] = useState(true);
|
||||
|
||||
// У36: показываем явное сообщение «доступ отозван» с кликабельным контактом,
|
||||
// если пользователь только что был выкинут из приложения фоновой сверкой (У35)
|
||||
// или получил 403 NO_PWA_ACCESS при последней попытке логина.
|
||||
const [revocation, setRevocation] = useState<RevocationInfo | null>(() => readRevocation());
|
||||
|
||||
// У36: после успешной аутентификации проверяем доступ к PWA через /me.
|
||||
// /auth/login универсальный (используется и CRM, и PWA), поэтому
|
||||
// отказ в PWA-доступе различается именно по ответу /me (403 NO_PWA_ACCESS).
|
||||
const verifyPwaAccessAndNavigate = async (token: string) => {
|
||||
setToken(token);
|
||||
try {
|
||||
await getMe();
|
||||
clearRevocation();
|
||||
setRevocation(null);
|
||||
toast.success("Вход выполнен");
|
||||
navigate("/", { replace: true });
|
||||
} catch (err) {
|
||||
if (err instanceof HTTPError && err.response.status === 403) {
|
||||
let detail: unknown = null;
|
||||
try {
|
||||
const body = (await err.response.json()) as { detail?: unknown };
|
||||
detail = body?.detail ?? null;
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
storeRevocation(detail);
|
||||
setRevocation(readRevocation());
|
||||
setToken(null);
|
||||
} else {
|
||||
toast.error("Не удалось проверить доступ к приложению");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Если revocation сохранён сессией — очищаем при ручной смене ввода логина.
|
||||
useEffect(() => {
|
||||
if (revocation && (username || password)) {
|
||||
clearRevocation();
|
||||
setRevocation(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [username, password]);
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: login,
|
||||
onSuccess: (data) => {
|
||||
if (data.requires_2fa && data.partial_token) {
|
||||
setStep({ kind: "totp", partialToken: data.partial_token });
|
||||
setCode("");
|
||||
toast.info("Нужен код 2FA");
|
||||
return;
|
||||
}
|
||||
if (data.access_token) {
|
||||
// У36: проверяем доступ к PWA через /me перед навигацией.
|
||||
void verifyPwaAccessAndNavigate(data.access_token);
|
||||
return;
|
||||
}
|
||||
toast.error("Неожиданный ответ сервера");
|
||||
},
|
||||
onError: () => toast.error("Неверный логин или пароль"),
|
||||
});
|
||||
|
||||
const twoFaMutation = useMutation({
|
||||
mutationFn: login2fa,
|
||||
onSuccess: (data) => {
|
||||
if (data.access_token) {
|
||||
void verifyPwaAccessAndNavigate(data.access_token);
|
||||
} else {
|
||||
toast.error("Сервер не вернул токен");
|
||||
}
|
||||
},
|
||||
onError: () => toast.error("Неверный код 2FA"),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-sm bg-card rounded-2xl shadow-sm p-6 space-y-4">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Premium Механик</h1>
|
||||
|
||||
{revocation && (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
|
||||
<div className="font-medium mb-1">У вас нет доступа к PWA Premium Мехник</div>
|
||||
<div>
|
||||
Обратитесь к старшему механику или администратору CRM
|
||||
{(() => {
|
||||
const tg = revocation.support?.tg_username;
|
||||
const phone = revocation.support?.phone;
|
||||
if (tg) {
|
||||
return (
|
||||
<>
|
||||
{": "}
|
||||
<a
|
||||
href={`https://t.me/${tg}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline font-medium"
|
||||
>
|
||||
@{tg}
|
||||
</a>
|
||||
{phone ? (
|
||||
<>
|
||||
{" · "}
|
||||
<a href={`tel:${phone}`} className="underline font-medium">{phone}</a>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (phone) {
|
||||
return (
|
||||
<>
|
||||
{": "}
|
||||
<a href={`tel:${phone}`} className="underline font-medium">{phone}</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return ".";
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === "credentials" && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Войдите учётной записью TaxiDashboard.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
loginMutation.mutate({ username, password });
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="username">Логин</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="password">Пароль</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={loginMutation.isPending} className="w-full">
|
||||
{loginMutation.isPending ? "Входим…" : "Войти"}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step.kind === "totp" && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Введите 6-значный код из приложения-аутентификатора (Google
|
||||
Authenticator, Aegis, 1Password, и т. п.).
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
twoFaMutation.mutate({
|
||||
partial_token: step.partialToken,
|
||||
code: code.trim(),
|
||||
remember_device: rememberDevice,
|
||||
});
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="totp">Код 2FA</Label>
|
||||
<Input
|
||||
id="totp"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberDevice}
|
||||
onChange={(e) => setRememberDevice(e.target.checked)}
|
||||
/>
|
||||
Запомнить это устройство на 30 дней
|
||||
</label>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={twoFaMutation.isPending || code.length !== 6}
|
||||
className="w-full"
|
||||
>
|
||||
{twoFaMutation.isPending ? "Проверяем…" : "Войти"}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep({ kind: "credentials" });
|
||||
setPassword("");
|
||||
setCode("");
|
||||
}}
|
||||
className="w-full text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Назад
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getTtState,
|
||||
deleteTtState,
|
||||
ttImageUrl,
|
||||
type TtPhotoRef,
|
||||
type TtSidePhoto,
|
||||
} from "@/api/vehicles";
|
||||
import { getMe } from "@/api/me";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { TtDamageMap } from "@/components/tt/TtDamageMap";
|
||||
|
||||
interface ZoneLabels {
|
||||
[zoneId: string]: string;
|
||||
}
|
||||
|
||||
interface DamageVocabulary {
|
||||
damage_types_ordered: string[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
let zoneLabelsCache: ZoneLabels | null = null;
|
||||
let damageVocabCache: DamageVocabulary | null = null;
|
||||
|
||||
async function loadZoneLabels(): Promise<ZoneLabels> {
|
||||
if (zoneLabelsCache) return zoneLabelsCache;
|
||||
const res = await fetch("/data/zone_labels.json");
|
||||
zoneLabelsCache = await res.json();
|
||||
return zoneLabelsCache!;
|
||||
}
|
||||
|
||||
async function loadDamageVocabulary(): Promise<DamageVocabulary> {
|
||||
if (damageVocabCache) return damageVocabCache;
|
||||
const res = await fetch("/data/damage_vocabulary.json");
|
||||
damageVocabCache = await res.json();
|
||||
return damageVocabCache!;
|
||||
}
|
||||
|
||||
const DEGREE_LABELS: Record<number, string> = {
|
||||
0: "Лёгкое",
|
||||
1: "Среднее",
|
||||
2: "Тяжёлое",
|
||||
};
|
||||
|
||||
function damageTypeName(
|
||||
id: number | null,
|
||||
vocab: DamageVocabulary | undefined,
|
||||
): string {
|
||||
if (id == null) return "Без типа";
|
||||
// Vendor enum is 1-based: type=1 → damage_types_ordered[0] ("Вмятина").
|
||||
const idx = id - 1;
|
||||
const arr = vocab?.damage_types_ordered;
|
||||
if (arr && idx >= 0 && idx < arr.length) return arr[idx];
|
||||
return `Тип ${id}`;
|
||||
}
|
||||
|
||||
const SIDE_LABELS: Record<number, string> = {
|
||||
1: "Перед",
|
||||
2: "Зад",
|
||||
3: "Левый борт",
|
||||
4: "Правый борт",
|
||||
5: "Сверху",
|
||||
6: "Передний угол",
|
||||
7: "Задний угол",
|
||||
8: "Салон",
|
||||
9: "Дополнительно",
|
||||
};
|
||||
|
||||
type PreviewItem =
|
||||
| { kind: "zone"; photo: TtPhotoRef }
|
||||
| { kind: "side"; photo: TtSidePhoto };
|
||||
|
||||
export default function TtStateDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const stateId = Number(id);
|
||||
const zoneRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const { data: state, isLoading, isError } = useQuery({
|
||||
queryKey: ["tt-state", stateId],
|
||||
queryFn: () => getTtState(stateId),
|
||||
enabled: Number.isFinite(stateId),
|
||||
});
|
||||
|
||||
const { data: labels } = useQuery({
|
||||
queryKey: ["zone-labels"],
|
||||
queryFn: loadZoneLabels,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const { data: vocab } = useQuery({
|
||||
queryKey: ["damage-vocabulary"],
|
||||
queryFn: loadDamageVocabulary,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const { data: me } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: getMe,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const isAdmin = me?.permissions?.includes("admin") ?? false;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteTtState(stateId),
|
||||
onSuccess: () => {
|
||||
toast.success("Осмотр удалён");
|
||||
// Drop any cached tt-history for vehicle list so новый рендер не покажет удалённый.
|
||||
queryClient.invalidateQueries({ queryKey: ["vehicle"] });
|
||||
navigate(-1);
|
||||
},
|
||||
onError: () => toast.error("Не удалось удалить осмотр"),
|
||||
});
|
||||
|
||||
const [preview, setPreview] = useState<PreviewItem | null>(null);
|
||||
const [showLines, setShowLines] = useState(false);
|
||||
|
||||
const damagedZoneIds = useMemo(() => {
|
||||
if (!state) return [];
|
||||
return Object.keys(state.damages_by_zone).map((k) => Number(k));
|
||||
}, [state]);
|
||||
|
||||
const damageOverlays = useMemo(() => {
|
||||
if (!state) return [];
|
||||
return Object.entries(state.damages_by_zone).flatMap(([zid, dmgs]) =>
|
||||
dmgs.map((d) => ({ zoneId: Number(zid), points: d.points || [] }))
|
||||
);
|
||||
}, [state]);
|
||||
|
||||
// Reset refs when state changes
|
||||
useEffect(() => {
|
||||
zoneRefs.current = {};
|
||||
}, [stateId]);
|
||||
|
||||
if (isLoading)
|
||||
return <div className="p-8 text-muted-foreground">Загружаю…</div>;
|
||||
if (isError || !state)
|
||||
return <div className="p-8 text-destructive">Осмотр не найден.</div>;
|
||||
|
||||
const date = state.unix_time
|
||||
? new Date(state.unix_time * 1000).toLocaleString("ru-RU")
|
||||
: "—";
|
||||
|
||||
const zoneIds = Array.from(
|
||||
new Set([
|
||||
...Object.keys(state.photos_by_zone),
|
||||
...Object.keys(state.damages_by_zone),
|
||||
])
|
||||
).sort((a, b) => Number(a) - Number(b));
|
||||
|
||||
function scrollToZone(zoneId: number) {
|
||||
const el = zoneRefs.current[String(zoneId)];
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-4 space-y-4 pb-20">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Назад
|
||||
</button>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="text-xs text-muted-foreground">Element Mechanic</div>
|
||||
<div className="text-lg font-semibold mt-1">
|
||||
{state.mechanic_name || "Осмотр"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{date}
|
||||
{state.mileage != null && (
|
||||
<> · {state.mileage.toLocaleString("ru-RU")} км</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Общие 9 ракурсов */}
|
||||
{state.side_photos.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
Общие фото
|
||||
</h2>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{state.side_photos.map((p) => {
|
||||
const label = p.photo_type
|
||||
? SIDE_LABELS[p.photo_type] || `Ракурс ${p.photo_type}`
|
||||
: "Ракурс";
|
||||
const thumbSrc = p.miniature_id
|
||||
? ttImageUrl(p.miniature_id)
|
||||
: ttImageUrl(p.image_id);
|
||||
return (
|
||||
<button
|
||||
key={p.image_id}
|
||||
onClick={() => {
|
||||
setPreview({ kind: "side", photo: p });
|
||||
setShowLines(false);
|
||||
}}
|
||||
className="aspect-square overflow-hidden rounded-lg bg-muted hover:opacity-80 transition-opacity relative"
|
||||
>
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt={label}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-1">
|
||||
<div className="text-white text-[10px] font-medium truncate">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Карта повреждений */}
|
||||
{damagedZoneIds.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
Карта повреждений
|
||||
<span className="ml-2 text-xs font-normal">
|
||||
{damagedZoneIds.length}{" "}
|
||||
{damagedZoneIds.length === 1 ? "зона" : "зон"}
|
||||
</span>
|
||||
</h2>
|
||||
<TtDamageMap
|
||||
damagedZoneIds={damagedZoneIds}
|
||||
damages={damageOverlays}
|
||||
onZoneClick={scrollToZone}
|
||||
/>
|
||||
<div className="text-[11px] text-muted-foreground mt-1 text-center">
|
||||
Тап на зону — прокрутка к деталям
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Детали по зонам с повреждениями */}
|
||||
{zoneIds.length === 0 ? (
|
||||
<Card className="p-4 text-sm text-muted-foreground">
|
||||
В осмотре нет ни фото, ни повреждений.
|
||||
</Card>
|
||||
) : (
|
||||
zoneIds.map((zid) => {
|
||||
const photos = state.photos_by_zone[zid] || [];
|
||||
const damages = state.damages_by_zone[zid] || [];
|
||||
const label = labels?.[zid] || `Зона ${zid}`;
|
||||
return (
|
||||
<div
|
||||
key={zid}
|
||||
ref={(el) => {
|
||||
zoneRefs.current[zid] = el;
|
||||
}}
|
||||
className="scroll-mt-4"
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2 capitalize flex items-center justify-between">
|
||||
<span>{label}</span>
|
||||
{damages.length > 0 && (
|
||||
<span className="text-destructive text-xs">
|
||||
⚠ {damages.length}{" "}
|
||||
{damages.length === 1 ? "повреждение" : "повреждений"}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{damages.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{damages.map((d, idx) => {
|
||||
const tname = damageTypeName(d.damage_type_id, vocab);
|
||||
const dname =
|
||||
d.degree != null ? DEGREE_LABELS[d.degree] : null;
|
||||
return (
|
||||
<span
|
||||
key={(d.guid ?? "g") + "_" + idx}
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-destructive/10 text-destructive border border-destructive/30"
|
||||
>
|
||||
<span className="font-medium">{tname}</span>
|
||||
{dname && (
|
||||
<>
|
||||
<span className="opacity-50">·</span>
|
||||
<span>{dname}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{photos.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{photos.map((p, idx) => (
|
||||
<button
|
||||
key={p.image_id + "_" + idx}
|
||||
onClick={() => {
|
||||
setPreview({ kind: "zone", photo: p });
|
||||
setShowLines(false);
|
||||
}}
|
||||
className="aspect-square overflow-hidden rounded-lg bg-muted hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img
|
||||
src={ttImageUrl(p.image_id)}
|
||||
alt={`Зона ${zid}`}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-3 text-xs text-muted-foreground">
|
||||
Фото нет — только координаты повреждений
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
"Скрыть этот осмотр из истории? Будет помечен как игнорируемый — sync не вернёт. Действие обратимо только из БД."
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="w-full py-3 rounded-lg border border-destructive text-destructive hover:bg-destructive/10 active:bg-destructive/20 transition-colors font-semibold"
|
||||
>
|
||||
{deleteMutation.isPending ? "Удаляю…" : "Удалить осмотр"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/90 flex flex-col"
|
||||
onClick={() => setPreview(null)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreview(null);
|
||||
}}
|
||||
className="text-white text-2xl px-3"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{preview.kind === "zone" &&
|
||||
preview.photo.image_with_lines_id && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowLines((v) => !v);
|
||||
}}
|
||||
className="text-white text-sm border border-white/40 px-3 py-1 rounded-lg"
|
||||
>
|
||||
{showLines ? "Без линий" : "С линиями"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={ttImageUrl(
|
||||
preview.kind === "zone" &&
|
||||
showLines &&
|
||||
preview.photo.image_with_lines_id
|
||||
? preview.photo.image_with_lines_id
|
||||
: preview.photo.image_id
|
||||
)}
|
||||
alt="Просмотр"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { getVehicle, getTtHistory } from "@/api/vehicles";
|
||||
import { createInspection, type InspectionType } from "@/api/inspections";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const INSPECTION_TYPE_LABELS: Record<InspectionType, string> = {
|
||||
handover: "Выдача",
|
||||
return: "Приёмка",
|
||||
periodic: "Плановый",
|
||||
"ad-hoc": "Свободный",
|
||||
initial: "Первичный",
|
||||
seizure: "Изъятие",
|
||||
"equipment-change": "Комплектация",
|
||||
"pre-repair": "В ремонт",
|
||||
"post-repair": "Из ремонта",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
in_progress: "В работе",
|
||||
completed: "Завершён",
|
||||
cancelled: "Отменён",
|
||||
};
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const sec = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (sec < 60) return "только что";
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min} мин назад`;
|
||||
const h = Math.floor(min / 60);
|
||||
if (h < 24) return `${h} ч назад`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 30) return `${d} дн назад`;
|
||||
return new Date(iso).toLocaleDateString("ru-RU");
|
||||
}
|
||||
|
||||
export default function VehicleCard() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const vehicleId = Number(id);
|
||||
|
||||
const action = searchParams.get("action") || undefined;
|
||||
|
||||
const [pendingType, setPendingType] = useState<InspectionType | null>(null);
|
||||
const [mileageInput, setMileageInput] = useState("");
|
||||
const [mileageSource, setMileageSource] = useState<
|
||||
"starline" | "last-inspection" | null
|
||||
>(null);
|
||||
|
||||
const { data: v, isLoading, isError } = useQuery({
|
||||
queryKey: ["vehicle", vehicleId],
|
||||
queryFn: () => getVehicle(vehicleId),
|
||||
});
|
||||
|
||||
// История из vendor (Element Mechanic). Молча возвращает пустой список
|
||||
// если машина ещё не синхронизирована — секцию просто скрываем.
|
||||
const { data: tt } = useQuery({
|
||||
queryKey: ["vehicle", vehicleId, "tt-history"],
|
||||
queryFn: () => getTtHistory(vehicleId),
|
||||
enabled: Number.isFinite(vehicleId),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const startInspection = useMutation({
|
||||
mutationFn: (input: { type: InspectionType; mileage: number | undefined }) =>
|
||||
createInspection({ vehicle_id: vehicleId, type: input.type, mileage: input.mileage }),
|
||||
onSuccess: (inspection) => {
|
||||
navigate(`/inspections/${inspection.id}/edit`);
|
||||
},
|
||||
onError: () => toast.error("Не удалось создать осмотр"),
|
||||
});
|
||||
|
||||
const handleStartClick = (type: InspectionType) => {
|
||||
setPendingType(type);
|
||||
// Приоритет: 1) StarLine OBD (свежий одометр), 2) пробег прошлого
|
||||
// осмотра. Менеджер всегда может скорректировать.
|
||||
if (v?.starline_mileage != null) {
|
||||
setMileageInput(String(v.starline_mileage));
|
||||
setMileageSource("starline");
|
||||
return;
|
||||
}
|
||||
const lastWithMileage = v?.recent_inspections.find((i) => i.mileage != null);
|
||||
if (lastWithMileage?.mileage != null) {
|
||||
setMileageInput(String(lastWithMileage.mileage));
|
||||
setMileageSource("last-inspection");
|
||||
return;
|
||||
}
|
||||
setMileageInput("");
|
||||
setMileageSource(null);
|
||||
};
|
||||
|
||||
if (isLoading)
|
||||
return <div className="p-8 text-muted-foreground">Загружаю…</div>;
|
||||
if (isError || !v)
|
||||
return <div className="p-8 text-destructive">Машина не найдена.</div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-4 space-y-4">
|
||||
<button
|
||||
onClick={() => navigate(`/vehicles${action ? `?action=${action}` : ""}`)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← К списку
|
||||
</button>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="text-xl font-semibold">{v.license_plate}</div>
|
||||
{v.status && (
|
||||
<span
|
||||
className={`inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
{
|
||||
"На линии": "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||
"Простой": "bg-muted text-muted-foreground",
|
||||
"Ремонтируется": "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
|
||||
"Ждет ремонта": "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
|
||||
"ДТП": "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
|
||||
"Бронь": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
"Выкуп": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
"На продаже": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
"Подготовка на продажу": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
"Ждет доставки до офиса": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
}[v.status] ?? "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{v.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{[v.make, v.model, v.year].filter(Boolean).join(" ") || "—"}
|
||||
</div>
|
||||
{v.vin && (
|
||||
<div className="text-xs text-muted-foreground mt-1">VIN: {v.vin}</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
Начать осмотр
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
onClick={() => handleStartClick("handover")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Выдача
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleStartClick("return")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Приёмка
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleStartClick("periodic")}
|
||||
disabled={startInspection.isPending}
|
||||
className="col-span-2"
|
||||
>
|
||||
Плановый
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
Прошлые осмотры
|
||||
</h2>
|
||||
{v.recent_inspections.length === 0 ? (
|
||||
<Card className="p-4 text-sm text-muted-foreground">
|
||||
Осмотров ещё не было.
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{v.recent_inspections.map((i) => (
|
||||
<Card
|
||||
key={i.id}
|
||||
className="p-3 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => navigate(`/inspections/${i.id}`)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="font-medium">
|
||||
{INSPECTION_TYPE_LABELS[i.type as InspectionType] ?? i.type}
|
||||
</div>
|
||||
{(i.photos_count ?? 0) > 0 && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
|
||||
title={`Фото: ${i.photos_count}`}
|
||||
>
|
||||
📷 {i.photos_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-2 flex-wrap">
|
||||
<span>{new Date(i.started_at).toLocaleString("ru-RU")}</span>
|
||||
{i.mileage != null && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<span>{i.mileage.toLocaleString("ru-RU")} км</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{i.driver_name && (
|
||||
<div className="text-xs mt-1 text-foreground/80 truncate">
|
||||
👤 {i.driver_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0">
|
||||
{STATUS_LABELS[i.status] ?? i.status}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tt && tt.states.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
История осмотров (Element Mechanic)
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{tt.states.map((s) => {
|
||||
const date = s.unix_time
|
||||
? new Date(s.unix_time * 1000).toLocaleString("ru-RU")
|
||||
: "—";
|
||||
return (
|
||||
<Card
|
||||
key={s.id}
|
||||
className="p-3 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => navigate(`/tt-states/${s.id}`)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">
|
||||
{s.mechanic_name || "Осмотр"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-2 flex-wrap">
|
||||
<span>{date}</span>
|
||||
{s.mileage != null && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<span>{s.mileage.toLocaleString("ru-RU")} км</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{s.driver_name && (
|
||||
<div className="text-xs mt-1 text-foreground/80 truncate">
|
||||
👤 {s.driver_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0 text-right">
|
||||
<div>📷 {s.photos_count}</div>
|
||||
{s.damages_count > 0 && (
|
||||
<div className="text-destructive mt-0.5">
|
||||
⚠ {s.damages_count}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingType && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center p-0 sm:p-4"
|
||||
onClick={() => setPendingType(null)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm bg-card rounded-t-2xl sm:rounded-2xl shadow-xl p-5 space-y-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{INSPECTION_TYPE_LABELS[pendingType]}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Введите пробег машины на текущий момент.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="mileage">Пробег, км</Label>
|
||||
<Input
|
||||
id="mileage"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={mileageInput}
|
||||
onChange={(e) => {
|
||||
setMileageInput(e.target.value.replace(/\D/g, ""));
|
||||
setMileageSource(null);
|
||||
}}
|
||||
placeholder="123456"
|
||||
autoFocus
|
||||
/>
|
||||
{mileageSource === "starline" && (
|
||||
<div className="text-[11px] text-muted-foreground flex items-center gap-1.5">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
<span>
|
||||
Из StarLine
|
||||
{v?.starline_mileage_at && (
|
||||
<> · {timeAgo(v.starline_mileage_at)}</>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground/60">
|
||||
· скорректируйте если надо
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{mileageSource === "last-inspection" && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Из прошлого осмотра · скорректируйте если надо
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPendingType(null)}
|
||||
className="flex-1"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
disabled={startInspection.isPending}
|
||||
onClick={() => {
|
||||
const m = mileageInput.trim() ? Number(mileageInput) : undefined;
|
||||
startInspection.mutate({
|
||||
type: pendingType,
|
||||
mileage: m && Number.isFinite(m) ? m : undefined,
|
||||
});
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
{startInspection.isPending ? "Создаём…" : "Начать"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
startInspection.mutate({ type: pendingType, mileage: undefined });
|
||||
}}
|
||||
className="w-full text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Пропустить пробег
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Экран выбора машины перед созданием ремонта (Фича 3).
|
||||
*
|
||||
* Открывается из `/repairs` по кнопке «+ Добавить ремонт».
|
||||
* Поиск по госномеру (У44 — релевантность). После тапа на строку
|
||||
* переходим в `/repairs/new/:carId` (CreateRepairPage).
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ChevronLeft, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { searchCars, type CarBrief } from "@/api/repairsCreate";
|
||||
|
||||
export default function CarSelectPage() {
|
||||
const navigate = useNavigate();
|
||||
const [q, setQ] = useState("");
|
||||
const [cars, setCars] = useState<CarBrief[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const list = await searchCars(q);
|
||||
if (!cancelled) setCars(list);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message || "Не удалось загрузить машины");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}, 200); // debounce
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [q]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate("/repairs")} className="-ml-2">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-base font-semibold">Выберите машину</h1>
|
||||
</div>
|
||||
<div className="px-3 pb-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Госномер…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 container max-w-2xl py-3">
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-12 rounded-md bg-muted animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : cars.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-12">
|
||||
{q ? "Ничего не найдено" : "Нет активных машин в парке"}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{cars.map((c) => (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/repairs/new/${c.id}`)}
|
||||
className="w-full flex items-baseline justify-between gap-3 rounded-md border bg-card hover:bg-accent/40 transition-colors p-3 text-left"
|
||||
>
|
||||
<span className="font-medium">{c.plate}</span>
|
||||
<span className="text-sm text-muted-foreground truncate">
|
||||
{[c.brand, c.model].filter(Boolean).join(" ")}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
/**
|
||||
* Карточка создания ремонта (Фича 4 + У19/У20/У21/У24/У25/У42/У45).
|
||||
*
|
||||
* Online-only flow (PR-7). Offline-first из У47/PR-8 — следующий PR.
|
||||
*
|
||||
* Блоки:
|
||||
* - Шапка: машина (snapshot из /cars/{id}/context) + механик + водитель.
|
||||
* - Пробег (предзаполняется StarLine, можно перебить вручную).
|
||||
* - Фото — multi-upload через presign + PUT в `tmp/`.
|
||||
* - Работы — выбор из справочника, цена редактируема через
|
||||
* «карандашик» (У19); комментарий обязателен при разблокировке.
|
||||
* - Запчасти — текст + qty, автодополнение (У43).
|
||||
* - Замена масла — раскрывающийся блок с дефолтным интервалом 10000 (У25).
|
||||
*
|
||||
* Сохранение: POST /repairs с `Idempotency-Key` (UUID v4 в state).
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ChevronLeft, Plus, Trash2, Pencil, Camera } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
createRepair,
|
||||
getCarContext,
|
||||
presignPhoto,
|
||||
searchWorks,
|
||||
suggestParts,
|
||||
uploadPhotoToTmp,
|
||||
type CarContext,
|
||||
type CreatePhotoPayload,
|
||||
type MechWork,
|
||||
type PresignResponse,
|
||||
} from "@/api/repairsCreate";
|
||||
|
||||
// ── Local state types ─────────────────────────────────────────────────────
|
||||
|
||||
interface PhotoState {
|
||||
presign: PresignResponse;
|
||||
tag: "auto" | "part" | "other";
|
||||
status: "uploading" | "uploaded" | "error";
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface WorkRow {
|
||||
// Идентификатор строки внутри UI (для key); тождественен work_catalog_id
|
||||
// + автоинкремент, чтобы дубли по У2 различались.
|
||||
rowKey: string;
|
||||
work_catalog_id: number;
|
||||
name: string; // снапшот имени из прайса
|
||||
price_catalog: number;
|
||||
price_applied: number;
|
||||
unlocked: boolean; // карандашик разблокировал поле
|
||||
override_comment: string;
|
||||
}
|
||||
|
||||
interface PartRow {
|
||||
rowKey: string;
|
||||
name: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
interface OilChangeState {
|
||||
enabled: boolean;
|
||||
mileage_at_change: number | null;
|
||||
mileage_is_dirty: boolean; // У24: мягкая подписка с шапочным
|
||||
next_in_km: number;
|
||||
sticker: PhotoState | null;
|
||||
}
|
||||
|
||||
|
||||
function uuid4(): string {
|
||||
// crypto.randomUUID требует secure context; mechanic.pptaxi.ru — https.
|
||||
return (crypto as any).randomUUID
|
||||
? (crypto as any).randomUUID()
|
||||
: `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
|
||||
export default function CreateRepairPage() {
|
||||
const navigate = useNavigate();
|
||||
const { carId: carIdRaw } = useParams<{ carId: string }>();
|
||||
const carId = carIdRaw ? Number(carIdRaw) : 0;
|
||||
|
||||
// Idempotency-Key для этой формы — стабилен между ре-сабмитами (У20).
|
||||
const idemKey = useRef<string>(uuid4());
|
||||
|
||||
const [ctx, setCtx] = useState<CarContext | null>(null);
|
||||
const [ctxError, setCtxError] = useState<string | null>(null);
|
||||
|
||||
const [mileage, setMileage] = useState<number | null>(null);
|
||||
const [photos, setPhotos] = useState<PhotoState[]>([]);
|
||||
const [works, setWorks] = useState<WorkRow[]>([]);
|
||||
const [parts, setParts] = useState<PartRow[]>([]);
|
||||
const [oilChange, setOilChange] = useState<OilChangeState>({
|
||||
enabled: false,
|
||||
mileage_at_change: null,
|
||||
mileage_is_dirty: false,
|
||||
next_in_km: 10000,
|
||||
sticker: null,
|
||||
});
|
||||
|
||||
const [worksPicker, setWorksPicker] = useState<{ open: boolean; q: string; results: MechWork[] }>(
|
||||
{ open: false, q: "", results: [] },
|
||||
);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Initial: подгрузить контекст машины.
|
||||
useEffect(() => {
|
||||
if (!carId) return;
|
||||
getCarContext(carId)
|
||||
.then((c) => {
|
||||
setCtx(c);
|
||||
setMileage(c.mileage_starline);
|
||||
})
|
||||
.catch((e) => setCtxError((e as Error).message || "Не удалось загрузить машину"));
|
||||
}, [carId]);
|
||||
|
||||
// У24: мягкая подписка пробега блока масла на шапочный.
|
||||
useEffect(() => {
|
||||
if (oilChange.enabled && !oilChange.mileage_is_dirty) {
|
||||
setOilChange((oc) => ({ ...oc, mileage_at_change: mileage }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mileage, oilChange.enabled]);
|
||||
|
||||
// ── Photo upload ─────────────────────────────────────────────────────────
|
||||
|
||||
const onPhotoInput = async (files: FileList | null, target: "gallery" | "sticker") => {
|
||||
if (!files || files.length === 0) return;
|
||||
for (const file of Array.from(files)) {
|
||||
const ext = (file.name.split(".").pop() || "jpg").toLowerCase();
|
||||
const placeholder: PhotoState = {
|
||||
presign: {
|
||||
photo_uuid: uuid4(),
|
||||
extension: ext,
|
||||
content_type: file.type || "image/jpeg",
|
||||
key: "",
|
||||
upload_url: "",
|
||||
expires_in: 0,
|
||||
},
|
||||
tag: "auto",
|
||||
status: "uploading",
|
||||
};
|
||||
if (target === "gallery") setPhotos((arr) => [...arr, placeholder]);
|
||||
else setOilChange((oc) => ({ ...oc, sticker: { ...placeholder, tag: "auto" } }));
|
||||
|
||||
try {
|
||||
const presign = await presignPhoto(ext, placeholder.presign.photo_uuid);
|
||||
await uploadPhotoToTmp(file, presign);
|
||||
const updated: PhotoState = { presign, tag: "auto", status: "uploaded" };
|
||||
if (target === "gallery") {
|
||||
setPhotos((arr) =>
|
||||
arr.map((p) => (p.presign.photo_uuid === presign.photo_uuid ? updated : p)),
|
||||
);
|
||||
} else {
|
||||
setOilChange((oc) => ({ ...oc, sticker: updated }));
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message || "Ошибка загрузки";
|
||||
if (target === "gallery") {
|
||||
setPhotos((arr) =>
|
||||
arr.map((p) =>
|
||||
p.presign.photo_uuid === placeholder.presign.photo_uuid
|
||||
? { ...p, status: "error", errorMessage: msg }
|
||||
: p,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setOilChange((oc) => ({
|
||||
...oc,
|
||||
sticker: oc.sticker ? { ...oc.sticker, status: "error", errorMessage: msg } : oc.sticker,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removePhoto = (photoUuid: string) =>
|
||||
setPhotos((arr) => arr.filter((p) => p.presign.photo_uuid !== photoUuid));
|
||||
|
||||
// ── Works picker ─────────────────────────────────────────────────────────
|
||||
|
||||
const openWorksPicker = async () => {
|
||||
setWorksPicker({ open: true, q: "", results: [] });
|
||||
const list = await searchWorks("");
|
||||
setWorksPicker((s) => ({ ...s, results: list }));
|
||||
};
|
||||
|
||||
const queryWorks = async (q: string) => {
|
||||
setWorksPicker((s) => ({ ...s, q }));
|
||||
const list = await searchWorks(q);
|
||||
setWorksPicker((s) => ({ ...s, results: list }));
|
||||
};
|
||||
|
||||
const addWorkFromCatalog = (w: MechWork) => {
|
||||
const price = Number(w.price);
|
||||
setWorks((arr) => [
|
||||
...arr,
|
||||
{
|
||||
rowKey: uuid4(),
|
||||
work_catalog_id: w.id,
|
||||
name: w.name,
|
||||
price_catalog: price,
|
||||
price_applied: price,
|
||||
unlocked: false,
|
||||
override_comment: "",
|
||||
},
|
||||
]);
|
||||
setWorksPicker({ open: false, q: "", results: [] });
|
||||
};
|
||||
|
||||
const toggleWorkUnlock = (rowKey: string) => {
|
||||
setWorks((arr) =>
|
||||
arr.map((w) =>
|
||||
w.rowKey === rowKey
|
||||
? w.unlocked
|
||||
? // повторный тап = отмена правки, возврат к цене прайса (У19)
|
||||
{ ...w, unlocked: false, price_applied: w.price_catalog, override_comment: "" }
|
||||
: { ...w, unlocked: true }
|
||||
: w,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const removeWork = (rowKey: string) =>
|
||||
setWorks((arr) => arr.filter((w) => w.rowKey !== rowKey));
|
||||
|
||||
const totalSum = useMemo(
|
||||
() => works.reduce((acc, w) => acc + Number(w.price_applied || 0), 0),
|
||||
[works],
|
||||
);
|
||||
|
||||
// ── Parts ────────────────────────────────────────────────────────────────
|
||||
|
||||
const [partsDraft, setPartsDraft] = useState<{ name: string; suggestions: string[] }>({ name: "", suggestions: [] });
|
||||
|
||||
const queryPartSuggestions = async (q: string) => {
|
||||
setPartsDraft({ name: q, suggestions: [] });
|
||||
if (!q.trim()) return;
|
||||
const list = await suggestParts(q);
|
||||
setPartsDraft({ name: q, suggestions: list.map((s) => s.name) });
|
||||
};
|
||||
|
||||
const addPart = (name: string) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setParts((arr) => [...arr, { rowKey: uuid4(), name: trimmed, qty: 1 }]);
|
||||
setPartsDraft({ name: "", suggestions: [] });
|
||||
};
|
||||
|
||||
const removePart = (rowKey: string) =>
|
||||
setParts((arr) => arr.filter((p) => p.rowKey !== rowKey));
|
||||
|
||||
// ── Submit ───────────────────────────────────────────────────────────────
|
||||
|
||||
const canSubmit = () =>
|
||||
photos.length > 0 &&
|
||||
photos.every((p) => p.status === "uploaded") &&
|
||||
works.length > 0 &&
|
||||
works.every((w) => !w.unlocked || w.override_comment.trim().length > 0);
|
||||
|
||||
const submitWhyDisabled = (): string => {
|
||||
if (photos.length === 0) return "Добавьте хотя бы одно фото";
|
||||
if (photos.some((p) => p.status !== "uploaded")) return "Дождитесь окончания загрузки фото";
|
||||
if (works.length === 0) return "Добавьте хотя бы одну работу";
|
||||
const unlockedNoComment = works.find((w) => w.unlocked && !w.override_comment.trim());
|
||||
if (unlockedNoComment) return "Укажите комментарий к ручной правке цены";
|
||||
return "";
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const reason = submitWhyDisabled();
|
||||
if (reason) {
|
||||
toast.error(reason);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const photoPayload: CreatePhotoPayload[] = photos.map((p, i) => ({
|
||||
photo_uuid: p.presign.photo_uuid,
|
||||
extension: p.presign.extension,
|
||||
tag: p.tag,
|
||||
sort_order: i,
|
||||
}));
|
||||
const result = await createRepair(
|
||||
{
|
||||
car_id: carId,
|
||||
mileage,
|
||||
works: works.map((w) => ({
|
||||
work_catalog_id: w.work_catalog_id,
|
||||
price_applied: Number(w.price_applied),
|
||||
manual_override: w.unlocked,
|
||||
override_comment: w.unlocked ? w.override_comment.trim() : null,
|
||||
})),
|
||||
parts: parts.map((p) => ({ name: p.name, qty: p.qty })),
|
||||
photos: photoPayload,
|
||||
oil_change: oilChange.enabled
|
||||
? {
|
||||
enabled: true,
|
||||
mileage_at_change: oilChange.mileage_at_change,
|
||||
next_in_km: oilChange.next_in_km,
|
||||
sticker_photo_uuid:
|
||||
oilChange.sticker?.status === "uploaded"
|
||||
? oilChange.sticker.presign.photo_uuid
|
||||
: null,
|
||||
sticker_extension:
|
||||
oilChange.sticker?.status === "uploaded"
|
||||
? oilChange.sticker.presign.extension
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
idemKey.current,
|
||||
);
|
||||
toast.success(`Ремонт #${result.id} сохранён`);
|
||||
navigate("/repairs", { replace: true });
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message || "Не удалось сохранить");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (ctxError) {
|
||||
return (
|
||||
<div className="container max-w-2xl py-8">
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm">
|
||||
{ctxError}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!ctx) {
|
||||
return (
|
||||
<div className="container max-w-2xl py-8">
|
||||
<div className="text-sm text-muted-foreground">Загрузка…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(-1)} className="-ml-2">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
<span className="ml-1">Назад</span>
|
||||
</Button>
|
||||
<h1 className="text-base font-semibold">Новый ремонт</h1>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={submitting || !canSubmit()}
|
||||
onClick={onSubmit}
|
||||
title={submitWhyDisabled() || undefined}
|
||||
>
|
||||
{submitting ? "Сохранение…" : "Сохранить"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-3 pb-3 text-sm">
|
||||
<div className="font-medium">{ctx.plate}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{[ctx.brand, ctx.model].filter(Boolean).join(" ")}
|
||||
{ctx.driver_name ? ` · ${ctx.driver_name}` : ` · Без водителя`}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 container max-w-2xl py-4 space-y-5">
|
||||
{/* Пробег */}
|
||||
<section>
|
||||
<Label htmlFor="mileage" className="text-sm">Пробег (км)</Label>
|
||||
<Input
|
||||
id="mileage"
|
||||
type="number"
|
||||
min={0}
|
||||
value={mileage ?? ""}
|
||||
onChange={(e) => setMileage(e.target.value === "" ? null : Number(e.target.value))}
|
||||
placeholder={ctx.mileage_starline == null ? "StarLine недоступен — введите вручную" : ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Фото */}
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<Label className="text-sm">Фото ремонта</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{photos.length === 0 ? "Минимум одно фото" : `${photos.length} шт.`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mt-2">
|
||||
{photos.map((p) => (
|
||||
<div key={p.presign.photo_uuid} className="relative aspect-square border rounded-md bg-muted overflow-hidden">
|
||||
{p.status === "uploading" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs">Загрузка…</div>
|
||||
)}
|
||||
{p.status === "error" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-destructive p-1 text-center">
|
||||
{p.errorMessage || "Ошибка"}
|
||||
</div>
|
||||
)}
|
||||
{p.status === "uploaded" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground">
|
||||
✓ Загружено
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePhoto(p.presign.photo_uuid)}
|
||||
className="absolute top-1 right-1 rounded-full bg-background/80 p-1 hover:bg-background"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<label className="aspect-square border-2 border-dashed rounded-md flex items-center justify-center cursor-pointer hover:bg-accent/40">
|
||||
<Camera className="h-6 w-6 text-muted-foreground" />
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => onPhotoInput(e.target.files, "gallery")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Работы */}
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<Label className="text-sm">Работы</Label>
|
||||
<Button variant="outline" size="sm" onClick={openWorksPicker}>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Добавить работу
|
||||
</Button>
|
||||
</div>
|
||||
{works.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground mt-2">Минимум одна работа</div>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{works.map((w) => (
|
||||
<li
|
||||
key={w.rowKey}
|
||||
className={`rounded-md border p-2 ${w.unlocked ? "bg-amber-50 border-amber-300" : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{w.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Прайс: {w.price_catalog} ₽
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={w.price_applied}
|
||||
disabled={!w.unlocked}
|
||||
onChange={(e) =>
|
||||
setWorks((arr) =>
|
||||
arr.map((row) =>
|
||||
row.rowKey === w.rowKey
|
||||
? { ...row, price_applied: Number(e.target.value) }
|
||||
: row,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="w-24"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleWorkUnlock(w.rowKey)}
|
||||
title={w.unlocked ? "Отменить ручную правку" : "Перебить цену"}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeWork(w.rowKey)}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{w.unlocked && (
|
||||
<textarea
|
||||
placeholder="Комментарий к ручной правке (обязателен)"
|
||||
value={w.override_comment}
|
||||
onChange={(e) =>
|
||||
setWorks((arr) =>
|
||||
arr.map((row) =>
|
||||
row.rowKey === w.rowKey ? { ...row, override_comment: e.target.value } : row,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="mt-2 w-full text-sm rounded-md border bg-background p-2 min-h-[60px]"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{works.length > 0 && (
|
||||
<div className="mt-3 text-right text-sm font-medium">Итого: {totalSum} ₽</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Запчасти */}
|
||||
<section>
|
||||
<Label className="text-sm">Запчасти и материалы</Label>
|
||||
{parts.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{parts.map((p) => (
|
||||
<li key={p.rowKey} className="flex items-center gap-2 text-sm">
|
||||
<span className="flex-1">{p.name}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={p.qty}
|
||||
onChange={(e) =>
|
||||
setParts((arr) =>
|
||||
arr.map((row) =>
|
||||
row.rowKey === p.rowKey ? { ...row, qty: Math.max(1, Number(e.target.value)) } : row,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="w-20"
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => removePart(p.rowKey)}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Input
|
||||
value={partsDraft.name}
|
||||
onChange={(e) => queryPartSuggestions(e.target.value)}
|
||||
placeholder="Например, Свеча NGK"
|
||||
onKeyDown={(e) => e.key === "Enter" && addPart(partsDraft.name)}
|
||||
/>
|
||||
<Button onClick={() => addPart(partsDraft.name)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{partsDraft.suggestions.length > 0 && (
|
||||
<ul className="mt-1 border rounded-md bg-popover divide-y">
|
||||
{partsDraft.suggestions.map((s) => (
|
||||
<li key={s}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left text-sm px-3 py-2 hover:bg-accent/40"
|
||||
onClick={() => addPart(s)}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Замена масла */}
|
||||
<section className="border rounded-md p-3">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={oilChange.enabled}
|
||||
onChange={(e) =>
|
||||
setOilChange((oc) => ({ ...oc, enabled: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Была замена масла
|
||||
</label>
|
||||
{oilChange.enabled && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<Label className="text-sm">Пробег на момент замены</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={oilChange.mileage_at_change ?? ""}
|
||||
onChange={(e) =>
|
||||
setOilChange((oc) => ({
|
||||
...oc,
|
||||
mileage_at_change: e.target.value === "" ? null : Number(e.target.value),
|
||||
mileage_is_dirty: true,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Следующая замена через (км)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={oilChange.next_in_km}
|
||||
onChange={(e) =>
|
||||
setOilChange((oc) => ({ ...oc, next_in_km: Number(e.target.value) || 0 }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Фото стикера (одно)</Label>
|
||||
<label className="mt-1 block border-2 border-dashed rounded-md p-4 text-center cursor-pointer hover:bg-accent/40">
|
||||
{oilChange.sticker == null ? (
|
||||
<span className="text-sm text-muted-foreground">Тап чтобы загрузить</span>
|
||||
) : oilChange.sticker.status === "uploading" ? (
|
||||
"Загрузка…"
|
||||
) : oilChange.sticker.status === "error" ? (
|
||||
<span className="text-sm text-destructive">
|
||||
Ошибка: {oilChange.sticker.errorMessage}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm">✓ Загружено</span>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={(e) => onPhotoInput(e.target.files, "sticker")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Works picker modal */}
|
||||
{worksPicker.open && (
|
||||
<div className="fixed inset-0 z-50 bg-background/95 flex flex-col">
|
||||
<header className="border-b p-3 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setWorksPicker({ open: false, q: "", results: [] })}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Поиск работы…"
|
||||
value={worksPicker.q}
|
||||
onChange={(e) => queryWorks(e.target.value)}
|
||||
/>
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<ul className="divide-y">
|
||||
{worksPicker.results.map((w) => (
|
||||
<li key={w.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addWorkFromCatalog(w)}
|
||||
className="w-full text-left p-3 hover:bg-accent/40 flex items-baseline justify-between gap-3"
|
||||
>
|
||||
<span className="flex-1 min-w-0 truncate">{w.name}</span>
|
||||
<span className="text-sm text-muted-foreground">{w.price} ₽</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{worksPicker.results.length === 0 && (
|
||||
<li className="p-6 text-center text-sm text-muted-foreground">
|
||||
Нет работ по запросу
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Лента ремонтов парка (Фича 2 + У5 + У38 + У39 + У40 + У41).
|
||||
*
|
||||
* Главный экран раздела «Ремонт». Все ремонты парка, сортировка
|
||||
* `created_at DESC`. Бесконечный скролл по 20. Pull-to-refresh,
|
||||
* обновление при возврате в приложение (через useRepairsFeed).
|
||||
*
|
||||
* Пустая лента — плейсхолдер «В парке пока нет ремонтов» (У41).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ChevronLeft, Plus, Wrench } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRepairsFeed } from "@/hooks/useRepairsFeed";
|
||||
import { formatFeedTimestamp } from "@/lib/timeFormat";
|
||||
import type { FeedItem } from "@/api/repairsFeed";
|
||||
|
||||
export default function RepairsFeedPage() {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
items,
|
||||
isLoading,
|
||||
isError,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useRepairsFeed();
|
||||
|
||||
// IntersectionObserver для бесконечной прокрутки.
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el || !hasNextPage) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
|
||||
void fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
// Pull-to-refresh: простой UX через тач-скролл сверху.
|
||||
// Делаем минимально-инвазивно: на тап-кнопку «обновить» вызываем refetch.
|
||||
// Полноценный pull-to-refresh с жестом — отдельная задача.
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const handleAdd = () => navigate("/repairs/new");
|
||||
const handleOpen = (item: FeedItem) => navigate(`/repairs/${item.id}`);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate("/")}
|
||||
className="-ml-2"
|
||||
aria-label="Назад"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
<span className="ml-1">Назад</span>
|
||||
</Button>
|
||||
<h1 className="text-base font-semibold">Ремонт</h1>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
aria-label="Добавить ремонт"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<span className="hidden sm:inline">Добавить</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 container max-w-2xl py-3 space-y-2">
|
||||
{/* Refresh tap-area для не-touch юзкейса; жест pull-to-refresh — в PR-8. */}
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={isFetching}
|
||||
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{isFetching ? "Обновляем…" : "Обновить"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-20 rounded-lg bg-muted animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && !isLoading && (
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm">
|
||||
Не удалось загрузить ленту ремонтов. Проверьте сеть и попробуйте снова.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && items.length === 0 && <EmptyState onAdd={handleAdd} />}
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<RepairRow item={item} onOpen={() => handleOpen(item)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} aria-hidden="true" />
|
||||
{isFetchingNextPage && (
|
||||
<div className="text-center text-xs text-muted-foreground py-4">
|
||||
Загружаем ещё…
|
||||
</div>
|
||||
)}
|
||||
{!hasNextPage && items.length > 0 && !isFetchingNextPage && (
|
||||
<div className="text-center text-xs text-muted-foreground py-4">
|
||||
Это все ремонты.
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function RepairRow({ item, onOpen }: { item: FeedItem; onOpen: () => void }) {
|
||||
const carLine = [item.car_plate, item.car_make, item.car_model]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="w-full flex items-stretch gap-3 rounded-lg border bg-card hover:bg-accent/40 transition-colors text-left p-3"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-md bg-muted overflow-hidden flex-shrink-0 flex items-center justify-center">
|
||||
{item.photo_url ? (
|
||||
// Lazy-load — браузер сам решит когда грузить.
|
||||
<img
|
||||
src={item.photo_url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Wrench className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="font-medium truncate">{carLine}</div>
|
||||
<div className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatFeedTimestamp(item.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
{item.driver_name && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
Водитель: {item.driver_name}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
Механик: {item.mechanic_name}
|
||||
</div>
|
||||
<WorksLine works={item.works_preview.map((w) => w.name)} extra={item.works_extra} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function WorksLine({ works, extra }: { works: string[]; extra: number }) {
|
||||
// У40: ellipsis в одну строку. Соединяем имена работ через «·».
|
||||
if (works.length === 0) return null;
|
||||
const tail = extra > 0 ? ` · +${extra} ${pluralWorks(extra)}` : "";
|
||||
return (
|
||||
<div className="text-sm truncate" title={works.join(", ") + (extra ? ` (+${extra})` : "")}>
|
||||
{works.join(" · ")}
|
||||
{tail && <span className="text-muted-foreground">{tail}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function pluralWorks(n: number): string {
|
||||
// 1 → «работа», 2..4 → «работы», 5+ → «работ»
|
||||
const mod10 = n % 10;
|
||||
const mod100 = n % 100;
|
||||
if (mod100 >= 11 && mod100 <= 14) return "работ";
|
||||
if (mod10 === 1) return "работа";
|
||||
if (mod10 >= 2 && mod10 <= 4) return "работы";
|
||||
return "работ";
|
||||
}
|
||||
|
||||
|
||||
function EmptyState({ onAdd }: { onAdd: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center py-16 px-4 space-y-4">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<Wrench className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-base font-semibold">В парке пока нет ремонтов</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
Создайте первый ремонт — он появится здесь.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onAdd}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Добавить ремонт
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
setToken: (t: string | null) => void;
|
||||
}
|
||||
|
||||
export const useAuth = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
setToken: (t) => set({ token: t }),
|
||||
}),
|
||||
{ name: "mechanic-auth" }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,53 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "1rem",
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["Inter", "system-ui", "sans-serif"],
|
||||
},
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Strict */
|
||||
"strict": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path alias */
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
includeAssets: ["icons/*.png"],
|
||||
manifest: false, // we ship our own at public/manifest.webmanifest
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /\/api\//,
|
||||
handler: "NetworkOnly",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "src") },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8000",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
# Caddy fragment for Premium Mechanic PWA on mechanic.pptaxi.ru.
|
||||
# Apply on VDS 100.64.0.12 by copying/including into /etc/caddy/Caddyfile
|
||||
# (or /etc/caddy/sites-enabled/ if the main Caddyfile uses imports).
|
||||
#
|
||||
# Assumes:
|
||||
# - TaxiDashboard backend listens on 127.0.0.1:8000
|
||||
# - Frontend PWA build is rsynced to /opt/sites/mechanic-pwa/dist/
|
||||
|
||||
mechanic.pptaxi.ru {
|
||||
encode gzip zstd
|
||||
|
||||
# PWA: pass /api/* to the backend so the frontend can use relative URLs.
|
||||
handle /api/* {
|
||||
reverse_proxy 127.0.0.1:8000
|
||||
}
|
||||
|
||||
# PWA static files with SPA fallback to index.html.
|
||||
handle {
|
||||
root * /opt/sites/mechanic-pwa/dist
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/mechanic.pptaxi.ru.log {
|
||||
roll_size 50mb
|
||||
roll_keep 5
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
# Basic security headers for a PWA
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options DENY
|
||||
# Camera permission for our origin only
|
||||
Permissions-Policy "camera=(self), microphone=(self), geolocation=()"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
# Set global CORS allow-origin on MinIO instance (taxi-minio container).
|
||||
# This MinIO version does not expose per-bucket PutBucketCors; CORS is global.
|
||||
#
|
||||
# Run on VDS 100.64.0.12 as root. Idempotent — safe to re-run after origin list changes.
|
||||
set -e
|
||||
export TERM=xterm
|
||||
|
||||
ALLOWED_ORIGINS="https://mechanic.pptaxi.ru,https://crm.pptaxi.ru,http://localhost:5173,http://localhost:8000"
|
||||
|
||||
docker exec taxi-minio sh -c "
|
||||
mc alias set local http://localhost:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null
|
||||
mc admin config set local api cors_allow_origin='$ALLOWED_ORIGINS'
|
||||
echo
|
||||
echo '=== verify cors_allow_origin ==='
|
||||
mc admin config get local api | tr ' ' '\n' | grep '^cors_allow_origin='
|
||||
"
|
||||
@@ -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 0x3baa0–0x3bd50 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
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"_meta": {
|
||||
"source": "Ttc.dll #US heap, contiguous block at offsets 0xc2e24–0xc3084",
|
||||
"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 0–12 are general exterior damage types (user-selectable for any zone)",
|
||||
"Items 13–18 appear to be salon-specific (Грязный салон, Запах табака, etc.) — shown only for interior zones (44–46 seats, salon)",
|
||||
"Установлено/Снято (19–20) and Контроль (21) are semi-system states used for equipment completeness zones (33–48 area)",
|
||||
"Штат (25) appears to be a system/staff marker, not user-selectable",
|
||||
"Порез, Прокол, Порвано are tire-specific damage types for zones 17–20",
|
||||
"Есть/Нет повреждений 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 0xc39e8–0xc39f8 (loading state transitions)"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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: ' '
|
||||
|
After Width: | Height: | Size: 129 KiB |
|
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
|
||||
@@ -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="
|
||||
|
||||
@@ -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"/>
|
||||