From bbaef9bc7d9896039b49a2b4bb89e50e57bd6a65 Mon Sep 17 00:00:00 2001 From: vladtechno Date: Thu, 30 Jul 2026 16:16:43 +1000 Subject: [PATCH] =?UTF-8?q?fix(repairs):=20=D0=BA=D0=BE=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B2=D0=BE=20=D0=B7=D0=B0=D0=BF=D1=87=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BC=D0=B5=D0=BD=D1=8F=D1=82=D1=8C=20=E2=80=94=20=C2=AB?= =?UTF-8?q?=D0=B7=D0=B0=D0=BB=D0=B8=D0=BF=D1=88=D0=B0=D1=8F=20=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D1=86=D0=B0=C2=BB=20(4123)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поле количества приводилось к минимуму на КАЖДЫЙ onChange (`Math.max(1, Number(v))`), поэтому стирание единицы мгновенно возвращало её обратно: набрать «4 свечи» было нельзя — цифра дописывалась и выходило «14». Ровно этот же баг однажды чинили в цене работы (3b7b2ae), там "" уже разрешено как промежуточное состояние стейта. - общий QtyStepper: "" остаётся в стейте, пока печатают; приведение — на blur и ещё раз на отправке (пустое = 1 шт: строка есть, значит деталь ставили); - тап по полю выделяет число — набор поверх заменяет, а не дописывает; - кнопки −/+: 4 свечи набираются одним пальцем в перчатках, мобильная цифровая клавиатура для этого слишком долгая; - то же в форме правки ремонта — там я повторил ту же ошибку при переводе запчастей на складской каталог; - регрессионные тесты на стирание/blur/степпер: баг повторился второй раз на том же паттерне, пусть дальше ловится сам. Co-Authored-By: Claude Opus 4.8 --- .../components/repairs/QtyStepper.test.tsx | 67 +++++++++++++++++++ .../src/components/repairs/QtyStepper.tsx | 66 ++++++++++++++++++ .../src/pages/repairs/CreateRepairPage.tsx | 24 ++++--- .../src/pages/repairs/EditRepairPage.tsx | 19 +++--- 4 files changed, 155 insertions(+), 21 deletions(-) create mode 100644 mechanic-pwa/frontend/src/components/repairs/QtyStepper.test.tsx create mode 100644 mechanic-pwa/frontend/src/components/repairs/QtyStepper.tsx diff --git a/mechanic-pwa/frontend/src/components/repairs/QtyStepper.test.tsx b/mechanic-pwa/frontend/src/components/repairs/QtyStepper.test.tsx new file mode 100644 index 0000000..4f737fd --- /dev/null +++ b/mechanic-pwa/frontend/src/components/repairs/QtyStepper.test.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi } from "vitest"; +import QtyStepper from "@/components/repairs/QtyStepper"; + +/** Обёртка с реальным стейтом — баг был именно в связке ввод↔стейт. */ +function Harness({ start = 1 }: { start?: number | "" }) { + const [qty, setQty] = useState(start); + return ( + <> + + {qty === "" ? "empty" : qty} + + ); +} + +test("количество можно стереть и набрать заново (жалоба «4 свечи»)", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByRole("spinbutton"); + + await user.clear(input); + // Ключевой момент: пустое поле ОСТАЁТСЯ пустым. Раньше сюда мгновенно + // возвращалась единица, и «4» превращалось в «14». + expect(screen.getByTestId("out")).toHaveTextContent("empty"); + + await user.type(input, "4"); + expect(screen.getByTestId("out")).toHaveTextContent("4"); +}); + +test("пустое поле на blur превращается в 1, а не в 0", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByRole("spinbutton"); + await user.clear(input); + await user.tab(); + expect(screen.getByTestId("out")).toHaveTextContent("1"); +}); + +test("кнопки −/+ меняют количество и не уводят ниже минимума", async () => { + const user = userEvent.setup(); + render(); + const minus = screen.getByLabelText("Меньше"); + const plus = screen.getByLabelText("Больше"); + + expect(minus).toBeDisabled(); // на единице уменьшать нечего + await user.click(plus); + await user.click(plus); + await user.click(plus); + expect(screen.getByTestId("out")).toHaveTextContent("4"); + + await user.click(minus); + expect(screen.getByTestId("out")).toHaveTextContent("3"); +}); + +test("тап по полю выделяет значение — цифра заменяет его, а не дописывается", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + await user.type(screen.getByRole("spinbutton"), "2"); + // 2, а не 12: без select-on-focus набор поверх единицы давал бы «12», + // и это ровно тот сорт сюрприза, на который жаловались механики. + expect(onChange).toHaveBeenCalledWith(2); + // Число, а не строка — иначе qty уехал бы в payload строкой. + expect(typeof onChange.mock.calls[0][0]).toBe("number"); +}); diff --git a/mechanic-pwa/frontend/src/components/repairs/QtyStepper.tsx b/mechanic-pwa/frontend/src/components/repairs/QtyStepper.tsx new file mode 100644 index 0000000..3410170 --- /dev/null +++ b/mechanic-pwa/frontend/src/components/repairs/QtyStepper.tsx @@ -0,0 +1,66 @@ +/** + * Количество детали в ремонте: −/+ и поле ввода. + * + * Почему не просто . Пустое поле обязано ОСТАВАТЬСЯ + * пустым, пока человек печатает. Если на каждый onChange приводить значение + * к минимуму (`Math.max(1, Number(v))`), то стирание единицы мгновенно + * возвращает единицу — и набрать «4» становится невозможно: цифра + * дописывается к ней («14»). Ровно на это пожаловались механики, и ровно так + * же однажды залипал ноль в цене работы. + * + * Поэтому "" — легальное промежуточное состояние стейта, а к разумному + * значению приводим на blur (и ещё раз на отправке формы). + * + * Кнопки −/+ здесь не украшение: 4 свечи набираются в перчатках одним + * пальцем, а мобильная цифровая клавиатура для этого слишком долгая. + */ +import { Minus, Plus } from "lucide-react"; +import { Input } from "@/components/ui/input"; + +interface Props { + value: number | ""; + onChange: (next: number | "") => void; + min?: number; + disabled?: boolean; +} + +export default function QtyStepper({ value, onChange, min = 1, disabled }: Props) { + const current = value === "" ? min : value; + + return ( +
+ + onChange(e.target.value === "" ? "" : Number(e.target.value))} + onFocus={(e) => e.currentTarget.select()} + onBlur={() => { + const n = value === "" ? min : Math.floor(Number(value)); + onChange(Number.isFinite(n) && n >= min ? n : min); + }} + className="w-14 text-center" + /> + +
+ ); +} diff --git a/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx index 5e3f374..c243811 100644 --- a/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx +++ b/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx @@ -24,6 +24,7 @@ import { Label } from "@/components/ui/label"; import { repairCanSubmit, repairSubmitWhyDisabled } from "@/lib/repairForm"; import WorksPickerModal from "@/components/repairs/WorksPickerModal"; import PartsPickerModal from "@/components/repairs/PartsPickerModal"; +import QtyStepper from "@/components/repairs/QtyStepper"; import MechanicsField, { type SelectedMechanic } from "@/components/repairs/MechanicsField"; import { getMe } from "@/api/me"; import { @@ -73,7 +74,9 @@ interface PartRow { name: string; article: string | null; in_stock: number; - qty: number; + // "" допустимо, пока поле редактируют: иначе стирание единицы мгновенно + // возвращало бы её и «4 свечи» было не набрать (см. QtyStepper). + qty: number | ""; } interface OilChangeState { @@ -303,7 +306,7 @@ export default function CreateRepairPage() { // Та же деталь уже в списке — увеличиваем количество, а не плодим строку. const existing = arr.find((p) => p.part_id === item.id); if (existing) { - return arr.map((p) => (p.part_id === item.id ? { ...p, qty: p.qty + 1 } : p)); + return arr.map((p) => (p.part_id === item.id ? { ...p, qty: Number(p.qty || 0) + 1 } : p)); } return [ ...arr, @@ -361,7 +364,11 @@ export default function CreateRepairPage() { manual_override: w.unlocked, override_comment: w.unlocked ? w.override_comment.trim() : null, })), - parts: parts.map((p) => ({ part_id: p.part_id, qty: p.qty, name: p.name })), + // Пустое поле количества (человек стёр и не дописал) — это 1 штука, + // а не ноль: строка в списке есть, значит деталь ставили. + parts: parts.map((p) => ({ + part_id: p.part_id, qty: Math.max(1, Number(p.qty) || 1), name: p.name, + })), photos: photoPayload, oil_changes: oilChange.enabled ? (["engine", "transmission"] as const) @@ -640,18 +647,13 @@ export default function CreateRepairPage() { : "нет в наличии"} - + onChange={(next) => setParts((arr) => - arr.map((row) => - row.rowKey === p.rowKey ? { ...row, qty: Math.max(1, Number(e.target.value)) } : row, - ), + arr.map((row) => (row.rowKey === p.rowKey ? { ...row, qty: next } : row)), ) } - className="w-20" />