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:
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user