Репозиторий: https://github.com/sergr377/org-creation-form
React + TypeScript + Vite приложение с Ant Design: кнопка «Создать» → выбор типа аккаунта (продавец/покупатель) → трёхшаговый мастер (данные организации → контактные лица → предпросмотр и отправка) в модальном окне antd.
- React 19, TypeScript, Vite
- Ant Design 6 (
antd,@ant-design/icons) - Состояние — только встроенный React (
useState), без сторонних стейт-менеджеров
npm install
npm run devОткроется Vite dev-сервер (по умолчанию http://localhost:5173, если порт занят — Vite выберет следующий свободный).
Другие команды:
npm run build # tsc -b && vite build — проверка типов + продакшн-сборка
npm run lint # eslint .
npm run preview # локальный просмотр собранного distsrc/
├── api/
│ └── organization.ts # мок createOrganization(): Promise<{ id }>, задержка + случайная ошибка
├── components/
│ ├── OrganizationDataStep.tsx # шаг 1 — данные организации
│ ├── ContactsStep.tsx # шаг 2 — контактные лица (Form.List)
│ └── PreviewStep.tsx # шаг 3 — предпросмотр + отправка
├── data/
│ └── organizationData.ts # моковые справочники: countries, currencies, taxSystems
├── types/
│ └── organization.ts # все общие типы: формы шагов, CreateOrganizationPayload/Result
├── utils/
│ ├── validation.ts # маски телефона по стране, email, нормализация (email/phone)
│ └── payload.ts # buildOrganizationPayload() — сборка payload из данных шагов 1–2
├── App.tsx # оркестрация: Dropdown, Modal, переключение шагов
└── main.tsx
App.tsx — единственный «владелец» состояния мастера:
accountType: 'seller' | 'buyer' | null— выбран в Dropdown, определяет заголовок модалки и набор полей продавца;currentStep: number(0 → 1 → 2) — какой шаг сейчас отрисован внутриModal;organizationData/contactsData— результаты валидации шагов 1 и 2, поднятые наверх (lifted state).
Каждый шаг — управляемый (controlled) компонент без собственного «глобального» состояния:
- получает
initialValuesот родителя (чтобы восстановить ранее введённые данные при возврате назад); - ведёт свою форму через
Form.useForm(); - по кнопке «Далее» вызывает
form.onFinish, который срабатывает только после успешной валидации antd-формы, и отдаёт готовые данные наверх черезonNext(data); - по кнопке «Назад» отдаёт текущие (необязательно валидные) значения через
onBack(data), чтобы ничего не терялось при возврате.
Такая схема — простой wizard на useState с явным подъёмом состояния — выбрана вместо Redux/Zustand/Context, потому что состояние локально для одной модалки, неглубокое (4 поля верхнего уровня) и не расшаривается между несвязанными частями приложения; на этом масштабе внешний стейт-менеджер добавил бы код без выгоды.
Валидация в каждом шаге — стандартные Form.Item rules antd (required, pattern, type: 'email') плюс кастомные async-валидаторы там, где нужна логика, недоступная декларативным правилам:
- проверка «не только пробелы» (с
trim()перед сравнением); - формат телефона, зависящий от выбранной страны;
- в
ContactsStep— пропуск полностью пустых строк, запрет дублей email/телефона между контактами, агрегирующее правилоForm.List«нужен минимум один контакт».
Выполнено:
- кнопка «Создать» →
Dropdown(Продавец/Покупатель) →Modalс заголовком «Создание организации: …»; - шаг 1: название, страна, юр. адрес (необязательное), региональные реквизиты по стране, поля продавца (система налогообложения — отфильтрована по стране, валюта, email, телефон) — только для
seller; - шаг 2: динамический список контактов (ФИО/email/телефон), добавление/удаление, минимум один контакт, запрет дублей email/телефона (регистронезависимо/нормализованно), игнорирование полностью пустых строк, блокировка при частично заполненных;
- шаг 3: предпросмотр всех данных, мок-отправка с задержкой 1.5с, индикатор загрузки,
message.success/message.error, сохранение данных и модалки открытой при ошибке; CreateOrganizationPayloadсобирается строго по типу из ТЗ,sellerDetailsотсутствует в payload для покупателя (а не простоundefined);- отдельный проход ревью на 8 критических категорий (обход валидации, преждевременная отправка, утечка данных продавца, рассинхрон региона, отправка без валидного контакта, потеря данных при «Назад», сброс/закрытие при ошибке) — критических проблем не найдено.
Не выполнено / вне рамок:
RegionalDetailsне определён в ТЗ (в примереCreateOrganizationPayloadтип только упомянут по имени) — спроектирован самостоятельно как дискриминированный union поcountry, это решение кандидата, а не готовая спецификация;- реального бэкенда нет — только мок с
setTimeoutи случайной ошибкой (по заданию); - нет автотестов (unit/e2e) — не запрашивались;
- нет персистентности между перезагрузками страницы (
localStorageи т.п.) — не запрашивалась; - код-сплиттинг бандла не настроен —
vite buildвыдаёт предупреждение о чанке >500 kB, это не блокирующая проблема для тестового проекта; - известная не критичная непоследовательность: при смене страны в шаге 1 сбрасываются региональные поля и система налогообложения, но не телефон продавца — устаревшее значение сразу же перевалидируется (
dependencies={['countryId']}) и блокирует переход, но визуально остаётся в поле до исправления пользователем. Решение сознательно не доводилось до идеала в рамках этой сессии — зафиксировано при ревью, при необходимости чинится одной строкой (добавитьphone: undefinedв сброс).
При смене страны в шаге 1 (handleCountryChange в OrganizationDataStep) регионально-зависимые поля (inn, kpp, bin, pinfl, taxSystemId) явно сбрасываются в undefined, а не остаются заполненными старыми значениями.
Почему так:
- набор и формат региональных реквизитов у стран разный (РФ — ИНН 10 цифр + КПП 9 цифр; Казахстан — БИН 12 цифр; Узбекистан — ПИНФЛ 14 + ИНН 9 цифр) — оставлять «старое» значение при смене страны означало бы либо показывать пользователю данные другой страны как будто они относятся к новой (визуальная путаница), либо полагаться только на last-moment-валидацию при сабмите;
- система налогообложения (
taxSystemId) тоже завязана на страну (справочникtaxSystemsфильтруется поcountryId) — прежде выбранный ID может физически не существовать в списке новой страны; - поля рендерятся условно (
{countryId === 'RF' && (...)}), т.е. при смене страны старыеForm.Itemреально размонтируются — если бы значение не сбрасывалось, оно осталось бы «висеть» в сторе формы невалидированным до следующего рендера того же поля; - валюта (
currencyId) намеренно не сбрасывается — в этом приложении валюта не привязана к стране (список валют глобальный), поэтому сохранение выбора корректно и удобнее для пользователя.
Состояние отправки — один useState<boolean> (submitting) в PreviewStep:
- До отправки: кнопка «Подтвердить» активна, «Назад»/«Отмена» активны.
- Во время отправки:
submitting = trueвыставляется синхронно передawait createOrganization(...); кнопка «Подтвердить» получаетloading(спиннер + автоматическая блокировка от повторных кликов), «Назад» и «Отмена» получают явныйdisabled— нельзя ни уйти назад, ни закрыть модалку, ни повторно кликнуть «Подтвердить» во время запроса. - Успех:
message.success(...)+ вызовonSuccess(result), который вApp.tsxполностью сбрасывает состояние мастера и закрывает модалку. - Ошибка:
message.error(...),submittingвозвращается вfalse(вfinally) — состояниеApp(шаг, данные организации, контакты) не трогается вообще, модалка остаётся открытой с введёнными данными, «Подтвердить» снова доступна для повтора.
Это было отдельно проверено вручную: временно выставлялась 100%-вероятность ошибки в моке, отправка нажималась дважды подряд — модалка не закрывалась и данные не терялись ни разу.
Всё приложение (все три шага мастера, типы, утилиты валидации, сборка payload, мок API, оркестрация в App.tsx) написано в Claude Code итеративно, по одному блоку требований за раз, в рамках одной сессии:
- Базовый
AppсDropdown+Modal. - Шаг 1 — данные организации, условные поля по стране/типу аккаунта, валидация по правилам из ТЗ.
- Шаг 2 — динамический список контактов (
Form.List), дедупликация, минимум один контакт. - Шаг 3 — предпросмотр, мок-отправка, обработка состояний загрузки/успеха/ошибки, типизированный payload.
- Отдельный ревью-проход на 8 конкретных критических категорий проблем.
Что было найдено и исправлено по ходу:
- deprecated-предупреждение antd 6 (
Space direction→Space orientation) — замечено в консоли браузера при ручном тестировании, поправлено сразу; - дублирование логики паттерна телефона между шагом 1 и шагом 2 — вынесено в общий
utils/validation.tsвместо копипаста; - при ревью найдена и задокументирована (но не исправлена автоматически, так как не запрашивалось) одна некритичная непоследовательность — телефон продавца не сбрасывается при смене страны (см. выше).
Отклонённых предложений ИИ не было: разработка велась пошагово, каждый шаг подтверждался проверкой (типы/линт/сборка/ручное тестирование) до перехода к следующему, поэтому спорных или отменённых решений не возникало.
Как проверялась корректность на каждом шаге:
tsc -b --noEmit— строгая проверка типов;eslint src— линт;npm run build— полная продакшн-сборка;- ручное тестирование в интерактивном браузере (Vite dev-сервер): заполнение форм, намеренный ввод невалидных/пустых/частично заполненных/задублированных/состоящих из пробелов значений, переключение страны для проверки условных полей и живой ревалидации, проверка disabled-состояния кнопки «Удалить» на последнем контакте, полный проход мастера туда-обратно («Назад») для проверки сохранности данных, принудительный форс ошибки мок-API (
SIMULATED_FAILURE_RATE = 1) для проверки error-ветки с последующим откатом значения.
~1 час
С 16:30 по 17:30
Ориентир по объёму работы с точки зрения сессии с Claude Code: 5 последовательных итераций (шаги 1–4 + ревью), каждая — с проверкой типов/линта/сборки и ручным тестированием в браузере перед переходом к следующей.