Дата в выписке и в списке штрафов встала первой строкой чернильным цветом: в
этих списках ищут «что было 12-го», а бледная подпись под заголовком для этого
не годилась.
Строка штрафа в выписке стала кнопкой в карточку — там фотография и подробности.
Остальные строки не притворяются ссылками.
Раздел «Мои пополнения» удалён вместе с кнопкой: он показывал только наши
платежи и у большинства водителей был пуст (1С проводит пополнения сама), а те
же операции видны в выписке фильтром «Пополнения».
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Светлый сине-серый фон #EEF1F5, белые карточки, чернильный текст #0F172A,
фирменный красный акцент #DC2626 у кнопки действия, зелёный #059669 у прихода
денег, янтарный #D97706 у предупреждений — токены сняты с живого сайта
(переменные --s-* в его инлайн-CSS).
Имена токенов теперь роли, а не краски: canvas / surface / line / ink / muted /
accent / pos / neg / warn. Прежние cream и ink описывали конкретный цвет, и при
смене палитры «cream» стал бы синим.
Тема браузера и манифест PWA переведены на новый фон — иначе шторка адресной
строки и заставка запуска остаются от прежней темы.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
С карточки убраны строки про номинал постановления, скидку и «в ГИБДД
постановление закрыто», из списка — абзац о том, кто и как платит ГИБДД.
Остаётся то, ради чего экран делался: фотография нарушения, статья, место,
скорость, начисленная сумма и погашен ли долг.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Слово «оплачен» водитель читает как «я ничего не должен», а постановление к тому
моменту оплачено парком — не им. Поэтому в списке и карточке состояние теперь
«не погашен / погашен / не выставлялся / уточняется», долг в шапке берётся из
баланса 1С, а карточка объясняет расхождение сумм: «постановление — 750 ₽, парк
оплатил его со скидкой и выставил вам фактическую сумму».
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Список с вкладками «Неоплаченные / Все» и суммой долга в шапке; в карточке —
фотография с камеры первой, до реквизитов: по ней водитель узнаёт свою машину и
свою поездку. Дальше сумма, скидка с датой (то, ради чего заходят вовремя),
скорость и разрешённый предел, постановление, камера, ссылка на PDF.
Строка «Долг по штрафам» на главном экране теперь ведёт сюда, а не сразу к
оплате: платить, не поняв за что, человек не станет. Аренда по-прежнему ведёт к
оплате — за тем её и открывают.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
За неделю у активного водителя два десятка строк, и половина одинаковая по виду
(«аренда автомобиля» каждый день) — список сливается в стену текста. Значок даёт
зацепку для глаза: нужное находится, не читая каждую строку.
Подбор по тегу 1С, а не по знаку суммы: аренда, пени, штраф, ДТП, депозит,
пополнение, компенсация, коррекция — у каждого свой. Общий компонент, как в
складе CRM: разложенные по местам иконки разъезжаются размерами при первой правке.
Монохромные намеренно — цвет в этом списке уже занят и означает направление
денег, цветной значок с ним бы спорил.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Две статьи, которые водитель проверяет каждый день: за них он платит и из-за них
ему звонят. Стоят под итогом, нажатие ведёт сразу к оплате этой статьи — чаще
всего человек за тем и открыл приложение, а заход в шторку это лишний шаг.
Остальные статьи (депозит, повреждения, прочее) на экран не выносим: они редкие,
им хватает шторки, иначе экран снова превращается в столбик из шести строк и
выписка уезжает за нижний край телефона.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
По образцу приложения вендора: баланс в парке и «зачисляется», под ними крупная
кнопка «Пополнить», ниже — период с календарями, фильтры и список операций
(аренда, пени, штрафы, пополнения).
Разбивка по статьям переехала в шторку «Пополнить». Она нужна ровно в момент
выбора, куда платить; всё остальное время это шесть строк, отодвигающих выписку
за нижний край телефона. В шторке только те статьи, в которые платёж проводится.
Экран суммы, придя из шторки, показывает выбранную категорию строкой, а не рядом
чипов: второй выбор того же самого сбивает с толку.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Итог берётся с сервера, а не складывается из строк (1С округляет статьи до
рублей, итог держит с копейками: −8 976 против −8 976,25). Статья, в которую
платёж провести нечем, показывается, но не притворяется кнопкой — «Страховка».
Пустой снимок 1С даёт «Уточняется» вместо нулей.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Второй шаг входа сотрудника не отправлялся вовсе: у поля с кодом не было ни
обработки Enter, ни автоотправки, а «Готово» на клавиатуре телефона — это как
раз Enter. Со стороны выглядело как «код не принят», хотя запрос на /auth/2fa
не уходил ни разу (в логах бэкенда — только login с ответом «нужен второй шаг»).
Теперь шесть цифр отправляются сразу, Enter тоже работает, поле получает фокус и
autocomplete=one-time-code (подсказка кода из СМС/менеджера паролей).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Сотрудник, вошедший водителем, при переключении на чужую карточку терял
собственный вход: токен один на приложение, и «посмотреть, как выглядит у
другого» означало заново входить через Telegram.
Теперь свой сеанс откладывается и возвращается кнопкой «Вернуться к своему
аккаунту». Откладывается ровно один раз — на переходе из обычного сеанса:
смена водителя внутри просмотра не должна подменять отложенное чужим токеном.
Заодно вход для сотрудника достижим изнутри приложения: длинный тап по названию
в шапке ведёт на /dev. Ссылкой на экране это не делаем — водителю она ни к чему,
а сотруднику иначе приходится набирать адрес руками.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Приложение обкатывается на боевом контуре, поэтому интерфейс надо проверять на
живых данных — а живые данные лежат у настоящих водителей.
* «Вход для сотрудника» (/dev) — логин учётки CRM, поддержан второй шаг 2FA;
дальше поиск водителя по ФИО/телефону/id и просмотр его экранов.
* Плашка «Смотрю как …» над всеми экранами: интерфейс в этом режиме неотличим
от водительского, и чужой баланс легко принять за свой.
* Вкладка «Отладка» (/debug, водителю недоступна): смена водителя, driver_id,
режим, срок токена, адрес API, коммит и время сборки, журнал последних
40 ответивших запросов, сброс кэша сервис-воркера.
* Пополнение под просмотром идёт в учебный контур: экран /mock-pay/:orderId
выбирает, что «ответит» банк, а настоящий экран оплаты разбирает эти статусы
своим боевым кодом. Ни рубля, ни чека 54-ФЗ.
Заодно доезжает незакоммиченная работа прошлой сессии: дев-прокси в
vite.config.ts, его тест и разобранный .env.example.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- pairing-сессия хранит poll_secret и confirm_code (lib/pairing), опрос ушёл
на POST: pair_session уезжал в query-параметре и оседал в access-логах, а
это половина ключа к JWT. Вторая половина в deep-link не уходит;
- на экране ожидания видно число, которое обязан назвать бот. По чужой ссылке
человек его не увидит — подтверждать нечего, токен не выдаётся;
- онбординг больше не просит вернуться и запросить код: Telegram подтверждает
номер шарингом контакта, приложение забирает токен опросом;
- суммы с копейками. formatMoney округлял до рубля, и «к оплате 350 ₽»
расходилось и со списанием банка, и с чеком (комиссия почти всегда даёт
копейки). Клавиша разделителя, нарисованная на клавиатуре, наконец работает;
- категорию долга выбирает водитель. Хардкод «Долг аренда» отправлял платёж
в аренду даже тем, у кого её нет; список приходит с сервера и там же
проверяется;
- ошибки бэкенда показываются словами (минимум СБП, потолок суммы) вместо
молчаливого «не удалось», статусы истории переведены полностью, возврат
денег отличим от отказа.
Тесты: 33 зелёных, tsc чист.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Задача CRM 4439. Плитка в меню действий → поиск по госномеру → список ремонтов
машины (дата, госномер, модель, пробег, состав работ) → существующая карточка
ремонта. Вход в историю есть и прямо из карточки авто, там же блок последних
ремонтов.
Что стоило внимания:
- поиск ищет и по машинам, выведенным из парка (их ремонты никуда не делись,
и в осмотрах такая машина видна), но не по проданным — парк ими не занимается.
Текст пустого результата говорит ровно это: механик с опечаткой в номере
делает вывод по этой строке, и она обязана совпадать с поведением бэкенда;
- сбой сети отличим от «ничего не нашли», и под ошибкой не остаётся выдача
прошлого запроса — иначе можно тапнуть и уехать в историю чужой машины;
- «Назад» возвращает туда, откуда пришли (лента, история машины, карточка авто),
а не в ленту всего парка. Прямая ссылка при пустом стеке не запирает экран;
- форма правки больше не оставляет лишнюю запись в стеке: до этого «Назад» из
карточки ремонта после «Отмены» возвращал обратно в форму правки;
- история и блок в карточке авто запрашивают ленту без фото — превью там не
рисуется, а бэкенд ради него лазил в хранилище на каждую строку.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Экран успеха рисовался только при PAID. При любом другом финальном статусе
опрос останавливался, а человек навсегда оставался на «Ожидаем оплату…» со
спиннером — то есть два из трёх тестовых сценариев СБП глазами не проверить.
- ветки отказа (в том числе исчерпанных попыток), истёкшего срока и возврата
средств: у возврата свой текст, новая попытка там ни при чём;
- ошибка запроса больше не выглядит как честное ожидание: после серии неудач
подряд экран говорит, что не может проверить оплату, и даёт повторить.
Раньше 404, 500 и рестарт бэкенда были неотличимы от «ждём оплату»;
- плашка способа оплаты показывала «СБП» и при оплате картой;
- после перезагрузки страницы сумма бралась из пустого router state и
показывалась как 0 ₽ — лучше не показать ничего, чем показать неправду.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
window.location.href=deep_link navigated the whole PWA to t.me, destroying the
/tg-wait polling screen -> the authenticated token was never fetched. Prefetch
the pairing session and open Telegram via an <a href> (OS app-switch, PWA stays
mounted polling); add an Open-Telegram fallback link on the wait screen.
Co-Authored-By: claude-flow <ruv@ruv.net>
Backend omits channels without a configured username; strict zod schema
threw on missing 'max', surfacing as 'Не удалось отправить код' instead of
routing to the onboarding screen. Make both optional + regression test.
Co-Authored-By: claude-flow <ruv@ruv.net>
MechanicsField (чипы + пикер ростера) в Create/Edit; mechanic_ids в payload;
RepairDetail показывает работавших + долю 1/N. Ростер: GET /v1/mechanic/mechanics-roster.
Co-Authored-By: claude-flow <ruv@ruv.net>
The two repair forms genuinely diverged in UX (inline price-unlock + photos
vs modal override, no photos), so a single merged form would be worse. The
real duplication was the works-catalog picker modal — extracted into one
tested presentational component, removing ~75 duplicated lines.
Co-Authored-By: claude-flow <ruv@ruv.net>
Header 'Итого' (sum of loaded items) -> 'За этот месяц' (sum + count
from 1st VLAT to now) via new /v1/mechanic/repairs/stats/month endpoint.
List now grouped by local day with a dashed separator showing date
('Сегодня' / 'Вчера' / '26 мая') + day total + repair count.
Bundle: index-BKRX_okx.js
В каждой карточке ленты ремонтов справа от строки работ выводим сумму
работ (works_total из бэка, без запчастей). Шапка над лентой переехала:
слева «Итого: NNN ₽ (X ремонтов)», справа кнопка «Обновить» (раньше
была одна сверху-справа).
В CreateRepairPage поле «Цена применённая» хранило цифру через
price_applied: number. При удалении символов React сразу подставлял 0
и его нельзя было стереть — приходилось каретку перед нулём ставить
руками. Поменяли тип на number | "" в WorkRow и в onChange оставляем
пустую строку в стейте, пока юзер не ввёл новую цифру. На submit и в
сумму "" трактуется как 0 (Number("") === 0).
EditRepairPage не задет — там цена правится через модалку, цена там
уже жила как string (useState(String(...))) и пустое было допустимо.
При создании/редактировании ремонта в форме был отдельный «Пробег на
момент замены» внутри блока замены масла — auto-sync от шапочного с
soft-dirty флагом по У24. На практике это всё равно заставляло механика
вводить значение дважды, если шапочный пробег вводился вручную (StarLine
недоступен): момент включения галочки случался после ручного ввода,
soft-sync не успевал отработать или механик не понимал что поле
автозаполнилось.
Упростили: единственный пробег — «Пробег (км)» в шапке формы. На submit
он же подставляется в oil_change.mileage_at_change. Поле «Следующая через»
оставили — это интервал, а не значение пробега.
Бэкенд не трогаем — схема та же (mileage_at_change всё ещё в payload).
В CreateRepairPage queryPartSuggestions после await перезаписывал
partsDraft.name значением q из момента старта запроса. При быстром вводе
("К" → "Ке" → "Кеп" → "Кепу") поздно вернувшийся ответ для "К" откатывал
поле обратно на "К" — пользователи это видели как «текст частично сам
стирается». Делаем функциональный setState и роняем ответ если name уже
другой. EditRepairPage не задет (там корректный паттерн).
Юзер обнаружил, что при поиске работ (worksPicker в Create/EditRepairPage)
шапка с инпутом всё ещё уезжает под status-bar. Эти модалки и аналоги
(PhotoAnnotateStep, ZoneDetailView, InspectionEditor) используют
fixed inset-0 — моё прежнее правило для .fixed.top-0 их не покрывало.
Добавил отдельный селектор для .fixed.inset-0.bg-background[/\95];
bg-black/40·50·90 (диалоги, фото-вьюверы) намеренно не задеты.
В iOS PWA при viewport-fit=cover и apple-mobile-web-app-status-bar-style=
black-translucent статус-бар прозрачно накладывается на шапки страниц
(пользователи жаловались скринами с overlap «11:37» поверх «Ремонт #12»).
Body теперь имеет padding-top/bottom env(safe-area-inset-*) — fix initial-
рендер и нижних кнопок. Для sticky/fixed .top-0 хедеров отдельно overrid'им
top: env(safe-area-inset-top), т.к. sticky прилипает к top:0 viewport, а
не к содержимому body. Селектор узкий (.sticky.top-0 / .fixed.top-0) —
не задевает .fixed.inset-0 полноэкранные модалки.
Активация раздела «Ремонт» в Premium Мехник PWA: лента + создание ремонта,
фоновая сверка прав (У35), различающие сообщения при отказе входа (У36).
Backend для всех новых API в taxi-dashboard (отдельный commit eccaebe).
Тулится на `ENABLE_STO_MODULE=true` в backend env.
PR-4: Login + useMeSync.
- src/api/me.ts: тип Me расширен (is_admin, can_login_pwa, departments,
support — для У35/У36).
- src/hooks/useMeSync.ts: фоновая сверка `/me` раз в 5 мин (У35). При
403 NO_PWA_ACCESS — сброс токена, revocation info в sessionStorage.
IndexedDB-черновики не трогаются.
- src/pages/Login.tsx: после успешного login делает getMe() и при 403
показывает баннер «У вас нет доступа» с кликабельным TG/телефонным
контактом (У36). Баннер также рендерится при revocation после
фоновой сверки.
- src/App.tsx: useMeSync() подключён в корне.
PR-5: лента ремонтов.
- src/pages/ActionMenu.tsx: снят `comingSoon` с тайла «Ремонт».
- src/pages/repairs/RepairsFeedPage.tsx: лента с infinite scroll (через
IntersectionObserver), плейсхолдер «В парке пока нет ремонтов» (У41),
ellipsis-имена работ + плюрализация «+N работ» (У40).
- src/hooks/useRepairsFeed.ts: useInfiniteQuery + stale-while-revalidate
+ visibility refresh (У38).
- src/lib/timeFormat.ts: формат «Сегодня / Вчера / 23 мая» (У39).
- src/api/repairsFeed.ts: GET /api/v1/mechanic/repairs клиент.
PR-7: создание ремонта (online-only).
- src/api/repairsCreate.ts: searchCars, getCarContext, searchWorks,
suggestParts, presignPhoto + uploadPhotoToTmp (PUT с правильным
Content-Type), createRepair (POST с Idempotency-Key).
- src/pages/repairs/CarSelectPage.tsx: поиск машины с debounce 200мс.
- src/pages/repairs/CreateRepairPage.tsx: карточка создания ремонта с 5
inline-блоками (Шапка / Пробег / Фото / Работы / Запчасти / Замена
масла). UUID v4 как Idempotency-Key. Карандашик-разблокировка цены
с обязательным комментарием (У19). Мягкая подписка пробега масла
на шапочный (У24). Дефолт next_in_km=10000 (У25). Полноэкранная
модалка поиска работ.
Routes: /repairs, /repairs/new, /repairs/new/:carId.
Co-Authored-By: claude-flow <ruv@ruv.net>
Previously slotsForType excluded all general slots for return-type
inspections and left only Домкрат, on the assumption that the car is
already familiar and приёмка should focus on damages/equipment. Feedback
from mechanics: при приёмке всё равно нужно фиксировать общее состояние
машины, иначе споры с водителем теряют доказательную базу. Now все типы
осмотров показывают одинаковый набор слотов.
Co-Authored-By: claude-flow <ruv@ruv.net>
Backend search now matches only on plate, so reflect that in the input
placeholder instead of misleading 'Поиск по номеру или VIN'.
Co-Authored-By: claude-flow <ruv@ruv.net>
Раньше textarea для заметок жил в самом низу страницы, после фото-сетки
и списка меток — на плановом осмотре приходилось много скроллить чтобы
добраться до основного поля ввода.
Перенёс Card 'Заметки механика' сразу под Card 'Схема'. На плановом осмотре
текстовое поле теперь второе на странице — не надо листать.
Раньше textarea для заметок жил только в InspectionReview (завершённый
осмотр). При создании нового планового осмотра (через '/inspections/<id>/edit')
поля ввода не было — приходилось завершить осмотр, потом открыть карточку
и только там вводить.
Теперь блок есть в обеих страницах:
- InspectionEditor (in_progress): textarea + кнопки Сохранить/Отменить
- InspectionEditor (completed): read-only вывод текста
- InspectionReview: оставлен прежний редактор как был
Бонус: при нажатии «Завершить осмотр» текущий draft заметки коммитится
тем же PATCH вместе со status=completed — не теряется если механик
ввёл текст и сразу нажал завершение, не сохранив отдельно.
VehicleSummary.status (cars_v2.element_status) рендерится как цветной
pill справа от гос.номера. Палитра по семантике:
emerald — На линии (норма)
amber — Ремонтируется / Ждет ремонта
red — ДТП
blue — Бронь / Выкуп / На продаже / Подготовка на продажу / Ждет
доставки до офиса (admin-операции)
muted — Простой и неизвестные статусы
Чип появляется в Home (списке машин) и в шапке VehicleCard. Backend
сторона — PremiumCRM 92d4317.
TtStateSummary.driver_id + driver_name добавлены в TS-тип. В строке
каждого Element-state'а под датой/пробегом теперь выводится '👤 ФИО'
если backend подтянул водителя. Логика идентична нашим осмотрам — общий
helper drivers_at_dates на backend (см. PremiumCRM aa55431).
Под датой/пробегом каждого осмотра добавлена строка «👤 ФИО» если backend
вернул driver_name (рассчитан как rental, активный на момент started_at —
см. PremiumCRM 4700314). Раньше механик/менеджер не мог быстро понять, у
кого мы принимали машину — приходилось открывать VDS-карточку.
InspectionSummary.driver_id + driver_name добавлены в TS-тип.
1. Сверху на /inspections/<id> добавлена кликабельная карточка машины:
гос.номер крупно + марка/модель/год + VIN. Использует новое поле
ins.vehicle, которое backend теперь возвращает в InspectionDetail
(см. PremiumCRM коммит a9c94d0). Клик ведёт на /vehicles/<id>.
2. Блок «Заметки механика» — textarea с локальным draft'ом и кнопкой
«Сохранить» (видна только если draft отличается от ins.notes). Под
капотом — PATCH /api/v1/mechanic/inspections/<id> с {notes}.
TG-импортированные осмотры из «Подготовка авто» уже приезжают с
заполненными notes (caption сообщения становится телом заметки), и
теперь механик может дополнить/исправить их прямо в карточке.
3. Бонус: добавлен показ пробега (mileage) в первой info-карте.
Counter «Фото (N)» по-прежнему учитывает TT carry-over (поэтому на 84-м
осмотре всё ещё 16), но карточки в гриде остаются только для наших MinIO-
фото + PWA-маркерных фото. TT-thumbnail рисуется один раз — inline под
своим маркером в списке.
1. Photo counter теперь учитывает уникальные tt_image_id из markers, не только
ins.photos + photo_id. На осмотрах, где почти все маркеры пришли из Element
carry-over, счётчик показывал 1 вместо 15+ — теперь корректно.
2. В галерее «Фото» добавлена секция карточек для TT-image carry-over (label
«из Element»). Картинки тянутся через ttImageUrl(hash), кликабельны.
3. Lightbox state поменялся с `number | null` на дискриминированный union
{ kind: 'photo', id } | { kind: 'tt', ttId }. Полноразмерное превью
выбирает между AuthImg (наши MinIO-фото) и обычным <img> (Element).
4. Превью маркеров в секции «Метки» теперь кликабельны (раньше только грид
с основными фото открывал lightbox). Работает и для photo_id, и для
tt_image_id маркеров.
Backend-зависимость: MarkerOut.tt_image_id (Pydantic) и
MechanicDamageMarker.tt_image_id (ORM) добавлены в PremiumCRM коммитом
d405e17 на crm.pptaxi.ru — без них фронт получает tt_image_id=undefined.
Single-screen grid layout showing all 5 PNG views + 4 bumper corner sun-zones + decorative
wheels at once. Tap any view for precise AddMarkerDialog; tap bumper corners for center-point
marker; global Tire Wear button opens new TireWearDialog (4-level radio cards).
Deletes MultiViewVehicleScheme. No backend changes. humanizeMarkerSide covers bumper-* and tires.
Co-Authored-By: claude-flow <ruv@ruv.net>
- Replace SVG sedan zone-click model with 5-view PNG scheme (top/front/back/left/right)
- New MultiViewVehicleScheme: tab selector, tap-to-exact-coord marker placement,
touchAction:manipulation prevents iOS double-tap zoom, marker dots at normalized x/y
- New AddMarkerDialog: bottom-sheet modal with damage_type (16 opts), severity (4 opts),
description, optional CameraCapture → uploadPhoto(side="free") → photo_id on submit
- InspectionEditor: removes addZoneMarker mutation and zone-count machinery; wires
pendingTap state → dialog open/close → query invalidation
- InspectionReview: adds read-only MultiViewVehicleScheme; both pages show photo_id
thumbnails via AuthImg in the marker list
- humanizeMarkerSide updated for new view names; keeps zone-* legacy mapping
- Deletes VehicleScheme.tsx and sedan-top.svg (no remaining imports)
- No backend changes, no new dependencies
Co-Authored-By: claude-flow <ruv@ruv.net>
Wire frontend to backend 2FA contract: two-step state machine in Login.tsx
(credentials → totp), new login2fa() call to POST /api/auth/2fa, credentials:include
on both fetches so the 30-day trusted_device cookie travels correctly.
Co-Authored-By: claude-flow <ruv@ruv.net>
- api/auth.ts: OAuth2 password flow via form-urlencoded POST /api/auth/login (not mechanic prefix)
- api/me.ts: GET /api/v1/mechanic/me with Me interface
- api/vehicles.ts: listVehicles (unwraps {items:[]}), getVehicle with VehicleDetail + InspectionSummary
- api/inspections.ts: createInspection returning full InspectionDetail; 9-value InspectionType union
- Login.tsx: full form with Логин/Пароль labels, useMutation, toast feedback, navigate on success
- Home.tsx: vehicle list with search, /me header with user name, logout, TanStack Query
- VehicleCard.tsx: vehicle info card, all 9 inspection type buttons (ru labels), history list
Co-Authored-By: claude-flow <ruv@ruv.net>