Premium Механик
+ {revocation && (
+
+
У вас нет доступа к PWA Premium Мехник
+
+ Обратитесь к старшему механику или администратору CRM
+ {(() => {
+ const tg = revocation.support?.tg_username;
+ const phone = revocation.support?.phone;
+ if (tg) {
+ return (
+ <>
+ {": "}
+
+ @{tg}
+
+ {phone ? (
+ <>
+ {" · "}
+
{phone}
+ >
+ ) : null}
+ >
+ );
+ }
+ if (phone) {
+ return (
+ <>
+ {": "}
+
{phone}
+ >
+ );
+ }
+ return ".";
+ })()}
+
+
+ )}
+
{step.kind === "credentials" && (
<>
diff --git a/mechanic-pwa/frontend/src/pages/repairs/CarSelectPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/CarSelectPage.tsx
new file mode 100644
index 0000000..17b3e96
--- /dev/null
+++ b/mechanic-pwa/frontend/src/pages/repairs/CarSelectPage.tsx
@@ -0,0 +1,101 @@
+/**
+ * Экран выбора машины перед созданием ремонта (Фича 3).
+ *
+ * Открывается из `/repairs` по кнопке «+ Добавить ремонт».
+ * Поиск по госномеру (У44 — релевантность). После тапа на строку
+ * переходим в `/repairs/new/:carId` (CreateRepairPage).
+ */
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { ChevronLeft, Search } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { searchCars, type CarBrief } from "@/api/repairsCreate";
+
+export default function CarSelectPage() {
+ const navigate = useNavigate();
+ const [q, setQ] = useState("");
+ const [cars, setCars] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ setError(null);
+ const t = setTimeout(async () => {
+ try {
+ const list = await searchCars(q);
+ if (!cancelled) setCars(list);
+ } catch (e) {
+ if (!cancelled) setError((e as Error).message || "Не удалось загрузить машины");
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ }, 200); // debounce
+ return () => {
+ cancelled = true;
+ clearTimeout(t);
+ };
+ }, [q]);
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {loading ? (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ ) : cars.length === 0 ? (
+
+ {q ? "Ничего не найдено" : "Нет активных машин в парке"}
+
+ ) : (
+
+ {cars.map((c) => (
+ -
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx
new file mode 100644
index 0000000..b9027db
--- /dev/null
+++ b/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx
@@ -0,0 +1,675 @@
+/**
+ * Карточка создания ремонта (Фича 4 + У19/У20/У21/У24/У25/У42/У45).
+ *
+ * Online-only flow (PR-7). Offline-first из У47/PR-8 — следующий PR.
+ *
+ * Блоки:
+ * - Шапка: машина (snapshot из /cars/{id}/context) + механик + водитель.
+ * - Пробег (предзаполняется StarLine, можно перебить вручную).
+ * - Фото — multi-upload через presign + PUT в `tmp/`.
+ * - Работы — выбор из справочника, цена редактируема через
+ * «карандашик» (У19); комментарий обязателен при разблокировке.
+ * - Запчасти — текст + qty, автодополнение (У43).
+ * - Замена масла — раскрывающийся блок с дефолтным интервалом 10000 (У25).
+ *
+ * Сохранение: POST /repairs с `Idempotency-Key` (UUID v4 в state).
+ */
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { ChevronLeft, Plus, Trash2, Pencil, Camera } from "lucide-react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ createRepair,
+ getCarContext,
+ presignPhoto,
+ searchWorks,
+ suggestParts,
+ uploadPhotoToTmp,
+ type CarContext,
+ type CreatePhotoPayload,
+ type MechWork,
+ type PresignResponse,
+} from "@/api/repairsCreate";
+
+// ── Local state types ─────────────────────────────────────────────────────
+
+interface PhotoState {
+ presign: PresignResponse;
+ tag: "auto" | "part" | "other";
+ status: "uploading" | "uploaded" | "error";
+ errorMessage?: string;
+}
+
+interface WorkRow {
+ // Идентификатор строки внутри UI (для key); тождественен work_catalog_id
+ // + автоинкремент, чтобы дубли по У2 различались.
+ rowKey: string;
+ work_catalog_id: number;
+ name: string; // снапшот имени из прайса
+ price_catalog: number;
+ price_applied: number;
+ unlocked: boolean; // карандашик разблокировал поле
+ override_comment: string;
+}
+
+interface PartRow {
+ rowKey: string;
+ name: string;
+ qty: number;
+}
+
+interface OilChangeState {
+ enabled: boolean;
+ mileage_at_change: number | null;
+ mileage_is_dirty: boolean; // У24: мягкая подписка с шапочным
+ next_in_km: number;
+ sticker: PhotoState | null;
+}
+
+
+function uuid4(): string {
+ // crypto.randomUUID требует secure context; mechanic.pptaxi.ru — https.
+ return (crypto as any).randomUUID
+ ? (crypto as any).randomUUID()
+ : `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
+}
+
+
+export default function CreateRepairPage() {
+ const navigate = useNavigate();
+ const { carId: carIdRaw } = useParams<{ carId: string }>();
+ const carId = carIdRaw ? Number(carIdRaw) : 0;
+
+ // Idempotency-Key для этой формы — стабилен между ре-сабмитами (У20).
+ const idemKey = useRef(uuid4());
+
+ const [ctx, setCtx] = useState(null);
+ const [ctxError, setCtxError] = useState(null);
+
+ const [mileage, setMileage] = useState(null);
+ const [photos, setPhotos] = useState([]);
+ const [works, setWorks] = useState([]);
+ const [parts, setParts] = useState([]);
+ const [oilChange, setOilChange] = useState({
+ enabled: false,
+ mileage_at_change: null,
+ mileage_is_dirty: false,
+ next_in_km: 10000,
+ sticker: null,
+ });
+
+ const [worksPicker, setWorksPicker] = useState<{ open: boolean; q: string; results: MechWork[] }>(
+ { open: false, q: "", results: [] },
+ );
+
+ const [submitting, setSubmitting] = useState(false);
+
+ // Initial: подгрузить контекст машины.
+ useEffect(() => {
+ if (!carId) return;
+ getCarContext(carId)
+ .then((c) => {
+ setCtx(c);
+ setMileage(c.mileage_starline);
+ })
+ .catch((e) => setCtxError((e as Error).message || "Не удалось загрузить машину"));
+ }, [carId]);
+
+ // У24: мягкая подписка пробега блока масла на шапочный.
+ useEffect(() => {
+ if (oilChange.enabled && !oilChange.mileage_is_dirty) {
+ setOilChange((oc) => ({ ...oc, mileage_at_change: mileage }));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [mileage, oilChange.enabled]);
+
+ // ── Photo upload ─────────────────────────────────────────────────────────
+
+ const onPhotoInput = async (files: FileList | null, target: "gallery" | "sticker") => {
+ if (!files || files.length === 0) return;
+ for (const file of Array.from(files)) {
+ const ext = (file.name.split(".").pop() || "jpg").toLowerCase();
+ const placeholder: PhotoState = {
+ presign: {
+ photo_uuid: uuid4(),
+ extension: ext,
+ content_type: file.type || "image/jpeg",
+ key: "",
+ upload_url: "",
+ expires_in: 0,
+ },
+ tag: "auto",
+ status: "uploading",
+ };
+ if (target === "gallery") setPhotos((arr) => [...arr, placeholder]);
+ else setOilChange((oc) => ({ ...oc, sticker: { ...placeholder, tag: "auto" } }));
+
+ try {
+ const presign = await presignPhoto(ext, placeholder.presign.photo_uuid);
+ await uploadPhotoToTmp(file, presign);
+ const updated: PhotoState = { presign, tag: "auto", status: "uploaded" };
+ if (target === "gallery") {
+ setPhotos((arr) =>
+ arr.map((p) => (p.presign.photo_uuid === presign.photo_uuid ? updated : p)),
+ );
+ } else {
+ setOilChange((oc) => ({ ...oc, sticker: updated }));
+ }
+ } catch (e) {
+ const msg = (e as Error).message || "Ошибка загрузки";
+ if (target === "gallery") {
+ setPhotos((arr) =>
+ arr.map((p) =>
+ p.presign.photo_uuid === placeholder.presign.photo_uuid
+ ? { ...p, status: "error", errorMessage: msg }
+ : p,
+ ),
+ );
+ } else {
+ setOilChange((oc) => ({
+ ...oc,
+ sticker: oc.sticker ? { ...oc.sticker, status: "error", errorMessage: msg } : oc.sticker,
+ }));
+ }
+ }
+ }
+ };
+
+ const removePhoto = (photoUuid: string) =>
+ setPhotos((arr) => arr.filter((p) => p.presign.photo_uuid !== photoUuid));
+
+ // ── Works picker ─────────────────────────────────────────────────────────
+
+ const openWorksPicker = async () => {
+ setWorksPicker({ open: true, q: "", results: [] });
+ const list = await searchWorks("");
+ setWorksPicker((s) => ({ ...s, results: list }));
+ };
+
+ const queryWorks = async (q: string) => {
+ setWorksPicker((s) => ({ ...s, q }));
+ const list = await searchWorks(q);
+ setWorksPicker((s) => ({ ...s, results: list }));
+ };
+
+ const addWorkFromCatalog = (w: MechWork) => {
+ const price = Number(w.price);
+ setWorks((arr) => [
+ ...arr,
+ {
+ rowKey: uuid4(),
+ work_catalog_id: w.id,
+ name: w.name,
+ price_catalog: price,
+ price_applied: price,
+ unlocked: false,
+ override_comment: "",
+ },
+ ]);
+ setWorksPicker({ open: false, q: "", results: [] });
+ };
+
+ const toggleWorkUnlock = (rowKey: string) => {
+ setWorks((arr) =>
+ arr.map((w) =>
+ w.rowKey === rowKey
+ ? w.unlocked
+ ? // повторный тап = отмена правки, возврат к цене прайса (У19)
+ { ...w, unlocked: false, price_applied: w.price_catalog, override_comment: "" }
+ : { ...w, unlocked: true }
+ : w,
+ ),
+ );
+ };
+
+ const removeWork = (rowKey: string) =>
+ setWorks((arr) => arr.filter((w) => w.rowKey !== rowKey));
+
+ const totalSum = useMemo(
+ () => works.reduce((acc, w) => acc + Number(w.price_applied || 0), 0),
+ [works],
+ );
+
+ // ── Parts ────────────────────────────────────────────────────────────────
+
+ const [partsDraft, setPartsDraft] = useState<{ name: string; suggestions: string[] }>({ name: "", suggestions: [] });
+
+ const queryPartSuggestions = async (q: string) => {
+ setPartsDraft({ name: q, suggestions: [] });
+ if (!q.trim()) return;
+ const list = await suggestParts(q);
+ setPartsDraft({ name: q, suggestions: list.map((s) => s.name) });
+ };
+
+ const addPart = (name: string) => {
+ const trimmed = name.trim();
+ if (!trimmed) return;
+ setParts((arr) => [...arr, { rowKey: uuid4(), name: trimmed, qty: 1 }]);
+ setPartsDraft({ name: "", suggestions: [] });
+ };
+
+ const removePart = (rowKey: string) =>
+ setParts((arr) => arr.filter((p) => p.rowKey !== rowKey));
+
+ // ── Submit ───────────────────────────────────────────────────────────────
+
+ const canSubmit = () =>
+ photos.length > 0 &&
+ photos.every((p) => p.status === "uploaded") &&
+ works.length > 0 &&
+ works.every((w) => !w.unlocked || w.override_comment.trim().length > 0);
+
+ const submitWhyDisabled = (): string => {
+ if (photos.length === 0) return "Добавьте хотя бы одно фото";
+ if (photos.some((p) => p.status !== "uploaded")) return "Дождитесь окончания загрузки фото";
+ if (works.length === 0) return "Добавьте хотя бы одну работу";
+ const unlockedNoComment = works.find((w) => w.unlocked && !w.override_comment.trim());
+ if (unlockedNoComment) return "Укажите комментарий к ручной правке цены";
+ return "";
+ };
+
+ const onSubmit = async () => {
+ const reason = submitWhyDisabled();
+ if (reason) {
+ toast.error(reason);
+ return;
+ }
+ setSubmitting(true);
+ try {
+ const photoPayload: CreatePhotoPayload[] = photos.map((p, i) => ({
+ photo_uuid: p.presign.photo_uuid,
+ extension: p.presign.extension,
+ tag: p.tag,
+ sort_order: i,
+ }));
+ const result = await createRepair(
+ {
+ car_id: carId,
+ mileage,
+ works: works.map((w) => ({
+ work_catalog_id: w.work_catalog_id,
+ price_applied: Number(w.price_applied),
+ manual_override: w.unlocked,
+ override_comment: w.unlocked ? w.override_comment.trim() : null,
+ })),
+ parts: parts.map((p) => ({ name: p.name, qty: p.qty })),
+ photos: photoPayload,
+ oil_change: oilChange.enabled
+ ? {
+ enabled: true,
+ mileage_at_change: oilChange.mileage_at_change,
+ next_in_km: oilChange.next_in_km,
+ sticker_photo_uuid:
+ oilChange.sticker?.status === "uploaded"
+ ? oilChange.sticker.presign.photo_uuid
+ : null,
+ sticker_extension:
+ oilChange.sticker?.status === "uploaded"
+ ? oilChange.sticker.presign.extension
+ : null,
+ }
+ : null,
+ },
+ idemKey.current,
+ );
+ toast.success(`Ремонт #${result.id} сохранён`);
+ navigate("/repairs", { replace: true });
+ } catch (e) {
+ toast.error((e as Error).message || "Не удалось сохранить");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ if (ctxError) {
+ return (
+
+ );
+ }
+ if (!ctx) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
Новый ремонт
+
+
+
+
{ctx.plate}
+
+ {[ctx.brand, ctx.model].filter(Boolean).join(" ")}
+ {ctx.driver_name ? ` · ${ctx.driver_name}` : ` · Без водителя`}
+
+
+
+
+
+ {/* Пробег */}
+
+
+ {/* Фото */}
+
+
+
+
+ {photos.length === 0 ? "Минимум одно фото" : `${photos.length} шт.`}
+
+
+
+ {photos.map((p) => (
+
+ {p.status === "uploading" && (
+
Загрузка…
+ )}
+ {p.status === "error" && (
+
+ {p.errorMessage || "Ошибка"}
+
+ )}
+ {p.status === "uploaded" && (
+
+ ✓ Загружено
+
+ )}
+
+
+ ))}
+
+
+
+
+ {/* Работы */}
+
+
+
+
+
+ {works.length === 0 ? (
+ Минимум одна работа
+ ) : (
+
+ {works.map((w) => (
+ -
+
+
+
{w.name}
+
+ Прайс: {w.price_catalog} ₽
+
+
+
+
+ setWorks((arr) =>
+ arr.map((row) =>
+ row.rowKey === w.rowKey
+ ? { ...row, price_applied: Number(e.target.value) }
+ : row,
+ ),
+ )
+ }
+ className="w-24"
+ />
+
+
+
+
+ {w.unlocked && (
+
+ ))}
+
+ )}
+ {works.length > 0 && (
+ Итого: {totalSum} ₽
+ )}
+
+
+ {/* Запчасти */}
+
+
+ {parts.length > 0 && (
+
+ )}
+
+
queryPartSuggestions(e.target.value)}
+ placeholder="Например, Свеча NGK"
+ onKeyDown={(e) => e.key === "Enter" && addPart(partsDraft.name)}
+ />
+
+
+ {partsDraft.suggestions.length > 0 && (
+
+ {partsDraft.suggestions.map((s) => (
+ -
+
+
+ ))}
+
+ )}
+
+
+ {/* Замена масла */}
+
+
+
+ {/* Works picker modal */}
+ {worksPicker.open && (
+
+
+
+
+ {worksPicker.results.map((w) => (
+ -
+
+
+ ))}
+ {worksPicker.results.length === 0 && (
+ -
+ Нет работ по запросу
+
+ )}
+
+
+
+ )}
+
+ );
+}
diff --git a/mechanic-pwa/frontend/src/pages/repairs/RepairsFeedPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/RepairsFeedPage.tsx
new file mode 100644
index 0000000..d3c18c9
--- /dev/null
+++ b/mechanic-pwa/frontend/src/pages/repairs/RepairsFeedPage.tsx
@@ -0,0 +1,228 @@
+/**
+ * Лента ремонтов парка (Фича 2 + У5 + У38 + У39 + У40 + У41).
+ *
+ * Главный экран раздела «Ремонт». Все ремонты парка, сортировка
+ * `created_at DESC`. Бесконечный скролл по 20. Pull-to-refresh,
+ * обновление при возврате в приложение (через useRepairsFeed).
+ *
+ * Пустая лента — плейсхолдер «В парке пока нет ремонтов» (У41).
+ */
+import { useCallback, useEffect, useRef } from "react";
+import { useNavigate } from "react-router-dom";
+import { ChevronLeft, Plus, Wrench } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useRepairsFeed } from "@/hooks/useRepairsFeed";
+import { formatFeedTimestamp } from "@/lib/timeFormat";
+import type { FeedItem } from "@/api/repairsFeed";
+
+export default function RepairsFeedPage() {
+ const navigate = useNavigate();
+ const {
+ items,
+ isLoading,
+ isError,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ refetch,
+ isFetching,
+ } = useRepairsFeed();
+
+ // IntersectionObserver для бесконечной прокрутки.
+ const sentinelRef = useRef(null);
+ useEffect(() => {
+ const el = sentinelRef.current;
+ if (!el || !hasNextPage) return;
+ const io = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting && !isFetchingNextPage) {
+ void fetchNextPage();
+ }
+ },
+ { rootMargin: "200px" },
+ );
+ io.observe(el);
+ return () => io.disconnect();
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
+
+ // Pull-to-refresh: простой UX через тач-скролл сверху.
+ // Делаем минимально-инвазивно: на тап-кнопку «обновить» вызываем refetch.
+ // Полноценный pull-to-refresh с жестом — отдельная задача.
+ const handleRefresh = useCallback(() => {
+ void refetch();
+ }, [refetch]);
+
+ const handleAdd = () => navigate("/repairs/new");
+ const handleOpen = (item: FeedItem) => navigate(`/repairs/${item.id}`);
+
+ return (
+
+
+
+
+
Ремонт
+
+
+
+
+
+ {/* Refresh tap-area для не-touch юзкейса; жест pull-to-refresh — в PR-8. */}
+
+
+
+
+ {isLoading && (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {isError && !isLoading && (
+
+ Не удалось загрузить ленту ремонтов. Проверьте сеть и попробуйте снова.
+
+ )}
+
+ {!isLoading && !isError && items.length === 0 && }
+
+ {items.length > 0 && (
+
+ {items.map((item) => (
+ -
+ handleOpen(item)} />
+
+ ))}
+
+ )}
+
+
+ {isFetchingNextPage && (
+
+ Загружаем ещё…
+
+ )}
+ {!hasNextPage && items.length > 0 && !isFetchingNextPage && (
+
+ Это все ремонты.
+
+ )}
+
+
+ );
+}
+
+
+function RepairRow({ item, onOpen }: { item: FeedItem; onOpen: () => void }) {
+ const carLine = [item.car_plate, item.car_make, item.car_model]
+ .filter(Boolean)
+ .join(" · ");
+ return (
+
+ );
+}
+
+
+function WorksLine({ works, extra }: { works: string[]; extra: number }) {
+ // У40: ellipsis в одну строку. Соединяем имена работ через «·».
+ if (works.length === 0) return null;
+ const tail = extra > 0 ? ` · +${extra} ${pluralWorks(extra)}` : "";
+ return (
+
+ {works.join(" · ")}
+ {tail && {tail}}
+
+ );
+}
+
+
+function pluralWorks(n: number): string {
+ // 1 → «работа», 2..4 → «работы», 5+ → «работ»
+ const mod10 = n % 10;
+ const mod100 = n % 100;
+ if (mod100 >= 11 && mod100 <= 14) return "работ";
+ if (mod10 === 1) return "работа";
+ if (mod10 >= 2 && mod10 <= 4) return "работы";
+ return "работ";
+}
+
+
+function EmptyState({ onAdd }: { onAdd: () => void }) {
+ return (
+
+
+
+
+
+
В парке пока нет ремонтов
+
+ Создайте первый ремонт — он появится здесь.
+
+
+
+
+ );
+}