feat(sto): activate Repair section in PWA (PR-4..PR-7)

Активация раздела «Ремонт» в Premium Мехник PWA: лента + создание ремонта,
фоновая сверка прав (У35), различающие сообщения при отказе входа (У36).

Backend для всех новых API в taxi-dashboard (отдельный commit eccaebe).
Тулится на `ENABLE_STO_MODULE=true` в backend env.

PR-4: Login + useMeSync.
- src/api/me.ts: тип Me расширен (is_admin, can_login_pwa, departments,
  support — для У35/У36).
- src/hooks/useMeSync.ts: фоновая сверка `/me` раз в 5 мин (У35). При
  403 NO_PWA_ACCESS — сброс токена, revocation info в sessionStorage.
  IndexedDB-черновики не трогаются.
- src/pages/Login.tsx: после успешного login делает getMe() и при 403
  показывает баннер «У вас нет доступа» с кликабельным TG/телефонным
  контактом (У36). Баннер также рендерится при revocation после
  фоновой сверки.
- src/App.tsx: useMeSync() подключён в корне.

PR-5: лента ремонтов.
- src/pages/ActionMenu.tsx: снят `comingSoon` с тайла «Ремонт».
- src/pages/repairs/RepairsFeedPage.tsx: лента с infinite scroll (через
  IntersectionObserver), плейсхолдер «В парке пока нет ремонтов» (У41),
  ellipsis-имена работ + плюрализация «+N работ» (У40).
- src/hooks/useRepairsFeed.ts: useInfiniteQuery + stale-while-revalidate
  + visibility refresh (У38).
- src/lib/timeFormat.ts: формат «Сегодня / Вчера / 23 мая» (У39).
- src/api/repairsFeed.ts: GET /api/v1/mechanic/repairs клиент.

PR-7: создание ремонта (online-only).
- src/api/repairsCreate.ts: searchCars, getCarContext, searchWorks,
  suggestParts, presignPhoto + uploadPhotoToTmp (PUT с правильным
  Content-Type), createRepair (POST с Idempotency-Key).
- src/pages/repairs/CarSelectPage.tsx: поиск машины с debounce 200мс.
- src/pages/repairs/CreateRepairPage.tsx: карточка создания ремонта с 5
  inline-блоками (Шапка / Пробег / Фото / Работы / Запчасти / Замена
  масла). UUID v4 как Idempotency-Key. Карандашик-разблокировка цены
  с обязательным комментарием (У19). Мягкая подписка пробега масла
  на шапочный (У24). Дефолт next_in_km=10000 (У25). Полноэкранная
  модалка поиска работ.

