Compare commits

..
6 Commits
Author SHA1 Message Date
tremble7681 fd0ec64da9 feat(pwa/repairs): show price per row + total sum header
В каждой карточке ленты ремонтов справа от строки работ выводим сумму
работ (works_total из бэка, без запчастей). Шапка над лентой переехала:
слева «Итого: NNN ₽ (X ремонтов)», справа кнопка «Обновить» (раньше
была одна сверху-справа).
2026-05-25 21:52:24 +10:00
tremble7681 3b7b2ae56d fix(pwa/repairs): allow clearing price input without sticky 0
В CreateRepairPage поле «Цена применённая» хранило цифру через
price_applied: number. При удалении символов React сразу подставлял 0
и его нельзя было стереть — приходилось каретку перед нулём ставить
руками. Поменяли тип на number | "" в WorkRow и в onChange оставляем
пустую строку в стейте, пока юзер не ввёл новую цифру. На submit и в
сумму "" трактуется как 0 (Number("") === 0).

EditRepairPage не задет — там цена правится через модалку, цена там
уже жила как string (useState(String(...))) и пустое было допустимо.
2026-05-25 21:43:07 +10:00
tremble7681 205078c5ce fix(pwa/repairs): drop duplicate mileage input under «Замена масла»
При создании/редактировании ремонта в форме был отдельный «Пробег на
момент замены» внутри блока замены масла — auto-sync от шапочного с
soft-dirty флагом по У24. На практике это всё равно заставляло механика
вводить значение дважды, если шапочный пробег вводился вручную (StarLine
недоступен): момент включения галочки случался после ручного ввода,
soft-sync не успевал отработать или механик не понимал что поле
автозаполнилось.

Упростили: единственный пробег — «Пробег (км)» в шапке формы. На submit
он же подставляется в oil_change.mileage_at_change. Поле «Следующая через»
оставили — это интервал, а не значение пробега.

