diff --git a/mechanic-pwa/frontend/src/api/auth.ts b/mechanic-pwa/frontend/src/api/auth.ts new file mode 100644 index 0000000..ca40a76 --- /dev/null +++ b/mechanic-pwa/frontend/src/api/auth.ts @@ -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 { + // 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; +} + +export function logout(): void { + useAuth.getState().setToken(null); +} diff --git a/mechanic-pwa/frontend/src/api/inspections.ts b/mechanic-pwa/frontend/src/api/inspections.ts new file mode 100644 index 0000000..c28399d --- /dev/null +++ b/mechanic-pwa/frontend/src/api/inspections.ts @@ -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; + photos: unknown[]; // typed properly in Phase I when photo UI is built + markers: unknown[]; +} + +export async function createInspection( + input: InspectionCreateInput +): Promise { + return api.post("inspections", { json: input }).json(); +} + +export type { InspectionSummary }; diff --git a/mechanic-pwa/frontend/src/api/me.ts b/mechanic-pwa/frontend/src/api/me.ts new file mode 100644 index 0000000..d64137e --- /dev/null +++ b/mechanic-pwa/frontend/src/api/me.ts @@ -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 { + return api.get("me").json(); +} diff --git a/mechanic-pwa/frontend/src/api/vehicles.ts b/mechanic-pwa/frontend/src/api/vehicles.ts new file mode 100644 index 0000000..5a0766e --- /dev/null +++ b/mechanic-pwa/frontend/src/api/vehicles.ts @@ -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 { + const searchParams: Record = {}; + 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 { + return api.get(`vehicles/${id}`).json(); +} diff --git a/mechanic-pwa/frontend/src/pages/Home.tsx b/mechanic-pwa/frontend/src/pages/Home.tsx index 9eb03a6..33bfa62 100644 --- a/mechanic-pwa/frontend/src/pages/Home.tsx +++ b/mechanic-pwa/frontend/src/pages/Home.tsx @@ -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 ( -
-

Главная

-

Список машин будет здесь.

+
+
+
+

Premium Механик

+ {meQuery.data && ( +

+ {meQuery.data.name} +

+ )} +
+ +
+ +
+

Машины

+ setQ(e.target.value)} + className="mb-4" + /> + + {vehiclesQuery.isLoading ? ( +
Загружаю…
+ ) : vehiclesQuery.isError ? ( +
Не удалось загрузить.
+ ) : (vehiclesQuery.data ?? []).length === 0 ? ( +
Ничего не найдено.
+ ) : ( +
+ {(vehiclesQuery.data ?? []).map((v) => ( + navigate(`/vehicles/${v.id}`)} + > +
{v.license_plate}
+
+ {[v.make, v.model, v.year].filter(Boolean).join(" ") || "—"} +
+ {v.last_inspection_at && ( +
+ Последний осмотр:{" "} + {new Date(v.last_inspection_at).toLocaleDateString("ru-RU")} +
+ )} +
+ ))} +
+ )} +
); } diff --git a/mechanic-pwa/frontend/src/pages/Login.tsx b/mechanic-pwa/frontend/src/pages/Login.tsx index c2d83cb..f684c65 100644 --- a/mechanic-pwa/frontend/src/pages/Login.tsx +++ b/mechanic-pwa/frontend/src/pages/Login.tsx @@ -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 ( -
-

Вход

-

Скоро здесь будет форма входа.

+
+
+

Premium Механик

+

+ Войдите учётной записью TaxiDashboard. +

+
{ + e.preventDefault(); + m.mutate({ username, password }); + }} + className="space-y-3" + > +
+ + setUsername(e.target.value)} + required + autoFocus + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ +
+
); } diff --git a/mechanic-pwa/frontend/src/pages/VehicleCard.tsx b/mechanic-pwa/frontend/src/pages/VehicleCard.tsx index a73e8ef..1464622 100644 --- a/mechanic-pwa/frontend/src/pages/VehicleCard.tsx +++ b/mechanic-pwa/frontend/src/pages/VehicleCard.tsx @@ -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 = { + handover: "Передача", + return: "Приёмка", + periodic: "Плановый", + "ad-hoc": "Свободный", + initial: "Первичный", + seizure: "Изъятие", + "equipment-change": "Комплектация", + "pre-repair": "В ремонт", + "post-repair": "Из ремонта", +}; + +const STATUS_LABELS: Record = { + 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
Загружаю…
; + if (isError || !v) + return
Машина не найдена.
; + return ( -
-

Машина #{id}

+
+ + + +
{v.license_plate}
+
+ {[v.make, v.model, v.year].filter(Boolean).join(" ") || "—"} +
+ {v.vin && ( +
VIN: {v.vin}
+ )} +
+ +
+

+ Начать осмотр +

+
+ + + + + + + + + +
+
+ +
+

+ Прошлые осмотры +

+ {v.recent_inspections.length === 0 ? ( + + Осмотров ещё не было. + + ) : ( +
+ {v.recent_inspections.map((i) => ( + navigate(`/inspections/${i.id}`)} + > +
+
+ {INSPECTION_TYPE_LABELS[i.type as InspectionType] ?? i.type} +
+
+ {STATUS_LABELS[i.status] ?? i.status} +
+
+
+ {new Date(i.started_at).toLocaleString("ru-RU")} +
+
+ ))} +
+ )} +
); }