feat(mechanic-pwa): Login + Home + VehicleCard pages with TanStack Query + OAuth2 login

- api/auth.ts: OAuth2 password flow via form-urlencoded POST /api/auth/login (not mechanic prefix)
- api/me.ts: GET /api/v1/mechanic/me with Me interface
- api/vehicles.ts: listVehicles (unwraps {items:[]}), getVehicle with VehicleDetail + InspectionSummary
- api/inspections.ts: createInspection returning full InspectionDetail; 9-value InspectionType union
- Login.tsx: full form with Логин/Пароль labels, useMutation, toast feedback, navigate on success
- Home.tsx: vehicle list with search, /me header with user name, logout, TanStack Query
- VehicleCard.tsx: vehicle info card, all 9 inspection type buttons (ru labels), history list

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-05-17 10:44:39 +10:00
co-authored by claude-flow
parent e815955d46
commit 537548d716
7 changed files with 443 additions and 10 deletions
+32
View File
@@ -0,0 +1,32 @@
import { useAuth } from "@/store/auth";
export interface LoginInput {
username: string;
password: string;
}
export interface LoginOutput {
access_token: string;
token_type: string;
}
export async function login(input: LoginInput): Promise<LoginOutput> {
// OAuth2 password flow — form-urlencoded, NOT JSON, NOT under /api/v1/mechanic/
const body = new URLSearchParams();
body.set("username", input.username);
body.set("password", input.password);
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!res.ok) {
throw new Error(`Login failed: ${res.status}`);
}
return res.json() as Promise<LoginOutput>;
}
export function logout(): void {
useAuth.getState().setToken(null);
}
@@ -0,0 +1,45 @@
import { api } from "./client";
import type { InspectionSummary } from "./vehicles";
export type InspectionType =
| "handover"
| "return"
| "periodic"
| "ad-hoc"
| "initial"
| "seizure"
| "equipment-change"
| "pre-repair"
| "post-repair";
export type InspectionStatus = "in_progress" | "completed" | "cancelled";
export interface InspectionCreateInput {
vehicle_id: number;
type: InspectionType;
driver_id?: number;
}
export interface InspectionDetail {
id: number;
vehicle_id: number;
type: InspectionType;
performed_by: number;
driver_id?: number | null;
started_at: string;
finished_at?: string | null;
status: InspectionStatus;
notes?: string | null;
prev_inspection_id?: number | null;
meta: Record<string, unknown>;
photos: unknown[]; // typed properly in Phase I when photo UI is built
markers: unknown[];
}
export async function createInspection(
input: InspectionCreateInput
): Promise<InspectionDetail> {
return api.post("inspections", { json: input }).json<InspectionDetail>();
}
export type { InspectionSummary };
+12
View File
@@ -0,0 +1,12 @@
import { api } from "./client";
export interface Me {
id: number;
name: string;
email?: string | null;
permissions: string[];
}
export async function getMe(): Promise<Me> {
return api.get("me").json<Me>();
}
+36
View File
@@ -0,0 +1,36 @@
import { api } from "./client";
export interface VehicleSummary {
id: number;
license_plate: string;
vin?: string | null;
make?: string | null;
model?: string | null;
year?: number | null;
last_inspection_at?: string | null;
}
export interface VehicleDetail extends VehicleSummary {
recent_inspections: InspectionSummary[];
}
export interface InspectionSummary {
id: number;
type: string;
status: string;
started_at: string;
finished_at?: string | null;
}
export async function listVehicles(q?: string): Promise<VehicleSummary[]> {
const searchParams: Record<string, string> = {};
if (q && q.trim()) searchParams.q = q.trim();
const res = await api
.get("vehicles", { searchParams })
.json<{ items: VehicleSummary[] }>();
return res.items;
}
export async function getVehicle(id: number): Promise<VehicleDetail> {
return api.get(`vehicles/${id}`).json<VehicleDetail>();
}
+84 -3
View File
@@ -1,8 +1,89 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } 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";
export default function Home() {
const [q, setQ] = useState("");
const navigate = useNavigate();
const meQuery = useQuery({
queryKey: ["me"],
queryFn: getMe,
staleTime: 60_000,
});
const vehiclesQuery = useQuery({
queryKey: ["vehicles", q],
queryFn: () => listVehicles(q || undefined),
});
return (
<div className="container py-8">
<h1 className="text-2xl font-semibold mb-4">Главная</h1>
<p className="text-muted-foreground">Список машин будет здесь.</p>
<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="p-4">
<h2 className="text-lg font-semibold mb-3">Машины</h2>
<Input
placeholder="Поиск по номеру или VIN"
value={q}
onChange={(e) => setQ(e.target.value)}
className="mb-4"
/>
{vehiclesQuery.isLoading ? (
<div className="text-sm text-muted-foreground">Загружаю</div>
) : vehiclesQuery.isError ? (
<div className="text-sm text-destructive">Не удалось загрузить.</div>
) : (vehiclesQuery.data ?? []).length === 0 ? (
<div className="text-sm text-muted-foreground">Ничего не найдено.</div>
) : (
<div className="space-y-2">
{(vehiclesQuery.data ?? []).map((v) => (
<Card
key={v.id}
className="p-4 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => navigate(`/vehicles/${v.id}`)}
>
<div className="font-semibold text-lg">{v.license_plate}</div>
<div className="text-sm text-muted-foreground">
{[v.make, v.model, v.year].filter(Boolean).join(" ") || "—"}
</div>
{v.last_inspection_at && (
<div className="text-xs text-muted-foreground mt-1">
Последний осмотр:{" "}
{new Date(v.last_inspection_at).toLocaleDateString("ru-RU")}
</div>
)}
</Card>
))}
</div>
)}
</main>
</div>
);
}
+66 -3
View File
@@ -1,8 +1,71 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { login } from "@/api/auth";
import { useAuth } from "@/store/auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function Login() {
const navigate = useNavigate();
const setToken = useAuth((s) => s.setToken);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const m = useMutation({
mutationFn: login,
onSuccess: (data) => {
setToken(data.access_token);
toast.success("Вход выполнен");
navigate("/", { replace: true });
},
onError: () => toast.error("Неверный логин или пароль"),
});
return (
<div className="container max-w-md py-12">
<h1 className="text-2xl font-semibold mb-4">Вход</h1>
<p className="text-muted-foreground">Скоро здесь будет форма входа.</p>
<div className="min-h-screen flex items-center justify-center bg-background p-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>
<p className="text-sm text-muted-foreground">
Войдите учётной записью TaxiDashboard.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
m.mutate({ username, password });
}}
className="space-y-3"
>
<div className="space-y-1">
<Label htmlFor="username">Логин</Label>
<Input
id="username"
type="text"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
/>
</div>
<div className="space-y-1">
<Label htmlFor="password">Пароль</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" disabled={m.isPending} className="w-full">
{m.isPending ? "Входим…" : "Войти"}
</Button>
</form>
</div>
</div>
);
}
+168 -4
View File
@@ -1,9 +1,173 @@
import { useParams } from "react-router-dom";
import { useParams, useNavigate } 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";
const INSPECTION_TYPE_LABELS: Record<InspectionType, string> = {
handover: "Передача",
return: "Приёмка",
periodic: "Плановый",
"ad-hoc": "Свободный",
initial: "Первичный",
seizure: "Изъятие",
"equipment-change": "Комплектация",
"pre-repair": "В ремонт",
"post-repair": "Из ремонта",
};
const STATUS_LABELS: Record<string, string> = {
in_progress: "В работе",
completed: "Завершён",
cancelled: "Отменён",
};
export default function VehicleCard() {
const { id } = useParams();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const vehicleId = Number(id);
const { data: v, isLoading, isError } = useQuery({
queryKey: ["vehicle", vehicleId],
queryFn: () => getVehicle(vehicleId),
});
const startInspection = useMutation({
mutationFn: (type: InspectionType) =>
createInspection({ vehicle_id: vehicleId, type }),
onSuccess: (inspection) => {
navigate(`/inspections/${inspection.id}/edit`);
},
onError: () => toast.error("Не удалось создать осмотр"),
});
if (isLoading)
return <div className="p-8 text-muted-foreground">Загружаю</div>;
if (isError || !v)
return <div className="p-8 text-destructive">Машина не найдена.</div>;
return (
<div className="container py-8">
<h1 className="text-2xl font-semibold mb-4">Машина #{id}</h1>
<div className="min-h-screen bg-background p-4 space-y-4">
<button
onClick={() => navigate("/")}
className="text-sm text-muted-foreground hover:text-foreground"
>
К списку
</button>
<Card className="p-4">
<div className="text-xl font-semibold">{v.license_plate}</div>
<div className="text-sm text-muted-foreground">
{[v.make, v.model, v.year].filter(Boolean).join(" ") || "—"}
</div>
{v.vin && (
<div className="text-xs text-muted-foreground mt-1">VIN: {v.vin}</div>
)}
</Card>
<div>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
Начать осмотр
</h2>
<div className="grid grid-cols-2 gap-2">
<Button
onClick={() => startInspection.mutate("handover")}
disabled={startInspection.isPending}
>
Передача
</Button>
<Button
onClick={() => startInspection.mutate("return")}
disabled={startInspection.isPending}
>
Приёмка
</Button>
<Button
variant="outline"
onClick={() => startInspection.mutate("periodic")}
disabled={startInspection.isPending}
>
Плановый
</Button>
<Button
variant="outline"
onClick={() => startInspection.mutate("ad-hoc")}
disabled={startInspection.isPending}
>
Свободный
</Button>
<Button
variant="outline"
onClick={() => startInspection.mutate("initial")}
disabled={startInspection.isPending}
>
Первичный
</Button>
<Button
variant="outline"
onClick={() => startInspection.mutate("seizure")}
disabled={startInspection.isPending}
>
Изъятие
</Button>
<Button
variant="outline"
onClick={() => startInspection.mutate("equipment-change")}
disabled={startInspection.isPending}
>
Комплектация
</Button>
<Button
variant="outline"
onClick={() => startInspection.mutate("pre-repair")}
disabled={startInspection.isPending}
>
В ремонт
</Button>
<Button
variant="outline"
onClick={() => startInspection.mutate("post-repair")}
disabled={startInspection.isPending}
>
Из ремонта
</Button>
</div>
</div>
<div>
<h2 className="text-sm font-semibold text-muted-foreground mb-2">
Прошлые осмотры
</h2>
{v.recent_inspections.length === 0 ? (
<Card className="p-4 text-sm text-muted-foreground">
Осмотров ещё не было.
</Card>
) : (
<div className="space-y-2">
{v.recent_inspections.map((i) => (
<Card
key={i.id}
className="p-3 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => navigate(`/inspections/${i.id}`)}
>
<div className="flex items-center justify-between">
<div className="font-medium">
{INSPECTION_TYPE_LABELS[i.type as InspectionType] ?? i.type}
</div>
<div className="text-xs text-muted-foreground">
{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>
</div>
);
}