fix(repairs): количество запчасти можно поменять — «залипшая единица» (4123)

Поле количества приводилось к минимуму на КАЖДЫЙ onChange
(`Math.max(1, Number(v))`), поэтому стирание единицы мгновенно возвращало
её обратно: набрать «4 свечи» было нельзя — цифра дописывалась и выходило
«14». Ровно этот же баг однажды чинили в цене работы (3b7b2ae), там ""
уже разрешено как промежуточное состояние стейта.

- общий QtyStepper: "" остаётся в стейте, пока печатают; приведение — на
  blur и ещё раз на отправке (пустое = 1 шт: строка есть, значит деталь
  ставили);
- тап по полю выделяет число — набор поверх заменяет, а не дописывает;
- кнопки −/+: 4 свечи набираются одним пальцем в перчатках, мобильная
  цифровая клавиатура для этого слишком долгая;
- то же в форме правки ремонта — там я повторил ту же ошибку при переводе
  запчастей на складской каталог;
- регрессионные тесты на стирание/blur/степпер: баг повторился второй раз
  на том же паттерне, пусть дальше ловится сам.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 16:16:43 +10:00
co-authored by Claude Opus 4.8
parent 4c1df109ca
commit bbaef9bc7d
4 changed files with 155 additions and 21 deletions
@@ -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<number | "">(start);
return (
<>
<QtyStepper value={qty} onChange={setQty} />
<span data-testid="out">{qty === "" ? "empty" : qty}</span>
</>
);
}
test("количество можно стереть и набрать заново (жалоба «4 свечи»)", async () => {
const user = userEvent.setup();
render(<Harness start={1} />);
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(<Harness start={3} />);
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(<Harness start={1} />);
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(<QtyStepper value={1} onChange={onChange} />);
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");
});
@@ -0,0 +1,66 @@
/**
* Количество детали в ремонте: −/+ и поле ввода.
*
* Почему не просто <input type="number">. Пустое поле обязано ОСТАВАТЬСЯ
* пустым, пока человек печатает. Если на каждый 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 (
<div className="flex items-center gap-1">
<button
type="button"
disabled={disabled || current <= min}
onClick={() => onChange(Math.max(min, current - 1))}
aria-label="Меньше"
className="h-9 w-9 rounded-md border flex items-center justify-center disabled:opacity-40 active:bg-accent"
>
<Minus className="h-4 w-4" />
</button>
<Input
type="number"
inputMode="numeric"
min={min}
value={value}
disabled={disabled}
onChange={(e) => 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"
/>
<button
type="button"
disabled={disabled}
onClick={() => onChange(current + 1)}
aria-label="Больше"
className="h-9 w-9 rounded-md border flex items-center justify-center disabled:opacity-40 active:bg-accent"
>
<Plus className="h-4 w-4" />
</button>
</div>
);
}
@@ -24,6 +24,7 @@ import { Label } from "@/components/ui/label";
import { repairCanSubmit, repairSubmitWhyDisabled } from "@/lib/repairForm"; import { repairCanSubmit, repairSubmitWhyDisabled } from "@/lib/repairForm";
import WorksPickerModal from "@/components/repairs/WorksPickerModal"; import WorksPickerModal from "@/components/repairs/WorksPickerModal";
import PartsPickerModal from "@/components/repairs/PartsPickerModal"; import PartsPickerModal from "@/components/repairs/PartsPickerModal";
import QtyStepper from "@/components/repairs/QtyStepper";
import MechanicsField, { type SelectedMechanic } from "@/components/repairs/MechanicsField"; import MechanicsField, { type SelectedMechanic } from "@/components/repairs/MechanicsField";
import { getMe } from "@/api/me"; import { getMe } from "@/api/me";
import { import {
@@ -73,7 +74,9 @@ interface PartRow {
name: string; name: string;
article: string | null; article: string | null;
in_stock: number; in_stock: number;
qty: number; // "" допустимо, пока поле редактируют: иначе стирание единицы мгновенно
// возвращало бы её и «4 свечи» было не набрать (см. QtyStepper).
qty: number | "";
} }
interface OilChangeState { interface OilChangeState {
@@ -303,7 +306,7 @@ export default function CreateRepairPage() {
// Та же деталь уже в списке — увеличиваем количество, а не плодим строку. // Та же деталь уже в списке — увеличиваем количество, а не плодим строку.
const existing = arr.find((p) => p.part_id === item.id); const existing = arr.find((p) => p.part_id === item.id);
if (existing) { 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 [ return [
...arr, ...arr,
@@ -361,7 +364,11 @@ export default function CreateRepairPage() {
manual_override: w.unlocked, manual_override: w.unlocked,
override_comment: w.unlocked ? w.override_comment.trim() : null, 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, photos: photoPayload,
oil_changes: oilChange.enabled oil_changes: oilChange.enabled
? (["engine", "transmission"] as const) ? (["engine", "transmission"] as const)
@@ -640,18 +647,13 @@ export default function CreateRepairPage() {
: "нет в наличии"} : "нет в наличии"}
</div> </div>
</div> </div>
<Input <QtyStepper
type="number"
min={1}
value={p.qty} value={p.qty}
onChange={(e) => onChange={(next) =>
setParts((arr) => setParts((arr) =>
arr.map((row) => arr.map((row) => (row.rowKey === p.rowKey ? { ...row, qty: next } : row)),
row.rowKey === p.rowKey ? { ...row, qty: Math.max(1, Number(e.target.value)) } : row,
),
) )
} }
className="w-20"
/> />
<Button variant="ghost" size="sm" onClick={() => removePart(p.rowKey)}> <Button variant="ghost" size="sm" onClick={() => removePart(p.rowKey)}>
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
@@ -20,6 +20,7 @@ import {
import { searchWorks, type MechWork, type PartCatalogItem } from "@/api/repairsCreate"; import { searchWorks, type MechWork, type PartCatalogItem } from "@/api/repairsCreate";
import WorksPickerModal from "@/components/repairs/WorksPickerModal"; import WorksPickerModal from "@/components/repairs/WorksPickerModal";
import PartsPickerModal from "@/components/repairs/PartsPickerModal"; import PartsPickerModal from "@/components/repairs/PartsPickerModal";
import QtyStepper from "@/components/repairs/QtyStepper";
import MechanicsField, { type SelectedMechanic } from "@/components/repairs/MechanicsField"; import MechanicsField, { type SelectedMechanic } from "@/components/repairs/MechanicsField";
interface WorkRow { interface WorkRow {
@@ -40,7 +41,8 @@ interface PartRow {
name: string; name: string;
article: string | null; article: string | null;
in_stock: number | null; in_stock: number | null;
qty: number; // "" — промежуточное состояние ввода, см. QtyStepper.
qty: number | "";
} }
interface OilState { interface OilState {
@@ -97,7 +99,7 @@ export default function EditRepairPage() {
setParts((arr) => { setParts((arr) => {
// Та же деталь уже в списке — увеличиваем количество, а не плодим строку. // Та же деталь уже в списке — увеличиваем количество, а не плодим строку.
const existing = arr.find((p) => p.part_id === item.id); 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)); if (existing) return arr.map((p) => (p.part_id === item.id ? { ...p, qty: Number(p.qty || 0) + 1 } : p));
return [...arr, { return [...arr, {
rowKey: uid(), part_id: item.id, name: item.name, rowKey: uid(), part_id: item.id, name: item.name,
article: item.article, in_stock: item.in_stock, qty: 1, article: item.article, in_stock: item.in_stock, qty: 1,
@@ -236,7 +238,8 @@ export default function EditRepairPage() {
parts: parts.filter((p) => p.name.trim().length > 0).map((p) => ({ parts: parts.filter((p) => p.name.trim().length > 0).map((p) => ({
part_id: p.part_id, part_id: p.part_id,
name: p.name.trim(), name: p.name.trim(),
qty: p.qty, // Пустое поле — 1 штука: строка есть, значит деталь ставили.
qty: Math.max(1, Number(p.qty) || 1),
})), })),
// Полная замена набора: пустой список = замен не было. Пробег — // Полная замена набора: пустой список = замен не было. Пробег —
// главный пробег ремонта (один источник истины). // главный пробег ремонта (один источник истины).
@@ -403,15 +406,11 @@ export default function EditRepairPage() {
{p.in_stock != null ? ` · на складе: ${p.in_stock}` : ""} {p.in_stock != null ? ` · на складе: ${p.in_stock}` : ""}
</div> </div>
</div> </div>
<Input <QtyStepper
type="number"
min={1}
value={p.qty} value={p.qty}
onChange={(e) => onChange={(next) =>
setParts((arr) => arr.map((x) => setParts((arr) => arr.map((x) => (x.rowKey === p.rowKey ? { ...x, qty: next } : x)))
x.rowKey === p.rowKey ? { ...x, qty: Math.max(1, Number(e.target.value)) } : x))
} }
className="w-20"
/> />
<Button <Button
variant="ghost" variant="ghost"