repairs: несколько замен масла за заезд (двигатель + коробка)

Блок «Замена масла» теперь мульти: чекбоксы Двигатель / Коробка (можно оба),
общий одометр (одна замена — один пробег). Отправляем список oil_changes
(1:N), стикер прикрепляем к первой выбранной. Тип 'transmission' объединяет
АКПП/вариатор/робот — интервал по модели в отчёте СТО.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 13:37:38 +10:00
co-authored by Claude Opus 4.8
parent 3c27b794dd
commit ce4b50109a
2 changed files with 60 additions and 46 deletions
@@ -72,7 +72,8 @@ export interface CreateRepairPayload {
works: CreateWorkPayload[];
parts: CreatePartPayload[];
photos: CreatePhotoPayload[];
oil_change: CreateOilChangePayload | null;
// 1:N — за один заезд бывает несколько замен (двигатель + коробка).
oil_changes: CreateOilChangePayload[];
}
export interface CreateRepairResponse {
@@ -61,14 +61,13 @@ interface PartRow {
qty: number;
}
type OilKind = "engine" | "cvt" | "akpp";
interface OilChangeState {
enabled: boolean;
mileage_at_change: number | null;
mileage_at_change: number | null; // общий одометр — один на все замены заезда
mileage_is_dirty: boolean; // У24: мягкая подписка с шапочным
next_in_km: number;
oil_type: OilKind;
next_in_km: number; // интервал двигателя
// Какие масла меняли за заезд (можно оба сразу):
types: { engine: boolean; transmission: boolean };
sticker: PhotoState | null;
}
@@ -101,7 +100,7 @@ export default function CreateRepairPage() {
mileage_at_change: null,
mileage_is_dirty: false,
next_in_km: 10000,
oil_type: "engine",
types: { engine: true, transmission: false },
sticker: null,
});
@@ -272,6 +271,8 @@ export default function CreateRepairPage() {
if (works.length === 0) return "Добавьте хотя бы одну работу";
const unlockedNoComment = works.find((w) => w.unlocked && !w.override_comment.trim());
if (unlockedNoComment) return "Укажите комментарий к ручной правке цены";
if (oilChange.enabled && !oilChange.types.engine && !oilChange.types.transmission)
return "Выберите тип масла (двигатель и/или коробка) или снимите «Была замена масла»";
return "";
};
@@ -301,22 +302,25 @@ export default function CreateRepairPage() {
})),
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,
oil_type: oilChange.oil_type,
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,
oil_changes: oilChange.enabled
? (["engine", "transmission"] as const)
.filter((t) => oilChange.types[t])
.map((t, idx) => ({
enabled: true,
mileage_at_change: oilChange.mileage_at_change,
next_in_km: t === "engine" ? oilChange.next_in_km : 0,
oil_type: t,
// Стикер прикрепляем к первой выбранной замене (обычно двигатель).
sticker_photo_uuid:
idx === 0 && oilChange.sticker?.status === "uploaded"
? oilChange.sticker.presign.photo_uuid
: null,
sticker_extension:
idx === 0 && oilChange.sticker?.status === "uploaded"
? oilChange.sticker.presign.extension
: null,
}))
: [],
},
idemKey.current,
);
@@ -585,25 +589,34 @@ export default function CreateRepairPage() {
{oilChange.enabled && (
<div className="mt-3 space-y-3">
<div>
<Label className="text-sm">Тип масла</Label>
<div className="mt-1 grid grid-cols-3 gap-2">
{([["engine", "Двигатель"], ["cvt", "Вариатор"], ["akpp", "АКПП"]] as [OilKind, string][]).map(
([v, l]) => (
<button
type="button"
key={v}
onClick={() => setOilChange((oc) => ({ ...oc, oil_type: v }))}
className={`rounded-md border px-2 py-2 text-sm ${
oilChange.oil_type === v
? "border-primary bg-primary/10 font-medium text-primary"
: "border-input"
}`}
>
{l}
</button>
),
)}
<Label className="text-sm">Что меняли (можно оба)</Label>
<div className="mt-1 grid grid-cols-2 gap-2">
{(
[
["engine", "Двигатель"],
["transmission", "Коробка"],
] as ["engine" | "transmission", string][]
).map(([v, l]) => (
<button
type="button"
key={v}
onClick={() =>
setOilChange((oc) => ({ ...oc, types: { ...oc.types, [v]: !oc.types[v] } }))
}
className={`rounded-md border px-2 py-2 text-sm ${
oilChange.types[v]
? "border-primary bg-primary/10 font-medium text-primary"
: "border-input"
}`}
>
{oilChange.types[v] ? "✓ " : ""}
{l}
</button>
))}
</div>
{!oilChange.types.engine && !oilChange.types.transmission && (
<p className="mt-1 text-xs text-destructive">Выберите хотя бы один тип масла.</p>
)}
</div>
<div>
<Label className="text-sm">Пробег на момент замены</Label>
@@ -620,9 +633,9 @@ export default function CreateRepairPage() {
}
/>
</div>
{oilChange.oil_type === "engine" ? (
{oilChange.types.engine && (
<div>
<Label className="text-sm">Следующая замена через (км)</Label>
<Label className="text-sm">Двигатель: следующая замена через (км)</Label>
<Input
type="number"
min={0}
@@ -632,10 +645,10 @@ export default function CreateRepairPage() {
}
/>
</div>
) : (
)}
{oilChange.types.transmission && (
<p className="text-xs text-muted-foreground">
Интервал {oilChange.oil_type === "cvt" ? "вариатора" : "АКПП"} задаётся по модели авто в
отчёте СТО.
Интервал коробки задаётся по модели авто в отчёте СТО.
</p>
)}
<div>