feat(mechanic-pwa): action menu + vehicle list at /vehicles + mileage dialog + photo icon
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import Login from "@/pages/Login";
|
||||
import ActionMenu from "@/pages/ActionMenu";
|
||||
import Home from "@/pages/Home";
|
||||
import VehicleCard from "@/pages/VehicleCard";
|
||||
import InspectionEditor from "@/pages/InspectionEditor";
|
||||
@@ -16,7 +17,8 @@ export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<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="/inspections/:id/edit" element={<RequireAuth><InspectionEditor /></RequireAuth>} />
|
||||
<Route path="/inspections/:id" element={<RequireAuth><InspectionReview /></RequireAuth>} />
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface InspectionCreateInput {
|
||||
vehicle_id: number;
|
||||
type: InspectionType;
|
||||
driver_id?: number;
|
||||
mileage?: number;
|
||||
}
|
||||
|
||||
export interface PhotoSummary {
|
||||
@@ -62,6 +63,7 @@ export interface InspectionDetail {
|
||||
meta: Record<string, unknown>;
|
||||
photos: PhotoSummary[];
|
||||
markers: MarkerSummary[];
|
||||
mileage?: number | null;
|
||||
}
|
||||
|
||||
export async function createInspection(
|
||||
@@ -76,7 +78,7 @@ export async function getInspection(id: number): Promise<InspectionDetail> {
|
||||
|
||||
export async function patchInspection(
|
||||
id: number,
|
||||
body: { status?: InspectionStatus; notes?: string; finished_at?: string }
|
||||
body: { status?: InspectionStatus; notes?: string; finished_at?: string; mileage?: number }
|
||||
): Promise<InspectionDetail> {
|
||||
return api.patch(`inspections/${id}`, { json: body }).json<InspectionDetail>();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface InspectionSummary {
|
||||
status: string;
|
||||
started_at: string;
|
||||
finished_at?: string | null;
|
||||
mileage?: number | null;
|
||||
photos_count?: number;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useState } from "react";
|
||||
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 { getMe } from "@/api/me";
|
||||
import { logout } from "@/api/auth";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const ACTION_TITLES: Record<string, string> = {
|
||||
inspect: "Осмотр",
|
||||
repair: "Ремонт",
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const [q, setQ] = useState("");
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const meQuery = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: getMe,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const action = searchParams.get("action") || undefined;
|
||||
const subtitle = action ? ACTION_TITLES[action] : undefined;
|
||||
|
||||
const vehiclesQuery = useQuery({
|
||||
queryKey: ["vehicles", q],
|
||||
@@ -25,29 +25,21 @@ export default function Home() {
|
||||
|
||||
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 });
|
||||
}}
|
||||
<header className="flex items-center gap-3 p-4 border-b">
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Выйти
|
||||
</Button>
|
||||
← Меню
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-lg font-semibold">
|
||||
Машины{subtitle && ` — ${subtitle}`}
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="p-4">
|
||||
<h2 className="text-lg font-semibold mb-3">Машины</h2>
|
||||
<Input
|
||||
placeholder="Поиск по номеру или VIN"
|
||||
value={q}
|
||||
@@ -67,7 +59,9 @@ export default function Home() {
|
||||
<Card
|
||||
key={v.id}
|
||||
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="text-sm text-muted-foreground">
|
||||
|
||||
@@ -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 { toast } from "sonner";
|
||||
import { getVehicle } from "@/api/vehicles";
|
||||
import { createInspection, type InspectionType } from "@/api/inspections";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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> = {
|
||||
handover: "Передача",
|
||||
@@ -27,22 +30,34 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
export default function VehicleCard() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
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({
|
||||
queryKey: ["vehicle", vehicleId],
|
||||
queryFn: () => getVehicle(vehicleId),
|
||||
});
|
||||
|
||||
const startInspection = useMutation({
|
||||
mutationFn: (type: InspectionType) =>
|
||||
createInspection({ vehicle_id: vehicleId, type }),
|
||||
mutationFn: (input: { type: InspectionType; mileage: number | undefined }) =>
|
||||
createInspection({ vehicle_id: vehicleId, type: input.type, mileage: input.mileage }),
|
||||
onSuccess: (inspection) => {
|
||||
navigate(`/inspections/${inspection.id}/edit`);
|
||||
},
|
||||
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)
|
||||
return <div className="p-8 text-muted-foreground">Загружаю…</div>;
|
||||
if (isError || !v)
|
||||
@@ -51,7 +66,7 @@ export default function VehicleCard() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-4 space-y-4">
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
onClick={() => navigate(`/vehicles${action ? `?action=${action}` : ""}`)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← К списку
|
||||
@@ -73,62 +88,62 @@ export default function VehicleCard() {
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
onClick={() => startInspection.mutate("handover")}
|
||||
onClick={() => handleStartClick("handover")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Передача
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => startInspection.mutate("return")}
|
||||
onClick={() => handleStartClick("return")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Приёмка
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startInspection.mutate("periodic")}
|
||||
onClick={() => handleStartClick("periodic")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Плановый
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startInspection.mutate("ad-hoc")}
|
||||
onClick={() => handleStartClick("ad-hoc")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Свободный
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startInspection.mutate("initial")}
|
||||
onClick={() => handleStartClick("initial")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Первичный
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startInspection.mutate("seizure")}
|
||||
onClick={() => handleStartClick("seizure")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Изъятие
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startInspection.mutate("equipment-change")}
|
||||
onClick={() => handleStartClick("equipment-change")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Комплектация
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startInspection.mutate("pre-repair")}
|
||||
onClick={() => handleStartClick("pre-repair")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
В ремонт
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startInspection.mutate("post-repair")}
|
||||
onClick={() => handleStartClick("post-repair")}
|
||||
disabled={startInspection.isPending}
|
||||
>
|
||||
Из ремонта
|
||||
@@ -152,22 +167,108 @@ export default function VehicleCard() {
|
||||
className="p-3 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => navigate(`/inspections/${i.id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="font-medium">
|
||||
{INSPECTION_TYPE_LABELS[i.type as InspectionType] ?? i.type}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{(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 className="text-xs text-muted-foreground shrink-0">
|
||||
{STATUS_LABELS[i.status] ?? i.status}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(i.started_at).toLocaleString("ru-RU")}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user