From 4d3a4af51193a74ea4c639056f159f83b265af24 Mon Sep 17 00:00:00 2001 From: Maximilian Kaufmann Date: Tue, 30 Jun 2026 13:15:35 +0300 Subject: [PATCH] fix(registry): make document number optional in the create form (#18, 2.4.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Add document" dialog marked «№ документа» with * and disabled the create button until it was filled — but the field was already optional on the backend (the `number` column is nullable and DocumentCreateRequest.number defaults to None). The requirement lived only in the form. Mirrors the INN-optional fix (2.2.1): align the UI with the backend's intent. - createDocSchema: `number` is now optional (max 500), no longer `.min(1)`. - Empty/whitespace number submits as `null` (document created without a number). - Label drops the `*`; aria-required removed; placeholder notes it's optional. - CreateDocumentPayload.number typed `string | null` to match the backend. - Registry table renders an empty number as «—» (app-wide convention). Company and Document Type stay required (the type drives custom fields and the notification schedule). Frontend-only — backend, schema and contracts unchanged. tsc + biome clean; dialog test updated (blank number now submits with null); no test regressions (8 failing registry tests are the pre-existing useSearch-mock failures, confirmed via git stash). Built + deployed to PreProd (version.json 2.4.4). Closes #18 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 ++++ web/package.json | 2 +- web/src/features/registry/types.ts | 3 +- .../pages/registry/DocumentCreateDialog.tsx | 16 ++++----- web/src/pages/registry/RegistryPage.tsx | 2 +- .../__tests__/DocumentCreateDialog.test.tsx | 33 ++++++++++--------- 6 files changed, 35 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4313d5a..83ba2dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ Versioning policy: see [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +## [2.4.4] — 2026-06-30 + +### Fixed +- **Поле «№ документа» при добавлении документа теперь необязательное (issue #18).** В окне «Добавить документ» номер был помечен `*` и блокировал кнопку «Создать документ», пока его не заполнят. При этом на бэкенде поле и так было необязательным (колонка `number` — nullable, серверная схема принимает `null`) — обязательность держалась только на форме. Теперь номер можно не указывать: документ создаётся без него (форма шлёт `null` для пустого значения), а в реестре пустой номер отображается как «—». Указанный номер по-прежнему ограничен 500 символами. + +> Только фронтенд: бэкенд, схема БД и публичный контракт не менялись (поле уже было nullable). Компания и Тип документа остаются обязательными. PATCH согласно [политике версионирования](CONTRIBUTING.md#versioning) — приведение формы к уже заложенной в бэкенде необязательности (как с ИНН в 2.2.1). + ## [2.4.3] — 2026-06-30 ### Fixed diff --git a/web/package.json b/web/package.json index c2f88db..b2383df 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@lotsman/web", - "version": "2.4.3", + "version": "2.4.4", "license": "BUSL-1.1", "private": true, "type": "module", diff --git a/web/src/features/registry/types.ts b/web/src/features/registry/types.ts index 13e80d1..044974f 100644 --- a/web/src/features/registry/types.ts +++ b/web/src/features/registry/types.ts @@ -174,7 +174,8 @@ export interface BulkArchiveResult { export interface CreateDocumentPayload { asset_id: string; type_code: string; - number: string; + /** № документа is optional (issue #18) — backend column is nullable. */ + number: string | null; issue_date: string | null; expiry_date: string | null; responsible_user_id: string | null; diff --git a/web/src/pages/registry/DocumentCreateDialog.tsx b/web/src/pages/registry/DocumentCreateDialog.tsx index de092d0..aefb453 100644 --- a/web/src/pages/registry/DocumentCreateDialog.tsx +++ b/web/src/pages/registry/DocumentCreateDialog.tsx @@ -32,7 +32,7 @@ import { QuickTypeDialog } from "./QuickTypeDialog"; const createDocSchema = z.object({ asset_id: z.string().min(1, "Выберите компанию"), type_code: z.string().min(1, "Выберите тип документа"), - number: z.string().min(1, "Обязательное поле"), + number: z.string().max(500, "Максимум 500 символов").nullable().optional(), issue_date: z.string().nullable().optional(), expiry_date: z.string().nullable().optional(), notes: z.string().max(10000, "Максимум 10 000 символов").nullable().optional(), @@ -125,7 +125,9 @@ export function DocumentCreateDialog({ open, onClose }: DocumentCreateDialogProp await createMutation.mutateAsync({ asset_id: values.asset_id, type_code: values.type_code, - number: values.number, + // № документа is optional (backend column is nullable) — send null for an + // empty/whitespace value so the document is created without a number. + number: values.number?.trim() ? values.number.trim() : null, issue_date: values.issue_date ?? null, expiry_date: values.expiry_date ?? null, responsible_user_id: claims?.sub ?? null, @@ -273,13 +275,9 @@ export function DocumentCreateDialog({ open, onClose }: DocumentCreateDialogProp )} - {/* № документа */} - - + {/* № документа — optional (issue #18) */} + + {/* Dates */} diff --git a/web/src/pages/registry/RegistryPage.tsx b/web/src/pages/registry/RegistryPage.tsx index 9aff840..feeb580 100644 --- a/web/src/pages/registry/RegistryPage.tsx +++ b/web/src/pages/registry/RegistryPage.tsx @@ -521,7 +521,7 @@ export function RegistryPage() { header: effectiveLabel("number", t("registry.col_number")), cell: (info) => ( - {info.getValue()} + {info.getValue() || "—"} ), enableSorting: true, diff --git a/web/src/pages/registry/__tests__/DocumentCreateDialog.test.tsx b/web/src/pages/registry/__tests__/DocumentCreateDialog.test.tsx index 946a89d..80f4339 100644 --- a/web/src/pages/registry/__tests__/DocumentCreateDialog.test.tsx +++ b/web/src/pages/registry/__tests__/DocumentCreateDialog.test.tsx @@ -5,7 +5,8 @@ * Unit tests for DocumentCreateDialog (US-5, US-7). * * Business rules under test: - * - Submit button is disabled until asset_id, type_code and number are filled + * - Submit button is disabled until asset_id and type_code are filled + * (№ документа is optional — issue #18; blank number submits as null) * - Notes field accepts up to 10 000 chars (schema boundary) * - useCreateDocument.mutate is called with the correct payload on valid submit * - Escape key resets the form and calls onClose @@ -143,7 +144,9 @@ describe("DocumentCreateDialog — required fields gate", () => { }); }); - it("test_number_field_required_error_shown_when_blank_submitted", async () => { + it("test_number_optional_blank_submits_with_null", async () => { + // issue #18: № документа is optional. With asset + type filled but number + // left blank, the form is valid and submits with number = null. const user = userEvent.setup(); await renderDialog(); @@ -151,25 +154,23 @@ describe("DocumentCreateDialog — required fields gate", () => { expect(screen.getByRole("combobox", { name: /Компания/i })).toBeTruthy(); }); - // Fill asset + type but leave number blank const assetSelect = screen.getByRole("combobox", { name: /Компания/i }); const typeSelect = screen.getByRole("combobox", { name: /Тип документа/i }); - await user.selectOptions(assetSelect, "a-001"); await user.selectOptions(typeSelect, "contract"); - // Click submit without filling number - const submitBtn = screen.queryByRole("button", { name: /Создать документ/i }); - if (submitBtn && !submitBtn.hasAttribute("disabled")) { - await user.click(submitBtn); - await waitFor(() => { - // Expect either a field error or that mutate was NOT called - expect(mockMutateAsync).not.toHaveBeenCalled(); - }); - } else { - // Submit already disabled — correct behavior - expect(submitBtn?.hasAttribute("disabled") ?? true).toBe(true); - } + // Submit is enabled even though the number is blank (no required-field gate). + const submitBtn = screen.getByRole("button", { name: /Создать документ/i }); + expect(submitBtn).not.toBeDisabled(); + await user.click(submitBtn); + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalled(); + }); + const payload = mockMutateAsync.mock.calls[0]?.[0] as Record; + expect(payload?.asset_id).toBe("a-001"); + expect(payload?.type_code).toBe("contract"); + expect(payload?.number).toBeNull(); }); });