Compare commits

...
4 Commits
Author SHA1 Message Date
tremble7681andclaude-flow 8c9caedb03 feat(repairs): 'с улицы' badge in PWA feed
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-05 14:14:27 +03:00
tremble7681andclaude-flow d0ebd9ddf5 feat(repairs): external mode in create form + /repairs/new-external route
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-05 14:12:57 +03:00
tremble7681andclaude-flow 7e6817b3ff feat(repairs): street-car entry button + sheet on car select
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-05 14:11:03 +03:00
tremble7681andclaude-flow 3d875642f8 feat(repairs): external_car in create payload type
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-05 14:09:45 +03:00
6 changed files with 100 additions and 6 deletions
+1
View File
@@ -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>