Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c9caedb03 | ||
|
|
d0ebd9ddf5 | ||
|
|
7e6817b3ff | ||
|
|
3d875642f8 |
@@ -39,6 +39,7 @@ export default function App() {
|
||||
<Route path="/repairs" element={<RequireAuth><RepairsFeedPage /></RequireAuth>} />
|
||||
<Route path="/repairs/new" element={<RequireAuth><CarSelectPage /></RequireAuth>} />
|
||||
<Route path="/repairs/new/:carId" element={<RequireAuth><CreateRepairPage /></RequireAuth>} />
|
||||
<Route path="/repairs/new-external" element={<RequireAuth><CreateRepairPage /></RequireAuth>} />
|
||||
<Route path="/repairs/:id" element={<RequireAuth><RepairDetailPage /></RequireAuth>} />
|
||||
<Route path="/repairs/:id/edit" element={<RequireAuth><EditRepairPage /></RequireAuth>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -65,8 +65,14 @@ export interface CreateOilChangePayload {
|
||||
sticker_extension: string | null;
|
||||
}
|
||||
|
||||
export interface ExternalCarPayload {
|
||||
plate: string;
|
||||
make: string | null;
|
||||
}
|
||||
|
||||
export interface CreateRepairPayload {
|
||||
car_id: number;
|
||||
car_id?: number;
|
||||
external_car?: ExternalCarPayload;
|
||||
mileage: number | null;
|
||||
works: CreateWorkPayload[];
|
||||
parts: CreatePartPayload[];
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface FeedItem {
|
||||
car_model: string | null;
|
||||
driver_name: string | null;
|
||||
mechanic_name: string;
|
||||
is_external: boolean;
|
||||
works_preview: FeedWorkBrief[];
|
||||
works_extra: number;
|
||||
works_total: number; // сумма работ без запчастей
|
||||
|
||||
@@ -18,6 +18,15 @@ export default function CarSelectPage() {
|
||||
const [cars, setCars] = useState<CarBrief[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [extOpen, setExtOpen] = useState(false);
|
||||
const [extPlate, setExtPlate] = useState("");
|
||||
const [extMake, setExtMake] = useState("");
|
||||
|
||||
const continueExternal = () => {
|
||||
const plate = extPlate.trim();
|
||||
if (!plate) return;
|
||||
navigate("/repairs/new-external", { state: { plate, make: extMake.trim() || null } });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -62,6 +71,13 @@ export default function CarSelectPage() {
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 container max-w-2xl py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExtOpen(true)}
|
||||
className="w-full mb-3 flex items-center justify-center gap-1 rounded-md border border-dashed bg-card hover:bg-accent/40 transition-colors p-3 text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
+ Машина не из парка
|
||||
</button>
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm">
|
||||
{error}
|
||||
@@ -96,6 +112,41 @@ export default function CarSelectPage() {
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
{extOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-background/95 flex flex-col">
|
||||
<header className="border-b p-3 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setExtOpen(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<h2 className="text-base font-semibold">Машина с улицы</h2>
|
||||
</header>
|
||||
<main className="flex-1 container max-w-2xl py-4 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Госномер *</label>
|
||||
<Input
|
||||
autoFocus
|
||||
value={extPlate}
|
||||
onChange={(e) => setExtPlate(e.target.value)}
|
||||
placeholder="Х999ХХ199"
|
||||
className="mt-1"
|
||||
onKeyDown={(e) => e.key === "Enter" && continueExternal()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Марка и модель (необязательно)</label>
|
||||
<Input
|
||||
value={extMake}
|
||||
onChange={(e) => setExtMake(e.target.value)}
|
||||
placeholder="Toyota Camry"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" disabled={!extPlate.trim()} onClick={continueExternal}>
|
||||
Продолжить →
|
||||
</Button>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* Сохранение: POST /repairs с `Idempotency-Key` (UUID v4 в state).
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
||||
import { ChevronLeft, Plus, Trash2, Pencil, Camera } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -94,6 +94,9 @@ function uuid4(): string {
|
||||
export default function CreateRepairPage() {
|
||||
const navigate = useNavigate();
|
||||
const { carId: carIdRaw } = useParams<{ carId: string }>();
|
||||
const location = useLocation();
|
||||
const isExternal = !carIdRaw;
|
||||
const externalState = location.state as { plate: string; make: string | null } | null;
|
||||
const carId = carIdRaw ? Number(carIdRaw) : 0;
|
||||
|
||||
// Idempotency-Key для этой формы — стабилен между ре-сабмитами (У20).
|
||||
@@ -120,6 +123,24 @@ export default function CreateRepairPage() {
|
||||
|
||||
// Initial: подгрузить контекст машины.
|
||||
useEffect(() => {
|
||||
if (isExternal) {
|
||||
if (!externalState) {
|
||||
// Прямой заход/рефреш без данных — возвращаем к выбору машины.
|
||||
navigate("/repairs/new", { replace: true });
|
||||
return;
|
||||
}
|
||||
setCtx({
|
||||
id: 0,
|
||||
plate: externalState.plate,
|
||||
brand: externalState.make,
|
||||
model: null,
|
||||
mileage_starline: null,
|
||||
driver_id: null,
|
||||
driver_name: null,
|
||||
});
|
||||
setMileage(null);
|
||||
return;
|
||||
}
|
||||
if (!carId) return;
|
||||
getCarContext(carId)
|
||||
.then((c) => {
|
||||
@@ -127,7 +148,8 @@ export default function CreateRepairPage() {
|
||||
setMileage(c.mileage_starline);
|
||||
})
|
||||
.catch((e) => setCtxError((e as Error).message || "Не удалось загрузить машину"));
|
||||
}, [carId]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [carId, isExternal]);
|
||||
|
||||
// У24 упрощено: пробег ввода в форме теперь один — главный «Пробег (км)»
|
||||
// в шапке. На submit подставляется и в oil_change.mileage_at_change, чтобы
|
||||
@@ -307,7 +329,9 @@ export default function CreateRepairPage() {
|
||||
}));
|
||||
const result = await createRepair(
|
||||
{
|
||||
car_id: carId,
|
||||
...(isExternal
|
||||
? { external_car: { plate: externalState!.plate, make: externalState!.make } }
|
||||
: { car_id: carId }),
|
||||
mileage,
|
||||
works: works.map((w) => ({
|
||||
work_catalog_id: w.work_catalog_id,
|
||||
@@ -385,7 +409,11 @@ export default function CreateRepairPage() {
|
||||
<div className="font-medium">{ctx.plate}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{[ctx.brand, ctx.model].filter(Boolean).join(" ")}
|
||||
{ctx.driver_name ? ` · ${ctx.driver_name}` : ` · Без водителя`}
|
||||
{isExternal
|
||||
? " · С улицы"
|
||||
: ctx.driver_name
|
||||
? ` · ${ctx.driver_name}`
|
||||
: " · Без водителя"}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -260,7 +260,14 @@ function RepairRow({ item, onOpen }: { item: FeedItem; onOpen: () => void }) {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="font-medium truncate">{carLine}</div>
|
||||
<div className="font-medium truncate">
|
||||
{carLine}
|
||||
{item.is_external && (
|
||||
<span className="ml-1.5 align-middle text-[10px] font-semibold uppercase tracking-wide text-amber-700 bg-amber-100 rounded px-1 py-0.5">
|
||||
с улицы
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{formatFeedTimestamp(item.created_at)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user