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" element={<RequireAuth><RepairsFeedPage /></RequireAuth>} />
|
||||||
<Route path="/repairs/new" element={<RequireAuth><CarSelectPage /></RequireAuth>} />
|
<Route path="/repairs/new" element={<RequireAuth><CarSelectPage /></RequireAuth>} />
|
||||||
<Route path="/repairs/new/:carId" element={<RequireAuth><CreateRepairPage /></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" element={<RequireAuth><RepairDetailPage /></RequireAuth>} />
|
||||||
<Route path="/repairs/:id/edit" element={<RequireAuth><EditRepairPage /></RequireAuth>} />
|
<Route path="/repairs/:id/edit" element={<RequireAuth><EditRepairPage /></RequireAuth>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -65,8 +65,14 @@ export interface CreateOilChangePayload {
|
|||||||
sticker_extension: string | null;
|
sticker_extension: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExternalCarPayload {
|
||||||
|
plate: string;
|
||||||
|
make: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateRepairPayload {
|
export interface CreateRepairPayload {
|
||||||
car_id: number;
|
car_id?: number;
|
||||||
|
external_car?: ExternalCarPayload;
|
||||||
mileage: number | null;
|
mileage: number | null;
|
||||||
works: CreateWorkPayload[];
|
works: CreateWorkPayload[];
|
||||||
parts: CreatePartPayload[];
|
parts: CreatePartPayload[];
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface FeedItem {
|
|||||||
car_model: string | null;
|
car_model: string | null;
|
||||||
driver_name: string | null;
|
driver_name: string | null;
|
||||||
mechanic_name: string;
|
mechanic_name: string;
|
||||||
|
is_external: boolean;
|
||||||
works_preview: FeedWorkBrief[];
|
works_preview: FeedWorkBrief[];
|
||||||
works_extra: number;
|
works_extra: number;
|
||||||
works_total: number; // сумма работ без запчастей
|
works_total: number; // сумма работ без запчастей
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ export default function CarSelectPage() {
|
|||||||
const [cars, setCars] = useState<CarBrief[]>([]);
|
const [cars, setCars] = useState<CarBrief[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -62,6 +71,13 @@ export default function CarSelectPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 container max-w-2xl py-3">
|
<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 && (
|
{error && (
|
||||||
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm">
|
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm">
|
||||||
{error}
|
{error}
|
||||||
@@ -96,6 +112,41 @@ export default function CarSelectPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</main>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
* Сохранение: POST /repairs с `Idempotency-Key` (UUID v4 в state).
|
* Сохранение: POST /repairs с `Idempotency-Key` (UUID v4 в state).
|
||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
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 { ChevronLeft, Plus, Trash2, Pencil, Camera } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -94,6 +94,9 @@ function uuid4(): string {
|
|||||||
export default function CreateRepairPage() {
|
export default function CreateRepairPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { carId: carIdRaw } = useParams<{ carId: string }>();
|
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;
|
const carId = carIdRaw ? Number(carIdRaw) : 0;
|
||||||
|
|
||||||
// Idempotency-Key для этой формы — стабилен между ре-сабмитами (У20).
|
// Idempotency-Key для этой формы — стабилен между ре-сабмитами (У20).
|
||||||
@@ -120,6 +123,24 @@ export default function CreateRepairPage() {
|
|||||||
|
|
||||||
// Initial: подгрузить контекст машины.
|
// Initial: подгрузить контекст машины.
|
||||||
useEffect(() => {
|
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;
|
if (!carId) return;
|
||||||
getCarContext(carId)
|
getCarContext(carId)
|
||||||
.then((c) => {
|
.then((c) => {
|
||||||
@@ -127,7 +148,8 @@ export default function CreateRepairPage() {
|
|||||||
setMileage(c.mileage_starline);
|
setMileage(c.mileage_starline);
|
||||||
})
|
})
|
||||||
.catch((e) => setCtxError((e as Error).message || "Не удалось загрузить машину"));
|
.catch((e) => setCtxError((e as Error).message || "Не удалось загрузить машину"));
|
||||||
}, [carId]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [carId, isExternal]);
|
||||||
|
|
||||||
// У24 упрощено: пробег ввода в форме теперь один — главный «Пробег (км)»
|
// У24 упрощено: пробег ввода в форме теперь один — главный «Пробег (км)»
|
||||||
// в шапке. На submit подставляется и в oil_change.mileage_at_change, чтобы
|
// в шапке. На submit подставляется и в oil_change.mileage_at_change, чтобы
|
||||||
@@ -307,7 +329,9 @@ export default function CreateRepairPage() {
|
|||||||
}));
|
}));
|
||||||
const result = await createRepair(
|
const result = await createRepair(
|
||||||
{
|
{
|
||||||
car_id: carId,
|
...(isExternal
|
||||||
|
? { external_car: { plate: externalState!.plate, make: externalState!.make } }
|
||||||
|
: { car_id: carId }),
|
||||||
mileage,
|
mileage,
|
||||||
works: works.map((w) => ({
|
works: works.map((w) => ({
|
||||||
work_catalog_id: w.work_catalog_id,
|
work_catalog_id: w.work_catalog_id,
|
||||||
@@ -385,7 +409,11 @@ export default function CreateRepairPage() {
|
|||||||
<div className="font-medium">{ctx.plate}</div>
|
<div className="font-medium">{ctx.plate}</div>
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">
|
||||||
{[ctx.brand, ctx.model].filter(Boolean).join(" ")}
|
{[ctx.brand, ctx.model].filter(Boolean).join(" ")}
|
||||||
{ctx.driver_name ? ` · ${ctx.driver_name}` : ` · Без водителя`}
|
{isExternal
|
||||||
|
? " · С улицы"
|
||||||
|
: ctx.driver_name
|
||||||
|
? ` · ${ctx.driver_name}`
|
||||||
|
: " · Без водителя"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -260,7 +260,14 @@ function RepairRow({ item, onOpen }: { item: FeedItem; onOpen: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<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">
|
<div className="text-xs text-muted-foreground flex-shrink-0">
|
||||||
{formatFeedTimestamp(item.created_at)}
|
{formatFeedTimestamp(item.created_at)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user