diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx
index 3a1fa2d2..754a3688 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx
@@ -728,6 +728,8 @@ export default function ParamsPanel({
const [fieldErrors, setFieldErrors] = useState<{
address?: string;
area?: string;
+ floor?: string;
+ totalFloors?: string;
}>({});
// ── Address autocomplete (geocode suggest, ЕКБ viewbox) ──
@@ -928,7 +930,12 @@ export default function ParamsPanel({
const areaNum = Number(area.replace(",", "."));
// Collect ALL blockers (don't early-return) so every offending field shows
// its own message at once.
- const errs: { address?: string; area?: string } = {};
+ const errs: {
+ address?: string;
+ area?: string;
+ floor?: string;
+ totalFloors?: string;
+ } = {};
if (trimmedAddress.length < 3) {
errs.address = "Укажите адрес квартиры — минимум 3 символа";
}
@@ -937,6 +944,29 @@ export default function ParamsPanel({
} else if (!Number.isFinite(areaNum) || areaNum <= 10) {
errs.area = "Площадь должна быть больше 10 м²";
}
+ // Floor / total-floors are both optional, but if filled must be
+ // mutually consistent (prod incident 6d0c268e: floor=17, total_floors=9
+ // was accepted silently — form + backend both let it through).
+ const floorNum = floor.trim() ? Number(floor) : null;
+ const totalFloorsNum = totalFloors.trim() ? Number(totalFloors) : null;
+ if (floorNum === 0) {
+ errs.floor = "Этаж не может быть нулевым";
+ } else if (floorNum != null && floorNum > 100) {
+ errs.floor = "Проверьте число этажей";
+ }
+ if (totalFloorsNum === 0) {
+ errs.totalFloors = "Этажей в доме не может быть 0";
+ } else if (totalFloorsNum != null && totalFloorsNum > 100) {
+ errs.totalFloors = "Проверьте число этажей";
+ }
+ if (
+ floorNum != null &&
+ totalFloorsNum != null &&
+ floorNum > totalFloorsNum &&
+ !errs.floor
+ ) {
+ errs.floor = "Этаж не может быть больше числа этажей в доме";
+ }
if (Object.keys(errs).length > 0) {
setFieldErrors(errs);
return;
@@ -1801,13 +1831,36 @@ export default function ParamsPanel({
type="text"
inputMode="numeric"
value={floor}
- onChange={(e) => setFloor(e.target.value.replace(/\D/g, ""))}
+ aria-invalid={fieldErrors.floor ? true : undefined}
+ aria-describedby={fieldErrors.floor ? "pp-floor-err" : undefined}
+ onChange={(e) => {
+ setFloor(e.target.value.replace(/\D/g, ""));
+ if (fieldErrors.floor || fieldErrors.totalFloors)
+ setFieldErrors((prev) => ({
+ ...prev,
+ floor: undefined,
+ totalFloors: undefined,
+ }));
+ }}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
placeholder="—"
- style={inputField}
+ style={
+ fieldErrors.floor
+ ? { ...inputField, border: `1px solid ${tokens.danger}` }
+ : inputField
+ }
/>
+ {fieldErrors.floor && (
+
+ {fieldErrors.floor}
+
+ )}
@@ -1822,15 +1875,38 @@ export default function ParamsPanel({
type="text"
inputMode="numeric"
value={totalFloors}
- onChange={(e) =>
- setTotalFloors(e.target.value.replace(/\D/g, ""))
+ aria-invalid={fieldErrors.totalFloors ? true : undefined}
+ aria-describedby={
+ fieldErrors.totalFloors ? "pp-total-floors-err" : undefined
}
+ onChange={(e) => {
+ setTotalFloors(e.target.value.replace(/\D/g, ""));
+ if (fieldErrors.floor || fieldErrors.totalFloors)
+ setFieldErrors((prev) => ({
+ ...prev,
+ floor: undefined,
+ totalFloors: undefined,
+ }));
+ }}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
placeholder="—"
- style={inputField}
+ style={
+ fieldErrors.totalFloors
+ ? { ...inputField, border: `1px solid ${tokens.danger}` }
+ : inputField
+ }
/>
+ {fieldErrors.totalFloors && (
+
+ {fieldErrors.totalFloors}
+
+ )}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/ParamsPanel.floors.test.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/ParamsPanel.floors.test.tsx
new file mode 100644
index 00000000..8dcbef05
--- /dev/null
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/ParamsPanel.floors.test.tsx
@@ -0,0 +1,92 @@
+// Клиентская валидация этажности — прод-инцидент 6d0c268e: оценка была принята
+// с floor=17 при total_floors=9 (пр-кт Академика Сахарова 81, 2023 г.п.), форма
+// пропустила взаимно противоречивый ввод, бэкенд тоже не отбил. Здесь закреплена
+// блокирующая проверка во фронте: сабмит не уходит в onSubmit и показывает
+// текст ошибки под полем «ЭТАЖ», пока floor > total_floors.
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import ParamsPanel from "../ParamsPanel";
+
+function renderPanel(onSubmit: (input: unknown) => void) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+// Валидные адрес/площадь заполняются в каждом кейсе, чтобы изолировать проверку
+// именно этажности — иначе сабмит блокировался бы этими полями первым.
+function fillRequiredFields(container: HTMLElement) {
+ const address = container.querySelector("#pp-address") as HTMLInputElement;
+ const area = container.querySelector("#pp-area") as HTMLInputElement;
+ fireEvent.change(address, {
+ target: { value: "Екатеринбург, пр-кт Академика Сахарова 81" },
+ });
+ fireEvent.change(area, { target: { value: "54" } });
+}
+
+describe("ParamsPanel — валидация этаж / всего этажей", () => {
+ it("floor=17, total_floors=9 — блокирует сабмит и показывает ошибку на поле ЭТАЖ", () => {
+ const onSubmit = vi.fn();
+ const { container } = renderPanel(onSubmit);
+ fillRequiredFields(container);
+
+ fireEvent.change(container.querySelector("#pp-floor") as HTMLInputElement, {
+ target: { value: "17" },
+ });
+ fireEvent.change(
+ container.querySelector("#pp-total-floors") as HTMLInputElement,
+ { target: { value: "9" } },
+ );
+
+ fireEvent.click(screen.getByText("ОЦЕНИТЬ КВАРТИРУ"));
+
+ expect(onSubmit).not.toHaveBeenCalled();
+ expect(
+ screen.getByText("Этаж не может быть больше числа этажей в доме"),
+ ).toBeInTheDocument();
+ });
+
+ it("floor=9, total_floors=17 — проходит валидацию и вызывает onSubmit", () => {
+ const onSubmit = vi.fn();
+ const { container } = renderPanel(onSubmit);
+ fillRequiredFields(container);
+
+ fireEvent.change(container.querySelector("#pp-floor") as HTMLInputElement, {
+ target: { value: "9" },
+ });
+ fireEvent.change(
+ container.querySelector("#pp-total-floors") as HTMLInputElement,
+ { target: { value: "17" } },
+ );
+
+ fireEvent.click(screen.getByText("ОЦЕНИТЬ КВАРТИРУ"));
+
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ expect(
+ screen.queryByText("Этаж не может быть больше числа этажей в доме"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("оба поля этажности пустые — не ошибка, сабмит проходит", () => {
+ const onSubmit = vi.fn();
+ const { container } = renderPanel(onSubmit);
+ fillRequiredFields(container);
+
+ fireEvent.click(screen.getByText("ОЦЕНИТЬ КВАРТИРУ"));
+
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ const [payload] = onSubmit.mock.calls[0] as [
+ { floor: unknown; total_floors: unknown },
+ ];
+ expect(payload.floor).toBeNull();
+ expect(payload.total_floors).toBeNull();
+ });
+});