Бэкенд не трогаем — схема та же (mileage_at_change всё ещё в payload).
2026-05-25 21:29:26 +10:00
tremble7681 8eac0b74fc fix(pwa): drop stale parts-suggestion responses to stop input self-erasing
В CreateRepairPage queryPartSuggestions после await перезаписывал
partsDraft.name значением q из момента старта запроса. При быстром вводе
("К" → "Ке" → "Кеп" → "Кепу") поздно вернувшийся ответ для "К" откатывал
поле обратно на "К" — пользователи это видели как «текст частично сам
стирается». Делаем функциональный setState и роняем ответ если name уже
другой. EditRepairPage не задет (там корректный паттерн).
2026-05-25 12:44:41 +10:00
tremble7681 9642eb6e45 fix(pwa): safe-area padding for full-screen page-replacement modals
Юзер обнаружил, что при поиске работ (worksPicker в Create/EditRepairPage)
шапка с инпутом всё ещё уезжает под status-bar. Эти модалки и аналоги
(PhotoAnnotateStep, ZoneDetailView, InspectionEditor) используют
fixed inset-0 — моё прежнее правило для .fixed.top-0 их не покрывало.
Добавил отдельный селектор для .fixed.inset-0.bg-background[/\95];
bg-black/40·50·90 (диалоги, фото-вьюверы) намеренно не задеты.
2026-05-25 12:13:30 +10:00
tremble7681 087693877d fix(pwa): respect iOS safe-area-inset on body + sticky/fixed headers
В iOS PWA при viewport-fit=cover и apple-mobile-web-app-status-bar-style=
black-translucent статус-бар прозрачно накладывается на шапки страниц
(пользователи жаловались скринами с overlap «11:37» поверх «Ремонт #12»).

Body теперь имеет padding-top/bottom env(safe-area-inset-*) — fix initial-
рендер и нижних кнопок. Для sticky/fixed .top-0 хедеров отдельно overrid'им
top: env(safe-area-inset-top), т.к. sticky прилипает к top:0 viewport, а
не к содержимому body. Селектор узкий (.sticky.top-0 / .fixed.top-0) —
не задевает .fixed.inset-0 полноэкранные модалки.
2026-05-25 12:04:08 +10:00
5 changed files with 96 additions and 63 deletions
@@ -14,6 +14,7 @@ export interface FeedItem {
mechanic_name: string; mechanic_name: string;
works_preview: FeedWorkBrief[]; works_preview: FeedWorkBrief[];
works_extra: number; works_extra: number;
works_total: number; // сумма работ без запчастей
photo_url: string | null; photo_url: string | null;
} }
+24
View File
@@ -31,5 +31,29 @@
* { @apply border-border; } * { @apply border-border; }
body { body {
@apply bg-background text-foreground font-sans; @apply bg-background text-foreground font-sans;
/* iOS PWA: status bar (status-bar-style=black-translucent) и home-indicator
перекрывают контент при viewport-fit=cover. Сдвигаем body content вниз
на высоту safe-area, чтобы шапки/кнопки не уезжали под системные зоны. */
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
}
}
@layer utilities {
/* iOS PWA: position:sticky + top:0 прилипает к самому верху viewport,
т.е. под статус-бар (containing-block тут не помогает). Переопределяем
top у всех Tailwind .top-0, чтобы шапки залипали ниже notch. */
.sticky.top-0,
.fixed.top-0 {
top: env(safe-area-inset-top);
}
/* Full-screen page-replacement модалки (fixed inset-0 с app background) —
поиск работ, аннотация фото, zone-detail, inspection-editor. Шапка внутри
должна сидеть ниже status-bar, нижние кнопки выше home-indicator.
bg-black/40·50·90 диалоги/просмотрщики не задеваем — у них inset-0 full-bleed. */
.fixed.inset-0.bg-background,
.fixed.inset-0.bg-background\/95 {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
} }
} }
@@ -54,7 +54,10 @@ interface WorkRow {
work_catalog_id: number; work_catalog_id: number;
name: string; // снапшот имени из прайса name: string; // снапшот имени из прайса
price_catalog: number; price_catalog: number;
price_applied: number; // "" допустим в процессе редактирования — иначе React при очистке inputа
// сразу подставляет "0" и его приходится курсором обходить, см. жалобу
// механиков. На submit пустое трактуется как 0 (или как ошибка валидации).
price_applied: number | "";
unlocked: boolean; // карандашик разблокировал поле unlocked: boolean; // карандашик разблокировал поле
override_comment: string; override_comment: string;
} }
@@ -67,8 +70,6 @@ interface PartRow {
interface OilChangeState { interface OilChangeState {
enabled: boolean; enabled: boolean;
mileage_at_change: number | null;
mileage_is_dirty: boolean; // У24: мягкая подписка с шапочным
next_in_km: number; next_in_km: number;
sticker: PhotoState | null; sticker: PhotoState | null;
} }
@@ -107,8 +108,6 @@ export default function CreateRepairPage() {
const [parts, setParts] = useState<PartRow[]>([]); const [parts, setParts] = useState<PartRow[]>([]);
const [oilChange, setOilChange] = useState<OilChangeState>({ const [oilChange, setOilChange] = useState<OilChangeState>({
enabled: false, enabled: false,
mileage_at_change: null,
mileage_is_dirty: false,
next_in_km: 10000, next_in_km: 10000,
sticker: null, sticker: null,
}); });
@@ -130,13 +129,9 @@ export default function CreateRepairPage() {
.catch((e) => setCtxError((e as Error).message || "Не удалось загрузить машину")); .catch((e) => setCtxError((e as Error).message || "Не удалось загрузить машину"));
}, [carId]); }, [carId]);
// У24: мягкая подписка пробега блока масла на шапочный. // У24 упрощено: пробег ввода в форме теперь один — главный «Пробег (км)»
useEffect(() => { // в шапке. На submit подставляется и в oil_change.mileage_at_change, чтобы
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 ───────────────────────────────────────────────────────── // ── Photo upload ─────────────────────────────────────────────────────────
@@ -259,7 +254,14 @@ export default function CreateRepairPage() {
setPartsDraft({ name: q, suggestions: [] }); setPartsDraft({ name: q, suggestions: [] });
if (!q.trim()) return; if (!q.trim()) return;
const list = await suggestParts(q); const list = await suggestParts(q);
setPartsDraft({ name: q, suggestions: list.map((s) => s.name) }); // У43 race-fix: пока запрос для "К" летел, юзер дописал до "Кепу" — поздний
// ответ не должен перезаписывать .name старым значением (это и есть
// «текст частично сам стирается»). Применяем suggestions только если
// ввод не менялся; иначе тихо роняем ответ.
setPartsDraft((prev) => {
if (prev.name !== q) return prev;
return { name: q, suggestions: list.map((s) => s.name) };
});
}; };
const addPart = (name: string) => { const addPart = (name: string) => {
@@ -318,7 +320,8 @@ export default function CreateRepairPage() {
oil_change: oilChange.enabled oil_change: oilChange.enabled
? { ? {
enabled: true, enabled: true,
mileage_at_change: oilChange.mileage_at_change, // Один источник истины — главный пробег ремонта (см. У24).
mileage_at_change: mileage,
next_in_km: oilChange.next_in_km, next_in_km: oilChange.next_in_km,
sticker_photo_uuid: sticker_photo_uuid:
oilChange.sticker?.status === "uploaded" oilChange.sticker?.status === "uploaded"
@@ -501,15 +504,19 @@ export default function CreateRepairPage() {
min={0} min={0}
value={w.price_applied} value={w.price_applied}
disabled={!w.unlocked} disabled={!w.unlocked}
onChange={(e) => onChange={(e) => {
const v = e.target.value;
// Пустое поле остаётся пустым в стейте (не сбрасываем в 0),
// чтобы юзер мог сразу набрать новую цену поверх.
const next: number | "" = v === "" ? "" : Number(v);
setWorks((arr) => setWorks((arr) =>
arr.map((row) => arr.map((row) =>
row.rowKey === w.rowKey row.rowKey === w.rowKey
? { ...row, price_applied: Number(e.target.value) } ? { ...row, price_applied: next }
: row, : row,
), ),
) );
} }}
className="w-24" className="w-24"
/> />
<Button <Button
@@ -618,21 +625,6 @@ export default function CreateRepairPage() {
</label> </label>
{oilChange.enabled && ( {oilChange.enabled && (
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-3">
<div>
<Label className="text-sm">Пробег на момент замены</Label>
<Input
type="number"
min={0}
value={oilChange.mileage_at_change ?? ""}
onChange={(e) =>
setOilChange((oc) => ({
...oc,
mileage_at_change: e.target.value === "" ? null : Number(e.target.value),
mileage_is_dirty: true,
}))
}
/>
</div>
<div> <div>
<Label className="text-sm">Следующая замена через (км)</Label> <Label className="text-sm">Следующая замена через (км)</Label>
<Input <Input
@@ -36,7 +36,6 @@ interface PartRow {
interface OilState { interface OilState {
enabled: boolean; enabled: boolean;
mileage_at_change: number | null;
next_in_km: number | null; next_in_km: number | null;
} }
@@ -60,7 +59,7 @@ export default function EditRepairPage() {
const [mileage, setMileage] = useState<number | null>(null); const [mileage, setMileage] = useState<number | null>(null);
const [works, setWorks] = useState<WorkRow[]>([]); const [works, setWorks] = useState<WorkRow[]>([]);
const [parts, setParts] = useState<PartRow[]>([]); const [parts, setParts] = useState<PartRow[]>([]);
const [oil, setOil] = useState<OilState>({ enabled: false, mileage_at_change: null, next_in_km: null }); const [oil, setOil] = useState<OilState>({ enabled: false, next_in_km: null });
const [worksPicker, setWorksPicker] = useState<{ open: boolean; q: string; results: MechWork[] }>( const [worksPicker, setWorksPicker] = useState<{ open: boolean; q: string; results: MechWork[] }>(
{ open: false, q: "", results: [] } { open: false, q: "", results: [] }
@@ -89,7 +88,6 @@ export default function EditRepairPage() {
if (d.oil_change) { if (d.oil_change) {
setOil({ setOil({
enabled: true, enabled: true,
mileage_at_change: d.oil_change.mileage_at_change,
next_in_km: d.oil_change.next_in_km, next_in_km: d.oil_change.next_in_km,
}); });
} }
@@ -174,7 +172,8 @@ export default function EditRepairPage() {
qty: p.qty, qty: p.qty,
})), })),
oil_change: oil.enabled oil_change: oil.enabled
? { enabled: true, mileage_at_change: oil.mileage_at_change, next_in_km: oil.next_in_km } // Один источник истины — главный пробег ремонта.
? { enabled: true, mileage_at_change: mileage, next_in_km: oil.next_in_km }
: { enabled: false, mileage_at_change: null, next_in_km: null }, : { enabled: false, mileage_at_change: null, next_in_km: null },
}); });
navigate(`/repairs/${detail.id}`, { replace: true }); navigate(`/repairs/${detail.id}`, { replace: true });
@@ -343,29 +342,16 @@ export default function EditRepairPage() {
Замена масла Замена масла
</label> </label>
{oil.enabled && ( {oil.enabled && (
<div className="mt-3 grid grid-cols-2 gap-2"> <div className="mt-3">
<div> <Label className="text-xs">Через, км</Label>
<Label className="text-xs">Пробег</Label> <Input
<Input type="number"
type="number" inputMode="numeric"
inputMode="numeric" value={oil.next_in_km ?? 10000}
value={oil.mileage_at_change ?? ""} onChange={(e) => setOil((o) => ({
onChange={(e) => setOil((o) => ({ ...o, next_in_km: e.target.value === "" ? null : Number(e.target.value),
...o, mileage_at_change: e.target.value === "" ? null : Number(e.target.value), }))}
}))} />
/>
</div>
<div>
<Label className="text-xs">Через, км</Label>
<Input
type="number"
inputMode="numeric"
value={oil.next_in_km ?? 10000}
onChange={(e) => setOil((o) => ({
...o, next_in_km: e.target.value === "" ? null : Number(e.target.value),
}))}
/>
</div>
</div> </div>
)} )}
</section> </section>
@@ -83,8 +83,19 @@ export default function RepairsFeedPage() {
</header> </header>
<main className="flex-1 container max-w-2xl py-3 space-y-2"> <main className="flex-1 container max-w-2xl py-3 space-y-2">
{/* Refresh tap-area для не-touch юзкейса; жест pull-to-refresh — в PR-8. */} {/* Итого слева — сумма работ загруженных ремонтов; Обновить справа. */}
<div className="flex items-center justify-end"> <div className="flex items-center justify-between">
<div className="text-sm">
<span className="text-muted-foreground">Итого:</span>{" "}
<span className="font-semibold tabular-nums">
{fmtMoney(items.reduce((s, it) => s + (it.works_total || 0), 0))}
</span>
{items.length > 0 && (
<span className="text-xs text-muted-foreground ml-2">
({items.length} {pluralRepairs(items.length)})
</span>
)}
</div>
<button <button
type="button" type="button"
onClick={handleRefresh} onClick={handleRefresh}
@@ -176,13 +187,32 @@ function RepairRow({ item, onOpen }: { item: FeedItem; onOpen: () => void }) {
<div className="text-xs text-muted-foreground truncate"> <div className="text-xs text-muted-foreground truncate">
Механик: {item.mechanic_name} Механик: {item.mechanic_name}
</div> </div>
<WorksLine works={item.works_preview.map((w) => w.name)} extra={item.works_extra} /> <div className="flex items-baseline justify-between gap-2">
<WorksLine works={item.works_preview.map((w) => w.name)} extra={item.works_extra} />
<div className="text-sm font-semibold tabular-nums flex-shrink-0">
{fmtMoney(item.works_total || 0)}
</div>
</div>
</div> </div>
</button> </button>
); );
} }
function fmtMoney(n: number): string {
return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }).format(Math.round(n));
}
function pluralRepairs(n: number): string {
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 WorksLine({ works, extra }: { works: string[]; extra: number }) { function WorksLine({ works, extra }: { works: string[]; extra: number }) {
// У40: ellipsis в одну строку. Соединяем имена работ через «·». // У40: ellipsis в одну строку. Соединяем имена работ через «·».
if (works.length === 0) return null; if (works.length === 0) return null;