diff --git a/mechanic-pwa/frontend/public/icons/1032_176495c5.docx b/mechanic-pwa/frontend/public/icons/1032_176495c5.docx new file mode 100644 index 0000000..fbcc7ac Binary files /dev/null and b/mechanic-pwa/frontend/public/icons/1032_176495c5.docx differ diff --git a/mechanic-pwa/frontend/public/icons/1032_8ca47941.docx b/mechanic-pwa/frontend/public/icons/1032_8ca47941.docx new file mode 100644 index 0000000..931388f Binary files /dev/null and b/mechanic-pwa/frontend/public/icons/1032_8ca47941.docx differ diff --git a/mechanic-pwa/frontend/public/icons/oferta_2026_small.docx b/mechanic-pwa/frontend/public/icons/oferta_2026_small.docx new file mode 100644 index 0000000..c4c9883 Binary files /dev/null and b/mechanic-pwa/frontend/public/icons/oferta_2026_small.docx differ diff --git a/mechanic-pwa/frontend/src/App.tsx b/mechanic-pwa/frontend/src/App.tsx index 35982c6..661f6dc 100644 --- a/mechanic-pwa/frontend/src/App.tsx +++ b/mechanic-pwa/frontend/src/App.tsx @@ -9,6 +9,8 @@ import InspectionEditor from "@/pages/InspectionEditor"; import InspectionReview from "@/pages/InspectionReview"; import TtStateDetail from "@/pages/TtStateDetail"; import RepairsFeedPage from "@/pages/repairs/RepairsFeedPage"; +import RepairDetailPage from "@/pages/repairs/RepairDetailPage"; +import EditRepairPage from "@/pages/repairs/EditRepairPage"; import CarSelectPage from "@/pages/repairs/CarSelectPage"; import CreateRepairPage from "@/pages/repairs/CreateRepairPage"; @@ -37,6 +39,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> ); diff --git a/mechanic-pwa/frontend/src/api/repairsFeed.ts b/mechanic-pwa/frontend/src/api/repairsFeed.ts index 2155c9b..0fc9e22 100644 --- a/mechanic-pwa/frontend/src/api/repairsFeed.ts +++ b/mechanic-pwa/frontend/src/api/repairsFeed.ts @@ -36,3 +36,88 @@ export async function getRepairsFeed(params: FeedParams = {}): Promise(); } + +// ── Детальная карточка ремонта ───────────────────────────────────────────── + +export interface RepairDetailWork { + id: number; + work_catalog_id: number; + name: string; + price_applied: number; + manual_override: boolean; + override_comment: string | null; +} + +export interface RepairDetailPart { + id: number; + name: string; + qty: number; +} + +export interface RepairDetailPhoto { + photo_uuid: string; + thumb_url: string | null; + orig_url: string | null; +} + +export interface RepairDetailOil { + mileage_at_change: number | null; + next_in_km: number | null; + has_sticker: boolean; + sticker_thumb_url: string | null; + sticker_orig_url: string | null; +} + +export interface RepairDetailResponse { + id: number; + created_at: string; + car_plate: string | null; + car_make: string | null; + car_model: string | null; + driver_name: string | null; + mechanic_name: string | null; + mileage: number | null; + works: RepairDetailWork[]; + parts: RepairDetailPart[]; + photos: RepairDetailPhoto[]; + oil_change: RepairDetailOil | null; + total_sum: number; + edited_in_crm: boolean; + can_edit: boolean; + edit_until: string | null; +} + +export async function getRepairDetail(id: number): Promise { + return api.get(`repairs/${id}`).json(); +} + +// ── PATCH (правка в 1-часовом окне У49) ──────────────────────────────────── + +export interface PatchWork { + work_catalog_id: number; + price_applied: number; + manual_override: boolean; + override_comment: string | null; +} + +export interface PatchPart { + name: string; + qty: number; +} + +export interface PatchOilChange { + enabled: boolean; + mileage_at_change: number | null; + next_in_km: number | null; +} + +export interface PatchRepairPayload { + mileage: number | null; + works: PatchWork[]; + parts: PatchPart[]; + oil_change: PatchOilChange | null; +} + +export async function patchRepair(id: number, body: PatchRepairPayload): Promise { + return api.patch(`repairs/${id}`, { json: body }).json(); +} diff --git a/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx b/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx index c6272d1..82e8448 100644 --- a/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx +++ b/mechanic-pwa/frontend/src/pages/InspectionEditor.tsx @@ -37,13 +37,17 @@ const SLOTS_GENERAL: { side: string; label: string }[] = [ ]; const SLOT_JACK = { side: "jack", label: "Домкрат" }; +// Шильдик замены масла — отдельный слот при выдаче/приёмке. +// Фото нужно как для сверки текущего пробега со следующим ТО, так и для +// контроля что водитель не клеит свой стикер. +const SLOT_OIL_STICKER = { side: "oil-sticker", label: "Шильдик замены масла" }; /** Все типы осмотров (выдача/приёмка/плановый/...) показывают одинаковый * набор слотов общих ракурсов + домкрат. Раньше «return» исключал общие * фото в пользу скорости приёмки, но фидбек: при приёмке всё равно надо * фиксировать общее состояние машины, как и при выдаче/осмотре. */ function slotsForType(_type: string): typeof SLOTS_GENERAL { - return [...SLOTS_GENERAL, SLOT_JACK]; + return [...SLOTS_GENERAL, SLOT_JACK, SLOT_OIL_STICKER]; } const SEVERITY_LABELS: Record = { diff --git a/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx index b9027db..53a6dcd 100644 --- a/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx +++ b/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx @@ -41,6 +41,10 @@ interface PhotoState { tag: "auto" | "part" | "other"; status: "uploading" | "uploaded" | "error"; errorMessage?: string; + // Превью локального файла через URL.createObjectURL — отдаётся в + // вместо плейсхолдера «Загружено». Освобождается через revokeObjectURL + // при удалении и при размонтировании страницы. + previewUrl?: string; } interface WorkRow { @@ -70,6 +74,14 @@ interface OilChangeState { } +// Цены ремонта — целые рубли без копеек. API отдаёт Numeric(10,2) +// строкой типа "500.00" — пропускаем через округление при отображении. +function fmtPrice(p: number | string): string { + const n = Number(p); + if (Number.isNaN(n)) return String(p); + return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }).format(Math.round(n)); +} + function uuid4(): string { // crypto.randomUUID требует secure context; mechanic.pptaxi.ru — https. return (crypto as any).randomUUID @@ -132,6 +144,7 @@ export default function CreateRepairPage() { if (!files || files.length === 0) return; for (const file of Array.from(files)) { const ext = (file.name.split(".").pop() || "jpg").toLowerCase(); + const previewUrl = URL.createObjectURL(file); const placeholder: PhotoState = { presign: { photo_uuid: uuid4(), @@ -143,6 +156,7 @@ export default function CreateRepairPage() { }, tag: "auto", status: "uploading", + previewUrl, }; if (target === "gallery") setPhotos((arr) => [...arr, placeholder]); else setOilChange((oc) => ({ ...oc, sticker: { ...placeholder, tag: "auto" } })); @@ -150,7 +164,7 @@ export default function CreateRepairPage() { try { const presign = await presignPhoto(ext, placeholder.presign.photo_uuid); await uploadPhotoToTmp(file, presign); - const updated: PhotoState = { presign, tag: "auto", status: "uploaded" }; + const updated: PhotoState = { presign, tag: "auto", status: "uploaded", previewUrl }; if (target === "gallery") { setPhotos((arr) => arr.map((p) => (p.presign.photo_uuid === presign.photo_uuid ? updated : p)), @@ -179,7 +193,11 @@ export default function CreateRepairPage() { }; const removePhoto = (photoUuid: string) => - setPhotos((arr) => arr.filter((p) => p.presign.photo_uuid !== photoUuid)); + setPhotos((arr) => { + const target = arr.find((p) => p.presign.photo_uuid === photoUuid); + if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl); + return arr.filter((p) => p.presign.photo_uuid !== photoUuid); + }); // ── Works picker ───────────────────────────────────────────────────────── @@ -196,7 +214,7 @@ export default function CreateRepairPage() { }; const addWorkFromCatalog = (w: MechWork) => { - const price = Number(w.price); + const price = Math.round(Number(w.price)); setWorks((arr) => [ ...arr, { @@ -395,17 +413,28 @@ export default function CreateRepairPage() {
{photos.map((p) => (
+ {p.previewUrl && ( + + )} {p.status === "uploading" && ( -
Загрузка…
+
+ Загрузка… +
)} {p.status === "error" && ( -
+
{p.errorMessage || "Ошибка"}
)} {p.status === "uploaded" && ( -
- ✓ Загружено +
+ + +
)} ))} diff --git a/mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx new file mode 100644 index 0000000..bdaf72f --- /dev/null +++ b/mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx @@ -0,0 +1,564 @@ +/** + * Правка ремонта в 1-часовом окне (У49). + * + * Минимальная версия: редактируются работы (имя из каталога + цена + + * ручная правка + комментарий), запчасти, замена масла, пробег. Фото + * НЕ редактируются — для смены фото нужно дождаться PR-9. + */ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { ChevronLeft, Plus, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + getRepairDetail, + patchRepair, + type RepairDetailResponse, +} from "@/api/repairsFeed"; +import { searchWorks, suggestParts, type MechWork } from "@/api/repairsCreate"; + +interface WorkRow { + rowKey: string; + work_catalog_id: number; + name: string; + price_catalog: number; + price_applied: number; + manual_override: boolean; + override_comment: string | null; +} + +interface PartRow { + rowKey: string; + name: string; + qty: number; +} + +interface OilState { + enabled: boolean; + mileage_at_change: number | null; + next_in_km: number | null; +} + +function uid(): string { + return Math.random().toString(36).slice(2, 10); +} + +function fmt(n: number): string { + return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }).format(Math.round(n)); +} + +export default function EditRepairPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Form state. + const [mileage, setMileage] = useState(null); + const [works, setWorks] = useState([]); + const [parts, setParts] = useState([]); + const [oil, setOil] = useState({ enabled: false, mileage_at_change: null, next_in_km: null }); + + const [worksPicker, setWorksPicker] = useState<{ open: boolean; q: string; results: MechWork[] }>( + { open: false, q: "", results: [] } + ); + const [overrideModal, setOverrideModal] = useState<{ rowKey: string; comment: string } | null>(null); + + useEffect(() => { + if (!id) return; + setLoading(true); + getRepairDetail(Number(id)) + .then((d) => { + setDetail(d); + setMileage(d.mileage); + setWorks(d.works.map((w) => ({ + rowKey: uid(), + work_catalog_id: w.work_catalog_id, + name: w.name, + price_catalog: w.price_applied, // в payload мы не знаем catalog price отдельно — фронт хранит applied; PATCH перечитает из БД каталога + price_applied: w.price_applied, + manual_override: w.manual_override, + override_comment: w.override_comment, + }))); + setParts(d.parts.map((p) => ({ + rowKey: uid(), name: p.name, qty: p.qty, + }))); + if (d.oil_change) { + setOil({ + enabled: true, + mileage_at_change: d.oil_change.mileage_at_change, + next_in_km: d.oil_change.next_in_km, + }); + } + if (!d.can_edit) { + setError("Окно правки закрыто. Изменения отправить нельзя."); + } + }) + .catch((e: Error) => setError(e.message || "Не удалось загрузить ремонт")) + .finally(() => setLoading(false)); + }, [id]); + + const totalSum = useMemo(() => works.reduce((s, w) => s + (w.price_applied || 0), 0), [works]); + + // 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 pickWork = (mw: MechWork) => { + const p = Math.round(Number(mw.price)); + setWorks((arr) => [...arr, { + rowKey: uid(), + work_catalog_id: mw.id, + name: mw.name, + price_catalog: p, + price_applied: p, + manual_override: false, + override_comment: null, + }]); + setWorksPicker({ open: false, q: "", results: [] }); + }; + + // Override-цена: открываем модалку для комментария. + const startOverride = (rowKey: string) => { + const cur = works.find((w) => w.rowKey === rowKey); + setOverrideModal({ rowKey, comment: cur?.override_comment ?? "" }); + }; + const confirmOverride = (newPrice: number) => { + if (!overrideModal) return; + setWorks((arr) => arr.map((w) => + w.rowKey === overrideModal.rowKey + ? { ...w, price_applied: newPrice, manual_override: true, override_comment: overrideModal.comment } + : w + )); + setOverrideModal(null); + }; + + const submit = async () => { + if (!detail) return; + setError(null); + + // Валидация: каждый manual_override → комментарий. + for (const w of works) { + if (w.manual_override && !(w.override_comment || "").trim()) { + setError(`У работы «${w.name}» включена ручная правка цены, но не указан комментарий`); + return; + } + } + if (works.length === 0) { + setError("Минимум одна работа в ремонте"); + return; + } + + setSaving(true); + try { + await patchRepair(detail.id, { + mileage, + works: works.map((w) => ({ + work_catalog_id: w.work_catalog_id, + price_applied: Math.round(w.price_applied), + manual_override: w.manual_override, + override_comment: w.manual_override ? (w.override_comment ?? "").trim() : null, + })), + parts: parts.filter((p) => p.name.trim().length > 0).map((p) => ({ + name: p.name.trim(), + qty: p.qty, + })), + oil_change: oil.enabled + ? { enabled: true, mileage_at_change: oil.mileage_at_change, next_in_km: oil.next_in_km } + : { enabled: false, mileage_at_change: null, next_in_km: null }, + }); + navigate(`/repairs/${detail.id}`, { replace: true }); + } catch (e) { + setError((e as Error).message || "Не удалось сохранить"); + setSaving(false); + } + }; + + return ( +
+
+
+ +

+ Правка ремонта {detail ? `#${detail.id}` : ""} +

+
+
+ +
+ {loading && ( +
+ Загрузка… +
+ )} + {error && ( +
+ {error} +
+ )} + {detail && ( + <> + {/* Шапка машины — read-only */} +
+
{detail.car_plate ?? "—"}
+ {(detail.car_make || detail.car_model) && ( +
{detail.car_make} {detail.car_model}
+ )} + {detail.driver_name && ( +
+ Водитель: + {detail.driver_name} +
+ )} +
+ + {/* Пробег */} +
+ + setMileage(e.target.value === "" ? null : Number(e.target.value))} + className="mt-1" + /> +
+ + {/* Работы */} +
+
+ + + {works.length} {works.length === 1 ? "шт" : "шт"} + +
+
+ {works.map((w) => ( +
+
+
+
{w.name}
+
+ + {fmt(w.price_applied)} ₽ + + {w.manual_override && ( + + ✎ ручная правка + + )} + {w.manual_override && w.price_applied !== w.price_catalog && ( + + {fmt(w.price_catalog)} + + )} +
+ {w.manual_override && w.override_comment && ( +
+ {w.override_comment} +
+ )} +
+ + +
+
+ ))} + +
+ Итого + {fmt(totalSum)} ₽ +
+
+
+ + {/* Запчасти */} +
+
+ + {parts.length} шт +
+
+ {parts.map((p) => ( + + setParts((arr) => arr.map((x) => x.rowKey === p.rowKey ? { ...x, name, qty } : x)) + } + onRemove={() => setParts((arr) => arr.filter((x) => x.rowKey !== p.rowKey))} + /> + ))} + +
+
+ + {/* Замена масла */} +
+ + {oil.enabled && ( +
+
+ + setOil((o) => ({ + ...o, mileage_at_change: e.target.value === "" ? null : Number(e.target.value), + }))} + /> +
+
+ + setOil((o) => ({ + ...o, next_in_km: e.target.value === "" ? null : Number(e.target.value), + }))} + /> +
+
+ )} +
+ +
+ Фото и стикер замены масла редактировать пока нельзя — для смены + нужно создать новый ремонт. +
+ + )} +
+ + {/* Sticky footer */} + {detail && ( +
+ + +
+ )} + + {/* Works picker modal */} + {worksPicker.open && ( +
+
+ + queryWorks(e.target.value)} + /> +
+
+ {worksPicker.results.map((mw) => ( + + ))} + {worksPicker.results.length === 0 && ( +
+ Ничего не найдено +
+ )} +
+
+ )} + + {/* Override modal */} + {overrideModal && (() => { + const cur = works.find((w) => w.rowKey === overrideModal.rowKey); + if (!cur) return null; + return ( + setOverrideModal(null)} + onSubmit={(newPrice, comment) => { + setOverrideModal((m) => m ? { ...m, comment } : m); + confirmOverride(newPrice); + }} + /> + ); + })()} +
+ ); +} + + +function PartEditRow({ name, qty, onChange, onRemove }: { + name: string; + qty: number; + onChange: (name: string, qty: number) => void; + onRemove: () => void; +}) { + const [suggestions, setSuggestions] = useState([]); + const [showSugg, setShowSugg] = useState(false); + + const fetchSuggestions = async (q: string) => { + if (!q.trim()) { setSuggestions([]); return; } + try { + const r = await suggestParts(q); + setSuggestions(r.map((x) => x.name)); + } catch { /* noop */ } + }; + + return ( +
+
+ { onChange(e.target.value, qty); fetchSuggestions(e.target.value); setShowSugg(true); }} + onFocus={() => name && fetchSuggestions(name)} + onBlur={() => setTimeout(() => setShowSugg(false), 150)} + /> + {showSugg && suggestions.length > 0 && ( +
+ {suggestions.map((s) => ( + + ))} +
+ )} +
+ onChange(name, Math.max(1, Number(e.target.value) || 1))} + className="w-20" + /> + +
+ ); +} + + +function OverridePriceModal({ workName, catalogPrice, appliedPrice, initialComment, onCancel, onSubmit }: { + workName: string; + catalogPrice: number; + appliedPrice: number; + initialComment: string; + onCancel: () => void; + onSubmit: (newPrice: number, comment: string) => void; +}) { + const [price, setPrice] = useState(String(Math.round(appliedPrice))); + const [comment, setComment] = useState(initialComment); + + return ( +
+
e.stopPropagation()}> +

Изменить цену

+
+
{workName}
+
+ По каталогу: {fmt(catalogPrice)} ₽ +
+
+
+ + setPrice(e.target.value)} + autoFocus + /> +
+
+ +