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