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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user