From a5bd7308b5d8afd611303e21df989cec5abfe16e Mon Sep 17 00:00:00 2001 From: vladtechno Date: Sun, 24 May 2026 00:16:44 +1000 Subject: [PATCH] feat(sto): activate Repair section in PWA (PR-4..PR-7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Активация раздела «Ремонт» в 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 --- mechanic-pwa/frontend/src/App.tsx | 13 + mechanic-pwa/frontend/src/api/me.ts | 10 + .../frontend/src/api/repairsCreate.ts | 136 ++++ mechanic-pwa/frontend/src/api/repairsFeed.ts | 38 + mechanic-pwa/frontend/src/hooks/useMeSync.ts | 121 ++++ .../frontend/src/hooks/useRepairsFeed.ts | 58 ++ mechanic-pwa/frontend/src/lib/timeFormat.ts | 59 ++ .../frontend/src/pages/ActionMenu.tsx | 6 +- mechanic-pwa/frontend/src/pages/Login.tsx | 106 ++- .../src/pages/repairs/CarSelectPage.tsx | 101 +++ .../src/pages/repairs/CreateRepairPage.tsx | 675 ++++++++++++++++++ .../src/pages/repairs/RepairsFeedPage.tsx | 228 ++++++ 12 files changed, 1539 insertions(+), 12 deletions(-) create mode 100644 mechanic-pwa/frontend/src/api/repairsCreate.ts create mode 100644 mechanic-pwa/frontend/src/api/repairsFeed.ts create mode 100644 mechanic-pwa/frontend/src/hooks/useMeSync.ts create mode 100644 mechanic-pwa/frontend/src/hooks/useRepairsFeed.ts create mode 100644 mechanic-pwa/frontend/src/lib/timeFormat.ts create mode 100644 mechanic-pwa/frontend/src/pages/repairs/CarSelectPage.tsx create mode 100644 mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx create mode 100644 mechanic-pwa/frontend/src/pages/repairs/RepairsFeedPage.tsx diff --git a/mechanic-pwa/frontend/src/App.tsx b/mechanic-pwa/frontend/src/App.tsx index 72df7f7..35982c6 100644 --- a/mechanic-pwa/frontend/src/App.tsx +++ b/mechanic-pwa/frontend/src/App.tsx @@ -1,5 +1,6 @@ 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"; @@ -7,6 +8,9 @@ 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); @@ -15,6 +19,11 @@ function RequireAuth({ children }: { children: React.ReactNode }) { } export default function App() { + // У35: фоновая сверка прав механика раз в 5 минут — выкидывает на /login + // с сообщением «Доступ отозван», если админ снял can_login_pwa или вывел + // пользователя из отдела механиков. + useMeSync(); + return ( } /> @@ -24,6 +33,10 @@ export default function App() { } /> } /> } /> + {/* Раздел «Ремонт» — активируется в PR-5 (Фича 1). */} + } /> + } /> + } /> } /> ); diff --git a/mechanic-pwa/frontend/src/api/me.ts b/mechanic-pwa/frontend/src/api/me.ts index d64137e..6ded9ff 100644 --- a/mechanic-pwa/frontend/src/api/me.ts +++ b/mechanic-pwa/frontend/src/api/me.ts @@ -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 { diff --git a/mechanic-pwa/frontend/src/api/repairsCreate.ts b/mechanic-pwa/frontend/src/api/repairsCreate.ts new file mode 100644 index 0000000..0d3bcce --- /dev/null +++ b/mechanic-pwa/frontend/src/api/repairsCreate.ts @@ -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 { + const qs = q ? `?q=${encodeURIComponent(q)}` : ""; + return api.get(`cars${qs}`).json(); +} + +export async function getCarContext(carId: number): Promise { + return api.get(`cars/${carId}/context`).json(); +} + +export async function searchWorks(q: string): Promise { + const qs = q ? `?q=${encodeURIComponent(q)}` : ""; + return api.get(`works${qs}`).json(); +} + +export async function suggestParts(q: string): Promise { + return api + .get(`parts/suggest?q=${encodeURIComponent(q)}`) + .json(); +} + +export async function presignPhoto(extension: string, photo_uuid?: string): Promise { + return api + .post("repairs/photos/presign", { + json: { extension, photo_uuid: photo_uuid ?? null }, + }) + .json(); +} + +/** + * Залить файл по presigned PUT. ВАЖНО: Content-Type ОБЯЗАН совпадать + * со значением, которое сервер вшил в подпись (см. У18 / sto_mechanic_form.py). + */ +export async function uploadPhotoToTmp(file: File | Blob, presign: PresignResponse): Promise { + 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 { + return api + .post("repairs", { + json: payload, + headers: { "Idempotency-Key": idempotencyKey }, + }) + .json(); +} diff --git a/mechanic-pwa/frontend/src/api/repairsFeed.ts b/mechanic-pwa/frontend/src/api/repairsFeed.ts new file mode 100644 index 0000000..2155c9b --- /dev/null +++ b/mechanic-pwa/frontend/src/api/repairsFeed.ts @@ -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 { + 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(); +} diff --git a/mechanic-pwa/frontend/src/hooks/useMeSync.ts b/mechanic-pwa/frontend/src/hooks/useMeSync.ts new file mode 100644 index 0000000..cfcf5b3 --- /dev/null +++ b/mechanic-pwa/frontend/src/hooks/useMeSync.ts @@ -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 }; diff --git a/mechanic-pwa/frontend/src/hooks/useRepairsFeed.ts b/mechanic-pwa/frontend/src/hooks/useRepairsFeed.ts new file mode 100644 index 0000000..04716ae --- /dev/null +++ b/mechanic-pwa/frontend/src/hooks/useRepairsFeed.ts @@ -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({ + 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, + }; +} diff --git a/mechanic-pwa/frontend/src/lib/timeFormat.ts b/mechanic-pwa/frontend/src/lib/timeFormat.ts new file mode 100644 index 0000000..f522131 --- /dev/null +++ b/mechanic-pwa/frontend/src/lib/timeFormat.ts @@ -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)}`; +} diff --git a/mechanic-pwa/frontend/src/pages/ActionMenu.tsx b/mechanic-pwa/frontend/src/pages/ActionMenu.tsx index 5b3a6b7..b3d43c6 100644 --- a/mechanic-pwa/frontend/src/pages/ActionMenu.tsx +++ b/mechanic-pwa/frontend/src/pages/ActionMenu.tsx @@ -1,6 +1,5 @@ import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; -import { toast } from "sonner"; import { getMe } from "@/api/me"; import { logout } from "@/api/auth"; import { Card } from "@/components/ui/card"; @@ -31,10 +30,9 @@ export default function ActionMenu() { { key: "repair", title: "Ремонт", - description: "Регистрация ремонта (в разработке)", + description: "Лента ремонтов парка + создать новый", emoji: "🔧", - onClick: () => toast.info("Раздел «Ремонт» в разработке"), - comingSoon: true, + onClick: () => navigate("/repairs"), }, ]; diff --git a/mechanic-pwa/frontend/src/pages/Login.tsx b/mechanic-pwa/frontend/src/pages/Login.tsx index b516449..da5e692 100644 --- a/mechanic-pwa/frontend/src/pages/Login.tsx +++ b/mechanic-pwa/frontend/src/pages/Login.tsx @@ -1,9 +1,17 @@ -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, 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"; @@ -22,6 +30,49 @@ export default function Login() { const [code, setCode] = useState(""); const [rememberDevice, setRememberDevice] = useState(true); + // У36: показываем явное сообщение «доступ отозван» с кликабельным контактом, + // если пользователь только что был выкинут из приложения фоновой сверкой (У35) + // или получил 403 NO_PWA_ACCESS при последней попытке логина. + const [revocation, setRevocation] = useState(() => 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) => { @@ -32,12 +83,10 @@ export default function Login() { return; } if (data.access_token) { - setToken(data.access_token); - toast.success("Вход выполнен"); - navigate("/", { replace: true }); + // У36: проверяем доступ к PWA через /me перед навигацией. + void verifyPwaAccessAndNavigate(data.access_token); return; } - // Defensive: shouldn't happen toast.error("Неожиданный ответ сервера"); }, onError: () => toast.error("Неверный логин или пароль"), @@ -47,9 +96,7 @@ export default function Login() { mutationFn: login2fa, onSuccess: (data) => { if (data.access_token) { - setToken(data.access_token); - toast.success("Вход выполнен"); - navigate("/", { replace: true }); + void verifyPwaAccessAndNavigate(data.access_token); } else { toast.error("Сервер не вернул токен"); } @@ -62,6 +109,49 @@ export default function Login() {

Premium Механик

+ {revocation && ( +
+
У вас нет доступа к PWA Premium Мехник
+
+ Обратитесь к старшему механику или администратору CRM + {(() => { + const tg = revocation.support?.tg_username; + const phone = revocation.support?.phone; + if (tg) { + return ( + <> + {": "} + + @{tg} + + {phone ? ( + <> + {" · "} + {phone} + + ) : null} + + ); + } + if (phone) { + return ( + <> + {": "} + {phone} + + ); + } + return "."; + })()} +
+
+ )} + {step.kind === "credentials" && ( <>

diff --git a/mechanic-pwa/frontend/src/pages/repairs/CarSelectPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/CarSelectPage.tsx new file mode 100644 index 0000000..17b3e96 --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/repairs/CarSelectPage.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +

+
+
+ +

Выберите машину

+
+
+
+ + setQ(e.target.value)} + className="pl-8" + /> +
+
+
+
+ {error && ( +
+ {error} +
+ )} + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ ) : cars.length === 0 ? ( +
+ {q ? "Ничего не найдено" : "Нет активных машин в парке"} +
+ ) : ( +
    + {cars.map((c) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx new file mode 100644 index 0000000..b9027db --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx @@ -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(uuid4()); + + const [ctx, setCtx] = useState(null); + const [ctxError, setCtxError] = useState(null); + + const [mileage, setMileage] = useState(null); + const [photos, setPhotos] = useState([]); + const [works, setWorks] = useState([]); + const [parts, setParts] = useState([]); + const [oilChange, setOilChange] = useState({ + 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 ( +
+
+ {ctxError} +
+
+ ); + } + if (!ctx) { + return ( +
+
Загрузка…
+
+ ); + } + + return ( +
+
+
+ +

Новый ремонт

+ +
+
+
{ctx.plate}
+
+ {[ctx.brand, ctx.model].filter(Boolean).join(" ")} + {ctx.driver_name ? ` · ${ctx.driver_name}` : ` · Без водителя`} +
+
+
+ +
+ {/* Пробег */} +
+ + setMileage(e.target.value === "" ? null : Number(e.target.value))} + placeholder={ctx.mileage_starline == null ? "StarLine недоступен — введите вручную" : ""} + className="mt-1" + /> +
+ + {/* Фото */} +
+
+ + + {photos.length === 0 ? "Минимум одно фото" : `${photos.length} шт.`} + +
+
+ {photos.map((p) => ( +
+ {p.status === "uploading" && ( +
Загрузка…
+ )} + {p.status === "error" && ( +
+ {p.errorMessage || "Ошибка"} +
+ )} + {p.status === "uploaded" && ( +
+ ✓ Загружено +
+ )} + +
+ ))} + +
+
+ + {/* Работы */} +
+
+ + +
+ {works.length === 0 ? ( +
Минимум одна работа
+ ) : ( +
    + {works.map((w) => ( +
  • +
    +
    +
    {w.name}
    +
    + Прайс: {w.price_catalog} ₽ +
    +
    +
    + + setWorks((arr) => + arr.map((row) => + row.rowKey === w.rowKey + ? { ...row, price_applied: Number(e.target.value) } + : row, + ), + ) + } + className="w-24" + /> + + +
    +
    + {w.unlocked && ( +