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 |
@@ -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.
|
||||
|
||||
## Текущая фаза
|
||||
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
# Premium Mechanic PWA — Implementation State
|
||||
|
||||
> Last updated: 2026-05-17 (Phase 1 MVP code-complete, awaiting production deploy)
|
||||
> Last updated: 2026-05-17 (Phase 1 MVP LIVE IN PRODUCTION)
|
||||
|
||||
## Status: **READY TO DEPLOY** (Phase J prep done, awaiting explicit user OK)
|
||||
## Status: 🟢 **DEPLOYED — Phase 1 MVP LIVE on https://mechanic.pptaxi.ru**
|
||||
|
||||
Backend (Phases B-F) and Frontend (Phases G-I) are feature-complete and pushed to Gitea. Deployment is the only step left — it requires SSH into `root@100.64.0.12` (prod VDS), Docker compose restart, and Caddy reload. **Not executed automatically.** See "Deployment runbook" section below for the exact steps.
|
||||
All 4 deployment phases executed successfully:
|
||||
|
||||
- ✅ **J1 Backend**: `git push vds feat/mechanic-mvp` → checkout on VDS → `docker build` → rolling restart blue+green → healthz 5/5 green on `https://crm.pptaxi.ru/api/v1/mechanic/healthz`
|
||||
- ✅ **J2 Frontend**: `scp dist/` → VDS at `/opt/sites/mechanic-pwa/dist/` (448K) → added Caddy block + bind mount → `docker compose up -d --force-recreate caddy` → Let's Encrypt cert auto-issued → HTTPS 200 on `https://mechanic.pptaxi.ru/`
|
||||
- ✅ **J3a Merge**: `feat/mechanic-mvp` → `main` (ff-only) in BOTH repos, pushed to Gitea + VDS
|
||||
- ✅ **J3b Prod sync**: VDS now on `main` branch, healthz still green
|
||||
|
||||
**Smoke checks passed:**
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `GET https://mechanic.pptaxi.ru/` | 200 OK, text/html, served from SPA |
|
||||
| `GET /manifest.webmanifest` | JSON with `"name": "Premium Механик"` |
|
||||
| `GET /api/v1/mechanic/healthz` (via mechanic.pptaxi.ru) | `{"status":"ok"}` |
|
||||
| `GET /api/v1/mechanic/me` (no auth) | 401 |
|
||||
| `GET /api/v1/mechanic/vehicles` (no auth) | 401 |
|
||||
| `GET /icons/icon-192.png` | 200 image/png, 2326 bytes |
|
||||
| `GET /sw.js` (service worker) | 200 text/javascript |
|
||||
| `GET /vehicles/12345` (SPA route) | 200 text/html (index.html fallback) |
|
||||
| `crm.pptaxi.ru/api/v1/mechanic/healthz` x5 | 5/5 ok (both blue+green serving) |
|
||||
|
||||
**Remaining manual smoke (mobile, J3 step 8):** open `https://mechanic.pptaxi.ru` on Android Chrome / iOS Safari, login with test mechanic account, create inspection, snap photo, verify MinIO upload + thumbnail appears, verify "Add to Home Screen" works. *Not automated — you'll do that on a phone.*
|
||||
|
||||
## What is done
|
||||
|
||||
|
||||
@@ -3,8 +3,16 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#2a2a2a" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Mechanic" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Premium Механик</title>
|
||||
</head>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"_meta": {
|
||||
"source": "Ttc.dll #US heap, contiguous block at offsets 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": "передняя правая стойка"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 514 B |
|
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 |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 56 KiB |
@@ -4,10 +4,11 @@
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#f5f1ea",
|
||||
"theme_color": "#2a2a2a",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#000000",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
|
||||
|
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 |
@@ -1,10 +1,16 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useMeSync } from "@/hooks/useMeSync";
|
||||
import Login from "@/pages/Login";
|
||||
import ActionMenu from "@/pages/ActionMenu";
|
||||
import Home from "@/pages/Home";
|
||||
import VehicleCard from "@/pages/VehicleCard";
|
||||
import InspectionEditor from "@/pages/InspectionEditor";
|
||||
import InspectionReview from "@/pages/InspectionReview";
|
||||
import TtStateDetail from "@/pages/TtStateDetail";
|
||||
import RepairsFeedPage from "@/pages/repairs/RepairsFeedPage";
|
||||
import CarSelectPage from "@/pages/repairs/CarSelectPage";
|
||||
import CreateRepairPage from "@/pages/repairs/CreateRepairPage";
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuth((s) => s.token);
|
||||
@@ -13,13 +19,24 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// У35: фоновая сверка прав механика раз в 5 минут — выкидывает на /login
|
||||
// с сообщением «Доступ отозван», если админ снял can_login_pwa или вывел
|
||||
// пользователя из отдела механиков.
|
||||
useMeSync();
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<RequireAuth><Home /></RequireAuth>} />
|
||||
<Route path="/" element={<RequireAuth><ActionMenu /></RequireAuth>} />
|
||||
<Route path="/vehicles" element={<RequireAuth><Home /></RequireAuth>} />
|
||||
<Route path="/vehicles/:id" element={<RequireAuth><VehicleCard /></RequireAuth>} />
|
||||
<Route path="/inspections/:id/edit" element={<RequireAuth><InspectionEditor /></RequireAuth>} />
|
||||
<Route path="/inspections/:id" element={<RequireAuth><InspectionReview /></RequireAuth>} />
|
||||
<Route path="/tt-states/:id" element={<RequireAuth><TtStateDetail /></RequireAuth>} />
|
||||
{/* Раздел «Ремонт» — активируется в PR-5 (Фича 1). */}
|
||||
<Route path="/repairs" element={<RequireAuth><RepairsFeedPage /></RequireAuth>} />
|
||||
<Route path="/repairs/new" element={<RequireAuth><CarSelectPage /></RequireAuth>} />
|
||||
<Route path="/repairs/new/:carId" element={<RequireAuth><CreateRepairPage /></RequireAuth>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -5,26 +5,52 @@ export interface LoginInput {
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** OAuth2-shaped login response, extended with backend's 2FA signaling fields. */
|
||||
export interface LoginOutput {
|
||||
access_token: string;
|
||||
access_token?: string | null;
|
||||
token_type: string;
|
||||
requires_2fa?: boolean;
|
||||
partial_token?: string | null;
|
||||
}
|
||||
|
||||
export interface TwoFactorInput {
|
||||
partial_token: string;
|
||||
code: string;
|
||||
remember_device: boolean;
|
||||
}
|
||||
|
||||
export async function login(input: LoginInput): Promise<LoginOutput> {
|
||||
// OAuth2 password flow — form-urlencoded, NOT JSON, NOT under /api/v1/mechanic/
|
||||
const body = new URLSearchParams();
|
||||
body.set("username", input.username);
|
||||
body.set("password", input.password);
|
||||
// PWA — длинная сессия (30 дней) чтобы механики не вводили пароль каждые 8 ч.
|
||||
body.set("long_session", "true");
|
||||
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
// Critical: include credentials so the 30-day trusted_device cookie is
|
||||
// sent back to backend on subsequent logins (lets us skip 2FA next time).
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Login failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<LoginOutput>;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function login2fa(input: TwoFactorInput): Promise<LoginOutput> {
|
||||
const res = await fetch("/api/auth/2fa", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...input, long_session: true }),
|
||||
credentials: "include", // accept the trusted_device cookie on Set-Cookie
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`2FA failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function logout(): void {
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface InspectionCreateInput {
|
||||
vehicle_id: number;
|
||||
type: InspectionType;
|
||||
driver_id?: number;
|
||||
mileage?: number;
|
||||
}
|
||||
|
||||
export interface PhotoSummary {
|
||||
@@ -26,6 +27,7 @@ export interface PhotoSummary {
|
||||
slot_index: number;
|
||||
storage_key: string;
|
||||
thumb_key?: string | null;
|
||||
annotated_key?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
status: string;
|
||||
@@ -36,6 +38,7 @@ export interface MarkerSummary {
|
||||
id: number;
|
||||
inspection_id: number;
|
||||
photo_id?: number | null;
|
||||
tt_image_id?: string | null;
|
||||
side?: string | null;
|
||||
x?: number | null;
|
||||
y?: number | null;
|
||||
@@ -48,9 +51,22 @@ export interface MarkerSummary {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface VehicleBrief {
|
||||
id: number;
|
||||
license_plate: string;
|
||||
vin?: string | null;
|
||||
make?: string | null;
|
||||
model?: string | null;
|
||||
year?: number | null;
|
||||
}
|
||||
|
||||
export interface InspectionDetail {
|
||||
id: number;
|
||||
vehicle_id: number;
|
||||
/** Сводка по машине (гос.номер / марка / модель / год). Backend attaches её
|
||||
в get_inspection_by_id_with_relations, чтобы карточка осмотра могла
|
||||
показать машину без второго запроса. */
|
||||
vehicle?: VehicleBrief | null;
|
||||
type: InspectionType;
|
||||
performed_by: number;
|
||||
driver_id?: number | null;
|
||||
@@ -62,6 +78,9 @@ export interface InspectionDetail {
|
||||
meta: Record<string, unknown>;
|
||||
photos: PhotoSummary[];
|
||||
markers: MarkerSummary[];
|
||||
mileage?: number | null;
|
||||
/** ФИО механика, выполнившего осмотр (lookup users.full_name по performed_by). */
|
||||
inspector_name?: string | null;
|
||||
}
|
||||
|
||||
export async function createInspection(
|
||||
@@ -76,7 +95,7 @@ export async function getInspection(id: number): Promise<InspectionDetail> {
|
||||
|
||||
export async function patchInspection(
|
||||
id: number,
|
||||
body: { status?: InspectionStatus; notes?: string; finished_at?: string }
|
||||
body: { status?: InspectionStatus; notes?: string; finished_at?: string; mileage?: number }
|
||||
): Promise<InspectionDetail> {
|
||||
return api.patch(`inspections/${id}`, { json: body }).json<InspectionDetail>();
|
||||
}
|
||||
@@ -101,4 +120,12 @@ export async function createMarker(
|
||||
.json<MarkerSummary>();
|
||||
}
|
||||
|
||||
export async function deleteInspection(inspectionId: number): Promise<void> {
|
||||
await api.delete(`inspections/${inspectionId}`);
|
||||
}
|
||||
|
||||
export async function deleteMarker(markerId: number): Promise<void> {
|
||||
await api.delete(`markers/${markerId}`);
|
||||
}
|
||||
|
||||
export type { InspectionSummary };
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { api } from "./client";
|
||||
|
||||
export interface PwaSupportContact {
|
||||
tg_username: string | null;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
export interface Me {
|
||||
id: number;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
permissions: string[];
|
||||
// Модуль СТО (У10 + У34) — расширение в backend PR-4.
|
||||
is_admin: boolean;
|
||||
can_login_pwa: boolean;
|
||||
departments: string[];
|
||||
support: PwaSupportContact;
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<Me> {
|
||||
|
||||
@@ -14,12 +14,27 @@ export interface PhotoConfirmResponse {
|
||||
slot_index: number;
|
||||
storage_key: string;
|
||||
thumb_key?: string | null;
|
||||
annotated_key?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
status: string;
|
||||
taken_at: string;
|
||||
}
|
||||
|
||||
export async function uploadPhotoAnnotation(
|
||||
inspectionId: number,
|
||||
photoId: number,
|
||||
blob: Blob,
|
||||
): Promise<PhotoConfirmResponse> {
|
||||
const form = new FormData();
|
||||
form.append("file", blob, "annotation.jpg");
|
||||
return api
|
||||
.post(`inspections/${inspectionId}/photos/${photoId}/annotation`, {
|
||||
body: form,
|
||||
})
|
||||
.json<PhotoConfirmResponse>();
|
||||
}
|
||||
|
||||
export async function requestUploadUrl(
|
||||
inspectionId: number,
|
||||
args: { side: string; slot_index: number; content_type: string; size: number }
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { api } from "./client";
|
||||
|
||||
export interface CarBrief {
|
||||
id: number;
|
||||
plate: string;
|
||||
brand: string | null;
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export interface CarContext {
|
||||
id: number;
|
||||
plate: string;
|
||||
brand: string | null;
|
||||
model: string | null;
|
||||
mileage_starline: number | null;
|
||||
driver_id: number | null;
|
||||
driver_name: string | null;
|
||||
}
|
||||
|
||||
export interface MechWork {
|
||||
id: number;
|
||||
name: string;
|
||||
category_id: number;
|
||||
price: number | string;
|
||||
norma_hours: number | string | null;
|
||||
}
|
||||
|
||||
export interface PartSuggestion {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PresignResponse {
|
||||
photo_uuid: string;
|
||||
extension: string;
|
||||
content_type: string;
|
||||
key: string;
|
||||
upload_url: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export interface CreatePhotoPayload {
|
||||
photo_uuid: string;
|
||||
extension: string;
|
||||
tag: "auto" | "part" | "other";
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface CreateWorkPayload {
|
||||
work_catalog_id: number;
|
||||
price_applied: number;
|
||||
manual_override: boolean;
|
||||
override_comment: string | null;
|
||||
}
|
||||
|
||||
export interface CreatePartPayload {
|
||||
name: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
export interface CreateOilChangePayload {
|
||||
enabled: boolean;
|
||||
mileage_at_change: number | null;
|
||||
next_in_km: number;
|
||||
sticker_photo_uuid: string | null;
|
||||
sticker_extension: string | null;
|
||||
}
|
||||
|
||||
export interface CreateRepairPayload {
|
||||
car_id: number;
|
||||
mileage: number | null;
|
||||
works: CreateWorkPayload[];
|
||||
parts: CreatePartPayload[];
|
||||
photos: CreatePhotoPayload[];
|
||||
oil_change: CreateOilChangePayload | null;
|
||||
}
|
||||
|
||||
export interface CreateRepairResponse {
|
||||
id: number;
|
||||
created_at_iso: string;
|
||||
tg_outbox_status: string;
|
||||
}
|
||||
|
||||
export async function searchCars(q: string): Promise<CarBrief[]> {
|
||||
const qs = q ? `?q=${encodeURIComponent(q)}` : "";
|
||||
return api.get(`cars${qs}`).json<CarBrief[]>();
|
||||
}
|
||||
|
||||
export async function getCarContext(carId: number): Promise<CarContext> {
|
||||
return api.get(`cars/${carId}/context`).json<CarContext>();
|
||||
}
|
||||
|
||||
export async function searchWorks(q: string): Promise<MechWork[]> {
|
||||
const qs = q ? `?q=${encodeURIComponent(q)}` : "";
|
||||
return api.get(`works${qs}`).json<MechWork[]>();
|
||||
}
|
||||
|
||||
export async function suggestParts(q: string): Promise<PartSuggestion[]> {
|
||||
return api
|
||||
.get(`parts/suggest?q=${encodeURIComponent(q)}`)
|
||||
.json<PartSuggestion[]>();
|
||||
}
|
||||
|
||||
export async function presignPhoto(extension: string, photo_uuid?: string): Promise<PresignResponse> {
|
||||
return api
|
||||
.post("repairs/photos/presign", {
|
||||
json: { extension, photo_uuid: photo_uuid ?? null },
|
||||
})
|
||||
.json<PresignResponse>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Залить файл по presigned PUT. ВАЖНО: Content-Type ОБЯЗАН совпадать
|
||||
* со значением, которое сервер вшил в подпись (см. У18 / sto_mechanic_form.py).
|
||||
*/
|
||||
export async function uploadPhotoToTmp(file: File | Blob, presign: PresignResponse): Promise<void> {
|
||||
const res = await fetch(presign.upload_url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": presign.content_type },
|
||||
body: file,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Photo upload failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRepair(
|
||||
payload: CreateRepairPayload,
|
||||
idempotencyKey: string,
|
||||
): Promise<CreateRepairResponse> {
|
||||
return api
|
||||
.post("repairs", {
|
||||
json: payload,
|
||||
headers: { "Idempotency-Key": idempotencyKey },
|
||||
})
|
||||
.json<CreateRepairResponse>();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { api } from "./client";
|
||||
|
||||
export interface FeedWorkBrief {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FeedItem {
|
||||
id: number;
|
||||
created_at: string; // ISO
|
||||
car_plate: string;
|
||||
car_make: string | null;
|
||||
car_model: string | null;
|
||||
driver_name: string | null;
|
||||
mechanic_name: string;
|
||||
works_preview: FeedWorkBrief[];
|
||||
works_extra: number;
|
||||
photo_url: string | null;
|
||||
}
|
||||
|
||||
export interface FeedResponse {
|
||||
items: FeedItem[];
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
export interface FeedParams {
|
||||
limit?: number;
|
||||
cursor?: string | null;
|
||||
}
|
||||
|
||||
export async function getRepairsFeed(params: FeedParams = {}): Promise<FeedResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.limit) search.set("limit", String(params.limit));
|
||||
if (params.cursor) search.set("cursor", params.cursor);
|
||||
const qs = search.toString();
|
||||
return api
|
||||
.get(`repairs${qs ? `?${qs}` : ""}`)
|
||||
.json<FeedResponse>();
|
||||
}
|
||||
@@ -8,10 +8,16 @@ export interface VehicleSummary {
|
||||
model?: string | null;
|
||||
year?: number | null;
|
||||
last_inspection_at?: string | null;
|
||||
/** Текущий статус машины из 1C Element (cars_v2.element_status):
|
||||
'На линии' / 'Простой' / 'Ремонтируется' / 'Ждет ремонта' / 'ДТП' /
|
||||
'Бронь' / 'Выкуп' / 'На продаже' / etc. NULL если не задан. */
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
export interface VehicleDetail extends VehicleSummary {
|
||||
recent_inspections: InspectionSummary[];
|
||||
starline_mileage?: number | null;
|
||||
starline_mileage_at?: string | null;
|
||||
}
|
||||
|
||||
export interface InspectionSummary {
|
||||
@@ -20,6 +26,13 @@ export interface InspectionSummary {
|
||||
status: string;
|
||||
started_at: string;
|
||||
finished_at?: string | null;
|
||||
mileage?: number | null;
|
||||
photos_count?: number;
|
||||
/** Водитель, прикреплённый к машине на момент started_at (lookup в
|
||||
driver_rentals_v2 по car_number + перекрывающему периоду). NULL если
|
||||
на тот момент машина была свободна. */
|
||||
driver_id?: number | null;
|
||||
driver_name?: string | null;
|
||||
}
|
||||
|
||||
export async function listVehicles(q?: string): Promise<VehicleSummary[]> {
|
||||
@@ -34,3 +47,79 @@ export async function listVehicles(q?: string): Promise<VehicleSummary[]> {
|
||||
export async function getVehicle(id: number): Promise<VehicleDetail> {
|
||||
return api.get(`vehicles/${id}`).json<VehicleDetail>();
|
||||
}
|
||||
|
||||
// ── TT-Control vendor archive (Element Mechanic) ───────────────────────────
|
||||
|
||||
export interface TtStateSummary {
|
||||
id: number;
|
||||
vendor_state_id: string;
|
||||
unix_time: number | null;
|
||||
mechanic_name: string | null;
|
||||
mileage: number | null;
|
||||
photos_count: number;
|
||||
damages_count: number;
|
||||
/** Водитель, прикреплённый к машине на момент unix_time (lookup в
|
||||
driver_rentals_v2 по car_number + перекрывающему/предыдущему периоду).
|
||||
NULL если на тот момент машина была свободна. */
|
||||
driver_id?: number | null;
|
||||
driver_name?: string | null;
|
||||
}
|
||||
|
||||
export interface TtHistory {
|
||||
vendor_vehicle_id: string | null;
|
||||
plate?: string | null;
|
||||
brand?: string | null;
|
||||
model?: string | null;
|
||||
states: TtStateSummary[];
|
||||
}
|
||||
|
||||
export interface TtPhotoRef {
|
||||
image_id: string;
|
||||
image_with_lines_id: string | null;
|
||||
unix_time: number | null;
|
||||
guid: string | null;
|
||||
}
|
||||
|
||||
export interface TtDamage {
|
||||
damage_type_id: number | null;
|
||||
degree: number | null;
|
||||
points: { x: number; y: number }[];
|
||||
guid: string | null;
|
||||
unix_time: number | null;
|
||||
}
|
||||
|
||||
export interface TtSidePhoto {
|
||||
image_id: string;
|
||||
miniature_id: string | null;
|
||||
photo_type: number | null;
|
||||
}
|
||||
|
||||
export interface TtStateDetail {
|
||||
id: number;
|
||||
vendor_state_id: string;
|
||||
unix_time: number | null;
|
||||
mechanic_name: string | null;
|
||||
mileage: number | null;
|
||||
side_photos: TtSidePhoto[];
|
||||
photos_by_zone: Record<string, TtPhotoRef[]>;
|
||||
damages_by_zone: Record<string, TtDamage[]>;
|
||||
}
|
||||
|
||||
export async function getTtHistory(vehicleId: number): Promise<TtHistory> {
|
||||
return api.get(`vehicles/${vehicleId}/tt-history`).json<TtHistory>();
|
||||
}
|
||||
|
||||
export async function getTtState(stateId: number): Promise<TtStateDetail> {
|
||||
return api.get(`tt-states/${stateId}`).json<TtStateDetail>();
|
||||
}
|
||||
|
||||
export async function deleteTtState(stateId: number): Promise<void> {
|
||||
await api.delete(`tt-states/${stateId}`);
|
||||
}
|
||||
|
||||
export function ttImageUrl(imageId: string, withLines: boolean = false): string {
|
||||
// tt-image endpoint без auth-guard'а (vendor сам публично отдаёт эти JPEG'и),
|
||||
// поэтому подходит для прямого <img src=...>.
|
||||
const base = import.meta.env.VITE_API_BASE ?? "/api/v1/mechanic";
|
||||
return `${base}/tt-image/${imageId}${withLines ? "?lines=true" : ""}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { loadDamageVocabulary, damageTypeRuToEnum } from "@/data/damageVocabulary";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
zoneId: number;
|
||||
zoneLabel: string;
|
||||
pointsCount: number;
|
||||
onCancel: () => void;
|
||||
onSave: (data: { damageType: string; severity: 0 | 1 | 2 }) => void;
|
||||
}
|
||||
|
||||
export function DamageClassifyDialog({ open, zoneId, zoneLabel, pointsCount, onCancel, onSave }: Props) {
|
||||
const [vocab, setVocab] = useState<Awaited<ReturnType<typeof loadDamageVocabulary>> | null>(null);
|
||||
const [damageType, setDamageType] = useState<string | null>(null);
|
||||
const [severity, setSeverity] = useState<0 | 1 | 2>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDamageType(null);
|
||||
setSeverity(0);
|
||||
loadDamageVocabulary().then(setVocab);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Salon zones (44=водительское сидение, 45=пассажирское, 46=задний диван) use salon_selectable; else exterior
|
||||
const isSalon = zoneId === 44 || zoneId === 45 || zoneId === 46;
|
||||
// Фильтруем типы для которых нет соответствия в backend DamageType enum —
|
||||
// иначе backend вернёт 422 на createMarker.
|
||||
const list = vocab
|
||||
? (isSalon ? vocab.salon_selectable : vocab.exterior_selectable).filter(
|
||||
(dt) => damageTypeRuToEnum(dt) != null,
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center p-0 sm:p-4"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md bg-card rounded-t-2xl sm:rounded-2xl shadow-xl flex flex-col max-h-[90vh]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="p-5 border-b">
|
||||
<h2 className="text-lg font-semibold capitalize">{zoneLabel}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Точек: {pointsCount}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="p-5 border-b space-y-3">
|
||||
<Label>Степень повреждения</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{([0, 1, 2] as const).map((idx) => {
|
||||
const label = vocab?.severity_scale.labels[idx] ?? ["Лёгкое", "Среднее", "Тяжёлое"][idx];
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => setSeverity(idx)}
|
||||
className={cn(
|
||||
"flex-1 py-2 px-2 rounded-md text-sm font-medium border transition-colors",
|
||||
severity === idx
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-background text-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-1.5">
|
||||
<Label>Тип повреждения</Label>
|
||||
{!vocab && <div className="text-sm text-muted-foreground">Загружаю…</div>}
|
||||
{vocab && list.map((dt) => (
|
||||
<label
|
||||
key={dt}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer border transition-colors",
|
||||
damageType === dt
|
||||
? "bg-accent border-primary"
|
||||
: "border-transparent hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="damage_type"
|
||||
value={dt}
|
||||
checked={damageType === dt}
|
||||
onChange={() => setDamageType(dt)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="text-sm">{dt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="p-5 border-t flex gap-2">
|
||||
<Button variant="outline" onClick={onCancel} className="flex-1">
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!damageType}
|
||||
onClick={() => damageType && onSave({ damageType, severity })}
|
||||
className="flex-1"
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
photoFile: File;
|
||||
zoneLabel: string;
|
||||
/** Called when user submits. annotated=null если "без линий". */
|
||||
onSubmit: (annotated: Blob | null) => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface Stroke {
|
||||
pts: { x: number; y: number }[]; // in image-space pixels
|
||||
}
|
||||
|
||||
/** Pen-on-photo step: показывает только что снятое фото, даёт обвести
|
||||
* пальцем красным. Flatten в JPEG при submit. Координаты strokes в native
|
||||
* пикселях фото — canvas resize'нут на тот же размер. */
|
||||
export function PhotoAnnotateStep({ photoFile, zoneLabel, onSubmit, onCancel }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
const [strokes, setStrokes] = useState<Stroke[]>([]);
|
||||
const currentStroke = useRef<Stroke | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Load image once
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(photoFile);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
imgRef.current = img;
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
}
|
||||
setImgLoaded(true);
|
||||
URL.revokeObjectURL(url);
|
||||
redraw([]);
|
||||
};
|
||||
img.onerror = () => URL.revokeObjectURL(url);
|
||||
img.src = url;
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [photoFile]);
|
||||
|
||||
function redraw(allStrokes: Stroke[]) {
|
||||
const canvas = canvasRef.current;
|
||||
const img = imgRef.current;
|
||||
if (!canvas || !img) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
// Linewidth scaled to image — на 4К фото 4px тонко смотрится
|
||||
const lw = Math.max(6, Math.round(canvas.width / 250));
|
||||
ctx.strokeStyle = "rgb(220, 38, 38)";
|
||||
ctx.lineWidth = lw;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
for (const s of allStrokes) {
|
||||
if (s.pts.length < 2) {
|
||||
// single tap → dot
|
||||
if (s.pts.length === 1) {
|
||||
ctx.fillStyle = "rgb(220, 38, 38)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.pts[0].x, s.pts[0].y, lw / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s.pts[0].x, s.pts[0].y);
|
||||
for (let i = 1; i < s.pts.length; i++) {
|
||||
ctx.lineTo(s.pts[i].x, s.pts[i].y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-draw whenever strokes change
|
||||
useEffect(() => {
|
||||
if (imgLoaded) redraw(strokes);
|
||||
}, [strokes, imgLoaded]);
|
||||
|
||||
function pointFromEvent(e: React.PointerEvent<HTMLCanvasElement>): { x: number; y: number } | null {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return null;
|
||||
// CSS px → canvas px
|
||||
const x = ((e.clientX - rect.left) / rect.width) * canvas.width;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * canvas.height;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function onPointerDown(e: React.PointerEvent<HTMLCanvasElement>) {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);
|
||||
const pt = pointFromEvent(e);
|
||||
if (!pt) return;
|
||||
currentStroke.current = { pts: [pt] };
|
||||
// Immediate visual feedback: draw a partial stroke without setState
|
||||
drawPartialStroke(currentStroke.current);
|
||||
}
|
||||
|
||||
function onPointerMove(e: React.PointerEvent<HTMLCanvasElement>) {
|
||||
if (!currentStroke.current) return;
|
||||
const pt = pointFromEvent(e);
|
||||
if (!pt) return;
|
||||
currentStroke.current.pts.push(pt);
|
||||
drawPartialStroke(currentStroke.current);
|
||||
}
|
||||
|
||||
function onPointerUp(e: React.PointerEvent<HTMLCanvasElement>) {
|
||||
if (!currentStroke.current) return;
|
||||
try {
|
||||
(e.target as HTMLCanvasElement).releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// ignore — pointer wasn't captured
|
||||
}
|
||||
const finished = currentStroke.current;
|
||||
currentStroke.current = null;
|
||||
if (finished.pts.length > 0) {
|
||||
setStrokes((s) => [...s, finished]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Avoid re-running full redraw on every move — just append last segment. */
|
||||
function drawPartialStroke(s: Stroke) {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || s.pts.length < 2) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const lw = Math.max(6, Math.round(canvas.width / 250));
|
||||
ctx.strokeStyle = "rgb(220, 38, 38)";
|
||||
ctx.lineWidth = lw;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
const a = s.pts[s.pts.length - 2];
|
||||
const b = s.pts[s.pts.length - 1];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setStrokes([]);
|
||||
}
|
||||
|
||||
function undoLast() {
|
||||
setStrokes((s) => s.slice(0, -1));
|
||||
}
|
||||
|
||||
async function finishWithAnnotation() {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, "image/jpeg", 0.85)
|
||||
);
|
||||
await onSubmit(blob);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function finishWithoutAnnotation() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(null);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-background flex flex-col">
|
||||
<header className="p-4 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Обведите повреждение</div>
|
||||
<h2 className="text-lg font-semibold capitalize">{zoneLabel}</h2>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={undoLast}
|
||||
disabled={strokes.length === 0 || submitting}
|
||||
className="text-sm px-3 py-1 border rounded-md disabled:opacity-40"
|
||||
title="Отменить последний штрих"
|
||||
>
|
||||
↶
|
||||
</button>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
disabled={strokes.length === 0 || submitting}
|
||||
className="text-sm px-3 py-1 border rounded-md disabled:opacity-40"
|
||||
title="Очистить"
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-auto p-2 flex items-center justify-center bg-black">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
className="max-w-full max-h-full block"
|
||||
style={{ touchAction: "none", cursor: "crosshair" }}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<footer className="border-t p-3 flex gap-2 bg-card">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className="flex-none"
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={finishWithoutAnnotation}
|
||||
disabled={submitting}
|
||||
className="flex-1"
|
||||
>
|
||||
Без линий
|
||||
</Button>
|
||||
<Button
|
||||
onClick={finishWithAnnotation}
|
||||
disabled={submitting || strokes.length === 0}
|
||||
className="flex-1"
|
||||
>
|
||||
{submitting ? "Сохраняю…" : "Готово"}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
zoneLabel: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ZoneConfirmDialog({ open, zoneLabel, onConfirm, onCancel }: Props) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center p-0 sm:p-4"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm bg-card rounded-t-2xl sm:rounded-2xl shadow-xl p-6 space-y-5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Добавить повреждение</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1 capitalize">
|
||||
{zoneLabel}?
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onCancel} className="flex-1">
|
||||
Нет
|
||||
</Button>
|
||||
<Button onClick={onConfirm} className="flex-1">
|
||||
Да
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ZoneBBox {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
rotation?: number; // degrees, 0 / 90 / -90 / 180
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
zoneId: number;
|
||||
zoneLabel: string;
|
||||
onConfirm: (points: Point[]) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const PADDING = 0.1;
|
||||
const COMPOSITE_W = 827;
|
||||
const COMPOSITE_H = 1209;
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const POINT_RADIUS = 18; // user-space units; visible on zoomed views
|
||||
|
||||
let bboxCache: Record<string, ZoneBBox> | null = null;
|
||||
|
||||
async function loadBBoxes(): Promise<Record<string, ZoneBBox>> {
|
||||
if (bboxCache) return bboxCache;
|
||||
const res = await fetch("/data/zone_bboxes.json");
|
||||
bboxCache = await res.json();
|
||||
return bboxCache!;
|
||||
}
|
||||
|
||||
function paddedViewBox(b: ZoneBBox): string {
|
||||
const rotation = b.rotation ?? 0;
|
||||
const cx = b.x + b.w / 2;
|
||||
const cy = b.y + b.h / 2;
|
||||
let w = b.w;
|
||||
let h = b.h;
|
||||
if (rotation === 90 || rotation === -90) {
|
||||
w = b.h;
|
||||
h = b.w;
|
||||
}
|
||||
const padX = w * PADDING;
|
||||
const padY = h * PADDING;
|
||||
const minX = cx - w / 2 - padX;
|
||||
const minY = cy - h / 2 - padY;
|
||||
return `${minX} ${minY} ${w + 2 * padX} ${h + 2 * padY}`;
|
||||
}
|
||||
|
||||
function rotationTransform(b: ZoneBBox): string | null {
|
||||
const rotation = b.rotation ?? 0;
|
||||
if (rotation === 0) return null;
|
||||
const cx = b.x + b.w / 2;
|
||||
const cy = b.y + b.h / 2;
|
||||
return `rotate(${rotation} ${cx} ${cy})`;
|
||||
}
|
||||
|
||||
export function ZoneDetailView({ open, zoneId, zoneLabel, onConfirm, onCancel }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [svgText, setSvgText] = useState<string | null>(null);
|
||||
const [points, setPoints] = useState<Point[]>([]);
|
||||
|
||||
// Load SVG + bboxes
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPoints([]);
|
||||
setSvgText(null);
|
||||
let cancelled = false;
|
||||
|
||||
Promise.all([
|
||||
fetch("/scheme/exterior.svg").then((r) => r.text()),
|
||||
loadBBoxes(),
|
||||
]).then(([text, bboxes]) => {
|
||||
if (cancelled) return;
|
||||
const b = bboxes[String(zoneId)];
|
||||
const finalVB = b ? paddedViewBox(b) : `0 0 ${COMPOSITE_W} ${COMPOSITE_H}`;
|
||||
const rotXform = b ? rotationTransform(b) : null;
|
||||
|
||||
let mutated = text;
|
||||
mutated = mutated.replace(/<script[\s\S]*?<\/script>/gi, "");
|
||||
mutated = mutated.replace(/(<svg\b[^>]*?)\sviewBox\s*=\s*"[^"]*"/i, `$1 viewBox="${finalVB}"`);
|
||||
mutated = mutated.replace(
|
||||
/(<svg\b[^>]*?)\s(?:width|height)\s*=\s*"[^"]*"/gi,
|
||||
"$1"
|
||||
);
|
||||
mutated = mutated.replace(
|
||||
/<svg\b/i,
|
||||
`<svg width="100%" height="100%" preserveAspectRatio="xMidYMid meet"`
|
||||
);
|
||||
// Wrap inner content in rotated <g> so visible orientation is correct.
|
||||
// We always wrap (even with identity rotation) so the point-overlay <g>
|
||||
// append target is consistent.
|
||||
const openMatch = mutated.match(/<svg\b[^>]*?>/i);
|
||||
const closeIdx = mutated.lastIndexOf("</svg>");
|
||||
if (openMatch && closeIdx > -1) {
|
||||
const openEnd = (openMatch.index ?? 0) + openMatch[0].length;
|
||||
const before = mutated.slice(0, openEnd);
|
||||
const inner = mutated.slice(openEnd, closeIdx);
|
||||
const after = mutated.slice(closeIdx);
|
||||
const xform = rotXform ? ` transform="${rotXform}"` : "";
|
||||
mutated = `${before}<g data-rot="1"${xform}>${inner}</g>${after}`;
|
||||
}
|
||||
|
||||
setSvgText(mutated);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [open, zoneId]);
|
||||
|
||||
// After SVG mounts, apply fill highlights
|
||||
useEffect(() => {
|
||||
if (!svgText) return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
||||
if (!svg) return;
|
||||
|
||||
svg.querySelectorAll("path[class]").forEach((p) => {
|
||||
const cls = p.getAttribute("class") ?? "";
|
||||
if (cls === String(zoneId)) {
|
||||
p.setAttribute("fill", "rgba(239, 68, 68, 0.18)");
|
||||
p.setAttribute("stroke", "rgb(239, 68, 68)");
|
||||
p.setAttribute("stroke-width", "2");
|
||||
} else {
|
||||
p.setAttribute("fill", "rgba(0,0,0,0.04)");
|
||||
p.setAttribute("stroke", "rgba(0,0,0,0.25)");
|
||||
}
|
||||
(p as SVGElement).style.pointerEvents = "none";
|
||||
});
|
||||
svg.querySelectorAll("path:not([class])").forEach((p) => {
|
||||
(p as SVGElement).style.opacity = "0.4";
|
||||
(p as SVGElement).style.pointerEvents = "none";
|
||||
});
|
||||
}, [svgText, zoneId]);
|
||||
|
||||
// Imperatively render points as SVG circles inside the rotated <g>.
|
||||
// This guarantees they sit at the exact same place as the SVG content,
|
||||
// regardless of letterboxing / aspect-ratio.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
||||
if (!svg) return;
|
||||
const rotGroup = svg.querySelector("g[data-rot]") as SVGGElement | null;
|
||||
if (!rotGroup) return;
|
||||
|
||||
// Remove existing overlay group if any
|
||||
const existing = rotGroup.querySelector("g[data-points]");
|
||||
if (existing) existing.remove();
|
||||
|
||||
// Build new overlay group
|
||||
const group = document.createElementNS(SVG_NS, "g");
|
||||
group.setAttribute("data-points", "1");
|
||||
|
||||
points.forEach((p, i) => {
|
||||
const cx = p.x * COMPOSITE_W;
|
||||
const cy = p.y * COMPOSITE_H;
|
||||
const circle = document.createElementNS(SVG_NS, "circle");
|
||||
circle.setAttribute("cx", String(cx));
|
||||
circle.setAttribute("cy", String(cy));
|
||||
circle.setAttribute("r", String(POINT_RADIUS));
|
||||
circle.setAttribute("fill", "rgb(239, 68, 68)");
|
||||
circle.setAttribute("stroke", "white");
|
||||
circle.setAttribute("stroke-width", "3");
|
||||
circle.setAttribute("data-point-index", String(i));
|
||||
(circle as SVGElement).style.cursor = "pointer";
|
||||
group.appendChild(circle);
|
||||
|
||||
const text = document.createElementNS(SVG_NS, "text");
|
||||
text.setAttribute("x", String(cx));
|
||||
text.setAttribute("y", String(cy));
|
||||
text.setAttribute("text-anchor", "middle");
|
||||
text.setAttribute("dominant-baseline", "central");
|
||||
text.setAttribute("fill", "white");
|
||||
text.setAttribute("font-size", "20");
|
||||
text.setAttribute("font-weight", "bold");
|
||||
text.setAttribute("data-point-label", String(i));
|
||||
(text as SVGElement).style.pointerEvents = "none";
|
||||
(text as SVGElement).style.userSelect = "none";
|
||||
text.textContent = String(i + 1);
|
||||
group.appendChild(text);
|
||||
});
|
||||
|
||||
rotGroup.appendChild(group);
|
||||
}, [svgText, points]);
|
||||
|
||||
function handleTap(e: React.MouseEvent<HTMLDivElement>) {
|
||||
// If user clicked an existing point circle, remove it (if it's the last one)
|
||||
const target = e.target as Element;
|
||||
const pointIdxAttr = target.getAttribute?.("data-point-index");
|
||||
if (pointIdxAttr != null) {
|
||||
const idx = Number(pointIdxAttr);
|
||||
if (idx === points.length - 1) {
|
||||
setPoints((p) => p.slice(0, -1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
||||
if (!svg) return;
|
||||
|
||||
// getScreenCTM gives matrix from SVG root user-space to screen pixels.
|
||||
// It does NOT include inner <g> transforms — so svgPt is in the rotated viewBox frame.
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = e.clientX;
|
||||
pt.y = e.clientY;
|
||||
const ctm = svg.getScreenCTM();
|
||||
if (!ctm) return;
|
||||
const svgPt = pt.matrixTransform(ctm.inverse());
|
||||
|
||||
// Convert from the visible (rotated) frame back to the original composite frame
|
||||
// so storage is in a canonical coord system.
|
||||
const bboxes = bboxCache;
|
||||
const b = bboxes?.[String(zoneId)] ?? null;
|
||||
const original = b
|
||||
? inverseRotateAroundBboxCenter({ x: svgPt.x, y: svgPt.y }, b)
|
||||
: { x: svgPt.x, y: svgPt.y };
|
||||
|
||||
const xN = Math.max(0, Math.min(1, original.x / COMPOSITE_W));
|
||||
const yN = Math.max(0, Math.min(1, original.y / COMPOSITE_H));
|
||||
setPoints((prev) => [...prev, { x: xN, y: yN }]);
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-background flex flex-col">
|
||||
<header className="flex items-center justify-between p-4 border-b">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Зона</div>
|
||||
<h2 className="text-lg font-semibold capitalize">{zoneLabel}</h2>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Точек: <span className="font-semibold text-foreground">{points.length}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-hidden relative bg-card">
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={handleTap}
|
||||
className="w-full h-full"
|
||||
style={{ touchAction: "manipulation" }}
|
||||
dangerouslySetInnerHTML={svgText ? { __html: svgText } : undefined}
|
||||
/>
|
||||
{!svgText && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground pointer-events-none">
|
||||
Загружаю схему…
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="border-t p-3 flex items-center gap-3 bg-card">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border border-destructive text-destructive hover:bg-destructive/10 active:bg-destructive/20 transition-colors font-semibold"
|
||||
aria-label="Отмена"
|
||||
>
|
||||
<span className="text-2xl leading-none">✕</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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import sedanSrc from "./schemes/sedan-top.svg?raw";
|
||||
|
||||
interface MarkerDot {
|
||||
x: number;
|
||||
y: number;
|
||||
severity?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Normalized 0..1 dots overlaid on the scheme. */
|
||||
markers?: MarkerDot[];
|
||||
/** Click handler — receives the zone id like "zone-front". */
|
||||
onZoneClick?: (zoneId: string) => void;
|
||||
/** Per-zone count overlays (e.g. {"zone-front": 2}). Optional. */
|
||||
zoneCounts?: Record<string, number>;
|
||||
}
|
||||
|
||||
const ZONES = [
|
||||
"zone-front",
|
||||
"zone-rear",
|
||||
"zone-left",
|
||||
"zone-right",
|
||||
"zone-hood",
|
||||
"zone-trunk",
|
||||
"zone-roof",
|
||||
];
|
||||
|
||||
const ZONE_LABELS: Record<string, string> = {
|
||||
"zone-front": "Передний бампер",
|
||||
"zone-rear": "Задний бампер",
|
||||
"zone-left": "Левый бок",
|
||||
"zone-right": "Правый бок",
|
||||
"zone-hood": "Капот",
|
||||
"zone-trunk": "Багажник",
|
||||
"zone-roof": "Крыша",
|
||||
};
|
||||
|
||||
// Approximate badge position per zone (percentage of container)
|
||||
const ZONE_BADGE_POSITIONS: Record<string, { x: string; y: string }> = {
|
||||
"zone-front": { x: "50%", y: "10%" },
|
||||
"zone-rear": { x: "50%", y: "90%" },
|
||||
"zone-left": { x: "12%", y: "50%" },
|
||||
"zone-right": { x: "88%", y: "50%" },
|
||||
"zone-hood": { x: "50%", y: "10%" },
|
||||
"zone-trunk": { x: "50%", y: "90%" },
|
||||
"zone-roof": { x: "50%", y: "50%" },
|
||||
};
|
||||
|
||||
export function VehicleScheme({ markers = [], onZoneClick, zoneCounts }: Props) {
|
||||
const [hoverZone, setHoverZone] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-xs mx-auto">
|
||||
<div
|
||||
className="vehicle-scheme cursor-pointer"
|
||||
dangerouslySetInnerHTML={{ __html: sedanSrc }}
|
||||
onClick={(e) => {
|
||||
const target = e.target as Element;
|
||||
const id = target.getAttribute?.("id");
|
||||
if (id && ZONES.includes(id)) onZoneClick?.(id);
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
const target = e.target as Element;
|
||||
const id = target.getAttribute?.("id");
|
||||
setHoverZone(id && ZONES.includes(id) ? id : null);
|
||||
}}
|
||||
onMouseLeave={() => setHoverZone(null)}
|
||||
/>
|
||||
|
||||
{/* Point markers (red dots) overlaid on the scheme via normalized coords */}
|
||||
{markers.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-3 h-3 rounded-full bg-destructive border border-white pointer-events-none"
|
||||
style={{
|
||||
left: `${m.x * 100}%`,
|
||||
top: `${m.y * 100}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Zone counts as small badges */}
|
||||
{zoneCounts &&
|
||||
Object.entries(zoneCounts).map(([zone, count]) => {
|
||||
if (count <= 0) return null;
|
||||
const pos = ZONE_BADGE_POSITIONS[zone];
|
||||
if (!pos) return null;
|
||||
return (
|
||||
<div
|
||||
key={zone}
|
||||
className="absolute bg-destructive text-destructive-foreground text-xs font-semibold rounded-full w-5 h-5 flex items-center justify-center pointer-events-none"
|
||||
style={{
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="text-xs text-center mt-1 text-muted-foreground min-h-[1em]">
|
||||
{hoverZone ? ZONE_LABELS[hoverZone] : "Тапни по зоне, чтобы добавить метку"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import exteriorSvgUrl from "/scheme/exterior.svg?url";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Zone color states
|
||||
const COLOR_DEFAULT_FILL = "rgba(0, 0, 0, 0)"; // transparent
|
||||
const COLOR_HOVER_FILL = "rgba(59, 130, 246, 0.20)"; // soft blue
|
||||
const COLOR_HAS_DAMAGE_FILL = "rgba(239, 68, 68, 0.30)"; // red tint for zones with active damage
|
||||
const COLOR_RESOLVED_FILL = "rgba(156, 163, 175, 0.30)"; // gray for resolved-only zones
|
||||
|
||||
export interface SchemeMarker {
|
||||
id: number;
|
||||
side?: string | null;
|
||||
damage_type?: string | null;
|
||||
severity?: string | null;
|
||||
resolved: boolean;
|
||||
/** Координаты точек повреждения в композитной SVG-системе (827×1209). */
|
||||
polygon?: { x: number; y: number }[] | null;
|
||||
}
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const POINT_RADIUS = 9;
|
||||
const POINT_FILL_ACTIVE = "rgb(220, 38, 38)";
|
||||
const POINT_FILL_RESOLVED = "rgb(107, 114, 128)";
|
||||
const COMPOSITE_W = 827;
|
||||
const COMPOSITE_H = 1209;
|
||||
|
||||
interface Props {
|
||||
markers: SchemeMarker[];
|
||||
onZoneTap?: (zoneId: number) => void;
|
||||
onTireWearClick?: () => void;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
/** Parse "zone-23" -> 23, anything else -> null */
|
||||
function parseZoneId(side: string | null | undefined): number | null {
|
||||
if (!side) return null;
|
||||
const m = /^zone-(\d+)$/.exec(side);
|
||||
if (!m) return null;
|
||||
const n = Number(m[1]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export function VendorVehicleScheme({
|
||||
markers,
|
||||
onZoneTap,
|
||||
onTireWearClick,
|
||||
editable = true,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [svgText, setSvgText] = useState<string | null>(null);
|
||||
const [hoverZone, setHoverZone] = useState<number | null>(null);
|
||||
|
||||
// Load SVG once from public/scheme/exterior.svg as a separate asset
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch(exteriorSvgUrl)
|
||||
.then((r) => r.text())
|
||||
.then((t) => {
|
||||
if (!cancelled) setSvgText(t);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSvgText("<!-- failed to load -->");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Count damage per zone, separately track has-any vs has-only-resolved
|
||||
const zoneStats = useMemo(() => {
|
||||
const stats = new Map<number, { count: number; activeCount: number }>();
|
||||
for (const m of markers) {
|
||||
const z = parseZoneId(m.side);
|
||||
if (z == null) continue;
|
||||
const s = stats.get(z) ?? { count: 0, activeCount: 0 };
|
||||
s.count += 1;
|
||||
if (!m.resolved) s.activeCount += 1;
|
||||
stats.set(z, s);
|
||||
}
|
||||
return stats;
|
||||
}, [markers]);
|
||||
|
||||
const tireCount = markers.filter((m) => m.side === "tires").length;
|
||||
|
||||
// Apply zone fills after SVG renders or when hover/damage state changes
|
||||
useEffect(() => {
|
||||
if (!svgText) return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const svg = el.querySelector("svg");
|
||||
if (!svg) return;
|
||||
|
||||
// Strip any inline <script> the vendor included
|
||||
svg.querySelectorAll("script").forEach((s) => s.remove());
|
||||
|
||||
// Make the SVG fill its container
|
||||
svg.setAttribute("width", "100%");
|
||||
svg.setAttribute("height", "100%");
|
||||
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||||
|
||||
// Reset all numeric-class paths to appropriate fill
|
||||
const all = svg.querySelectorAll("path[class]");
|
||||
all.forEach((p) => {
|
||||
const cls = p.getAttribute("class") ?? "";
|
||||
const zid = /^\d+$/.test(cls) ? Number(cls) : null;
|
||||
if (zid == null) return;
|
||||
|
||||
const s = zoneStats.get(zid);
|
||||
let fill = COLOR_DEFAULT_FILL;
|
||||
if (s) {
|
||||
fill = s.activeCount > 0 ? COLOR_HAS_DAMAGE_FILL : COLOR_RESOLVED_FILL;
|
||||
}
|
||||
if (hoverZone === zid && editable) fill = COLOR_HOVER_FILL;
|
||||
|
||||
p.setAttribute("fill", fill);
|
||||
(p as SVGElement).style.cursor = editable ? "pointer" : "default";
|
||||
(p as SVGElement).style.transition = "fill 0.15s";
|
||||
});
|
||||
}, [svgText, zoneStats, hoverZone, editable]);
|
||||
|
||||
// Overlay точек повреждений (multi-point координаты в композитной 827×1209)
|
||||
useEffect(() => {
|
||||
if (!svgText) return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const svg = el.querySelector("svg") as SVGSVGElement | null;
|
||||
if (!svg) return;
|
||||
|
||||
// Удаляем предыдущий overlay
|
||||
const existing = svg.querySelector("g[data-damage-points]");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const group = document.createElementNS(SVG_NS, "g");
|
||||
group.setAttribute("data-damage-points", "1");
|
||||
|
||||
let counter = 1;
|
||||
for (const m of markers) {
|
||||
const pts = m.polygon || [];
|
||||
if (pts.length === 0) continue;
|
||||
const fill = m.resolved ? POINT_FILL_RESOLVED : POINT_FILL_ACTIVE;
|
||||
|
||||
for (const pt of pts) {
|
||||
if (
|
||||
typeof pt.x !== "number" ||
|
||||
typeof pt.y !== "number" ||
|
||||
!Number.isFinite(pt.x) ||
|
||||
!Number.isFinite(pt.y)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// Polygon хранится в normalised 0..1 (см. ZoneDetailView). SVG viewBox
|
||||
// в композитной 827×1209, поэтому умножаем перед отрисовкой.
|
||||
const cx = pt.x * COMPOSITE_W;
|
||||
const cy = pt.y * COMPOSITE_H;
|
||||
const circle = document.createElementNS(SVG_NS, "circle");
|
||||
circle.setAttribute("cx", String(cx));
|
||||
circle.setAttribute("cy", String(cy));
|
||||
circle.setAttribute("r", String(POINT_RADIUS));
|
||||
circle.setAttribute("fill", fill);
|
||||
circle.setAttribute("stroke", "white");
|
||||
circle.setAttribute("stroke-width", "2");
|
||||
(circle as SVGElement).style.pointerEvents = "none";
|
||||
group.appendChild(circle);
|
||||
|
||||
const text = document.createElementNS(SVG_NS, "text");
|
||||
text.setAttribute("x", String(cx));
|
||||
text.setAttribute("y", String(cy));
|
||||
text.setAttribute("text-anchor", "middle");
|
||||
text.setAttribute("dominant-baseline", "central");
|
||||
text.setAttribute("fill", "white");
|
||||
text.setAttribute("font-size", "11");
|
||||
text.setAttribute("font-weight", "bold");
|
||||
(text as SVGElement).style.pointerEvents = "none";
|
||||
(text as SVGElement).style.userSelect = "none";
|
||||
text.textContent = String(counter);
|
||||
group.appendChild(text);
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
svg.appendChild(group);
|
||||
}, [svgText, markers]);
|
||||
|
||||
// Walk up from event.target to find a path[class="N"] (numeric zone id)
|
||||
function zoneFromEvent(e: React.MouseEvent | React.TouchEvent): number | null {
|
||||
let node = e.target as Element | null;
|
||||
while (node && node !== e.currentTarget) {
|
||||
if (node.tagName?.toLowerCase() === "path") {
|
||||
const cls = node.getAttribute("class") ?? "";
|
||||
if (/^\d+$/.test(cls)) return Number(cls);
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||
if (!editable) return;
|
||||
const z = zoneFromEvent(e);
|
||||
if (z != null) onZoneTap?.(z);
|
||||
}
|
||||
|
||||
function handleMove(e: React.MouseEvent<HTMLDivElement>) {
|
||||
if (!editable) return;
|
||||
setHoverZone(zoneFromEvent(e));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* SVG container — aspect ratio matches vendor viewBox 827×1209 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={handleClick}
|
||||
onMouseMove={handleMove}
|
||||
onMouseLeave={() => setHoverZone(null)}
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-md bg-card border rounded-lg p-2 aspect-[827/1209]",
|
||||
"overflow-hidden",
|
||||
)}
|
||||
style={{ touchAction: "manipulation" }}
|
||||
dangerouslySetInnerHTML={svgText ? { __html: svgText } : undefined}
|
||||
/>
|
||||
|
||||
{/* Summary line: count of zones with damage */}
|
||||
{zoneStats.size > 0 && (
|
||||
<div className="mx-auto max-w-md text-xs text-muted-foreground text-center">
|
||||
Зон с повреждениями:{" "}
|
||||
<span className="font-semibold text-foreground">{zoneStats.size}</span>
|
||||
{" · "}
|
||||
Всего меток:{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{markers.filter((m) => parseZoneId(m.side) != null).length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global tire-wear button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTireWearClick}
|
||||
disabled={!editable && !onTireWearClick}
|
||||
className="w-full max-w-md mx-auto flex items-center justify-between gap-3 rounded-lg border bg-card px-4 py-3 hover:bg-accent transition-colors text-sm font-medium"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xl">⊙</span>
|
||||
<span>Состояние резины</span>
|
||||
</span>
|
||||
{tireCount > 0 && (
|
||||
<span className="bg-destructive text-destructive-foreground text-xs font-bold rounded-full px-2 py-0.5">
|
||||
{tireCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{editable && (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Тапните по зоне машины, чтобы добавить метку повреждения.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 400">
|
||||
<rect x="40" y="20" width="120" height="360" rx="40" ry="40"
|
||||
fill="none" stroke="hsl(0 0% 16%)" stroke-width="2"/>
|
||||
<rect x="55" y="60" width="90" height="60" rx="10" fill="hsl(200 30% 88%)"/>
|
||||
<rect x="55" y="280" width="90" height="50" rx="10" fill="hsl(200 30% 88%)"/>
|
||||
<rect x="55" y="130" width="90" height="140" fill="hsl(40 20% 92%)" stroke="hsl(0 0% 60%)" stroke-width="1"/>
|
||||
<rect id="zone-front" x="40" y="20" width="120" height="40" fill="transparent"/>
|
||||
<rect id="zone-rear" x="40" y="340" width="120" height="40" fill="transparent"/>
|
||||
<rect id="zone-left" x="40" y="60" width="20" height="280" fill="transparent"/>
|
||||
<rect id="zone-right" x="140" y="60" width="20" height="280" fill="transparent"/>
|
||||
<rect id="zone-hood" x="55" y="20" width="90" height="40" fill="transparent"/>
|
||||
<rect id="zone-trunk" x="55" y="340" width="90" height="40" fill="transparent"/>
|
||||
<rect id="zone-roof" x="55" y="130" width="90" height="140" fill="transparent"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,65 @@
|
||||
export interface DamageVocabulary {
|
||||
damage_types_ordered: string[];
|
||||
exterior_selectable: string[];
|
||||
salon_selectable: string[];
|
||||
severity_scale: {
|
||||
positions: number;
|
||||
labels: string[];
|
||||
values: number[];
|
||||
default: string;
|
||||
};
|
||||
}
|
||||
|
||||
let cache: DamageVocabulary | null = null;
|
||||
|
||||
export async function loadDamageVocabulary(): Promise<DamageVocabulary> {
|
||||
if (cache) return cache;
|
||||
const res = await fetch("/data/damage_vocabulary.json");
|
||||
const data = await res.json();
|
||||
cache = {
|
||||
damage_types_ordered: data.damage_types_ordered,
|
||||
exterior_selectable: data.exterior_selectable,
|
||||
salon_selectable: data.salon_selectable,
|
||||
severity_scale: data.severity_scale,
|
||||
};
|
||||
return cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping русского названия из damage_vocabulary.json в DamageType enum
|
||||
* нашего backend'а (см. mechanic_schemas.DamageType Literal).
|
||||
* Возвращает null для типов которым нет точного соответствия —
|
||||
* вызывающий код должен такие отфильтровать из UI.
|
||||
*/
|
||||
export const DAMAGE_RU_TO_ENUM: Record<string, string | null> = {
|
||||
"Вмятина": "dent",
|
||||
"Царапина": "scratch",
|
||||
"Трещина": "crack",
|
||||
"Повреждено": "damaged",
|
||||
"Скол": "chip",
|
||||
"Отсутствует": "missing",
|
||||
"Прожжено": "burn",
|
||||
"Погнуто": "bent",
|
||||
"Требуется мойка": null,
|
||||
"Не работает": "not-working",
|
||||
"Порез": "cut",
|
||||
"Прокол": "puncture",
|
||||
"Порвано": "torn",
|
||||
"Пятна": "stain",
|
||||
"Грязный салон": "stain",
|
||||
"Запах табака": null,
|
||||
"Повреждение салонных ковриков": "damaged",
|
||||
"Повреждение обшивки дверей": "damaged",
|
||||
"Сломанные ручки": "damaged",
|
||||
"Установлено": "removed",
|
||||
"Снято": "removed",
|
||||
"Контроль": null,
|
||||
"Требуется химчистка": "stain",
|
||||
"Затертость": "scuff",
|
||||
"Грыжа": "bulge",
|
||||
"Штат": null,
|
||||
};
|
||||
|
||||
export function damageTypeRuToEnum(ru: string): string | null {
|
||||
return DAMAGE_RU_TO_ENUM[ru] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
let cache: Record<string, string> | null = null;
|
||||
|
||||
export async function loadZoneLabels(): Promise<Record<string, string>> {
|
||||
if (cache) return cache;
|
||||
const res = await fetch("/data/zone_labels.json");
|
||||
const data = await res.json();
|
||||
// The JSON has a "_meta" key + numeric string keys → filter to numeric only
|
||||
cache = Object.fromEntries(
|
||||
Object.entries(data).filter(([k]) => /^\d+$/.test(k))
|
||||
) as Record<string, string>;
|
||||
return cache;
|
||||
}
|
||||
|
||||
export function getZoneLabelSync(zoneId: number, labels: Record<string, string> | null): string {
|
||||
return labels?.[String(zoneId)] ?? `зона ${zoneId}`;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Фоновая сверка прав механика раз в 5 минут (У35).
|
||||
*
|
||||
* Если за время активной сессии админ снял `can_login_pwa` или вывел
|
||||
* пользователя из отдела механиков (или потерял `is_admin`), сервер
|
||||
* на ближайшем `/me` вернёт 403 с `code: "NO_PWA_ACCESS"`. В этом
|
||||
* случае хук:
|
||||
* 1) Сбрасывает access-токен (через `useAuth.setToken(null)`) —
|
||||
* `client.ts` перехватчик 401 не сработает, но мы делаем то же
|
||||
* сами явно.
|
||||
* 2) Кладёт информацию о ревокации в sessionStorage, чтобы экран
|
||||
* Login смог отрендерить корректное сообщение
|
||||
* «Ваш доступ отозван» (с кликабельным контактом саппорта).
|
||||
* 3) **Не трогает IndexedDB-черновики** — они переживают сброс
|
||||
* токена (У35). Если доступ вернут — механик при следующем
|
||||
* входе увидит «Продолжить незавершённый ремонт» (У18).
|
||||
*
|
||||
* Параметр интервала задан в файле и при необходимости подкручивается
|
||||
* с одного места.
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { HTTPError } from "ky";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { getMe, type Me } from "@/api/me";
|
||||
|
||||
export const SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 минут (У35)
|
||||
|
||||
export interface RevocationInfo {
|
||||
reason: "NO_PWA_ACCESS";
|
||||
support: {
|
||||
tg_username: string | null;
|
||||
phone: string | null;
|
||||
};
|
||||
at: string; // ISO timestamp
|
||||
}
|
||||
|
||||
export const REVOCATION_STORAGE_KEY = "mechanic-pwa.revocation";
|
||||
|
||||
function storeRevocation(detail: unknown): void {
|
||||
// backend кладёт detail = { code, support }
|
||||
const d = detail as { code?: string; support?: RevocationInfo["support"] } | null | undefined;
|
||||
if (!d || d.code !== "NO_PWA_ACCESS") return;
|
||||
const payload: RevocationInfo = {
|
||||
reason: "NO_PWA_ACCESS",
|
||||
support: d.support ?? { tg_username: null, phone: null },
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
sessionStorage.setItem(REVOCATION_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch {
|
||||
/* sessionStorage недоступен (приватный режим?) — silent */
|
||||
}
|
||||
}
|
||||
|
||||
export function readRevocation(): RevocationInfo | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(REVOCATION_STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as RevocationInfo) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearRevocation(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(REVOCATION_STORAGE_KEY);
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключить к корневому компоненту PWA, чтобы фоновая сверка работала
|
||||
* пока приложение открыто. Без зависимостей — стартует один раз
|
||||
* на mount и завершается на unmount.
|
||||
*/
|
||||
export function useMeSync(): void {
|
||||
const token = useAuth((s) => s.token);
|
||||
const setToken = useAuth((s) => s.setToken);
|
||||
const tickInFlight = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return; // нечего сверять
|
||||
|
||||
const tick = async () => {
|
||||
if (tickInFlight.current) return;
|
||||
tickInFlight.current = true;
|
||||
try {
|
||||
const _me: Me = await getMe();
|
||||
void _me;
|
||||
} catch (err) {
|
||||
if (err instanceof HTTPError && err.response.status === 403) {
|
||||
// backend FastAPI кладёт detail в JSON
|
||||
let detail: unknown = null;
|
||||
try {
|
||||
const body = (await err.response.json()) as { detail?: unknown };
|
||||
detail = body?.detail ?? null;
|
||||
} catch {
|
||||
/* пустое или не-JSON тело */
|
||||
}
|
||||
storeRevocation(detail);
|
||||
setToken(null);
|
||||
}
|
||||
// 401 ловится в beforeError client.ts автоматически.
|
||||
} finally {
|
||||
tickInFlight.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => { void tick(); };
|
||||
document.addEventListener("visibilitychange", handleFocus);
|
||||
const id = window.setInterval(tick, SYNC_INTERVAL_MS);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", handleFocus);
|
||||
};
|
||||
}, [token, setToken]);
|
||||
}
|
||||
|
||||
/** Вспомогательная функция для use в Login.tsx — единичная проверка после login. */
|
||||
export { storeRevocation };
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Хук ленты ремонтов с stale-while-revalidate (У38).
|
||||
*
|
||||
* Использует `useInfiniteQuery` для бесконечной пагинации по 20 (У38).
|
||||
* На `visibilitychange` (когда вкладка/приложение получают фокус) —
|
||||
* автоматический рефреш первой страницы. Pull-to-refresh вызывается
|
||||
* напрямую из компонента через `refetch()`.
|
||||
*
|
||||
* SWR-кэш живёт в react-query (память + localStorage persist уже
|
||||
* сконфигурирован в QueryClient на уровне приложения, если такого нет —
|
||||
* первая отрисовка идёт без кэша, но дальше работает).
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { useInfiniteQuery, type QueryKey } from "@tanstack/react-query";
|
||||
import { getRepairsFeed, type FeedItem, type FeedResponse } from "@/api/repairsFeed";
|
||||
|
||||
export const FEED_QUERY_KEY: QueryKey = ["repairs-feed"];
|
||||
export const FEED_PAGE_SIZE = 20;
|
||||
|
||||
export function useRepairsFeed() {
|
||||
const query = useInfiniteQuery<FeedResponse>({
|
||||
queryKey: FEED_QUERY_KEY,
|
||||
queryFn: ({ pageParam }) =>
|
||||
getRepairsFeed({ limit: FEED_PAGE_SIZE, cursor: (pageParam as string | null) ?? null }),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
staleTime: 30_000, // 30 секунд считаем «свежим» — потом фон обновляет
|
||||
refetchOnWindowFocus: false, // делаем свой visibilitychange-листенер ниже
|
||||
});
|
||||
|
||||
// У38: обновление при возврате в приложение/вкладку.
|
||||
useEffect(() => {
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
// Рефрешим только первую страницу, бесконечный скролл сбрасывается.
|
||||
void query.refetch();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => document.removeEventListener("visibilitychange", onVisible);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Удобный flatten для рендера в одну ленту.
|
||||
const items: FeedItem[] = query.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
return {
|
||||
items,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
hasNextPage: !!query.hasNextPage,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Форматы времени для ленты ремонтов PWA (У39).
|
||||
*
|
||||
* Гибрид «Сегодня в 14:32 / Вчера в 18:00 / 23 мая, 14:32 /
|
||||
* 23 мая 2025, 14:32» — для рабочего инструмента нужно точное время,
|
||||
* а не относительное «2 часа назад».
|
||||
*
|
||||
* В детальной карточке ремонта используется всегда полный формат
|
||||
* (см. `formatFullDateTime`).
|
||||
*/
|
||||
|
||||
const HHMM = new Intl.DateTimeFormat("ru-RU", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const DAY_MONTH = new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
|
||||
const DAY_MONTH_YEAR = new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
function startOfDay(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
|
||||
/** У39: формат строки ленты. */
|
||||
export function formatFeedTimestamp(input: string | Date, now: Date = new Date()): string {
|
||||
const dt = typeof input === "string" ? new Date(input) : input;
|
||||
if (Number.isNaN(dt.getTime())) return String(input);
|
||||
|
||||
const todayStart = startOfDay(now);
|
||||
const yesterdayStart = new Date(todayStart);
|
||||
yesterdayStart.setDate(yesterdayStart.getDate() - 1);
|
||||
|
||||
if (dt >= todayStart) {
|
||||
return `Сегодня в ${HHMM.format(dt)}`;
|
||||
}
|
||||
if (dt >= yesterdayStart) {
|
||||
return `Вчера в ${HHMM.format(dt)}`;
|
||||
}
|
||||
if (dt.getFullYear() === now.getFullYear()) {
|
||||
return `${DAY_MONTH.format(dt)}, ${HHMM.format(dt)}`;
|
||||
}
|
||||
return `${DAY_MONTH_YEAR.format(dt)}, ${HHMM.format(dt)}`;
|
||||
}
|
||||
|
||||
/** Полный формат для детальной карточки. */
|
||||
export function formatFullDateTime(input: string | Date): string {
|
||||
const dt = typeof input === "string" ? new Date(input) : input;
|
||||
if (Number.isNaN(dt.getTime())) return String(input);
|
||||
return `${DAY_MONTH_YEAR.format(dt)}, ${HHMM.format(dt)}`;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getMe } from "@/api/me";
|
||||
import { logout } from "@/api/auth";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ActionTile {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
comingSoon?: boolean;
|
||||
}
|
||||
|
||||
export default function ActionMenu() {
|
||||
const navigate = useNavigate();
|
||||
const meQuery = useQuery({ queryKey: ["me"], queryFn: getMe, staleTime: 60_000 });
|
||||
|
||||
const tiles: ActionTile[] = [
|
||||
{
|
||||
key: "inspect",
|
||||
title: "Осмотр",
|
||||
description: "Создать или продолжить осмотр машины",
|
||||
emoji: "🔍",
|
||||
onClick: () => navigate("/vehicles?action=inspect"),
|
||||
},
|
||||
{
|
||||
key: "repair",
|
||||
title: "Ремонт",
|
||||
description: "Лента ремонтов парка + создать новый",
|
||||
emoji: "🔧",
|
||||
onClick: () => navigate("/repairs"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="flex items-center justify-between p-4 border-b">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Premium Механик</h1>
|
||||
{meQuery.data && (
|
||||
<p className="text-xs text-muted-foreground">{meQuery.data.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
logout();
|
||||
navigate("/login", { replace: true });
|
||||
}}
|
||||
>
|
||||
Выйти
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<main className="container max-w-2xl py-8 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-muted-foreground">
|
||||
Что делаем?
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{tiles.map((t) => (
|
||||
<Card
|
||||
key={t.key}
|
||||
onClick={t.disabled ? undefined : t.onClick}
|
||||
className={
|
||||
"p-5 transition-shadow " +
|
||||
(t.disabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "cursor-pointer hover:shadow-md active:shadow-sm")
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-3xl leading-none">{t.emoji}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-base">{t.title}</h3>
|
||||
{t.comingSoon && (
|
||||
<span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5">
|
||||
скоро
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,54 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { listVehicles } from "@/api/vehicles";
|
||||
import { getMe } from "@/api/me";
|
||||
import { logout } from "@/api/auth";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const ACTION_TITLES: Record<string, string> = {
|
||||
inspect: "Осмотр",
|
||||
repair: "Ремонт",
|
||||
};
|
||||
|
||||
// Группа цветов по семантике статуса. Палитра подсчитана из Tailwind с
|
||||
// прозрачностью, чтобы чип не перебивал гос.номер. Tone:
|
||||
// ok — на линии (зелёный)
|
||||
// idle — простой (серый)
|
||||
// repair — в ремонте / ждёт ремонта (янтарный)
|
||||
// alert — ДТП (красный)
|
||||
// admin — выкуп / на продаже / бронь / подготовка к продаже / ждёт доставки
|
||||
// sold — продана (не отображается — отфильтрована из выдачи)
|
||||
const STATUS_TONE: Record<string, string> = {
|
||||
"На линии": "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||
"Простой": "bg-muted text-muted-foreground",
|
||||
"Ремонтируется": "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
|
||||
"Ждет ремонта": "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
|
||||
"ДТП": "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
|
||||
"Бронь": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
"Выкуп": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
"На продаже": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
"Подготовка на продажу": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
"Ждет доставки до офиса": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
};
|
||||
|
||||
function StatusChip({ status }: { status: string }) {
|
||||
const tone =
|
||||
STATUS_TONE[status] ??
|
||||
"bg-muted text-muted-foreground";
|
||||
return (
|
||||
<span className={`inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-full ${tone}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [q, setQ] = useState("");
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const meQuery = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: getMe,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const action = searchParams.get("action") || undefined;
|
||||
const subtitle = action ? ACTION_TITLES[action] : undefined;
|
||||
|
||||
const vehiclesQuery = useQuery({
|
||||
queryKey: ["vehicles", q],
|
||||
@@ -25,31 +57,23 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="flex items-center justify-between p-4 border-b">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Premium Механик</h1>
|
||||
{meQuery.data && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{meQuery.data.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
logout();
|
||||
navigate("/login", { replace: true });
|
||||
}}
|
||||
<header className="flex items-center gap-3 p-4 border-b">
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Выйти
|
||||
</Button>
|
||||
← Меню
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-lg font-semibold">
|
||||
Машины{subtitle && ` — ${subtitle}`}
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="p-4">
|
||||
<h2 className="text-lg font-semibold mb-3">Машины</h2>
|
||||
<Input
|
||||
placeholder="Поиск по номеру или VIN"
|
||||
placeholder="Поиск по госномеру"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
className="mb-4"
|
||||
@@ -67,9 +91,14 @@ export default function Home() {
|
||||
<Card
|
||||
key={v.id}
|
||||
className="p-4 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => navigate(`/vehicles/${v.id}`)}
|
||||
onClick={() =>
|
||||
navigate(`/vehicles/${v.id}${action ? `?action=${action}` : ""}`)
|
||||
}
|
||||
>
|
||||
<div className="font-semibold text-lg">{v.license_plate}</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="font-semibold text-lg">{v.license_plate}</div>
|
||||
{v.status && <StatusChip status={v.status} />}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{[v.make, v.model, v.year].filter(Boolean).join(" ") || "—"}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
@@ -5,14 +6,26 @@ import {
|
||||
getInspection,
|
||||
patchInspection,
|
||||
createMarker,
|
||||
deleteMarker,
|
||||
type MarkerSummary,
|
||||
} from "@/api/inspections";
|
||||
import { uploadPhoto, uploadPhotoAnnotation } from "@/api/photos";
|
||||
import { PhotoSlot } from "@/components/camera/PhotoSlot";
|
||||
import { VehicleScheme } from "@/components/vehicle-scheme/VehicleScheme";
|
||||
import { CameraCapture } from "@/components/camera/CameraCapture";
|
||||
import { VendorVehicleScheme } from "@/components/vehicle-scheme/VendorVehicleScheme";
|
||||
import { TireWearDialog } from "@/components/inspection/TireWearDialog";
|
||||
import { ZoneConfirmDialog } from "@/components/damage-flow/ZoneConfirmDialog";
|
||||
import { ZoneDetailView } from "@/components/damage-flow/ZoneDetailView";
|
||||
import { DamageClassifyDialog } from "@/components/damage-flow/DamageClassifyDialog";
|
||||
import { PhotoAnnotateStep } from "@/components/damage-flow/PhotoAnnotateStep";
|
||||
import { AuthImg } from "@/components/AuthImg";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { loadZoneLabels } from "@/data/zoneLabels";
|
||||
import { damageTypeRuToEnum } from "@/data/damageVocabulary";
|
||||
import { ttImageUrl } from "@/api/vehicles";
|
||||
|
||||
const SLOTS: { side: string; label: string }[] = [
|
||||
const SLOTS_GENERAL: { side: string; label: string }[] = [
|
||||
{ side: "front", label: "Перед" },
|
||||
{ side: "rear", label: "Зад" },
|
||||
{ side: "left", label: "Левый бок" },
|
||||
@@ -23,6 +36,16 @@ const SLOTS: { side: string; label: string }[] = [
|
||||
{ side: "free", label: "Свободное" },
|
||||
];
|
||||
|
||||
const SLOT_JACK = { side: "jack", label: "Домкрат" };
|
||||
|
||||
/** Все типы осмотров (выдача/приёмка/плановый/...) показывают одинаковый
|
||||
* набор слотов общих ракурсов + домкрат. Раньше «return» исключал общие
|
||||
* фото в пользу скорости приёмки, но фидбек: при приёмке всё равно надо
|
||||
* фиксировать общее состояние машины, как и при выдаче/осмотре. */
|
||||
function slotsForType(_type: string): typeof SLOTS_GENERAL {
|
||||
return [...SLOTS_GENERAL, SLOT_JACK];
|
||||
}
|
||||
|
||||
const SEVERITY_LABELS: Record<string, string> = {
|
||||
cosmetic: "косметика",
|
||||
minor: "лёгкое",
|
||||
@@ -49,9 +72,32 @@ const DAMAGE_TYPE_LABELS: Record<string, string> = {
|
||||
"not-working": "не работает",
|
||||
};
|
||||
|
||||
function humanizeMarkerSide(side: string | null | undefined): string {
|
||||
// Vendor 3-position severity index → backend literal
|
||||
const SEVERITY_MAP = ["minor", "moderate", "severe"] as const;
|
||||
|
||||
function humanizeMarkerSide(
|
||||
side: string | null | undefined,
|
||||
zoneLabels?: Record<string, string> | null,
|
||||
): string {
|
||||
if (!side) return "—";
|
||||
const zoneMap: Record<string, string> = {
|
||||
if (side === "tires") return "Резина";
|
||||
const zm = /^zone-(\d+)$/.exec(side);
|
||||
if (zm) {
|
||||
const label = zoneLabels?.[zm[1]];
|
||||
if (label) return label;
|
||||
return `Зона ${zm[1]}`;
|
||||
}
|
||||
const map: Record<string, string> = {
|
||||
top: "Сверху",
|
||||
front: "Перед",
|
||||
back: "Зад",
|
||||
left: "Левый",
|
||||
right: "Правый",
|
||||
"bumper-fl": "Бампер ПЛ",
|
||||
"bumper-fr": "Бампер ПП",
|
||||
"bumper-rl": "Бампер ЗЛ",
|
||||
"bumper-rr": "Бампер ЗП",
|
||||
// Legacy named zone-* support (data from earlier prod)
|
||||
"zone-front": "Передний бампер",
|
||||
"zone-rear": "Задний бампер",
|
||||
"zone-left": "Левый бок",
|
||||
@@ -60,22 +106,50 @@ function humanizeMarkerSide(side: string | null | undefined): string {
|
||||
"zone-trunk": "Багажник",
|
||||
"zone-roof": "Крыша",
|
||||
};
|
||||
return zoneMap[side] ?? side;
|
||||
return map[side] ?? side;
|
||||
}
|
||||
|
||||
type DamageFlow =
|
||||
| { kind: "idle" }
|
||||
| { kind: "confirm"; zoneId: number; zoneLabel: string }
|
||||
| { kind: "placePoints"; zoneId: number; zoneLabel: string }
|
||||
| { kind: "classify"; zoneId: number; zoneLabel: string; points: { x: number; y: number }[] }
|
||||
| { kind: "capture"; zoneId: number; zoneLabel: string; points: { x: number; y: number }[]; damageType: string; severity: 0 | 1 | 2 }
|
||||
| { kind: "annotate"; zoneId: number; zoneLabel: string; points: { x: number; y: number }[]; damageType: string; severity: 0 | 1 | 2; photoFile: File };
|
||||
|
||||
export default function InspectionEditor() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const insId = Number(id);
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [flow, setFlow] = useState<DamageFlow>({ kind: "idle" });
|
||||
const [zoneLabels, setZoneLabels] = useState<Record<string, string> | null>(null);
|
||||
const [tireWearOpen, setTireWearOpen] = useState(false);
|
||||
// Local draft заметок механика — синхронизируется с ins.notes когда осмотр
|
||||
// прогружается / обновляется. Кнопка «Сохранить» появляется только когда
|
||||
// draft отличается от сохранённого (см. notesDirty ниже).
|
||||
const [notesDraft, setNotesDraft] = useState<string>("");
|
||||
|
||||
useEffect(() => { loadZoneLabels().then(setZoneLabels); }, []);
|
||||
|
||||
const { data: ins, isLoading, isError } = useQuery({
|
||||
queryKey: ["inspection", insId],
|
||||
queryFn: () => getInspection(insId),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (ins?.notes !== undefined) setNotesDraft(ins.notes ?? "");
|
||||
}, [ins?.notes]);
|
||||
|
||||
const finalize = useMutation({
|
||||
mutationFn: () => patchInspection(insId, { status: "completed" }),
|
||||
mutationFn: () =>
|
||||
// При завершении осмотра коммитим текст заметки тем же patch'ем —
|
||||
// если механик что-то ввёл и сразу нажал «Завершить», не теряем.
|
||||
patchInspection(insId, {
|
||||
status: "completed",
|
||||
notes: (notesDraft ?? "").trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success("Осмотр завершён");
|
||||
navigate(`/inspections/${insId}`);
|
||||
@@ -83,19 +157,22 @@ export default function InspectionEditor() {
|
||||
onError: () => toast.error("Не удалось завершить"),
|
||||
});
|
||||
|
||||
const addZoneMarker = useMutation({
|
||||
mutationFn: (zone: string) =>
|
||||
createMarker(insId, {
|
||||
side: zone,
|
||||
damage_type: "damaged",
|
||||
severity: "minor",
|
||||
description: "Метка со схемы",
|
||||
}),
|
||||
const notesMutation = useMutation({
|
||||
mutationFn: (next: string) => patchInspection(insId, { notes: next }),
|
||||
onSuccess: (updated) => {
|
||||
toast.success("Заметка сохранена");
|
||||
qc.setQueryData(["inspection", insId], updated);
|
||||
},
|
||||
onError: () => toast.error("Не удалось сохранить заметку"),
|
||||
});
|
||||
|
||||
const removeMarker = useMutation({
|
||||
mutationFn: (markerId: number) => deleteMarker(markerId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["inspection", insId] });
|
||||
toast.success("Метка добавлена");
|
||||
toast.success("Метка удалена");
|
||||
},
|
||||
onError: () => toast.error("Ошибка"),
|
||||
onError: () => toast.error("Не удалось удалить"),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="p-8 text-muted-foreground">Загружаю…</div>;
|
||||
@@ -105,26 +182,17 @@ export default function InspectionEditor() {
|
||||
// Photos lookup by side+slot for initialPhotoId
|
||||
const photosBySide = new Map<string, typeof ins.photos[0]>();
|
||||
for (const p of ins.photos) photosBySide.set(`${p.side}-${p.slot_index}`, p);
|
||||
|
||||
// Point markers (x/y populated) drawn as dots on the scheme
|
||||
const dots = ins.markers
|
||||
.filter((m: MarkerSummary) => m.x != null && m.y != null)
|
||||
.map((m: MarkerSummary) => ({
|
||||
x: Number(m.x),
|
||||
y: Number(m.y),
|
||||
severity: m.severity ?? "minor",
|
||||
}));
|
||||
|
||||
// Zone counts (markers with side="zone-*", no x/y)
|
||||
const zoneCounts: Record<string, number> = {};
|
||||
for (const m of ins.markers) {
|
||||
if (m.side?.startsWith("zone-")) {
|
||||
zoneCounts[m.side] = (zoneCounts[m.side] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
// Photos lookup by id — нужен для marker thumb (annotated vs original)
|
||||
const photosById = new Map<number, typeof ins.photos[0]>();
|
||||
for (const p of ins.photos) photosById.set(p.id, p);
|
||||
|
||||
const editable = ins.status === "in_progress";
|
||||
|
||||
const handleZoneTap = (zoneId: number) => {
|
||||
const label = zoneLabels?.[String(zoneId)] ?? `зона ${zoneId}`;
|
||||
setFlow({ kind: "confirm", zoneId, zoneLabel: label });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-4 space-y-4">
|
||||
<button
|
||||
@@ -145,20 +213,195 @@ export default function InspectionEditor() {
|
||||
</div>
|
||||
|
||||
<Card className="p-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
Схема — тапни по зоне, чтобы добавить метку
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||
Схема — тапните на зону, чтобы добавить метку
|
||||
</h2>
|
||||
<VehicleScheme
|
||||
markers={dots}
|
||||
zoneCounts={zoneCounts}
|
||||
onZoneClick={editable ? (zone) => addZoneMarker.mutate(zone) : undefined}
|
||||
<VendorVehicleScheme
|
||||
markers={ins.markers.map((m: MarkerSummary) => ({
|
||||
id: m.id,
|
||||
side: m.side,
|
||||
damage_type: m.damage_type,
|
||||
severity: m.severity,
|
||||
resolved: m.resolved,
|
||||
polygon: m.polygon,
|
||||
}))}
|
||||
onZoneTap={editable ? handleZoneTap : undefined}
|
||||
onTireWearClick={editable ? () => setTireWearOpen(true) : undefined}
|
||||
editable={editable}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Блок «Заметки» поднят сразу под схему — главное текстовое поле для
|
||||
планового осмотра, не хочется чтобы механик пролистывал фото+метки
|
||||
чтобы добраться до textarea. */}
|
||||
<Card className="p-4 space-y-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">
|
||||
Заметки механика
|
||||
</h2>
|
||||
{editable ? (
|
||||
<>
|
||||
<textarea
|
||||
value={notesDraft}
|
||||
onChange={(e) => setNotesDraft(e.target.value)}
|
||||
placeholder="Текст осмотра, замечания, рекомендации…"
|
||||
rows={Math.max(3, Math.min(notesDraft.split("\n").length + 1, 12))}
|
||||
className="w-full text-sm p-2 rounded border border-input bg-background resize-y focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
{(notesDraft ?? "") !== (ins.notes ?? "") && (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNotesDraft(ins.notes ?? "")}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-3 py-1"
|
||||
disabled={notesMutation.isPending}
|
||||
>
|
||||
Отменить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => notesMutation.mutate(notesDraft)}
|
||||
disabled={notesMutation.isPending}
|
||||
className="text-xs bg-primary text-primary-foreground rounded px-3 py-1 disabled:opacity-60"
|
||||
>
|
||||
{notesMutation.isPending ? "Сохраняю…" : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm whitespace-pre-wrap">{ins.notes || "—"}</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Multi-step damage flow */}
|
||||
{flow.kind === "confirm" && (
|
||||
<ZoneConfirmDialog
|
||||
open
|
||||
zoneLabel={flow.zoneLabel}
|
||||
onConfirm={() =>
|
||||
setFlow({ kind: "placePoints", zoneId: flow.zoneId, zoneLabel: flow.zoneLabel })
|
||||
}
|
||||
onCancel={() => setFlow({ kind: "idle" })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{flow.kind === "placePoints" && (
|
||||
<ZoneDetailView
|
||||
open
|
||||
zoneId={flow.zoneId}
|
||||
zoneLabel={flow.zoneLabel}
|
||||
onConfirm={(points) =>
|
||||
setFlow({ kind: "classify", zoneId: flow.zoneId, zoneLabel: flow.zoneLabel, points })
|
||||
}
|
||||
onCancel={() => setFlow({ kind: "idle" })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{flow.kind === "classify" && (
|
||||
<DamageClassifyDialog
|
||||
open
|
||||
zoneId={flow.zoneId}
|
||||
zoneLabel={flow.zoneLabel}
|
||||
pointsCount={flow.points.length}
|
||||
onCancel={() =>
|
||||
setFlow({ kind: "placePoints", zoneId: flow.zoneId, zoneLabel: flow.zoneLabel })
|
||||
}
|
||||
onSave={({ damageType, severity }) =>
|
||||
setFlow({
|
||||
kind: "capture",
|
||||
zoneId: flow.zoneId,
|
||||
zoneLabel: flow.zoneLabel,
|
||||
points: flow.points,
|
||||
damageType,
|
||||
severity,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{flow.kind === "capture" && (
|
||||
<CapturePhotoStep
|
||||
zoneLabel={flow.zoneLabel}
|
||||
onCancel={() => setFlow({ kind: "idle" })}
|
||||
onPhotoCaptured={(file) =>
|
||||
setFlow({
|
||||
kind: "annotate",
|
||||
zoneId: flow.zoneId,
|
||||
zoneLabel: flow.zoneLabel,
|
||||
points: flow.points,
|
||||
damageType: flow.damageType,
|
||||
severity: flow.severity,
|
||||
photoFile: file,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{flow.kind === "annotate" && (
|
||||
<PhotoAnnotateStep
|
||||
photoFile={flow.photoFile}
|
||||
zoneLabel={flow.zoneLabel}
|
||||
onCancel={() =>
|
||||
setFlow({
|
||||
kind: "capture",
|
||||
zoneId: flow.zoneId,
|
||||
zoneLabel: flow.zoneLabel,
|
||||
points: flow.points,
|
||||
damageType: flow.damageType,
|
||||
severity: flow.severity,
|
||||
})
|
||||
}
|
||||
onSubmit={async (annotated) => {
|
||||
try {
|
||||
const damageEnum = damageTypeRuToEnum(flow.damageType);
|
||||
if (!damageEnum) {
|
||||
toast.error(`Не поддерживается: ${flow.damageType}`);
|
||||
setFlow({ kind: "idle" });
|
||||
return;
|
||||
}
|
||||
const result = await uploadPhoto(insId, "free", 0, flow.photoFile);
|
||||
if (annotated) {
|
||||
try {
|
||||
await uploadPhotoAnnotation(insId, result.id, annotated);
|
||||
} catch (err) {
|
||||
// Аннотация — best-effort. Фото оригинал уже сохранилось.
|
||||
console.warn("annotation upload failed (non-fatal)", err);
|
||||
}
|
||||
}
|
||||
await createMarker(insId, {
|
||||
side: `zone-${flow.zoneId}`,
|
||||
polygon: flow.points,
|
||||
damage_type: damageEnum,
|
||||
severity: SEVERITY_MAP[flow.severity],
|
||||
photo_id: result.id,
|
||||
});
|
||||
void qc.invalidateQueries({ queryKey: ["inspection", insId] });
|
||||
toast.success("Повреждение записано");
|
||||
} catch (err) {
|
||||
console.error("save failed", err);
|
||||
toast.error("Не удалось сохранить");
|
||||
} finally {
|
||||
setFlow({ kind: "idle" });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TireWearDialog
|
||||
open={tireWearOpen}
|
||||
inspectionId={insId}
|
||||
onClose={() => setTireWearOpen(false)}
|
||||
onCreated={() => {
|
||||
setTireWearOpen(false);
|
||||
void qc.invalidateQueries({ queryKey: ["inspection", insId] });
|
||||
toast.success("Состояние резины записано");
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">Фото</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SLOTS.map((s) => (
|
||||
{slotsForType(ins.type).map((s) => (
|
||||
<PhotoSlot
|
||||
key={`${s.side}-0`}
|
||||
inspectionId={insId}
|
||||
@@ -185,8 +428,8 @@ export default function InspectionEditor() {
|
||||
<Card className="p-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{humanizeMarkerSide(m.side)}
|
||||
<span className="font-medium capitalize">
|
||||
{humanizeMarkerSide(m.side, zoneLabels)}
|
||||
</span>
|
||||
{" — "}
|
||||
<span>
|
||||
@@ -203,17 +446,64 @@ export default function InspectionEditor() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{m.carried_over_from_id && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
перенесено
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{m.carried_over_from_id && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
перенесено
|
||||
</span>
|
||||
)}
|
||||
{!m.carried_over_from_id &&
|
||||
m.description?.startsWith("Перенесено из Element Mechanic") && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
из Element Mechanic
|
||||
</span>
|
||||
)}
|
||||
{editable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const verb = m.carried_over_from_id
|
||||
? "Удалить (повреждение устранено в ремонте)?"
|
||||
: "Удалить метку?";
|
||||
if (window.confirm(verb)) {
|
||||
removeMarker.mutate(m.id);
|
||||
}
|
||||
}}
|
||||
disabled={removeMarker.isPending}
|
||||
className="text-xs text-destructive hover:underline px-1"
|
||||
title="Удалить метку"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{m.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{m.description}
|
||||
</div>
|
||||
)}
|
||||
{m.photo_id ? (
|
||||
<div className="mt-2 max-w-[8rem]">
|
||||
<AuthImg
|
||||
src={
|
||||
photosById.get(m.photo_id)?.annotated_key
|
||||
? `photos/${m.photo_id}/annotated`
|
||||
: `photos/${m.photo_id}/thumb`
|
||||
}
|
||||
alt="Фото повреждения"
|
||||
className="w-full aspect-[4/3] object-cover rounded bg-muted"
|
||||
/>
|
||||
</div>
|
||||
) : m.tt_image_id ? (
|
||||
<div className="mt-2 max-w-[8rem]">
|
||||
<img
|
||||
src={ttImageUrl(m.tt_image_id)}
|
||||
alt="Фото из Element Mechanic"
|
||||
loading="lazy"
|
||||
className="w-full aspect-[4/3] object-cover rounded bg-muted"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
@@ -233,3 +523,35 @@ export default function InspectionEditor() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapturePhotoStep({
|
||||
zoneLabel,
|
||||
onCancel,
|
||||
onPhotoCaptured,
|
||||
}: {
|
||||
zoneLabel: string;
|
||||
onCancel: () => void;
|
||||
onPhotoCaptured: (file: File) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-background flex flex-col">
|
||||
<header className="p-4 border-b">
|
||||
<div className="text-xs text-muted-foreground">Фото повреждения</div>
|
||||
<h2 className="text-lg font-semibold capitalize">{zoneLabel}</h2>
|
||||
</header>
|
||||
<main className="flex-1 p-4 flex flex-col justify-center max-w-md w-full mx-auto">
|
||||
<CameraCapture
|
||||
onCapture={onPhotoCaptured}
|
||||
buttonLabel="Сделать фото"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="mt-4 w-full"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getInspection, type MarkerSummary, type PhotoSummary } from "@/api/inspections";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getInspection,
|
||||
deleteInspection,
|
||||
patchInspection,
|
||||
type MarkerSummary,
|
||||
type PhotoSummary,
|
||||
} from "@/api/inspections";
|
||||
import { getMe } from "@/api/me";
|
||||
import { AuthImg } from "@/components/AuthImg";
|
||||
import { ttImageUrl } from "@/api/vehicles";
|
||||
import { VendorVehicleScheme } from "@/components/vehicle-scheme/VendorVehicleScheme";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { loadZoneLabels } from "@/data/zoneLabels";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
in_progress: "В работе",
|
||||
@@ -10,6 +22,66 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
cancelled: "Отменён",
|
||||
};
|
||||
|
||||
const SEVERITY_LABELS: Record<string, string> = {
|
||||
cosmetic: "косметика",
|
||||
minor: "лёгкое",
|
||||
moderate: "среднее",
|
||||
severe: "тяжёлое",
|
||||
};
|
||||
|
||||
const DAMAGE_TYPE_LABELS: Record<string, string> = {
|
||||
damaged: "повреждено",
|
||||
scratch: "царапина",
|
||||
chip: "скол",
|
||||
dent: "вмятина",
|
||||
crack: "трещина",
|
||||
scuff: "затёртость",
|
||||
burn: "прожог",
|
||||
stain: "пятно",
|
||||
bent: "погнуто",
|
||||
torn: "порвано",
|
||||
cut: "порез",
|
||||
puncture: "прокол",
|
||||
bulge: "грыжа",
|
||||
missing: "отсутствует",
|
||||
removed: "снято",
|
||||
"not-working": "не работает",
|
||||
};
|
||||
|
||||
function humanizeMarkerSide(
|
||||
side: string | null | undefined,
|
||||
zoneLabels?: Record<string, string> | null,
|
||||
): string {
|
||||
if (!side) return "—";
|
||||
if (side === "tires") return "Резина";
|
||||
const zm = /^zone-(\d+)$/.exec(side);
|
||||
if (zm) {
|
||||
const label = zoneLabels?.[zm[1]];
|
||||
if (label) return label;
|
||||
return `Зона ${zm[1]}`;
|
||||
}
|
||||
const map: Record<string, string> = {
|
||||
top: "Сверху",
|
||||
front: "Перед",
|
||||
back: "Зад",
|
||||
left: "Левый",
|
||||
right: "Правый",
|
||||
"bumper-fl": "Бампер ПЛ",
|
||||
"bumper-fr": "Бампер ПП",
|
||||
"bumper-rl": "Бампер ЗЛ",
|
||||
"bumper-rr": "Бампер ЗП",
|
||||
// Legacy named zone-* support (data from earlier prod)
|
||||
"zone-front": "Передний бампер",
|
||||
"zone-rear": "Задний бампер",
|
||||
"zone-left": "Левый бок",
|
||||
"zone-right": "Правый бок",
|
||||
"zone-hood": "Капот",
|
||||
"zone-trunk": "Багажник",
|
||||
"zone-roof": "Крыша",
|
||||
};
|
||||
return map[side] ?? side;
|
||||
}
|
||||
|
||||
export default function InspectionReview() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const insId = Number(id);
|
||||
@@ -20,10 +92,81 @@ export default function InspectionReview() {
|
||||
queryFn: () => getInspection(insId),
|
||||
});
|
||||
|
||||
const { data: me } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: getMe,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const isAdmin = me?.permissions?.includes("admin") ?? false;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteInspection(insId),
|
||||
onSuccess: () => {
|
||||
toast.success("Осмотр удалён");
|
||||
queryClient.invalidateQueries({ queryKey: ["vehicle"] });
|
||||
navigate(-1);
|
||||
},
|
||||
onError: () => toast.error("Не удалось удалить осмотр"),
|
||||
});
|
||||
|
||||
const { data: zoneLabels } = useQuery({
|
||||
queryKey: ["zone-labels"],
|
||||
queryFn: loadZoneLabels,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
// Lightbox: либо ID нашего фото в MinIO (через AuthImg), либо vendor TT
|
||||
// image hash (через ttImageUrl).
|
||||
const [preview, setPreview] = useState<
|
||||
{ kind: "photo"; id: number } | { kind: "tt"; ttId: string } | null
|
||||
>(null);
|
||||
|
||||
// Локальный draft заметок — синхронизируется с ins.notes при загрузке/обновлении.
|
||||
// Кнопка «Сохранить» появляется только если draft отличается от сохранённого.
|
||||
const [notesDraft, setNotesDraft] = useState<string>("");
|
||||
useEffect(() => {
|
||||
if (ins?.notes !== undefined) setNotesDraft(ins.notes ?? "");
|
||||
}, [ins?.notes]);
|
||||
|
||||
const notesMutation = useMutation({
|
||||
mutationFn: (next: string) => patchInspection(insId, { notes: next }),
|
||||
onSuccess: (updated) => {
|
||||
toast.success("Заметка сохранена");
|
||||
queryClient.setQueryData(["inspection", insId], updated);
|
||||
},
|
||||
onError: () => toast.error("Не удалось сохранить заметку"),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="p-8 text-muted-foreground">Загружаю…</div>;
|
||||
if (isError || !ins)
|
||||
return <div className="p-8 text-destructive">Осмотр не найден.</div>;
|
||||
|
||||
const notesDirty = (notesDraft ?? "") !== (ins.notes ?? "");
|
||||
|
||||
// Lookup photo by id для определения annotated_key.
|
||||
const photosById = new Map<number, typeof ins.photos[0]>();
|
||||
for (const p of ins.photos) photosById.set(p.id, p);
|
||||
|
||||
// Все уникальные фото осмотра: основные + photo_id из маркеров (наши локальные
|
||||
// фото повреждений) + tt_image_id из маркеров (carry-over из Element). Без
|
||||
// tt_image_id counter недоучитывал импортированные дефекты — видно на
|
||||
// осмотрах, где почти все маркеры пришли из вендора.
|
||||
const markerPhotoIds = ins.markers
|
||||
.map((m: MarkerSummary) => m.photo_id)
|
||||
.filter((id): id is number => id != null);
|
||||
const markerTtIds = ins.markers
|
||||
.map((m: MarkerSummary) => m.tt_image_id)
|
||||
.filter((id): id is string => !!id);
|
||||
const uniquePhotoIds = Array.from(
|
||||
new Set([...ins.photos.map((p: PhotoSummary) => p.id), ...markerPhotoIds])
|
||||
);
|
||||
const uniqueTtIds = Array.from(new Set(markerTtIds));
|
||||
const totalPhotoCount = uniquePhotoIds.length + uniqueTtIds.length;
|
||||
// Дополнительные фото-карточки в галерее: photo_id маркеров, которых нет в
|
||||
// основном списке (`ins.photos` — это side-photos осмотра).
|
||||
const extraMarkerPhotos = markerPhotoIds.filter((id) => !photosById.has(id));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-4 space-y-4">
|
||||
<button
|
||||
@@ -35,6 +178,28 @@ export default function InspectionReview() {
|
||||
|
||||
<h1 className="text-xl font-semibold">Осмотр #{ins.id}</h1>
|
||||
|
||||
{ins.vehicle && (
|
||||
<Card
|
||||
className="p-4 text-sm space-y-1 cursor-pointer hover:bg-accent/40 transition-colors"
|
||||
onClick={() => navigate(`/vehicles/${ins.vehicle?.id}`)}
|
||||
>
|
||||
<div className="text-lg font-semibold tracking-wide">
|
||||
{ins.vehicle.license_plate}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{[ins.vehicle.make, ins.vehicle.model, ins.vehicle.year]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "—"}
|
||||
</div>
|
||||
{ins.vehicle.vin && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-semibold">VIN: </span>
|
||||
{ins.vehicle.vin}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="p-4 text-sm space-y-1">
|
||||
<div>
|
||||
<span className="font-semibold">Тип: </span>
|
||||
@@ -44,6 +209,12 @@ export default function InspectionReview() {
|
||||
<span className="font-semibold">Статус: </span>
|
||||
{STATUS_LABELS[ins.status] ?? ins.status}
|
||||
</div>
|
||||
{ins.inspector_name && (
|
||||
<div>
|
||||
<span className="font-semibold">Механик: </span>
|
||||
{ins.inspector_name}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold">Начат: </span>
|
||||
{new Date(ins.started_at).toLocaleString("ru-RU")}
|
||||
@@ -54,24 +225,78 @@ export default function InspectionReview() {
|
||||
{new Date(ins.finished_at).toLocaleString("ru-RU")}
|
||||
</div>
|
||||
)}
|
||||
{ins.notes && (
|
||||
{ins.mileage != null && (
|
||||
<div>
|
||||
<span className="font-semibold">Заметки: </span>
|
||||
{ins.notes}
|
||||
<span className="font-semibold">Пробег: </span>
|
||||
{ins.mileage.toLocaleString("ru-RU")} км
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 space-y-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">
|
||||
Заметки механика
|
||||
</h2>
|
||||
<textarea
|
||||
value={notesDraft}
|
||||
onChange={(e) => setNotesDraft(e.target.value)}
|
||||
placeholder="Текст осмотра, замечания, рекомендации…"
|
||||
rows={Math.max(3, Math.min(notesDraft.split("\n").length + 1, 12))}
|
||||
className="w-full text-sm p-2 rounded border border-input bg-background resize-y focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
{notesDirty && (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNotesDraft(ins.notes ?? "")}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-3 py-1"
|
||||
disabled={notesMutation.isPending}
|
||||
>
|
||||
Отменить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => notesMutation.mutate(notesDraft)}
|
||||
disabled={notesMutation.isPending}
|
||||
className="text-xs bg-primary text-primary-foreground rounded px-3 py-1 disabled:opacity-60"
|
||||
>
|
||||
{notesMutation.isPending ? "Сохраняю…" : "Сохранить"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||
Схема повреждений
|
||||
</h2>
|
||||
<VendorVehicleScheme
|
||||
markers={ins.markers.map((m: MarkerSummary) => ({
|
||||
id: m.id,
|
||||
side: m.side,
|
||||
damage_type: m.damage_type,
|
||||
severity: m.severity,
|
||||
resolved: m.resolved,
|
||||
polygon: m.polygon,
|
||||
}))}
|
||||
editable={false}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
Фото ({ins.photos.length})
|
||||
Фото ({totalPhotoCount})
|
||||
</h2>
|
||||
{ins.photos.length === 0 ? (
|
||||
{totalPhotoCount === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">Нет фото.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ins.photos.map((p: PhotoSummary) => (
|
||||
<Card key={p.id} className="overflow-hidden">
|
||||
<Card
|
||||
key={p.id}
|
||||
className="overflow-hidden cursor-pointer"
|
||||
onClick={() => setPreview({ kind: "photo", id: p.id })}
|
||||
>
|
||||
<AuthImg
|
||||
src={`photos/${p.id}/thumb`}
|
||||
alt={p.side}
|
||||
@@ -80,6 +305,24 @@ export default function InspectionReview() {
|
||||
<div className="text-xs text-muted-foreground p-2">{p.side}</div>
|
||||
</Card>
|
||||
))}
|
||||
{/* Фото-маркеров, которых нет в основном списке (наши локальные) */}
|
||||
{extraMarkerPhotos.map((id) => (
|
||||
<Card
|
||||
key={`m-${id}`}
|
||||
className="overflow-hidden cursor-pointer"
|
||||
onClick={() => setPreview({ kind: "photo", id })}
|
||||
>
|
||||
<AuthImg
|
||||
src={`photos/${id}/thumb`}
|
||||
alt="повреждение"
|
||||
className="w-full aspect-[4/3] object-cover bg-muted"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground p-2">повреждение</div>
|
||||
</Card>
|
||||
))}
|
||||
{/* TT-фото carry-over дефектов из Element живут ТОЛЬКО внутри
|
||||
секции «Метки», чтобы не дублировать одну и ту же картинку
|
||||
в карточке два раза. В счётчике «Фото (N)» они учтены. */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -97,14 +340,22 @@ export default function InspectionReview() {
|
||||
<Card className="p-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span>{m.side ?? "—"}</span>
|
||||
<span className="font-medium capitalize">
|
||||
{humanizeMarkerSide(m.side, zoneLabels)}
|
||||
</span>
|
||||
{" — "}
|
||||
<span>{m.damage_type ?? "—"}</span>
|
||||
<span>
|
||||
{m.damage_type
|
||||
? (DAMAGE_TYPE_LABELS[m.damage_type] ?? m.damage_type)
|
||||
: "—"}
|
||||
</span>
|
||||
{m.severity && (
|
||||
<span className="text-muted-foreground">
|
||||
<>
|
||||
{" — "}
|
||||
{m.severity}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{SEVERITY_LABELS[m.severity] ?? m.severity}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -125,12 +376,102 @@ export default function InspectionReview() {
|
||||
{m.description}
|
||||
</div>
|
||||
)}
|
||||
{m.photo_id ? (
|
||||
<div
|
||||
className="mt-2 max-w-[8rem] cursor-pointer"
|
||||
onClick={() =>
|
||||
setPreview({ kind: "photo", id: m.photo_id as number })
|
||||
}
|
||||
>
|
||||
<AuthImg
|
||||
src={
|
||||
photosById.get(m.photo_id)?.annotated_key
|
||||
? `photos/${m.photo_id}/annotated`
|
||||
: `photos/${m.photo_id}/thumb`
|
||||
}
|
||||
alt="Фото повреждения"
|
||||
className="w-full aspect-[4/3] object-cover rounded bg-muted"
|
||||
/>
|
||||
</div>
|
||||
) : m.tt_image_id ? (
|
||||
<div
|
||||
className="mt-2 max-w-[8rem] cursor-pointer"
|
||||
onClick={() =>
|
||||
setPreview({ kind: "tt", ttId: m.tt_image_id as string })
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={ttImageUrl(m.tt_image_id)}
|
||||
alt="Фото из Element Mechanic"
|
||||
loading="lazy"
|
||||
className="w-full aspect-[4/3] object-cover rounded bg-muted"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
"Удалить этот осмотр? Все фото будут безвозвратно удалены из хранилища. Действие необратимо."
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="w-full py-3 rounded-lg border border-destructive text-destructive hover:bg-destructive/10 active:bg-destructive/20 transition-colors font-semibold"
|
||||
>
|
||||
{deleteMutation.isPending ? "Удаляю…" : "Удалить осмотр"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lightbox preview — click photo to enlarge, click background to close.
|
||||
Соответствует UX в TtStateDetail (импортированные из Element осмотры). */}
|
||||
{preview != null && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/90 flex flex-col"
|
||||
onClick={() => setPreview(null)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreview(null);
|
||||
}}
|
||||
className="text-white text-2xl px-3"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{preview.kind === "photo" ? (
|
||||
<AuthImg
|
||||
src={`photos/${preview.id}`}
|
||||
alt="Просмотр"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={ttImageUrl(preview.ttId)}
|
||||
alt="Просмотр (Element)"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,70 +1,260 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { HTTPError } from "ky";
|
||||
import { toast } from "sonner";
|
||||
import { login } from "@/api/auth";
|
||||
import { login, login2fa } from "@/api/auth";
|
||||
import { getMe } from "@/api/me";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import {
|
||||
clearRevocation,
|
||||
readRevocation,
|
||||
storeRevocation,
|
||||
type RevocationInfo,
|
||||
} from "@/hooks/useMeSync";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type Step =
|
||||
| { kind: "credentials" }
|
||||
| { kind: "totp"; partialToken: string };
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const setToken = useAuth((s) => s.setToken);
|
||||
|
||||
const [step, setStep] = useState<Step>({ kind: "credentials" });
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [rememberDevice, setRememberDevice] = useState(true);
|
||||
|
||||
const m = useMutation({
|
||||
mutationFn: login,
|
||||
onSuccess: (data) => {
|
||||
setToken(data.access_token);
|
||||
// У36: показываем явное сообщение «доступ отозван» с кликабельным контактом,
|
||||
// если пользователь только что был выкинут из приложения фоновой сверкой (У35)
|
||||
// или получил 403 NO_PWA_ACCESS при последней попытке логина.
|
||||
const [revocation, setRevocation] = useState<RevocationInfo | null>(() => readRevocation());
|
||||
|
||||
// У36: после успешной аутентификации проверяем доступ к PWA через /me.
|
||||
// /auth/login универсальный (используется и CRM, и PWA), поэтому
|
||||
// отказ в PWA-доступе различается именно по ответу /me (403 NO_PWA_ACCESS).
|
||||
const verifyPwaAccessAndNavigate = async (token: string) => {
|
||||
setToken(token);
|
||||
try {
|
||||
await getMe();
|
||||
clearRevocation();
|
||||
setRevocation(null);
|
||||
toast.success("Вход выполнен");
|
||||
navigate("/", { replace: true });
|
||||
} catch (err) {
|
||||
if (err instanceof HTTPError && err.response.status === 403) {
|
||||
let detail: unknown = null;
|
||||
try {
|
||||
const body = (await err.response.json()) as { detail?: unknown };
|
||||
detail = body?.detail ?? null;
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
storeRevocation(detail);
|
||||
setRevocation(readRevocation());
|
||||
setToken(null);
|
||||
} else {
|
||||
toast.error("Не удалось проверить доступ к приложению");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Если revocation сохранён сессией — очищаем при ручной смене ввода логина.
|
||||
useEffect(() => {
|
||||
if (revocation && (username || password)) {
|
||||
clearRevocation();
|
||||
setRevocation(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [username, password]);
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: login,
|
||||
onSuccess: (data) => {
|
||||
if (data.requires_2fa && data.partial_token) {
|
||||
setStep({ kind: "totp", partialToken: data.partial_token });
|
||||
setCode("");
|
||||
toast.info("Нужен код 2FA");
|
||||
return;
|
||||
}
|
||||
if (data.access_token) {
|
||||
// У36: проверяем доступ к PWA через /me перед навигацией.
|
||||
void verifyPwaAccessAndNavigate(data.access_token);
|
||||
return;
|
||||
}
|
||||
toast.error("Неожиданный ответ сервера");
|
||||
},
|
||||
onError: () => toast.error("Неверный логин или пароль"),
|
||||
});
|
||||
|
||||
const twoFaMutation = useMutation({
|
||||
mutationFn: login2fa,
|
||||
onSuccess: (data) => {
|
||||
if (data.access_token) {
|
||||
void verifyPwaAccessAndNavigate(data.access_token);
|
||||
} else {
|
||||
toast.error("Сервер не вернул токен");
|
||||
}
|
||||
},
|
||||
onError: () => toast.error("Неверный код 2FA"),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-sm bg-card rounded-2xl shadow-sm p-6 space-y-4">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Premium Механик</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Войдите учётной записью TaxiDashboard.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
m.mutate({ username, password });
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="username">Логин</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{revocation && (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
|
||||
<div className="font-medium mb-1">У вас нет доступа к PWA Premium Мехник</div>
|
||||
<div>
|
||||
Обратитесь к старшему механику или администратору CRM
|
||||
{(() => {
|
||||
const tg = revocation.support?.tg_username;
|
||||
const phone = revocation.support?.phone;
|
||||
if (tg) {
|
||||
return (
|
||||
<>
|
||||
{": "}
|
||||
<a
|
||||
href={`https://t.me/${tg}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline font-medium"
|
||||
>
|
||||
@{tg}
|
||||
</a>
|
||||
{phone ? (
|
||||
<>
|
||||
{" · "}
|
||||
<a href={`tel:${phone}`} className="underline font-medium">{phone}</a>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (phone) {
|
||||
return (
|
||||
<>
|
||||
{": "}
|
||||
<a href={`tel:${phone}`} className="underline font-medium">{phone}</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return ".";
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="password">Пароль</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={m.isPending} className="w-full">
|
||||
{m.isPending ? "Входим…" : "Войти"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step.kind === "credentials" && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Войдите учётной записью TaxiDashboard.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
loginMutation.mutate({ username, password });
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="username">Логин</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="password">Пароль</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={loginMutation.isPending} className="w-full">
|
||||
{loginMutation.isPending ? "Входим…" : "Войти"}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step.kind === "totp" && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Введите 6-значный код из приложения-аутентификатора (Google
|
||||
Authenticator, Aegis, 1Password, и т. п.).
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
twoFaMutation.mutate({
|
||||
partial_token: step.partialToken,
|
||||
code: code.trim(),
|
||||
remember_device: rememberDevice,
|
||||
});
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="totp">Код 2FA</Label>
|
||||
<Input
|
||||
id="totp"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberDevice}
|
||||
onChange={(e) => setRememberDevice(e.target.checked)}
|
||||
/>
|
||||
Запомнить это устройство на 30 дней
|
||||
</label>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={twoFaMutation.isPending || code.length !== 6}
|
||||
className="w-full"
|
||||
>
|
||||
{twoFaMutation.isPending ? "Проверяем…" : "Войти"}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep({ kind: "credentials" });
|
||||
setPassword("");
|
||||
setCode("");
|
||||
}}
|
||||
className="w-full text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Назад
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getTtState,
|
||||
deleteTtState,
|
||||
ttImageUrl,
|
||||
type TtPhotoRef,
|
||||
type TtSidePhoto,
|
||||
} from "@/api/vehicles";
|
||||
import { getMe } from "@/api/me";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { TtDamageMap } from "@/components/tt/TtDamageMap";
|
||||
|
||||
interface ZoneLabels {
|
||||
[zoneId: string]: string;
|
||||
}
|
||||
|
||||
interface DamageVocabulary {
|
||||
damage_types_ordered: string[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
let zoneLabelsCache: ZoneLabels | null = null;
|
||||
let damageVocabCache: DamageVocabulary | null = null;
|
||||
|
||||
async function loadZoneLabels(): Promise<ZoneLabels> {
|
||||
if (zoneLabelsCache) return zoneLabelsCache;
|
||||
const res = await fetch("/data/zone_labels.json");
|
||||
zoneLabelsCache = await res.json();
|
||||
return zoneLabelsCache!;
|
||||
}
|
||||
|
||||
async function loadDamageVocabulary(): Promise<DamageVocabulary> {
|
||||
if (damageVocabCache) return damageVocabCache;
|
||||
const res = await fetch("/data/damage_vocabulary.json");
|
||||
damageVocabCache = await res.json();
|
||||
return damageVocabCache!;
|
||||
}
|
||||
|
||||
const DEGREE_LABELS: Record<number, string> = {
|
||||
0: "Лёгкое",
|
||||
1: "Среднее",
|
||||
2: "Тяжёлое",
|
||||
};
|
||||
|
||||
function damageTypeName(
|
||||
id: number | null,
|
||||
vocab: DamageVocabulary | undefined,
|
||||
): string {
|
||||
if (id == null) return "Без типа";
|
||||
// Vendor enum is 1-based: type=1 → damage_types_ordered[0] ("Вмятина").
|
||||
const idx = id - 1;
|
||||
const arr = vocab?.damage_types_ordered;
|
||||
if (arr && idx >= 0 && idx < arr.length) return arr[idx];
|
||||
return `Тип ${id}`;
|
||||
}
|
||||
|
||||
const SIDE_LABELS: Record<number, string> = {
|
||||
1: "Перед",
|
||||
2: "Зад",
|
||||
3: "Левый борт",
|
||||
4: "Правый борт",
|
||||
5: "Сверху",
|
||||
6: "Передний угол",
|
||||
7: "Задний угол",
|
||||
8: "Салон",
|
||||
9: "Дополнительно",
|
||||
};
|
||||
|
||||
type PreviewItem =
|
||||
| { kind: "zone"; photo: TtPhotoRef }
|
||||
| { kind: "side"; photo: TtSidePhoto };
|
||||
|
||||
export default function TtStateDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const stateId = Number(id);
|
||||
const zoneRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const { data: state, isLoading, isError } = useQuery({
|
||||
queryKey: ["tt-state", stateId],
|
||||
queryFn: () => getTtState(stateId),
|
||||
enabled: Number.isFinite(stateId),
|
||||
});
|
||||
|
||||
const { data: labels } = useQuery({
|
||||
queryKey: ["zone-labels"],
|
||||
queryFn: loadZoneLabels,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const { data: vocab } = useQuery({
|
||||
queryKey: ["damage-vocabulary"],
|
||||
queryFn: loadDamageVocabulary,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const { data: me } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: getMe,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const isAdmin = me?.permissions?.includes("admin") ?? false;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteTtState(stateId),
|
||||
onSuccess: () => {
|
||||
toast.success("Осмотр удалён");
|
||||
// Drop any cached tt-history for vehicle list so новый рендер не покажет удалённый.
|
||||
queryClient.invalidateQueries({ queryKey: ["vehicle"] });
|
||||
navigate(-1);
|
||||
},
|
||||
onError: () => toast.error("Не удалось удалить осмотр"),
|
||||
});
|
||||
|
||||
const [preview, setPreview] = useState<PreviewItem | null>(null);
|
||||
const [showLines, setShowLines] = useState(false);
|
||||
|
||||
const damagedZoneIds = useMemo(() => {
|
||||
if (!state) return [];
|
||||
return Object.keys(state.damages_by_zone).map((k) => Number(k));
|
||||
}, [state]);
|
||||
|
||||
const damageOverlays = useMemo(() => {
|
||||
if (!state) return [];
|
||||
return Object.entries(state.damages_by_zone).flatMap(([zid, dmgs]) =>
|
||||
dmgs.map((d) => ({ zoneId: Number(zid), points: d.points || [] }))
|
||||
);
|
||||
}, [state]);
|
||||
|
||||
// Reset refs when state changes
|
||||
useEffect(() => {
|
||||
zoneRefs.current = {};
|
||||
}, [stateId]);
|
||||
|
||||
if (isLoading)
|
||||
return <div className="p-8 text-muted-foreground">Загружаю…</div>;
|
||||
if (isError || !state)
|
||||
return <div className="p-8 text-destructive">Осмотр не найден.</div>;
|
||||
|
||||
const date = state.unix_time
|
||||
? new Date(state.unix_time * 1000).toLocaleString("ru-RU")
|
||||
: "—";
|
||||
|
||||
const zoneIds = Array.from(
|
||||
new Set([
|
||||
...Object.keys(state.photos_by_zone),
|
||||
...Object.keys(state.damages_by_zone),
|
||||
])
|
||||
).sort((a, b) => Number(a) - Number(b));
|
||||
|
||||
function scrollToZone(zoneId: number) {
|
||||
const el = zoneRefs.current[String(zoneId)];
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-4 space-y-4 pb-20">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Назад
|
||||
</button>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="text-xs text-muted-foreground">Element Mechanic</div>
|
||||
<div className="text-lg font-semibold mt-1">
|
||||
{state.mechanic_name || "Осмотр"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{date}
|
||||
{state.mileage != null && (
|
||||
<> · {state.mileage.toLocaleString("ru-RU")} км</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Общие 9 ракурсов */}
|
||||
{state.side_photos.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
Общие фото
|
||||
</h2>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{state.side_photos.map((p) => {
|
||||
const label = p.photo_type
|
||||
? SIDE_LABELS[p.photo_type] || `Ракурс ${p.photo_type}`
|
||||
: "Ракурс";
|
||||
const thumbSrc = p.miniature_id
|
||||
? ttImageUrl(p.miniature_id)
|
||||
: ttImageUrl(p.image_id);
|
||||
return (
|
||||
<button
|
||||
key={p.image_id}
|
||||
onClick={() => {
|
||||
setPreview({ kind: "side", photo: p });
|
||||
setShowLines(false);
|
||||
}}
|
||||
className="aspect-square overflow-hidden rounded-lg bg-muted hover:opacity-80 transition-opacity relative"
|
||||
>
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt={label}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-1">
|
||||
<div className="text-white text-[10px] font-medium truncate">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Карта повреждений */}
|
||||
{damagedZoneIds.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
Карта повреждений
|
||||
<span className="ml-2 text-xs font-normal">
|
||||
{damagedZoneIds.length}{" "}
|
||||
{damagedZoneIds.length === 1 ? "зона" : "зон"}
|
||||
</span>
|
||||
</h2>
|
||||
<TtDamageMap
|
||||
damagedZoneIds={damagedZoneIds}
|
||||
damages={damageOverlays}
|
||||
onZoneClick={scrollToZone}
|
||||
/>
|
||||
<div className="text-[11px] text-muted-foreground mt-1 text-center">
|
||||
Тап на зону — прокрутка к деталям
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Детали по зонам с повреждениями */}
|
||||
{zoneIds.length === 0 ? (
|
||||
<Card className="p-4 text-sm text-muted-foreground">
|
||||
В осмотре нет ни фото, ни повреждений.
|
||||
</Card>
|
||||
) : (
|
||||
zoneIds.map((zid) => {
|
||||
const photos = state.photos_by_zone[zid] || [];
|
||||
const damages = state.damages_by_zone[zid] || [];
|
||||
const label = labels?.[zid] || `Зона ${zid}`;
|
||||
return (
|
||||
<div
|
||||
key={zid}
|
||||
ref={(el) => {
|
||||
zoneRefs.current[zid] = el;
|
||||
}}
|
||||
className="scroll-mt-4"
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2 capitalize flex items-center justify-between">
|
||||
<span>{label}</span>
|
||||
{damages.length > 0 && (
|
||||
<span className="text-destructive text-xs">
|
||||
⚠ {damages.length}{" "}
|
||||
{damages.length === 1 ? "повреждение" : "повреждений"}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{damages.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{damages.map((d, idx) => {
|
||||
const tname = damageTypeName(d.damage_type_id, vocab);
|
||||
const dname =
|
||||
d.degree != null ? DEGREE_LABELS[d.degree] : null;
|
||||
return (
|
||||
<span
|
||||
key={(d.guid ?? "g") + "_" + idx}
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-destructive/10 text-destructive border border-destructive/30"
|
||||
>
|
||||
<span className="font-medium">{tname}</span>
|
||||
{dname && (
|
||||
<>
|
||||
<span className="opacity-50">·</span>
|
||||
<span>{dname}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{photos.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{photos.map((p, idx) => (
|
||||
<button
|
||||
key={p.image_id + "_" + idx}
|
||||
onClick={() => {
|
||||
setPreview({ kind: "zone", photo: p });
|
||||
setShowLines(false);
|
||||
}}
|
||||
className="aspect-square overflow-hidden rounded-lg bg-muted hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img
|
||||
src={ttImageUrl(p.image_id)}
|
||||
alt={`Зона ${zid}`}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-3 text-xs text-muted-foreground">
|
||||
Фото нет — только координаты повреждений
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
"Скрыть этот осмотр из истории? Будет помечен как игнорируемый — sync не вернёт. Действие обратимо только из БД."
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="w-full py-3 rounded-lg border border-destructive text-destructive hover:bg-destructive/10 active:bg-destructive/20 transition-colors font-semibold"
|
||||
>
|
||||
{deleteMutation.isPending ? "Удаляю…" : "Удалить осмотр"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/90 flex flex-col"
|
||||
onClick={() => setPreview(null)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreview(null);
|
||||
}}
|
||||
className="text-white text-2xl px-3"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{preview.kind === "zone" &&
|
||||
preview.photo.image_with_lines_id && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowLines((v) => !v);
|
||||
}}
|
||||
className="text-white text-sm border border-white/40 px-3 py-1 rounded-lg"
|
||||
>
|
||||
{showLines ? "Без линий" : "С линиями"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={ttImageUrl(
|
||||
preview.kind === "zone" &&
|
||||
showLines &&
|
||||
preview.photo.image_with_lines_id
|
||||
? preview.photo.image_with_lines_id
|
||||
: preview.photo.image_id
|
||||
)}
|
||||
alt="Просмотр"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { getVehicle } from "@/api/vehicles";
|
||||
import { getVehicle, getTtHistory } from "@/api/vehicles";
|
||||
import { createInspection, type InspectionType } from "@/api/inspections";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const INSPECTION_TYPE_LABELS: Record<InspectionType, string> = {
|
||||
handover: "Передача",
|
||||
handover: "Выдача",
|
||||
return: "Приёмка",
|
||||
periodic: "Плановый",
|
||||
"ad-hoc": "Свободный",
|
||||
@@ -24,25 +27,74 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
cancelled: "Отменён",
|
||||
};
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const sec = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (sec < 60) return "только что";
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min} мин назад`;
|
||||
const h = Math.floor(min / 60);
|
||||
if (h < 24) return `${h} ч назад`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 30) return `${d} дн назад`;
|
||||
return new Date(iso).toLocaleDateString("ru-RU");
|
||||
}
|
||||
|
||||
export default function VehicleCard() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const vehicleId = Number(id);
|
||||
|
||||
const action = searchParams.get("action") || undefined;
|
||||
|
||||
const [pendingType, setPendingType] = useState<InspectionType | null>(null);
|
||||
const [mileageInput, setMileageInput] = useState("");
|
||||
const [mileageSource, setMileageSource] = useState<
|
||||
"starline" | "last-inspection" | null
|
||||
>(null);
|
||||
|
||||
const { data: v, isLoading, isError } = useQuery({
|
||||
queryKey: ["vehicle", vehicleId],
|
||||
queryFn: () => getVehicle(vehicleId),
|
||||
});
|
||||
|
||||
// История из vendor (Element Mechanic). Молча возвращает пустой список
|
||||
// если машина ещё не синхронизирована — секцию просто скрываем.
|
||||
const { data: tt } = useQuery({
|
||||
queryKey: ["vehicle", vehicleId, "tt-history"],
|
||||
queryFn: () => getTtHistory(vehicleId),
|
||||
enabled: Number.isFinite(vehicleId),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const startInspection = useMutation({
|
||||
mutationFn: (type: InspectionType) =>
|
||||
createInspection({ vehicle_id: vehicleId, type }),
|
||||
mutationFn: (input: { type: InspectionType; mileage: number | undefined }) =>
|
||||
createInspection({ vehicle_id: vehicleId, type: input.type, mileage: input.mileage }),
|
||||
onSuccess: (inspection) => {
|
||||
navigate(`/inspections/${inspection.id}/edit`);
|
||||
},
|
||||
onError: () => toast.error("Не удалось создать осмотр"),
|
||||
});
|
||||
|
||||
const handleStartClick = (type: InspectionType) => {
|
||||
setPendingType(type);
|
||||
// Приоритет: 1) StarLine OBD (свежий одометр), 2) пробег прошлого
|
||||
// осмотра. Менеджер всегда может скорректировать.
|
||||
if (v?.starline_mileage != null) {
|
||||
setMileageInput(String(v.starline_mileage));
|
||||
setMileageSource("starline");
|
||||
return;
|
||||
}
|
||||
const lastWithMileage = v?.recent_inspections.find((i) => i.mileage != null);
|
||||
if (lastWithMileage?.mileage != null) {
|
||||
setMileageInput(String(lastWithMileage.mileage));
|
||||
setMileageSource("last-inspection");
|
||||
return;
|
||||
}
|
||||
setMileageInput("");
|
||||
setMileageSource(null);
|
||||
};
|
||||
|
||||
if (isLoading)
|
||||
return <div className="p-8 text-muted-foreground">Загружаю…</div>;
|
||||
if (isError || !v)
|
||||
@@ -51,14 +103,36 @@ export default function VehicleCard() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-4 space-y-4">
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
onClick={() => navigate(`/vehicles${action ? `?action=${action}` : ""}`)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← К списку
|
||||
</button>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="text-xl font-semibold">{v.license_plate}</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="text-xl font-semibold">{v.license_plate}</div>
|
||||
{v.status && (
|
||||
<span
|
||||
className={`inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
{
|
||||
"На линии": "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||
"Простой": "bg-muted text-muted-foreground",
|
||||
"Ремонтируется": "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
|
||||
"Ждет ремонта": "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
|
||||
"ДТП": "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300",
|
||||
"Бронь": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
"Выкуп": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
"На продаже": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
"Подготовка на продажу": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
"Ждет доставки до офиса": "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
}[v.status] ?? "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{v.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{[v.make, v.model, v.year].filter(Boolean).join(" ") || "—"}
|
||||
</div>
|
||||
@@ -73,66 +147,25 @@ export default function VehicleCard() {
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
onClick={() => startInspection.mutate("handover")}
|
||||
onClick={() => handleStartClick("handover")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Передача
|
||||
Выдача
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => startInspection.mutate("return")}
|
||||
onClick={() => handleStartClick("return")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Приёмка
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startInspection.mutate("periodic")}
|
||||
onClick={() => handleStartClick("periodic")}
|
||||
disabled={startInspection.isPending}
|
||||
className="col-span-2"
|
||||
>
|
||||
Плановый
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startInspection.mutate("ad-hoc")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Свободный
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startInspection.mutate("initial")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Первичный
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startInspection.mutate("seizure")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Изъятие
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startInspection.mutate("equipment-change")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Комплектация
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startInspection.mutate("pre-repair")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
В ремонт
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startInspection.mutate("post-repair")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Из ремонта
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,22 +185,187 @@ export default function VehicleCard() {
|
||||
className="p-3 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => navigate(`/inspections/${i.id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium">
|
||||
{INSPECTION_TYPE_LABELS[i.type as InspectionType] ?? i.type}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="font-medium">
|
||||
{INSPECTION_TYPE_LABELS[i.type as InspectionType] ?? i.type}
|
||||
</div>
|
||||
{(i.photos_count ?? 0) > 0 && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
|
||||
title={`Фото: ${i.photos_count}`}
|
||||
>
|
||||
📷 {i.photos_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-2 flex-wrap">
|
||||
<span>{new Date(i.started_at).toLocaleString("ru-RU")}</span>
|
||||
{i.mileage != null && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<span>{i.mileage.toLocaleString("ru-RU")} км</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{i.driver_name && (
|
||||
<div className="text-xs mt-1 text-foreground/80 truncate">
|
||||
👤 {i.driver_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-xs text-muted-foreground shrink-0">
|
||||
{STATUS_LABELS[i.status] ?? i.status}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(i.started_at).toLocaleString("ru-RU")}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tt && tt.states.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
|
||||
История осмотров (Element Mechanic)
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{tt.states.map((s) => {
|
||||
const date = s.unix_time
|
||||
? new Date(s.unix_time * 1000).toLocaleString("ru-RU")
|
||||
: "—";
|
||||
return (
|
||||
<Card
|
||||
key={s.id}
|
||||
className="p-3 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => navigate(`/tt-states/${s.id}`)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">
|
||||
{s.mechanic_name || "Осмотр"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-2 flex-wrap">
|
||||
<span>{date}</span>
|
||||
{s.mileage != null && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">·</span>
|
||||
<span>{s.mileage.toLocaleString("ru-RU")} км</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{s.driver_name && (
|
||||
<div className="text-xs mt-1 text-foreground/80 truncate">
|
||||
👤 {s.driver_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0 text-right">
|
||||
<div>📷 {s.photos_count}</div>
|
||||
{s.damages_count > 0 && (
|
||||
<div className="text-destructive mt-0.5">
|
||||
⚠ {s.damages_count}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingType && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center p-0 sm:p-4"
|
||||
onClick={() => setPendingType(null)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm bg-card rounded-t-2xl sm:rounded-2xl shadow-xl p-5 space-y-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{INSPECTION_TYPE_LABELS[pendingType]}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Введите пробег машины на текущий момент.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="mileage">Пробег, км</Label>
|
||||
<Input
|
||||
id="mileage"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={mileageInput}
|
||||
onChange={(e) => {
|
||||
setMileageInput(e.target.value.replace(/\D/g, ""));
|
||||
setMileageSource(null);
|
||||
}}
|
||||
placeholder="123456"
|
||||
autoFocus
|
||||
/>
|
||||
{mileageSource === "starline" && (
|
||||
<div className="text-[11px] text-muted-foreground flex items-center gap-1.5">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
<span>
|
||||
Из StarLine
|
||||
{v?.starline_mileage_at && (
|
||||
<> · {timeAgo(v.starline_mileage_at)}</>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground/60">
|
||||
· скорректируйте если надо
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{mileageSource === "last-inspection" && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Из прошлого осмотра · скорректируйте если надо
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPendingType(null)}
|
||||
className="flex-1"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
disabled={startInspection.isPending}
|
||||
onClick={() => {
|
||||
const m = mileageInput.trim() ? Number(mileageInput) : undefined;
|
||||
startInspection.mutate({
|
||||
type: pendingType,
|
||||
mileage: m && Number.isFinite(m) ? m : undefined,
|
||||
});
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
{startInspection.isPending ? "Создаём…" : "Начать"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
startInspection.mutate({ type: pendingType, mileage: undefined });
|
||||
}}
|
||||
className="w-full text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Пропустить пробег
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Экран выбора машины перед созданием ремонта (Фича 3).
|
||||
*
|
||||
* Открывается из `/repairs` по кнопке «+ Добавить ремонт».
|
||||
* Поиск по госномеру (У44 — релевантность). После тапа на строку
|
||||
* переходим в `/repairs/new/:carId` (CreateRepairPage).
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ChevronLeft, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { searchCars, type CarBrief } from "@/api/repairsCreate";
|
||||
|
||||
export default function CarSelectPage() {
|
||||
const navigate = useNavigate();
|
||||
const [q, setQ] = useState("");
|
||||
const [cars, setCars] = useState<CarBrief[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const list = await searchCars(q);
|
||||
if (!cancelled) setCars(list);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message || "Не удалось загрузить машины");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}, 200); // debounce
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [q]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate("/repairs")} className="-ml-2">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-base font-semibold">Выберите машину</h1>
|
||||
</div>
|
||||
<div className="px-3 pb-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Госномер…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 container max-w-2xl py-3">
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-12 rounded-md bg-muted animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : cars.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-12">
|
||||
{q ? "Ничего не найдено" : "Нет активных машин в парке"}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{cars.map((c) => (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/repairs/new/${c.id}`)}
|
||||
className="w-full flex items-baseline justify-between gap-3 rounded-md border bg-card hover:bg-accent/40 transition-colors p-3 text-left"
|
||||
>
|
||||
<span className="font-medium">{c.plate}</span>
|
||||
<span className="text-sm text-muted-foreground truncate">
|
||||
{[c.brand, c.model].filter(Boolean).join(" ")}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
/**
|
||||
* Карточка создания ремонта (Фича 4 + У19/У20/У21/У24/У25/У42/У45).
|
||||
*
|
||||
* Online-only flow (PR-7). Offline-first из У47/PR-8 — следующий PR.
|
||||
*
|
||||
* Блоки:
|
||||
* - Шапка: машина (snapshot из /cars/{id}/context) + механик + водитель.
|
||||
* - Пробег (предзаполняется StarLine, можно перебить вручную).
|
||||
* - Фото — multi-upload через presign + PUT в `tmp/`.
|
||||
* - Работы — выбор из справочника, цена редактируема через
|
||||
* «карандашик» (У19); комментарий обязателен при разблокировке.
|
||||
* - Запчасти — текст + qty, автодополнение (У43).
|
||||
* - Замена масла — раскрывающийся блок с дефолтным интервалом 10000 (У25).
|
||||
*
|
||||
* Сохранение: POST /repairs с `Idempotency-Key` (UUID v4 в state).
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ChevronLeft, Plus, Trash2, Pencil, Camera } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
createRepair,
|
||||
getCarContext,
|
||||
presignPhoto,
|
||||
searchWorks,
|
||||
suggestParts,
|
||||
uploadPhotoToTmp,
|
||||
type CarContext,
|
||||
type CreatePhotoPayload,
|
||||
type MechWork,
|
||||
type PresignResponse,
|
||||
} from "@/api/repairsCreate";
|
||||
|
||||
// ── Local state types ─────────────────────────────────────────────────────
|
||||
|
||||
interface PhotoState {
|
||||
presign: PresignResponse;
|
||||
tag: "auto" | "part" | "other";
|
||||
status: "uploading" | "uploaded" | "error";
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface WorkRow {
|
||||
// Идентификатор строки внутри UI (для key); тождественен work_catalog_id
|
||||
// + автоинкремент, чтобы дубли по У2 различались.
|
||||
rowKey: string;
|
||||
work_catalog_id: number;
|
||||
name: string; // снапшот имени из прайса
|
||||
price_catalog: number;
|
||||
price_applied: number;
|
||||
unlocked: boolean; // карандашик разблокировал поле
|
||||
override_comment: string;
|
||||
}
|
||||
|
||||
interface PartRow {
|
||||
rowKey: string;
|
||||
name: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
interface OilChangeState {
|
||||
enabled: boolean;
|
||||
mileage_at_change: number | null;
|
||||
mileage_is_dirty: boolean; // У24: мягкая подписка с шапочным
|
||||
next_in_km: number;
|
||||
sticker: PhotoState | null;
|
||||
}
|
||||
|
||||
|
||||
function uuid4(): string {
|
||||
// crypto.randomUUID требует secure context; mechanic.pptaxi.ru — https.
|
||||
return (crypto as any).randomUUID
|
||||
? (crypto as any).randomUUID()
|
||||
: `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
|
||||
export default function CreateRepairPage() {
|
||||
const navigate = useNavigate();
|
||||
const { carId: carIdRaw } = useParams<{ carId: string }>();
|
||||
const carId = carIdRaw ? Number(carIdRaw) : 0;
|
||||
|
||||
// Idempotency-Key для этой формы — стабилен между ре-сабмитами (У20).
|
||||
const idemKey = useRef<string>(uuid4());
|
||||
|
||||
const [ctx, setCtx] = useState<CarContext | null>(null);
|
||||
const [ctxError, setCtxError] = useState<string | null>(null);
|
||||
|
||||
const [mileage, setMileage] = useState<number | null>(null);
|
||||
const [photos, setPhotos] = useState<PhotoState[]>([]);
|
||||
const [works, setWorks] = useState<WorkRow[]>([]);
|
||||
const [parts, setParts] = useState<PartRow[]>([]);
|
||||
const [oilChange, setOilChange] = useState<OilChangeState>({
|
||||
enabled: false,
|
||||
mileage_at_change: null,
|
||||
mileage_is_dirty: false,
|
||||
next_in_km: 10000,
|
||||
sticker: null,
|
||||
});
|
||||
|
||||
const [worksPicker, setWorksPicker] = useState<{ open: boolean; q: string; results: MechWork[] }>(
|
||||
{ open: false, q: "", results: [] },
|
||||
);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Initial: подгрузить контекст машины.
|
||||
useEffect(() => {
|
||||
if (!carId) return;
|
||||
getCarContext(carId)
|
||||
.then((c) => {
|
||||
setCtx(c);
|
||||
setMileage(c.mileage_starline);
|
||||
})
|
||||
.catch((e) => setCtxError((e as Error).message || "Не удалось загрузить машину"));
|
||||
}, [carId]);
|
||||
|
||||
// У24: мягкая подписка пробега блока масла на шапочный.
|
||||
useEffect(() => {
|
||||
if (oilChange.enabled && !oilChange.mileage_is_dirty) {
|
||||
setOilChange((oc) => ({ ...oc, mileage_at_change: mileage }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mileage, oilChange.enabled]);
|
||||
|
||||
// ── Photo upload ─────────────────────────────────────────────────────────
|
||||
|
||||
const onPhotoInput = async (files: FileList | null, target: "gallery" | "sticker") => {
|
||||
if (!files || files.length === 0) return;
|
||||
for (const file of Array.from(files)) {
|
||||
const ext = (file.name.split(".").pop() || "jpg").toLowerCase();
|
||||
const placeholder: PhotoState = {
|
||||
presign: {
|
||||
photo_uuid: uuid4(),
|
||||
extension: ext,
|
||||
content_type: file.type || "image/jpeg",
|
||||
key: "",
|
||||
upload_url: "",
|
||||
expires_in: 0,
|
||||
},
|
||||
tag: "auto",
|
||||
status: "uploading",
|
||||
};
|
||||
if (target === "gallery") setPhotos((arr) => [...arr, placeholder]);
|
||||
else setOilChange((oc) => ({ ...oc, sticker: { ...placeholder, tag: "auto" } }));
|
||||
|
||||
try {
|
||||
const presign = await presignPhoto(ext, placeholder.presign.photo_uuid);
|
||||
await uploadPhotoToTmp(file, presign);
|
||||
const updated: PhotoState = { presign, tag: "auto", status: "uploaded" };
|
||||
if (target === "gallery") {
|
||||
setPhotos((arr) =>
|
||||
arr.map((p) => (p.presign.photo_uuid === presign.photo_uuid ? updated : p)),
|
||||
);
|
||||
} else {
|
||||
setOilChange((oc) => ({ ...oc, sticker: updated }));
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message || "Ошибка загрузки";
|
||||
if (target === "gallery") {
|
||||
setPhotos((arr) =>
|
||||
arr.map((p) =>
|
||||
p.presign.photo_uuid === placeholder.presign.photo_uuid
|
||||
? { ...p, status: "error", errorMessage: msg }
|
||||
: p,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setOilChange((oc) => ({
|
||||
...oc,
|
||||
sticker: oc.sticker ? { ...oc.sticker, status: "error", errorMessage: msg } : oc.sticker,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removePhoto = (photoUuid: string) =>
|
||||
setPhotos((arr) => arr.filter((p) => p.presign.photo_uuid !== photoUuid));
|
||||
|
||||
// ── Works picker ─────────────────────────────────────────────────────────
|
||||
|
||||
const openWorksPicker = async () => {
|
||||
setWorksPicker({ open: true, q: "", results: [] });
|
||||
const list = await searchWorks("");
|
||||
setWorksPicker((s) => ({ ...s, results: list }));
|
||||
};
|
||||
|
||||
const queryWorks = async (q: string) => {
|
||||
setWorksPicker((s) => ({ ...s, q }));
|
||||
const list = await searchWorks(q);
|
||||
setWorksPicker((s) => ({ ...s, results: list }));
|
||||
};
|
||||
|
||||
const addWorkFromCatalog = (w: MechWork) => {
|
||||
const price = Number(w.price);
|
||||
setWorks((arr) => [
|
||||
...arr,
|
||||
{
|
||||
rowKey: uuid4(),
|
||||
work_catalog_id: w.id,
|
||||
name: w.name,
|
||||
price_catalog: price,
|
||||
price_applied: price,
|
||||
unlocked: false,
|
||||
override_comment: "",
|
||||
},
|
||||
]);
|
||||
setWorksPicker({ open: false, q: "", results: [] });
|
||||
};
|
||||
|
||||
const toggleWorkUnlock = (rowKey: string) => {
|
||||
setWorks((arr) =>
|
||||
arr.map((w) =>
|
||||
w.rowKey === rowKey
|
||||
? w.unlocked
|
||||
? // повторный тап = отмена правки, возврат к цене прайса (У19)
|
||||
{ ...w, unlocked: false, price_applied: w.price_catalog, override_comment: "" }
|
||||
: { ...w, unlocked: true }
|
||||
: w,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const removeWork = (rowKey: string) =>
|
||||
setWorks((arr) => arr.filter((w) => w.rowKey !== rowKey));
|
||||
|
||||
const totalSum = useMemo(
|
||||
() => works.reduce((acc, w) => acc + Number(w.price_applied || 0), 0),
|
||||
[works],
|
||||
);
|
||||
|
||||
// ── Parts ────────────────────────────────────────────────────────────────
|
||||
|
||||
const [partsDraft, setPartsDraft] = useState<{ name: string; suggestions: string[] }>({ name: "", suggestions: [] });
|
||||
|
||||
const queryPartSuggestions = async (q: string) => {
|
||||
setPartsDraft({ name: q, suggestions: [] });
|
||||
if (!q.trim()) return;
|
||||
const list = await suggestParts(q);
|
||||
setPartsDraft({ name: q, suggestions: list.map((s) => s.name) });
|
||||
};
|
||||
|
||||
const addPart = (name: string) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setParts((arr) => [...arr, { rowKey: uuid4(), name: trimmed, qty: 1 }]);
|
||||
setPartsDraft({ name: "", suggestions: [] });
|
||||
};
|
||||
|
||||
const removePart = (rowKey: string) =>
|
||||
setParts((arr) => arr.filter((p) => p.rowKey !== rowKey));
|
||||
|
||||
// ── Submit ───────────────────────────────────────────────────────────────
|
||||
|
||||
const canSubmit = () =>
|
||||
photos.length > 0 &&
|
||||
photos.every((p) => p.status === "uploaded") &&
|
||||
works.length > 0 &&
|
||||
works.every((w) => !w.unlocked || w.override_comment.trim().length > 0);
|
||||
|
||||
const submitWhyDisabled = (): string => {
|
||||
if (photos.length === 0) return "Добавьте хотя бы одно фото";
|
||||
if (photos.some((p) => p.status !== "uploaded")) return "Дождитесь окончания загрузки фото";
|
||||
if (works.length === 0) return "Добавьте хотя бы одну работу";
|
||||
const unlockedNoComment = works.find((w) => w.unlocked && !w.override_comment.trim());
|
||||
if (unlockedNoComment) return "Укажите комментарий к ручной правке цены";
|
||||
return "";
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const reason = submitWhyDisabled();
|
||||
if (reason) {
|
||||
toast.error(reason);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const photoPayload: CreatePhotoPayload[] = photos.map((p, i) => ({
|
||||
photo_uuid: p.presign.photo_uuid,
|
||||
extension: p.presign.extension,
|
||||
tag: p.tag,
|
||||
sort_order: i,
|
||||
}));
|
||||
const result = await createRepair(
|
||||
{
|
||||
car_id: carId,
|
||||
mileage,
|
||||
works: works.map((w) => ({
|
||||
work_catalog_id: w.work_catalog_id,
|
||||
price_applied: Number(w.price_applied),
|
||||
manual_override: w.unlocked,
|
||||
override_comment: w.unlocked ? w.override_comment.trim() : null,
|
||||
})),
|
||||
parts: parts.map((p) => ({ name: p.name, qty: p.qty })),
|
||||
photos: photoPayload,
|
||||
oil_change: oilChange.enabled
|
||||
? {
|
||||
enabled: true,
|
||||
mileage_at_change: oilChange.mileage_at_change,
|
||||
next_in_km: oilChange.next_in_km,
|
||||
sticker_photo_uuid:
|
||||
oilChange.sticker?.status === "uploaded"
|
||||
? oilChange.sticker.presign.photo_uuid
|
||||
: null,
|
||||
sticker_extension:
|
||||
oilChange.sticker?.status === "uploaded"
|
||||
? oilChange.sticker.presign.extension
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
idemKey.current,
|
||||
);
|
||||
toast.success(`Ремонт #${result.id} сохранён`);
|
||||
navigate("/repairs", { replace: true });
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message || "Не удалось сохранить");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (ctxError) {
|
||||
return (
|
||||
<div className="container max-w-2xl py-8">
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm">
|
||||
{ctxError}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!ctx) {
|
||||
return (
|
||||
<div className="container max-w-2xl py-8">
|
||||
<div className="text-sm text-muted-foreground">Загрузка…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(-1)} className="-ml-2">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
<span className="ml-1">Назад</span>
|
||||
</Button>
|
||||
<h1 className="text-base font-semibold">Новый ремонт</h1>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={submitting || !canSubmit()}
|
||||
onClick={onSubmit}
|
||||
title={submitWhyDisabled() || undefined}
|
||||
>
|
||||
{submitting ? "Сохранение…" : "Сохранить"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-3 pb-3 text-sm">
|
||||
<div className="font-medium">{ctx.plate}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{[ctx.brand, ctx.model].filter(Boolean).join(" ")}
|
||||
{ctx.driver_name ? ` · ${ctx.driver_name}` : ` · Без водителя`}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 container max-w-2xl py-4 space-y-5">
|
||||
{/* Пробег */}
|
||||
<section>
|
||||
<Label htmlFor="mileage" className="text-sm">Пробег (км)</Label>
|
||||
<Input
|
||||
id="mileage"
|
||||
type="number"
|
||||
min={0}
|
||||
value={mileage ?? ""}
|
||||
onChange={(e) => setMileage(e.target.value === "" ? null : Number(e.target.value))}
|
||||
placeholder={ctx.mileage_starline == null ? "StarLine недоступен — введите вручную" : ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Фото */}
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<Label className="text-sm">Фото ремонта</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{photos.length === 0 ? "Минимум одно фото" : `${photos.length} шт.`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mt-2">
|
||||
{photos.map((p) => (
|
||||
<div key={p.presign.photo_uuid} className="relative aspect-square border rounded-md bg-muted overflow-hidden">
|
||||
{p.status === "uploading" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs">Загрузка…</div>
|
||||
)}
|
||||
{p.status === "error" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-destructive p-1 text-center">
|
||||
{p.errorMessage || "Ошибка"}
|
||||
</div>
|
||||
)}
|
||||
{p.status === "uploaded" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground">
|
||||
✓ Загружено
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePhoto(p.presign.photo_uuid)}
|
||||
className="absolute top-1 right-1 rounded-full bg-background/80 p-1 hover:bg-background"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<label className="aspect-square border-2 border-dashed rounded-md flex items-center justify-center cursor-pointer hover:bg-accent/40">
|
||||
<Camera className="h-6 w-6 text-muted-foreground" />
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => onPhotoInput(e.target.files, "gallery")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Работы */}
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<Label className="text-sm">Работы</Label>
|
||||
<Button variant="outline" size="sm" onClick={openWorksPicker}>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Добавить работу
|
||||
</Button>
|
||||
</div>
|
||||
{works.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground mt-2">Минимум одна работа</div>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{works.map((w) => (
|
||||
<li
|
||||
key={w.rowKey}
|
||||
className={`rounded-md border p-2 ${w.unlocked ? "bg-amber-50 border-amber-300" : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{w.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Прайс: {w.price_catalog} ₽
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={w.price_applied}
|
||||
disabled={!w.unlocked}
|
||||
onChange={(e) =>
|
||||
setWorks((arr) =>
|
||||
arr.map((row) =>
|
||||
row.rowKey === w.rowKey
|
||||
? { ...row, price_applied: Number(e.target.value) }
|
||||
: row,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="w-24"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleWorkUnlock(w.rowKey)}
|
||||
title={w.unlocked ? "Отменить ручную правку" : "Перебить цену"}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeWork(w.rowKey)}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{w.unlocked && (
|
||||
<textarea
|
||||
placeholder="Комментарий к ручной правке (обязателен)"
|
||||
value={w.override_comment}
|
||||
onChange={(e) =>
|
||||
setWorks((arr) =>
|
||||
arr.map((row) =>
|
||||
row.rowKey === w.rowKey ? { ...row, override_comment: e.target.value } : row,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="mt-2 w-full text-sm rounded-md border bg-background p-2 min-h-[60px]"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{works.length > 0 && (
|
||||
<div className="mt-3 text-right text-sm font-medium">Итого: {totalSum} ₽</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Запчасти */}
|
||||
<section>
|
||||
<Label className="text-sm">Запчасти и материалы</Label>
|
||||
{parts.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{parts.map((p) => (
|
||||
<li key={p.rowKey} className="flex items-center gap-2 text-sm">
|
||||
<span className="flex-1">{p.name}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={p.qty}
|
||||
onChange={(e) =>
|
||||
setParts((arr) =>
|
||||
arr.map((row) =>
|
||||
row.rowKey === p.rowKey ? { ...row, qty: Math.max(1, Number(e.target.value)) } : row,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="w-20"
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => removePart(p.rowKey)}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Input
|
||||
value={partsDraft.name}
|
||||
onChange={(e) => queryPartSuggestions(e.target.value)}
|
||||
placeholder="Например, Свеча NGK"
|
||||
onKeyDown={(e) => e.key === "Enter" && addPart(partsDraft.name)}
|
||||
/>
|
||||
<Button onClick={() => addPart(partsDraft.name)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{partsDraft.suggestions.length > 0 && (
|
||||
<ul className="mt-1 border rounded-md bg-popover divide-y">
|
||||
{partsDraft.suggestions.map((s) => (
|
||||
<li key={s}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left text-sm px-3 py-2 hover:bg-accent/40"
|
||||
onClick={() => addPart(s)}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Замена масла */}
|
||||
<section className="border rounded-md p-3">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={oilChange.enabled}
|
||||
onChange={(e) =>
|
||||
setOilChange((oc) => ({ ...oc, enabled: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Была замена масла
|
||||
</label>
|
||||
{oilChange.enabled && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<Label className="text-sm">Пробег на момент замены</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={oilChange.mileage_at_change ?? ""}
|
||||
onChange={(e) =>
|
||||
setOilChange((oc) => ({
|
||||
...oc,
|
||||
mileage_at_change: e.target.value === "" ? null : Number(e.target.value),
|
||||
mileage_is_dirty: true,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Следующая замена через (км)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={oilChange.next_in_km}
|
||||
onChange={(e) =>
|
||||
setOilChange((oc) => ({ ...oc, next_in_km: Number(e.target.value) || 0 }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm">Фото стикера (одно)</Label>
|
||||
<label className="mt-1 block border-2 border-dashed rounded-md p-4 text-center cursor-pointer hover:bg-accent/40">
|
||||
{oilChange.sticker == null ? (
|
||||
<span className="text-sm text-muted-foreground">Тап чтобы загрузить</span>
|
||||
) : oilChange.sticker.status === "uploading" ? (
|
||||
"Загрузка…"
|
||||
) : oilChange.sticker.status === "error" ? (
|
||||
<span className="text-sm text-destructive">
|
||||
Ошибка: {oilChange.sticker.errorMessage}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm">✓ Загружено</span>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={(e) => onPhotoInput(e.target.files, "sticker")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Works picker modal */}
|
||||
{worksPicker.open && (
|
||||
<div className="fixed inset-0 z-50 bg-background/95 flex flex-col">
|
||||
<header className="border-b p-3 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setWorksPicker({ open: false, q: "", results: [] })}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Поиск работы…"
|
||||
value={worksPicker.q}
|
||||
onChange={(e) => queryWorks(e.target.value)}
|
||||
/>
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<ul className="divide-y">
|
||||
{worksPicker.results.map((w) => (
|
||||
<li key={w.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addWorkFromCatalog(w)}
|
||||
className="w-full text-left p-3 hover:bg-accent/40 flex items-baseline justify-between gap-3"
|
||||
>
|
||||
<span className="flex-1 min-w-0 truncate">{w.name}</span>
|
||||
<span className="text-sm text-muted-foreground">{w.price} ₽</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{worksPicker.results.length === 0 && (
|
||||
<li className="p-6 text-center text-sm text-muted-foreground">
|
||||
Нет работ по запросу
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Лента ремонтов парка (Фича 2 + У5 + У38 + У39 + У40 + У41).
|
||||
*
|
||||
* Главный экран раздела «Ремонт». Все ремонты парка, сортировка
|
||||
* `created_at DESC`. Бесконечный скролл по 20. Pull-to-refresh,
|
||||
* обновление при возврате в приложение (через useRepairsFeed).
|
||||
*
|
||||
* Пустая лента — плейсхолдер «В парке пока нет ремонтов» (У41).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ChevronLeft, Plus, Wrench } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRepairsFeed } from "@/hooks/useRepairsFeed";
|
||||
import { formatFeedTimestamp } from "@/lib/timeFormat";
|
||||
import type { FeedItem } from "@/api/repairsFeed";
|
||||
|
||||
export default function RepairsFeedPage() {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
items,
|
||||
isLoading,
|
||||
isError,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useRepairsFeed();
|
||||
|
||||
// IntersectionObserver для бесконечной прокрутки.
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el || !hasNextPage) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
|
||||
void fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
// Pull-to-refresh: простой UX через тач-скролл сверху.
|
||||
// Делаем минимально-инвазивно: на тап-кнопку «обновить» вызываем refetch.
|
||||
// Полноценный pull-to-refresh с жестом — отдельная задача.
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const handleAdd = () => navigate("/repairs/new");
|
||||
const handleOpen = (item: FeedItem) => navigate(`/repairs/${item.id}`);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate("/")}
|
||||
className="-ml-2"
|
||||
aria-label="Назад"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
<span className="ml-1">Назад</span>
|
||||
</Button>
|
||||
<h1 className="text-base font-semibold">Ремонт</h1>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
aria-label="Добавить ремонт"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<span className="hidden sm:inline">Добавить</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 container max-w-2xl py-3 space-y-2">
|
||||
{/* Refresh tap-area для не-touch юзкейса; жест pull-to-refresh — в PR-8. */}
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={isFetching}
|
||||
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{isFetching ? "Обновляем…" : "Обновить"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-20 rounded-lg bg-muted animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && !isLoading && (
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm">
|
||||
Не удалось загрузить ленту ремонтов. Проверьте сеть и попробуйте снова.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && items.length === 0 && <EmptyState onAdd={handleAdd} />}
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<RepairRow item={item} onOpen={() => handleOpen(item)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} aria-hidden="true" />
|
||||
{isFetchingNextPage && (
|
||||
<div className="text-center text-xs text-muted-foreground py-4">
|
||||
Загружаем ещё…
|
||||
</div>
|
||||
)}
|
||||
{!hasNextPage && items.length > 0 && !isFetchingNextPage && (
|
||||
<div className="text-center text-xs text-muted-foreground py-4">
|
||||
Это все ремонты.
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function RepairRow({ item, onOpen }: { item: FeedItem; onOpen: () => void }) {
|
||||
const carLine = [item.car_plate, item.car_make, item.car_model]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="w-full flex items-stretch gap-3 rounded-lg border bg-card hover:bg-accent/40 transition-colors text-left p-3"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-md bg-muted overflow-hidden flex-shrink-0 flex items-center justify-center">
|
||||
{item.photo_url ? (
|
||||
// Lazy-load — браузер сам решит когда грузить.
|
||||
<img
|
||||
src={item.photo_url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Wrench className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="font-medium truncate">{carLine}</div>
|
||||
<div className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatFeedTimestamp(item.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
{item.driver_name && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
Водитель: {item.driver_name}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
Механик: {item.mechanic_name}
|
||||
</div>
|
||||
<WorksLine works={item.works_preview.map((w) => w.name)} extra={item.works_extra} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function WorksLine({ works, extra }: { works: string[]; extra: number }) {
|
||||
// У40: ellipsis в одну строку. Соединяем имена работ через «·».
|
||||
if (works.length === 0) return null;
|
||||
const tail = extra > 0 ? ` · +${extra} ${pluralWorks(extra)}` : "";
|
||||
return (
|
||||
<div className="text-sm truncate" title={works.join(", ") + (extra ? ` (+${extra})` : "")}>
|
||||
{works.join(" · ")}
|
||||
{tail && <span className="text-muted-foreground">{tail}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function pluralWorks(n: number): string {
|
||||
// 1 → «работа», 2..4 → «работы», 5+ → «работ»
|
||||
const mod10 = n % 10;
|
||||
const mod100 = n % 100;
|
||||
if (mod100 >= 11 && mod100 <= 14) return "работ";
|
||||
if (mod10 === 1) return "работа";
|
||||
if (mod10 >= 2 && mod10 <= 4) return "работы";
|
||||
return "работ";
|
||||
}
|
||||
|
||||
|
||||
function EmptyState({ onAdd }: { onAdd: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center py-16 px-4 space-y-4">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<Wrench className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-base font-semibold">В парке пока нет ремонтов</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
Создайте первый ремонт — он появится здесь.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onAdd}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Добавить ремонт
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="13" stroke = "#ffffff" fill = "#009966" d="M652.1500000000001,632.2400000000002C651.2700000000001,636.0700000000003,651.5900000000001,640.0800000000003,651.21,643.9800000000002C649.0500000000001,687.5100000000002,644.38,730.8600000000002,641.5300000000001,774.3400000000003C647.3700000000001,774.7400000000002,653.2600000000001,774.3900000000002,659.0000000000001,773.1700000000003C663.3200000000002,772.2300000000002,668.0800000000002,771.7100000000003,671.5700000000002,768.7100000000003C675.5500000000002,765.2300000000002,678.2800000000002,760.6400000000002,681.5200000000002,756.5300000000003C687.2700000000002,748.9700000000004,694.2200000000003,742.0100000000003,702.9800000000002,738.0500000000003C714.9100000000002,732.8500000000003,727.9800000000002,731.2200000000003,740.8200000000003,730.0100000000003C739.9600000000003,698.3500000000004,740.9500000000003,666.6500000000003,739.9600000000003,635.0000000000003C739.2000000000003,633.9700000000004,737.7300000000002,633.9500000000004,736.6500000000003,633.5000000000003C714.9500000000003,627.6000000000004,692.2500000000003,628.2200000000004,669.9900000000004,628.9900000000004C663.9600000000004,629.2200000000004,657.5500000000003,629.1600000000003,652.1500000000003,632.2400000000004Z"/><path onclick="clicked(evt)" class="13" stroke = "#ffffff" fill = "#009966" d="M651,633 594,646 591,726 595,760 637,775 637,765 608,752 599,726 601,651 651,641z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="16" stroke = "#ffffff" fill = "#009966" d="M381.04,565.1600000000003C362.39000000000004,566.8500000000004,343.52000000000004,569.4000000000003,325.94,576.1700000000003C328.2,587.7000000000003,329.96999999999997,599.3300000000003,330.62,611.0800000000003C334.28000000000003,651.9000000000003,335.37,692.9000000000003,336.93,733.8400000000003C337.44,743.8800000000002,336.90000000000003,753.9600000000003,337.93,763.9700000000003C360.27,770.7800000000002,383.83,771.5800000000003,407.01,771.8300000000003C432.42,772.0000000000002,458.31,771.3100000000003,482.81,763.9200000000003C483.68,747.2900000000003,483.62,730.6300000000003,484.43,714.0000000000003C485.8,680.6400000000003,486.79,647.2500000000003,489.84000000000003,613.9900000000004C490.64000000000004,601.3600000000004,491.96000000000004,588.7300000000004,494.87,576.4000000000003C485.46,572.2100000000003,475.25,570.2400000000004,465.18,568.3800000000003C437.42,563.4400000000003,409.09000000000003,563.1100000000004,381.04,565.1600000000003Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" stroke = "#ffffff" class="17" fill = "#009966" d="M725.4200000000005,371.65000000000043C708.2400000000006,375.02000000000044,694.0000000000006,389.59000000000043,691.1200000000006,406.87000000000046C688.6600000000005,420.2900000000005,692.9900000000006,434.7200000000005,702.4800000000006,444.5300000000004C710.7700000000006,453.3700000000004,722.9000000000005,458.58000000000044,735.0500000000006,458.15000000000043C750.1700000000006,458.1000000000004,764.7700000000007,449.2400000000004,772.1300000000007,436.08000000000044C776.1400000000007,429.12000000000046,778.1700000000006,421.0400000000004,777.8500000000007,413.01000000000045C777.4900000000007,397.5300000000004,768.0400000000008,382.74000000000046,754.2700000000007,375.7300000000005C745.5100000000007,371.0400000000005,735.1300000000007,369.75000000000045,725.4200000000006,371.6500000000005Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="18" stroke = "#ffffff" fill = "#009966" d="M79.47000000000048,371.64000000000044C62.27000000000048,375.00000000000045,48.01000000000048,389.57000000000045,45.12000000000048,406.86000000000047C42.70000000000048,420.06000000000046,46.85000000000048,434.2200000000005,56.00000000000048,444.0300000000005C64.31000000000049,453.1400000000005,76.64000000000048,458.5900000000005,89.0200000000005,458.1500000000005C104.1700000000005,458.1200000000005,118.8000000000005,449.2100000000005,126.1600000000005,436.0300000000005C130.1500000000005,429.0900000000005,132.1700000000005,421.0300000000005,131.8500000000005,413.0300000000005C131.5000000000005,397.5300000000005,122.02000000000051,382.7200000000005,108.2300000000005,375.7000000000005C99.4900000000005,371.0400000000005,89.1500000000005,369.7700000000005,79.4700000000005,371.6400000000005Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="19" stroke = "#ffffff" fill = "#009966" d="M723.5500000000003,753.7500000000003C706.6800000000003,757.8400000000004,693.1300000000003,772.8100000000003,690.8800000000003,790.0500000000003C688.9600000000004,803.1000000000003,693.3900000000003,816.9200000000003,702.6400000000003,826.3400000000003C710.8700000000003,835.0900000000003,722.9100000000003,840.1700000000003,734.9400000000003,839.8600000000002C749.2500000000002,839.7500000000002,763.1600000000003,831.9200000000002,770.8400000000003,819.8800000000002C775.6000000000003,812.5500000000002,778.1600000000003,803.7800000000002,777.8600000000002,795.0300000000002C777.6300000000002,779.2800000000002,767.9600000000003,764.1700000000002,753.8700000000002,757.1900000000002C744.6300000000002,752.3700000000001,733.6300000000002,751.2500000000001,723.5500000000002,753.7500000000001Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="20" stroke = "#ffffff" fill = "#009966" d="M77.59000000000026,753.7400000000004C61.45000000000026,757.6600000000003,48.30000000000026,771.4800000000004,45.250000000000256,787.8200000000004C42.55000000000025,801.2900000000004,46.720000000000255,815.9100000000004,56.19000000000025,825.8800000000003C64.47000000000025,834.9100000000003,76.72000000000025,840.2000000000004,88.99000000000025,839.8600000000004C103.56000000000026,839.7500000000003,117.67000000000024,831.6000000000004,125.27000000000025,819.2100000000004C131.06000000000026,810.0200000000003,133.18000000000026,798.6400000000003,131.11000000000024,787.9900000000004C128.60000000000025,774.6100000000004,119.44000000000024,762.6700000000003,107.13000000000024,756.8300000000004C98.07000000000023,752.3100000000004,87.39000000000024,751.3300000000004,77.59000000000023,753.7400000000004Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="21" stroke = "#ffffff" fill = "#009966" d="M307.76000000000005,1038.83C311.14000000000004,1045.8899999999999,314.6,1052.9099999999999,318.02000000000004,1059.96C320.43000000000006,1065.13,323.04,1070.8600000000001,328.23,1073.76C331.98,1075.14,336.08000000000004,1074.98,340,1075.43C374.31,1077.52,408.7,1076.6200000000001,443.06,1076.8400000000001C458.77,1076.2,474.54,1076.4700000000003,490.18,1074.6100000000001C495.5,1074.2,498.77,1069.2900000000002,501.15000000000003,1065.0500000000002C505.43,1056.1900000000003,509.90000000000003,1047.41,514,1038.4700000000003C445.25,1038.6300000000003,376.5,1038.3400000000001,307.76,1038.8300000000002Z"/><path onclick="clicked(evt)" class="21" stroke = "#ffffff" fill = "#009966" d="M303.57,1024.0600000000002C303.29,1027.0100000000002,304.86,1029.7200000000003,305.75,1032.4600000000003C375.75,1032.4900000000002,445.76,1032.4600000000003,515.76,1032.6100000000004C517.04,1029.2700000000004,519.17,1025.5800000000004,517.51,1021.9900000000004C515.9,1018.5000000000003,513.35,1014.4700000000004,509.11,1014.2000000000004C505.43,1013.6500000000004,501.7,1013.8300000000004,498,1013.8000000000004C436.98,1013.8000000000004,375.95,1013.7800000000004,314.93,1013.9300000000004C309.02,1013.4900000000004,304.74,1018.7700000000004,303.57,1024.0600000000004Z"/><path onclick ="clicked(evt)" class="21" stroke = "#ffffff" fill = "#009966" d="M316.5000000000001,841.3900000000003C316.8400000000001,860.6000000000004,319.10000000000014,879.7200000000004,320.16000000000014,898.8900000000003C330.7400000000001,908.0000000000003,343.6300000000001,915.5800000000004,357.85000000000014,916.5200000000003C376.46000000000015,918.7300000000004,395.22000000000014,919.3100000000003,413.95000000000016,919.1300000000003C432.98000000000013,919.3700000000003,452.0600000000002,918.1500000000003,470.89000000000016,915.3900000000003C481.28000000000014,912.5100000000003,490.89000000000016,907.0500000000003,499.10000000000014,900.1100000000004C500.92000000000013,899.0600000000004,500.51000000000016,896.8000000000004,500.7400000000001,895.0700000000004C501.85000000000014,877.1800000000004,503.8700000000001,859.3300000000004,504.1300000000001,841.4100000000004C496.3300000000001,847.7100000000004,486.7100000000001,851.1000000000005,477.1300000000001,853.6600000000004C453.92000000000013,859.5800000000004,429.8100000000001,860.5700000000004,405.9600000000001,860.1800000000004C384.1400000000001,860.1000000000004,362.0700000000001,858.8200000000004,340.9500000000001,852.9600000000004C332.2500000000001,850.4700000000004,323.6100000000001,847.1400000000003,316.5000000000001,841.3900000000003Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="22" stroke = "#ffffff" fill = "#009966" d="M283.93000000000006,1063.81C282.95000000000005,1065.78,281.52000000000004,1067.7,281.5400000000001,1070.01C281.4800000000001,1075.64,281.5100000000001,1081.27,281.5800000000001,1086.91C298.9500000000001,1089.1000000000001,316.5400000000001,1087.44,333.9800000000001,1088.43C389.32000000000005,1088.97,444.70000000000005,1089.19,500.0300000000001,1088.1200000000001C513.3600000000001,1087.93,526.7800000000001,1088.64,540.0300000000001,1086.91C539.69,1079.26,541.6400000000001,1071,537.8500000000001,1063.95C527.2300000000001,1065.29,516.5000000000001,1065.21,505.83000000000015,1065.91C502.3600000000001,1071.8400000000001,497.65000000000015,1078.8600000000001,489.9900000000002,1078.99C463.7400000000002,1081.6,437.33000000000015,1080.5,411.00000000000017,1081.19C384.3100000000002,1080.48,357.5300000000002,1081.65,330.9200000000002,1078.9C323.5300000000002,1078.5,319.4300000000002,1071.52,315.75000000000017,1066.0400000000002C305.1800000000002,1064.91,294.4300000000002,1065.5900000000001,283.9300000000002,1063.8100000000002Z"/><path onclick ="clicked(evt)" class="22" stroke = "#ffffff" fill = "#009966" d="M281.9700000000001,1091.1699999999998C282.5700000000001,1097.2599999999998,282.8900000000001,1103.4399999999998,284.5200000000001,1109.3799999999999C285.9000000000001,1114.52,289.3900000000001,1118.6699999999998,292.1000000000001,1123.1499999999999C295.5400000000001,1121.6699999999998,299.18000000000006,1120.6299999999999,302.9500000000001,1120.61C340.6200000000001,1118.9599999999998,378.3400000000001,1119.59,416.0400000000001,1119.4699999999998C450.3900000000001,1119.5299999999997,484.75000000000006,1119.0899999999997,519.0600000000001,1120.6299999999999C522.73,1120.6799999999998,526.2500000000001,1121.77,529.6300000000001,1123.12C532.1000000000001,1118.75,535.5200000000001,1114.86,536.9600000000002,1109.9799999999998C538.7600000000001,1103.8999999999999,539.0800000000002,1097.5199999999998,539.6400000000001,1091.2599999999998C533.1200000000001,1092.0499999999997,526.5500000000001,1092.2099999999998,519.9900000000001,1092.1699999999998C492.65000000000015,1092.0399999999997,465.33000000000015,1093.0199999999998,438.0000000000001,1092.8799999999999C401.04000000000013,1092.87,364.09000000000015,1093.06,327.1400000000001,1092.31C312.1000000000001,1091.6699999999998,296.9500000000001,1093.07,281.9700000000001,1091.1699999999998Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="0" stroke = "#ffffff" fill = "#009966" d="M279.1300000000006,119.04000000000042C278.11000000000064,124.02000000000042,278.4100000000006,129.1700000000004,278.8800000000006,134.20000000000041C283.79000000000065,131.44000000000042,289.3900000000006,130.3400000000004,294.9600000000006,129.94000000000042C302.93000000000063,129.02000000000044,310.9600000000006,128.81000000000043,318.9800000000006,128.47000000000043H489.0300000000006C502.3500000000006,128.35000000000042,515.7000000000006,128.84000000000043,528.9200000000006,130.51000000000042C532.8500000000006,131.01000000000042,536.6200000000007,132.35000000000042,540.1200000000007,134.20000000000041C540.5900000000007,129.1800000000004,540.8500000000007,124.06000000000041,539.9100000000007,119.08000000000041C536.6800000000006,118.20000000000041,533.4100000000007,117.4600000000004,530.0700000000006,117.23000000000042C511.7100000000006,115.90000000000042,493.3200000000006,115.43000000000042,474.92000000000064,115.53000000000041H400.97000000000065C368.97000000000065,115.75000000000041,336.95000000000067,114.95000000000041,304.97000000000065,116.25000000000041C296.34000000000066,116.95000000000041,287.52000000000066,116.59000000000042,279.1300000000007,119.04000000000042Z"/><path onclick="clicked(evt)" class="0" stroke = "#ffffff" fill = "#009966" d="M290.1600000000006,88.97000000000043C287.4800000000006,94.70000000000043,284.1100000000006,100.09000000000043,281.6300000000006,105.93000000000043C280.5700000000006,108.86000000000044,279.6600000000006,111.84000000000043,278.49000000000063,114.73000000000043C281.9400000000006,113.99000000000044,285.41000000000065,113.28000000000043,288.9500000000006,113.07000000000043C303.9500000000006,112.08000000000044,318.9700000000006,111.34000000000043,334.0100000000006,111.47000000000044H455.9700000000006C478.3400000000006,111.55000000000044,500.7300000000006,111.03000000000044,523.0600000000006,112.64000000000044C528.9200000000006,112.91000000000044,534.8000000000006,113.32000000000045,540.5200000000007,114.70000000000044C537.3700000000007,105.78000000000044,533.2500000000007,97.29000000000045,528.9200000000006,88.89000000000044C449.34000000000066,88.93000000000045,369.7500000000007,88.77000000000044,290.16000000000065,88.97000000000044Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="23" stroke = "#ffffff" fill = "#009966" d="M485.51 767.67c-18.23 5.96-37.52 7.47-56.56 8.45-31.79.15-64.39 1-95.06-8.74-2.21-.27-2.71 2.05-3.18 3.68-2.45 9.43-4.95 18.86-7.11 28.36-2.24 10.5-5.21 20.86-6.48 31.54-.1 1.98-.89 4.47 1.02 5.89 6.4 6.39 15.26 9.35 23.72 11.92 18.9 5.23 38.58 6.93 58.13 7.06 19.06.4 38.23.33 57.13-2.56 11.89-1.8 23.84-4.28 34.85-9.29 4.28-2.08 8.57-4.57 11.59-8.33.61-1.47-.03-3.15-.02-4.67-1.25-10.53-4.14-20.76-6.37-31.1-2.37-10.23-4.88-20.43-7.76-30.52-.29-1.79-2.42-2.38-3.9-1.69"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="24" stroke = "#ffffff" fill = "#009966" d="M191.33000000000015,526.1200000000005C189.73000000000016,527.8500000000005,188.46000000000015,530.1800000000004,185.99000000000015,530.8000000000004C180.93000000000015,531.9900000000005,174.42000000000016,532.6600000000004,171.07000000000016,527.7500000000005C172.59000000000017,557.9100000000004,173.11000000000016,588.1500000000004,174.29000000000016,618.3400000000005C191.19000000000017,622.5500000000005,208.07000000000016,626.8900000000004,224.88000000000017,631.4500000000005C224.67000000000016,617.0600000000005,222.95000000000016,602.7300000000005,220.64000000000016,588.5500000000005C219.15000000000015,578.9500000000005,216.30000000000015,569.5800000000005,212.28000000000014,560.7500000000006C206.75000000000014,548.7800000000005,200.48000000000013,537.0500000000005,192.47000000000014,526.5500000000005L191.33000000000015,526.1200000000006Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="25" stroke = "#ffffff" fill = "#009966" d="M630.2900000000002,526.0600000000005C622.2400000000002,536.3100000000005,616.1100000000002,547.9200000000005,610.5500000000002,559.6600000000005C606.6800000000002,568.0400000000005,603.7000000000002,576.8700000000006,602.1500000000002,585.9900000000006C599.5300000000002,600.9900000000006,597.7300000000002,616.1600000000005,597.4500000000002,631.3900000000006C614.3000000000002,626.8800000000006,631.2400000000001,622.6500000000005,648.1100000000001,618.2000000000005C648.9900000000001,588.1000000000005,650.0900000000001,558.0100000000004,651.0200000000001,527.9200000000005C649.3700000000001,529.6000000000005,647.6000000000001,531.6600000000005,645.0000000000001,531.5300000000005C641.4000000000001,531.6000000000006,637.4400000000002,531.7600000000006,634.2800000000001,529.7800000000005C632.6600000000001,528.8400000000005,632.2700000000001,526.3400000000005,630.2900000000001,526.0600000000005Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="26" stroke = "#ffffff" fill = "#009966" d="M175.47100000000012,644.4100000000004C176.78000000000011,672.3590000000004,179.32800000000012,700.2400000000005,181.61000000000013,728.1200000000005C194.67000000000013,728.1500000000004,207.73000000000013,727.5500000000004,220.79000000000013,727.5600000000005C226.01000000000013,703.0700000000005,224.67000000000013,677.8800000000006,225.40000000000015,653.0200000000006C217.58600000000015,651.6180000000005,209.78300000000016,650.1510000000005,201.98000000000013,648.6790000000005L198.86000000000013,648.0910000000006C191.05700000000013,646.6190000000006,183.25400000000013,645.1520000000006,175.4400000000001,643.7500000000006L175.47100000000012,644.4100000000005Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="27" stroke = "#ffffff" fill = "#009966" d="M596.9300000000001,653.0300000000004C597.5000000000001,677.8700000000005,596.5600000000001,702.9500000000004,601.3800000000001,727.4700000000005C614.5100000000001,727.6600000000005,627.6300000000001,728.1000000000005,640.7600000000001,728.1100000000005C642.9900000000001,700.0100000000004,645.5800000000002,671.9200000000005,646.9300000000001,643.7600000000004C630.24,646.7400000000005,613.61,650.0000000000005,596.9300000000001,653.0300000000004Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="30" stroke = "#ffffff" fill = "#009966" d="M602.9300000000001,734.6100000000004C605.47,743.8000000000004,609.61,752.7300000000004,616.19,759.7500000000003C621.6500000000001,765.7100000000004,629.62,768.3800000000003,637.3900000000001,769.6700000000003C638.4300000000001,758.1800000000003,639.3100000000001,746.6800000000003,640.1100000000001,735.1700000000003C627.7100000000002,735.1900000000003,615.3200000000002,734.7800000000003,602.9300000000002,734.6100000000004Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="38" fill = "#009966" d="M510.42000000000064,202.94000000000045C512.3700000000007,204.39000000000044,513.1100000000007,206.72000000000045,514.3500000000006,208.70000000000044C516.6000000000006,210.08000000000044,519.5200000000006,209.33000000000044,522.0500000000006,209.47000000000045C527.8700000000007,209.34000000000046,534.7300000000006,207.16000000000045,537.0900000000006,201.29000000000045C535.8600000000006,200.15000000000046,534.7400000000006,198.62000000000046,532.9500000000006,198.42000000000044C526.9600000000006,197.41000000000045,520.9400000000006,196.55000000000044,514.9200000000006,195.83000000000044C513.4200000000006,198.20000000000044,511.90000000000066,200.56000000000043,510.42000000000064,202.94000000000045Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="39" stroke = "#ffffff" fill = "#009966" d="M286.86000000000064,198.26000000000045C284.7500000000006,198.34000000000046,283.2500000000006,199.87000000000046,281.90000000000066,201.31000000000046C285.5600000000007,209.50000000000045,296.14000000000067,210.18000000000046,303.90000000000066,209.21000000000046C306.02000000000066,207.63000000000045,306.23000000000064,204.45000000000047,308.5700000000007,202.95000000000047C307.0700000000007,200.59000000000046,305.7300000000007,198.11000000000047,303.9800000000007,195.94000000000048C298.2300000000007,196.33000000000047,292.5600000000007,197.47000000000048,286.8600000000007,198.26000000000047Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="40" fill = "#009966" d="M81.51000000000022,767.6500000000003C94.18000000000022,764.4000000000003,108.40000000000022,771.0500000000003,114.21000000000022,782.7300000000004C117.98000000000022,790.0300000000003,118.51000000000022,798.9200000000004,115.54000000000022,806.5900000000004C112.11000000000021,815.8200000000004,103.71000000000022,822.9900000000004,94.03000000000021,824.8600000000004C84.02000000000021,827.0300000000003,73.02000000000021,823.3500000000004,66.28000000000021,815.6400000000003C58.700000000000216,807.3300000000004,56.710000000000214,794.5100000000003,61.40000000000021,784.2800000000003C65.01000000000022,776.0000000000003,72.69000000000021,769.6200000000003,81.51000000000022,767.6500000000003Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="41" fill = "#009966" d="M727.5600000000002,767.6400000000003C743.2000000000002,763.4900000000004,760.6000000000001,774.9700000000004,762.9700000000001,790.9700000000004C766.2500000000001,806.3900000000003,754.6800000000002,822.8200000000004,739.0900000000001,825.0200000000003C724.4000000000001,827.9500000000003,708.7500000000001,817.5600000000003,705.6800000000002,802.9100000000003C701.6000000000001,787.7300000000004,712.1800000000002,770.7700000000003,727.5600000000002,767.6400000000003Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="1" stroke = "#ffffff" fill = "#009966" d="M488.5400000000007,132.88000000000042C488.51000000000073,138.73000000000042,488.5500000000007,144.59000000000043,488.5000000000007,150.44000000000042C501.65000000000066,151.63000000000042,514.7800000000007,153.65000000000043,528.0100000000007,153.49000000000044C530.7700000000007,153.45000000000044,533.6300000000007,153.33000000000044,536.2300000000007,152.28000000000043C537.9800000000007,147.98000000000042,539.4200000000008,142.77000000000044,537.6800000000007,138.27000000000044C533.1300000000008,134.98000000000044,527.2600000000008,134.63000000000045,521.8500000000007,134.11000000000044C510.7800000000007,132.99000000000044,499.6500000000007,132.97000000000045,488.5400000000007,132.88000000000045Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path class="42" onclick="clicked(evt)" fill = "#009966" d="M729.4400000000005,385.6000000000004C743.4900000000005,382.95000000000044,758.4300000000005,392.3500000000004,762.2300000000005,406.1100000000004C766.0100000000004,418.0000000000004,761.0200000000004,431.84000000000043,750.6500000000004,438.7300000000004C739.5500000000004,446.5600000000004,723.1200000000005,445.1100000000004,713.6200000000005,435.3700000000004C703.8000000000004,426.0700000000004,702.0400000000004,409.7900000000004,709.5800000000005,398.5800000000004C713.9900000000005,391.7100000000004,721.3700000000005,386.86000000000035,729.4400000000005,385.60000000000036Z" />
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="43" stroke="#ffffff" fill = "#009966" d="M83.49000000000046,385.59000000000043C97.50000000000047,382.9700000000004,112.41000000000047,392.3500000000004,116.21000000000046,406.07000000000045C120.02000000000046,417.9700000000004,115.03000000000046,431.84000000000043,104.65000000000046,438.7300000000005C93.53000000000046,446.57000000000045,77.06000000000046,445.1000000000005,67.57000000000046,435.32000000000045C57.770000000000465,426.00000000000045,56.050000000000466,409.71000000000043,63.61000000000046,398.52000000000044C68.04000000000046,391.6700000000004,75.42000000000046,386.84000000000043,83.49000000000046,385.59000000000043Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<g transform="translate(330, 580)"><path onclick="clicked(evt)" stroke="#ffffff" fill = "#009966" transform = "scale(0.15, 0.15)" class="44" ontouchstart="onstart(44)" ontouchend="onend(44)" fill = "#ffffff" d="M450.08,360.192c-9.237-8.683-19.84-12.907-29.163-15.211c1.749-7.488,3.264-15.467,4.459-23.915c2.091-14.528,1.557-29.333-1.6-44.053l-26.965-126.272c-4.608-16.299-14.315-28.437-26.432-37.675c0.491-0.619,1.131-1.109,1.6-1.771c6.891-9.899,8.832-22.421,5.355-33.899l-8.021-48c-0.213-1.323-0.555-2.624-1.024-3.883C362.635,10.24,347.957,0,331.744,0H180.235c-16.213,0-30.891,10.24-37.141,27.413l-12.629,47.68c-4.48,12.096-2.773,25.6,4.587,36.181c1.152,1.643,2.581,2.987,3.947,4.395c-11.051,9.173-19.883,20.949-24.235,36.437L88.117,277.013c-3.136,14.635-3.669,29.44-1.6,44.075c1.216,8.448,2.709,16.405,4.48,23.893c-9.301,2.325-19.883,6.549-29.099,15.211c-8.789,8.235-19.243,23.424-19.221,49.899C44.405,445.355,71.157,512,159.989,512h192c88.832,0,115.584-66.645,117.333-102.933C469.323,383.616,458.869,368.427,450.08,360.192z M138.656,467.285c-48.64-10.005-52.992-53.739-53.333-58.219c0-12.224,4.267-16.32,5.675-17.664c3.051-2.923,7.339-4.587,11.243-5.461c5.845,9.237,12.587,16.768,20.096,22.485c0.299,0.277,0.619,0.469,0.896,0.747c1.621,1.536,3.285,2.88,4.971,4.224c1.707,1.323,3.456,2.517,5.248,3.648c1.728,1.109,3.392,2.304,5.205,3.221V467.285z M330.656,469.333H181.323v-46.891c1.856-0.619,3.947-1.28,6.379-1.984c0.512-0.149,1.109-0.32,1.643-0.469c2.24-0.64,4.715-1.323,7.339-1.984c0.96-0.256,1.941-0.512,2.965-0.747c2.944-0.725,6.08-1.429,9.408-2.112c0.619-0.128,1.195-0.277,1.835-0.405c3.904-0.768,8.043-1.472,12.331-2.112c1.152-0.171,2.325-0.32,3.499-0.469c3.371-0.448,6.869-0.832,10.411-1.152c1.387-0.128,2.752-0.256,4.139-0.341c4.8-0.341,9.685-0.597,14.677-0.597c4.949,0,9.813,0.256,14.571,0.597c1.408,0.085,2.773,0.213,4.16,0.341c3.541,0.32,6.997,0.704,10.368,1.152c1.173,0.149,2.368,0.299,3.499,0.469c4.288,0.64,8.448,1.344,12.352,2.133c0.597,0.107,1.131,0.235,1.707,0.363c3.349,0.683,6.549,1.408,9.536,2.133c1.024,0.256,2.027,0.512,3.029,0.768c2.56,0.64,4.971,1.301,7.168,1.92c0.597,0.192,1.301,0.363,1.877,0.533c2.453,0.725,4.587,1.408,6.443,2.005V469.333z M270.112,85.333c-3.264-0.064-5.995-0.043-8.171,0h-11.691c-1.344-0.021-2.795-0.021-4.416,0h-74.027l11.285-42.667h145.195l7.125,42.667H270.112z M373.323,467.285v-47.04c1.813-0.917,3.499-2.155,5.227-3.243c1.685-1.088,3.328-2.219,4.949-3.456c1.728-1.365,3.456-2.709,5.099-4.288c0.171-0.149,0.363-0.277,0.533-0.427c7.744-5.76,14.635-13.419,20.608-22.891c3.755,0.853,8,2.411,11.136,5.355c1.429,1.344,5.781,5.44,5.824,16.555C426.315,413.419,421.963,457.259,373.323,467.285z" /></g>
|
||||
@@ -0,0 +1 @@
|
||||
<g transform="translate(412, 580)"><path onclick="clicked(evt)" stroke="#ffffff" fill = "#009966" stroke="#000" transform = "scale(0.15, 0.15)" class="45" ontouchstart="onstart(45)" ontouchend="onend(45)" fill = "#ffffff" d="M450.08,360.192c-9.237-8.683-19.84-12.907-29.163-15.211c1.749-7.488,3.264-15.467,4.459-23.915c2.091-14.528,1.557-29.333-1.6-44.053l-26.965-126.272c-4.608-16.299-14.315-28.437-26.432-37.675c0.491-0.619,1.131-1.109,1.6-1.771c6.891-9.899,8.832-22.421,5.355-33.899l-8.021-48c-0.213-1.323-0.555-2.624-1.024-3.883C362.635,10.24,347.957,0,331.744,0H180.235c-16.213,0-30.891,10.24-37.141,27.413l-12.629,47.68c-4.48,12.096-2.773,25.6,4.587,36.181c1.152,1.643,2.581,2.987,3.947,4.395c-11.051,9.173-19.883,20.949-24.235,36.437L88.117,277.013c-3.136,14.635-3.669,29.44-1.6,44.075c1.216,8.448,2.709,16.405,4.48,23.893c-9.301,2.325-19.883,6.549-29.099,15.211c-8.789,8.235-19.243,23.424-19.221,49.899C44.405,445.355,71.157,512,159.989,512h192c88.832,0,115.584-66.645,117.333-102.933C469.323,383.616,458.869,368.427,450.08,360.192z M138.656,467.285c-48.64-10.005-52.992-53.739-53.333-58.219c0-12.224,4.267-16.32,5.675-17.664c3.051-2.923,7.339-4.587,11.243-5.461c5.845,9.237,12.587,16.768,20.096,22.485c0.299,0.277,0.619,0.469,0.896,0.747c1.621,1.536,3.285,2.88,4.971,4.224c1.707,1.323,3.456,2.517,5.248,3.648c1.728,1.109,3.392,2.304,5.205,3.221V467.285z M330.656,469.333H181.323v-46.891c1.856-0.619,3.947-1.28,6.379-1.984c0.512-0.149,1.109-0.32,1.643-0.469c2.24-0.64,4.715-1.323,7.339-1.984c0.96-0.256,1.941-0.512,2.965-0.747c2.944-0.725,6.08-1.429,9.408-2.112c0.619-0.128,1.195-0.277,1.835-0.405c3.904-0.768,8.043-1.472,12.331-2.112c1.152-0.171,2.325-0.32,3.499-0.469c3.371-0.448,6.869-0.832,10.411-1.152c1.387-0.128,2.752-0.256,4.139-0.341c4.8-0.341,9.685-0.597,14.677-0.597c4.949,0,9.813,0.256,14.571,0.597c1.408,0.085,2.773,0.213,4.16,0.341c3.541,0.32,6.997,0.704,10.368,1.152c1.173,0.149,2.368,0.299,3.499,0.469c4.288,0.64,8.448,1.344,12.352,2.133c0.597,0.107,1.131,0.235,1.707,0.363c3.349,0.683,6.549,1.408,9.536,2.133c1.024,0.256,2.027,0.512,3.029,0.768c2.56,0.64,4.971,1.301,7.168,1.92c0.597,0.192,1.301,0.363,1.877,0.533c2.453,0.725,4.587,1.408,6.443,2.005V469.333z M270.112,85.333c-3.264-0.064-5.995-0.043-8.171,0h-11.691c-1.344-0.021-2.795-0.021-4.416,0h-74.027l11.285-42.667h145.195l7.125,42.667H270.112z M373.323,467.285v-47.04c1.813-0.917,3.499-2.155,5.227-3.243c1.685-1.088,3.328-2.219,4.949-3.456c1.728-1.365,3.456-2.709,5.099-4.288c0.171-0.149,0.363-0.277,0.533-0.427c7.744-5.76,14.635-13.419,20.608-22.891c3.755,0.853,8,2.411,11.136,5.355c1.429,1.344,5.781,5.44,5.824,16.555C426.315,413.419,421.963,457.259,373.323,467.285z" /></g>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="47" stroke = "#ffffff" fill = "#009966" d="M73.89000000000013,632C74.06000000000013,642.7000000000004,73.44000000000013,658.3400000000004,73.47000000000013,724.0000000000005C73.59000000000013,729.2600000000004,72.83000000000013,734.5200000000004,73.42000000000013,739.7700000000004C74.52000000000012,739.9100000000004,75.62000000000013,740.0700000000004,76.73000000000013,740.2300000000005C78.43000000000013,724.5700000000005,76.78000000000013,708.7600000000004,77.82000000000014,693.0600000000005C77.66000000000014,654.3600000000005,78.32000000000014,615.6400000000006,77.12000000000013,632Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="49" stroke = "#ffffff" fill = "#009966" d="M110.88000000000005,848.8300000000003C103.55000000000005,851.7100000000003,95.64000000000006,853.0800000000003,87.78000000000006,853.2700000000003C87.83000000000006,858.0300000000003,88.54000000000006,862.7700000000003,89.43000000000006,867.4500000000003C93.00000000000006,886.5400000000003,97.27000000000007,905.6200000000002,104.21000000000006,923.8000000000003C105.73000000000006,924.5000000000003,107.17000000000006,925.6000000000003,108.89000000000007,925.7500000000003C113.30000000000007,925.8800000000003,117.99000000000007,926.4800000000004,121.95000000000007,924.1000000000004C126.97000000000007,924.3500000000004,132.57000000000008,922.8900000000003,135.82000000000008,918.8300000000004C138.32000000000008,916.1300000000003,137.30000000000007,912.3000000000004,137.48000000000008,909.0000000000003C137.5300000000001,881.6600000000003,137.35000000000008,854.3200000000004,137.57000000000008,826.9800000000004C131.20000000000007,836.8300000000004,121.44000000000008,844.0200000000003,110.88000000000008,848.8300000000004Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="50" stroke = "#ffffff" fill = "#009966" d="M684.9300000000001,827.2500000000002C684.83,856.5100000000002,684.8800000000001,885.7800000000002,684.9100000000001,915.0400000000002C685.96,921.3000000000002,693.34,924.2700000000002,699.08,924.0200000000002C701.62,923.7000000000002,703.4200000000001,926.2700000000002,706,925.8400000000003C710.23,925.6900000000003,715.97,926.9600000000003,718.57,922.6300000000002C723.59,909.4800000000002,726.95,895.7600000000002,730.0300000000001,882.0500000000002C731.84,872.5200000000002,734.2600000000001,863.0300000000002,734.6100000000001,853.2900000000002C715.0900000000001,853.3800000000002,695.8300000000002,843.4900000000002,684.9300000000002,827.2500000000002Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="51" stroke = "#ffffff" fill = "#009966" d="M90.69000000000051,300.70000000000044C85.06000000000051,303.2800000000004,84.14000000000051,310.11000000000047,82.6800000000005,315.43000000000046C81.0400000000005,329.94000000000045,81.6300000000005,344.6000000000005,81.55000000000051,359.18000000000046C89.39000000000051,358.77000000000044,97.36000000000051,358.61000000000047,105.05000000000051,360.42000000000047C111.25000000000051,361.63000000000045,116.9300000000005,364.50000000000045,122.44000000000051,367.4700000000005C121.75000000000051,350.1400000000005,120.09000000000052,332.87000000000046,118.0400000000005,315.6400000000005C117.44000000000051,311.2600000000005,117.3700000000005,306.8300000000005,117.5000000000005,302.4100000000005C110.1400000000005,295.7900000000005,99.18000000000049,297.3500000000005,90.6900000000005,300.7000000000005Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="52" stroke = "#ffffff" fill = "#009966" d="M704.9500000000005,302.20000000000044C704.8900000000006,306.51000000000045,704.9600000000005,310.84000000000043,704.3400000000005,315.13000000000045C702.2400000000005,332.55000000000047,700.6900000000005,350.0300000000004,699.8000000000005,367.5400000000004C704.9100000000005,364.7300000000004,710.2000000000005,362.08000000000044,715.9200000000005,360.7800000000004C724.0200000000006,358.57000000000045,732.5100000000006,358.77000000000044,740.8300000000005,359.1600000000004C740.7800000000005,345.45000000000044,741.1100000000005,331.71000000000043,740.0000000000005,318.02000000000044C739.5400000000004,313.4700000000004,738.0900000000005,309.0500000000004,736.2000000000005,304.9100000000004C734.0500000000005,300.7200000000004,729.0900000000005,299.5500000000004,724.8700000000005,298.6900000000004C718.1600000000004,296.9300000000004,710.4200000000004,297.6200000000004,704.9500000000005,302.2000000000004Z"/>
|
||||
@@ -0,0 +1 @@
|
||||
<path onclick="clicked(evt)" class="2" stroke = "#ffffff" fill = "#009966" d = "M298.9900000000007,133.96000000000043C292.9800000000007,134.56000000000043,286.3500000000007,134.62000000000043,281.2800000000007,138.32000000000045C279.6100000000007,142.82000000000045,281.0300000000007,147.97000000000045,282.75000000000074,152.27000000000044C287.60000000000076,154.06000000000043,292.88000000000073,153.38000000000045,297.94000000000074,153.45000000000044C308.81000000000074,152.81000000000046,319.6400000000007,151.50000000000045,330.48000000000076,150.46000000000043C330.44000000000074,144.59000000000043,330.54000000000076,138.71000000000043,330.41000000000076,132.84000000000043C319.94000000000074,133.27000000000044,309.43000000000075,132.82000000000042,298.99000000000075,133.96000000000043Z"/>
|
||||