Routes: /repairs, /repairs/new, /repairs/new/:carId.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-05-24 00:16:44 +10:00
co-authored by claude-flow
parent 9b4aa504ec
commit a5bd7308b5
12 changed files with 1539 additions and 12 deletions
+13
View File
@@ -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 (
<Routes>
<Route path="/login" element={<Login />} />
@@ -24,6 +33,10 @@ export default function App() {
<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>
);
+10
View File
@@ -1,10 +1,20 @@
import { api } from "./client";
export interface PwaSupportContact {
tg_username: string | null;
phone: string | null;
}
export interface Me {
id: number;
name: string;
email?: string | null;
permissions: string[];
// Модуль СТО (У10 + У34) — расширение в backend PR-4.
is_admin: boolean;
can_login_pwa: boolean;
departments: string[];
support: PwaSupportContact;
}
export async function getMe(): Promise<Me> {
@@ -0,0 +1,136 @@
import { api } from "./client";
export interface CarBrief {
id: number;
plate: string;
brand: string | null;
model: string | null;
}
export interface CarContext {
id: number;
plate: string;
brand: string | null;
model: string | null;
mileage_starline: number | null;
driver_id: number | null;
driver_name: string | null;
}
export interface MechWork {
id: number;
name: string;
category_id: number;
price: number | string;
norma_hours: number | string | null;
}
export interface PartSuggestion {
name: string;
}
export interface PresignResponse {
photo_uuid: string;
extension: string;
content_type: string;
key: string;
upload_url: string;
expires_in: number;
}
export interface CreatePhotoPayload {
photo_uuid: string;
extension: string;
tag: "auto" | "part" | "other";
sort_order: number;
}
export interface CreateWorkPayload {
work_catalog_id: number;
price_applied: number;
manual_override: boolean;
override_comment: string | null;
}
export interface CreatePartPayload {
name: string;
qty: number;
}
export interface CreateOilChangePayload {
enabled: boolean;
mileage_at_change: number | null;
next_in_km: number;
sticker_photo_uuid: string | null;
sticker_extension: string | null;
}
export interface CreateRepairPayload {
car_id: number;
mileage: number | null;
works: CreateWorkPayload[];
parts: CreatePartPayload[];
photos: CreatePhotoPayload[];
oil_change: CreateOilChangePayload | null;
}
export interface CreateRepairResponse {
id: number;
created_at_iso: string;
tg_outbox_status: string;
}
export async function searchCars(q: string): Promise<CarBrief[]> {
const qs = q ? `?q=${encodeURIComponent(q)}` : "";
return api.get(`cars${qs}`).json<CarBrief[]>();
}
export async function getCarContext(carId: number): Promise<CarContext> {
return api.get(`cars/${carId}/context`).json<CarContext>();
}
export async function searchWorks(q: string): Promise<MechWork[]> {
const qs = q ? `?q=${encodeURIComponent(q)}` : "";
return api.get(`works${qs}`).json<MechWork[]>();
}
export async function suggestParts(q: string): Promise<PartSuggestion[]> {
return api
.get(`parts/suggest?q=${encodeURIComponent(q)}`)
.json<PartSuggestion[]>();
}
export async function presignPhoto(extension: string, photo_uuid?: string): Promise<PresignResponse> {
return api
.post("repairs/photos/presign", {
json: { extension, photo_uuid: photo_uuid ?? null },
})
.json<PresignResponse>();
}
/**
* Залить файл по presigned PUT. ВАЖНО: Content-Type ОБЯЗАН совпадать
* со значением, которое сервер вшил в подпись (см. У18 / sto_mechanic_form.py).
*/
export async function uploadPhotoToTmp(file: File | Blob, presign: PresignResponse): Promise<void> {
const res = await fetch(presign.upload_url, {
method: "PUT",
headers: { "Content-Type": presign.content_type },
body: file,
});
if (!res.ok) {
throw new Error(`Photo upload failed: ${res.status} ${res.statusText}`);
}
}
export async function createRepair(
payload: CreateRepairPayload,
idempotencyKey: string,
): Promise<CreateRepairResponse> {
return api
.post("repairs", {
json: payload,
headers: { "Idempotency-Key": idempotencyKey },
})
.json<CreateRepairResponse>();
}
@@ -0,0 +1,38 @@
import { api } from "./client";
export interface FeedWorkBrief {
name: string;
}
export interface FeedItem {
id: number;
created_at: string; // ISO
car_plate: string;
car_make: string | null;
car_model: string | null;
driver_name: string | null;
mechanic_name: string;
works_preview: FeedWorkBrief[];
works_extra: number;
photo_url: string | null;
}
export interface FeedResponse {
items: FeedItem[];
next_cursor: string | null;
}
export interface FeedParams {
limit?: number;
cursor?: string | null;
}
export async function getRepairsFeed(params: FeedParams = {}): Promise<FeedResponse> {
const search = new URLSearchParams();
if (params.limit) search.set("limit", String(params.limit));
if (params.cursor) search.set("cursor", params.cursor);
const qs = search.toString();
return api
.get(`repairs${qs ? `?${qs}` : ""}`)
.json<FeedResponse>();
}
@@ -0,0 +1,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)}`;
}
@@ -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"),
},
];
+98 -8
View File
@@ -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<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) => {
@@ -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() {
<div className="w-full max-w-sm bg-card rounded-2xl shadow-sm p-6 space-y-4">
<h1 className="text-2xl font-semibold text-foreground">Premium Механик</h1>
{revocation && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
<div className="font-medium mb-1">У вас нет доступа к PWA Premium Мехник</div>
<div>
Обратитесь к старшему механику или администратору CRM
{(() => {
const tg = revocation.support?.tg_username;
const phone = revocation.support?.phone;
if (tg) {
return (
<>
{": "}
<a
href={`https://t.me/${tg}`}
target="_blank"
rel="noreferrer"
className="underline font-medium"
>
@{tg}
</a>
{phone ? (
<>
{" · "}
<a href={`tel:${phone}`} className="underline font-medium">{phone}</a>
</>
) : null}
</>
);
}
if (phone) {
return (
<>
{": "}
<a href={`tel:${phone}`} className="underline font-medium">{phone}</a>
</>
);
}
return ".";
})()}
</div>
</div>
)}
{step.kind === "credentials" && (
<>
<p className="text-sm text-muted-foreground">
@@ -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>
);
}