feat(mechanic-pwa): action menu + vehicle list at /vehicles + mileage dialog + photo icon

This commit is contained in:
2026-05-17 22:13:04 +10:00
parent ab9ddf7289
commit 8893710254
6 changed files with 252 additions and 51 deletions
+3 -1
View File
@@ -1,6 +1,7 @@
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 Login from "@/pages/Login"; import Login from "@/pages/Login";
import ActionMenu from "@/pages/ActionMenu";
import Home from "@/pages/Home"; import Home from "@/pages/Home";
import VehicleCard from "@/pages/VehicleCard"; import VehicleCard from "@/pages/VehicleCard";
import InspectionEditor from "@/pages/InspectionEditor"; import InspectionEditor from "@/pages/InspectionEditor";
@@ -16,7 +17,8 @@ export default function App() {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/" element={<RequireAuth><Home /></RequireAuth>} /> <Route path="/" element={<RequireAuth><ActionMenu /></RequireAuth>} />
<Route path="/vehicles" element={<RequireAuth><Home /></RequireAuth>} />
<Route path="/vehicles/:id" element={<RequireAuth><VehicleCard /></RequireAuth>} /> <Route path="/vehicles/:id" element={<RequireAuth><VehicleCard /></RequireAuth>} />
<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>} />
+3 -1
View File
@@ -18,6 +18,7 @@ export interface InspectionCreateInput {
vehicle_id: number; vehicle_id: number;
type: InspectionType; type: InspectionType;
driver_id?: number; driver_id?: number;
mileage?: number;
} }
export interface PhotoSummary { export interface PhotoSummary {
@@ -62,6 +63,7 @@ export interface InspectionDetail {
meta: Record<string, unknown>; meta: Record<string, unknown>;
photos: PhotoSummary[]; photos: PhotoSummary[];
markers: MarkerSummary[]; markers: MarkerSummary[];
mileage?: number | null;
} }
export async function createInspection( export async function createInspection(
@@ -76,7 +78,7 @@ export async function getInspection(id: number): Promise<InspectionDetail> {
export async function patchInspection( export async function patchInspection(
id: number, id: number,
body: { status?: InspectionStatus; notes?: string; finished_at?: string } body: { status?: InspectionStatus; notes?: string; finished_at?: string; mileage?: number }
): Promise<InspectionDetail> { ): Promise<InspectionDetail> {
return api.patch(`inspections/${id}`, { json: body }).json<InspectionDetail>(); return api.patch(`inspections/${id}`, { json: body }).json<InspectionDetail>();
} }
@@ -20,6 +20,8 @@ export interface InspectionSummary {
status: string; status: string;
started_at: string; started_at: string;
finished_at?: string | null; finished_at?: string | null;
mileage?: number | null;
photos_count?: number;
} }
export async function listVehicles(q?: string): Promise<VehicleSummary[]> { export async function listVehicles(q?: string): Promise<VehicleSummary[]> {
@@ -0,0 +1,100 @@
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";
import { Button } from "@/components/ui/button";
interface ActionTile {
key: string;
title: string;
description: string;
emoji: string;
onClick: () => void;
disabled?: boolean;
comingSoon?: boolean;
}
export default function ActionMenu() {
const navigate = useNavigate();
const meQuery = useQuery({ queryKey: ["me"], queryFn: getMe, staleTime: 60_000 });
const tiles: ActionTile[] = [
{
key: "inspect",
title: "Осмотр",
description: "Создать или продолжить осмотр машины",
emoji: "🔍",
onClick: () => navigate("/vehicles?action=inspect"),
},
{
key: "repair",
title: "Ремонт",
description: "Регистрация ремонта (в разработке)",
emoji: "🔧",
onClick: () => toast.info("Раздел «Ремонт» в разработке"),
comingSoon: true,
},
];
return (
<div className="min-h-screen bg-background">
<header className="flex items-center justify-between p-4 border-b">
<div>
<h1 className="text-xl font-semibold">Premium Механик</h1>
{meQuery.data && (
<p className="text-xs text-muted-foreground">{meQuery.data.name}</p>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
logout();
navigate("/login", { replace: true });
}}
>
Выйти
</Button>
</header>
<main className="container max-w-2xl py-8 space-y-4">
<h2 className="text-lg font-semibold text-muted-foreground">
Что делаем?
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{tiles.map((t) => (
<Card
key={t.key}
onClick={t.disabled ? undefined : t.onClick}
className={
"p-5 transition-shadow " +
(t.disabled
? "opacity-50 cursor-not-allowed"
: "cursor-pointer hover:shadow-md active:shadow-sm")
}
>
<div className="flex items-start gap-3">
<div className="text-3xl leading-none">{t.emoji}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-base">{t.title}</h3>
{t.comingSoon && (
<span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5">
скоро
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
{t.description}
</p>
</div>
</div>
</Card>
))}
</div>
</main>
</div>
);
}
+23 -29
View File
@@ -1,22 +1,22 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { listVehicles } from "@/api/vehicles"; import { listVehicles } from "@/api/vehicles";
import { getMe } from "@/api/me";
import { logout } from "@/api/auth";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
const ACTION_TITLES: Record<string, string> = {
inspect: "Осмотр",
repair: "Ремонт",
};
export default function Home() { export default function Home() {
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const meQuery = useQuery({ const action = searchParams.get("action") || undefined;
queryKey: ["me"], const subtitle = action ? ACTION_TITLES[action] : undefined;
queryFn: getMe,
staleTime: 60_000,
});
const vehiclesQuery = useQuery({ const vehiclesQuery = useQuery({
queryKey: ["vehicles", q], queryKey: ["vehicles", q],
@@ -25,29 +25,21 @@ export default function Home() {
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<header className="flex items-center justify-between p-4 border-b"> <header className="flex items-center gap-3 p-4 border-b">
<div> <button
<h1 className="text-xl font-semibold">Premium Механик</h1> onClick={() => navigate("/")}
{meQuery.data && ( className="text-sm text-muted-foreground hover:text-foreground"
<p className="text-xs text-muted-foreground">
{meQuery.data.name}
</p>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
logout();
navigate("/login", { replace: true });
}}
> >
Выйти Меню
</Button> </button>
<div className="flex-1">
<h1 className="text-lg font-semibold">
Машины{subtitle && `${subtitle}`}
</h1>
</div>
</header> </header>
<main className="p-4"> <main className="p-4">
<h2 className="text-lg font-semibold mb-3">Машины</h2>
<Input <Input
placeholder="Поиск по номеру или VIN" placeholder="Поиск по номеру или VIN"
value={q} value={q}
@@ -67,7 +59,9 @@ export default function Home() {
<Card <Card
key={v.id} key={v.id}
className="p-4 cursor-pointer hover:shadow-md transition-shadow" className="p-4 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => navigate(`/vehicles/${v.id}`)} onClick={() =>
navigate(`/vehicles/${v.id}${action ? `?action=${action}` : ""}`)
}
> >
<div className="font-semibold text-lg">{v.license_plate}</div> <div className="font-semibold text-lg">{v.license_plate}</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
+121 -20
View File
@@ -1,10 +1,13 @@
import { useParams, useNavigate } from "react-router-dom"; import { useState } from "react";
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { getVehicle } from "@/api/vehicles"; import { getVehicle } from "@/api/vehicles";
import { createInspection, type InspectionType } from "@/api/inspections"; import { createInspection, type InspectionType } from "@/api/inspections";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const INSPECTION_TYPE_LABELS: Record<InspectionType, string> = { const INSPECTION_TYPE_LABELS: Record<InspectionType, string> = {
handover: "Передача", handover: "Передача",
@@ -27,22 +30,34 @@ const STATUS_LABELS: Record<string, string> = {
export default function VehicleCard() { export default function VehicleCard() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const vehicleId = Number(id); const vehicleId = Number(id);
const action = searchParams.get("action") || undefined;
const [pendingType, setPendingType] = useState<InspectionType | null>(null);
const [mileageInput, setMileageInput] = useState("");
const { data: v, isLoading, isError } = useQuery({ const { data: v, isLoading, isError } = useQuery({
queryKey: ["vehicle", vehicleId], queryKey: ["vehicle", vehicleId],
queryFn: () => getVehicle(vehicleId), queryFn: () => getVehicle(vehicleId),
}); });
const startInspection = useMutation({ const startInspection = useMutation({
mutationFn: (type: InspectionType) => mutationFn: (input: { type: InspectionType; mileage: number | undefined }) =>
createInspection({ vehicle_id: vehicleId, type }), createInspection({ vehicle_id: vehicleId, type: input.type, mileage: input.mileage }),
onSuccess: (inspection) => { onSuccess: (inspection) => {
navigate(`/inspections/${inspection.id}/edit`); navigate(`/inspections/${inspection.id}/edit`);
}, },
onError: () => toast.error("Не удалось создать осмотр"), onError: () => toast.error("Не удалось создать осмотр"),
}); });
const handleStartClick = (type: InspectionType) => {
setPendingType(type);
const lastWithMileage = v?.recent_inspections.find((i) => i.mileage != null);
setMileageInput(lastWithMileage?.mileage ? String(lastWithMileage.mileage) : "");
};
if (isLoading) if (isLoading)
return <div className="p-8 text-muted-foreground">Загружаю</div>; return <div className="p-8 text-muted-foreground">Загружаю</div>;
if (isError || !v) if (isError || !v)
@@ -51,7 +66,7 @@ export default function VehicleCard() {
return ( return (
<div className="min-h-screen bg-background p-4 space-y-4"> <div className="min-h-screen bg-background p-4 space-y-4">
<button <button
onClick={() => navigate("/")} onClick={() => navigate(`/vehicles${action ? `?action=${action}` : ""}`)}
className="text-sm text-muted-foreground hover:text-foreground" className="text-sm text-muted-foreground hover:text-foreground"
> >
К списку К списку
@@ -73,62 +88,62 @@ export default function VehicleCard() {
</h2> </h2>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Button <Button
onClick={() => startInspection.mutate("handover")} onClick={() => handleStartClick("handover")}
disabled={startInspection.isPending} disabled={startInspection.isPending}
> >
Передача Передача
</Button> </Button>
<Button <Button
onClick={() => startInspection.mutate("return")} onClick={() => handleStartClick("return")}
disabled={startInspection.isPending} disabled={startInspection.isPending}
> >
Приёмка Приёмка
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => startInspection.mutate("periodic")} onClick={() => handleStartClick("periodic")}
disabled={startInspection.isPending} disabled={startInspection.isPending}
> >
Плановый Плановый
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => startInspection.mutate("ad-hoc")} onClick={() => handleStartClick("ad-hoc")}
disabled={startInspection.isPending} disabled={startInspection.isPending}
> >
Свободный Свободный
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => startInspection.mutate("initial")} onClick={() => handleStartClick("initial")}
disabled={startInspection.isPending} disabled={startInspection.isPending}
> >
Первичный Первичный
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => startInspection.mutate("seizure")} onClick={() => handleStartClick("seizure")}
disabled={startInspection.isPending} disabled={startInspection.isPending}
> >
Изъятие Изъятие
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => startInspection.mutate("equipment-change")} onClick={() => handleStartClick("equipment-change")}
disabled={startInspection.isPending} disabled={startInspection.isPending}
> >
Комплектация Комплектация
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => startInspection.mutate("pre-repair")} onClick={() => handleStartClick("pre-repair")}
disabled={startInspection.isPending} disabled={startInspection.isPending}
> >
В ремонт В ремонт
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => startInspection.mutate("post-repair")} onClick={() => handleStartClick("post-repair")}
disabled={startInspection.isPending} disabled={startInspection.isPending}
> >
Из ремонта Из ремонта
@@ -152,22 +167,108 @@ export default function VehicleCard() {
className="p-3 cursor-pointer hover:shadow-md transition-shadow" className="p-3 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => navigate(`/inspections/${i.id}`)} onClick={() => navigate(`/inspections/${i.id}`)}
> >
<div className="flex items-center justify-between"> <div className="flex items-start justify-between gap-3">
<div className="font-medium"> <div className="flex-1 min-w-0">
{INSPECTION_TYPE_LABELS[i.type as InspectionType] ?? i.type} <div className="flex items-center gap-2 flex-wrap">
<div className="font-medium">
{INSPECTION_TYPE_LABELS[i.type as InspectionType] ?? i.type}
</div>
{(i.photos_count ?? 0) > 0 && (
<span
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
title={`Фото: ${i.photos_count}`}
>
📷 {i.photos_count}
</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-2 flex-wrap">
<span>{new Date(i.started_at).toLocaleString("ru-RU")}</span>
{i.mileage != null && (
<>
<span className="text-muted-foreground/50">·</span>
<span>{i.mileage.toLocaleString("ru-RU")} км</span>
</>
)}
</div>
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground shrink-0">
{STATUS_LABELS[i.status] ?? i.status} {STATUS_LABELS[i.status] ?? i.status}
</div> </div>
</div> </div>
<div className="text-xs text-muted-foreground mt-1">
{new Date(i.started_at).toLocaleString("ru-RU")}
</div>
</Card> </Card>
))} ))}
</div> </div>
)} )}
</div> </div>
{pendingType && (
<div
className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center p-0 sm:p-4"
onClick={() => setPendingType(null)}
>
<div
className="w-full max-w-sm bg-card rounded-t-2xl sm:rounded-2xl shadow-xl p-5 space-y-4"
onClick={(e) => e.stopPropagation()}
>
<div>
<h2 className="text-lg font-semibold">
{INSPECTION_TYPE_LABELS[pendingType]}
</h2>
<p className="text-xs text-muted-foreground mt-1">
Введите пробег машины на текущий момент.
</p>
</div>
<div className="space-y-1">
<Label htmlFor="mileage">Пробег, км</Label>
<Input
id="mileage"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={mileageInput}
onChange={(e) => setMileageInput(e.target.value.replace(/\D/g, ""))}
placeholder="123456"
autoFocus
/>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setPendingType(null)}
className="flex-1"
>
Отмена
</Button>
<Button
disabled={startInspection.isPending}
onClick={() => {
const m = mileageInput.trim() ? Number(mileageInput) : undefined;
startInspection.mutate({
type: pendingType,
mileage: m && Number.isFinite(m) ? m : undefined,
});
}}
className="flex-1"
>
{startInspection.isPending ? "Создаём…" : "Начать"}
</Button>
</div>
<button
type="button"
onClick={() => {
startInspection.mutate({ type: pendingType, mileage: undefined });
}}
className="w-full text-xs text-muted-foreground hover:text-foreground"
>
Пропустить пробег
</button>
</div>
</div>
)}
</div> </div>
); );
} }