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 { Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
|
import { useMeSync } from "@/hooks/useMeSync";
|
||||||
import Login from "@/pages/Login";
|
import Login from "@/pages/Login";
|
||||||
import ActionMenu from "@/pages/ActionMenu";
|
import ActionMenu from "@/pages/ActionMenu";
|
||||||
import Home from "@/pages/Home";
|
import Home from "@/pages/Home";
|
||||||
@@ -7,6 +8,9 @@ import VehicleCard from "@/pages/VehicleCard";
|
|||||||
import InspectionEditor from "@/pages/InspectionEditor";
|
import InspectionEditor from "@/pages/InspectionEditor";
|
||||||
import InspectionReview from "@/pages/InspectionReview";
|
import InspectionReview from "@/pages/InspectionReview";
|
||||||
import TtStateDetail from "@/pages/TtStateDetail";
|
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 }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
const token = useAuth((s) => s.token);
|
const token = useAuth((s) => s.token);
|
||||||
@@ -15,6 +19,11 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
// У35: фоновая сверка прав механика раз в 5 минут — выкидывает на /login
|
||||||
|
// с сообщением «Доступ отозван», если админ снял can_login_pwa или вывел
|
||||||
|
// пользователя из отдела механиков.
|
||||||
|
useMeSync();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<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/edit" element={<RequireAuth><InspectionEditor /></RequireAuth>} />
|
||||||
<Route path="/inspections/:id" element={<RequireAuth><InspectionReview /></RequireAuth>} />
|
<Route path="/inspections/:id" element={<RequireAuth><InspectionReview /></RequireAuth>} />
|
||||||
<Route path="/tt-states/:id" element={<RequireAuth><TtStateDetail /></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 />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export interface PwaSupportContact {
|
||||||
|
tg_username: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Me {
|
export interface Me {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
|
// Модуль СТО (У10 + У34) — расширение в backend PR-4.
|
||||||
|
is_admin: boolean;
|
||||||
|
can_login_pwa: boolean;
|
||||||
|
departments: string[];
|
||||||
|
support: PwaSupportContact;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMe(): Promise<Me> {
|
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 { useNavigate } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { getMe } from "@/api/me";
|
import { getMe } from "@/api/me";
|
||||||
import { logout } from "@/api/auth";
|
import { logout } from "@/api/auth";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
@@ -31,10 +30,9 @@ export default function ActionMenu() {
|
|||||||
{
|
{
|
||||||
key: "repair",
|
key: "repair",
|
||||||
title: "Ремонт",
|
title: "Ремонт",
|
||||||
description: "Регистрация ремонта (в разработке)",
|
description: "Лента ремонтов парка + создать новый",
|
||||||
emoji: "🔧",
|
emoji: "🔧",
|
||||||
onClick: () => toast.info("Раздел «Ремонт» в разработке"),
|
onClick: () => navigate("/repairs"),
|
||||||
comingSoon: true,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { HTTPError } from "ky";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { login, login2fa } from "@/api/auth";
|
import { login, login2fa } from "@/api/auth";
|
||||||
|
import { getMe } from "@/api/me";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
|
import {
|
||||||
|
clearRevocation,
|
||||||
|
readRevocation,
|
||||||
|
storeRevocation,
|
||||||
|
type RevocationInfo,
|
||||||
|
} from "@/hooks/useMeSync";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -22,6 +30,49 @@ export default function Login() {
|
|||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [rememberDevice, setRememberDevice] = useState(true);
|
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({
|
const loginMutation = useMutation({
|
||||||
mutationFn: login,
|
mutationFn: login,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -32,12 +83,10 @@ export default function Login() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.access_token) {
|
if (data.access_token) {
|
||||||
setToken(data.access_token);
|
// У36: проверяем доступ к PWA через /me перед навигацией.
|
||||||
toast.success("Вход выполнен");
|
void verifyPwaAccessAndNavigate(data.access_token);
|
||||||
navigate("/", { replace: true });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Defensive: shouldn't happen
|
|
||||||
toast.error("Неожиданный ответ сервера");
|
toast.error("Неожиданный ответ сервера");
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Неверный логин или пароль"),
|
onError: () => toast.error("Неверный логин или пароль"),
|
||||||
@@ -47,9 +96,7 @@ export default function Login() {
|
|||||||
mutationFn: login2fa,
|
mutationFn: login2fa,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data.access_token) {
|
if (data.access_token) {
|
||||||
setToken(data.access_token);
|
void verifyPwaAccessAndNavigate(data.access_token);
|
||||||
toast.success("Вход выполнен");
|
|
||||||
navigate("/", { replace: true });
|
|
||||||
} else {
|
} else {
|
||||||
toast.error("Сервер не вернул токен");
|
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">
|
<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>
|
<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" && (
|
{step.kind === "credentials" && (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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