From 07648ba3ac06270081faa89c6dfe898f001661fe Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 00:20:39 +0400 Subject: [PATCH 1/6] =?UTF-8?q?AURORA-1683=20OpenSpec=20document-comments-?= =?UTF-8?q?ui=20=D0=B8=20=D0=BF=D0=BB=D0=B0=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-27-document-comments-ui.md | 115 ++++++++++++++++++ .../document-comments-ui/.openspec.yaml | 2 + .../changes/document-comments-ui/design.md | 64 ++++++++++ .../changes/document-comments-ui/proposal.md | 53 ++++++++ .../specs/document-review-ui/spec.md | 23 ++++ .../specs/primary-files-ui/spec.md | 16 +++ 6 files changed, 273 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-27-document-comments-ui.md create mode 100644 openspec/changes/document-comments-ui/.openspec.yaml create mode 100644 openspec/changes/document-comments-ui/design.md create mode 100644 openspec/changes/document-comments-ui/proposal.md create mode 100644 openspec/changes/document-comments-ui/specs/document-review-ui/spec.md create mode 100644 openspec/changes/document-comments-ui/specs/primary-files-ui/spec.md diff --git a/docs/superpowers/plans/2026-10-27-document-comments-ui.md b/docs/superpowers/plans/2026-10-27-document-comments-ui.md new file mode 100644 index 0000000..79cf13b --- /dev/null +++ b/docs/superpowers/plans/2026-10-27-document-comments-ui.md @@ -0,0 +1,115 @@ +# Комментарии к документу в UI — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** список комментариев документа на экране проверки [41] (боковая панель) и счётчик с поповером в панели «Первичные документы» [39-01]. + +**Architecture:** только фронт `web/`; API ядра готов (`GET`/`POST /documents/{id}/comments`, `comments_count`). Общий компонент `CommentsList` используют боковая панель экрана документа и поповер строки панели. Чистые функции (форматирование времени, строки панели) — с тестами vitest; компоненты проверяются в браузере. + +**Tech Stack:** React 18, TS strict, antd 6, vitest, biome + ESLint + Prettier. + +**Spec:** `openspec/changes/document-comments-ui/` (proposal, design D-1…D-5, дельты `document-review-ui`, `primary-files-ui`). + +**Ветка:** `feat/180-document-comments` (worktree `/Users/andrey.epifancev/projects/awp-ca/.wt/doc-comments/arm-core`), issue RSHB/arm-core#180, Jira AURORA-1683. + +## Global Constraints + +- Тексты UI: «Комментарии (N)», «Комментарии к документу», «Комментариев нет», «Не удалось загрузить комментарии», «Добавить», «Комментарии к документу <имя файла>», «Комментарий отсутствует», «Закрыть». +- Время — по Москве, «dd.mm.yyyy hh:mm». +- Текст комментария — до 4000 символов; пустой после обрезки не отправляется. +- Правок и удаления комментариев нет; бэкенд и DBML не меняются. +- Стиль: табы, одинарные кавычки, ширина 140 (biome); комментарии в коде — по-русски. + +## Review Focus + +- Клик по кнопке комментариев и внутри поповера в строке панели не должен открывать документ (всплытие к `onRow.onClick`) — проверка в браузере, Task 3. +- Длинный текст без пробелов и многострочный текст не ломают ширину панели/поповера (`pre-wrap` + `overflow-wrap: anywhere`) — проверка в браузере, Task 4. +- Ответ на запрос списка от прошлого открытия, пришедший после закрытия или переоткрытия, не должен перетирать текущий (счётчик запросов, как в `FileWarningsPopover`) — Task 2/3. +- Ошибка добавления (403/422) оставляет текст в поле — Task 2, проверка в браузере. +- Ядро без `comments_count` в документе — счётчик 0 (тест в Task 1). + +--- + +### Task 1: Данные — тип, запрос, время, счётчик строки + +**Files:** +- Modify: `web/src/types/api.ts` (после `DocumentBrief`) +- Modify: `web/src/services/api/documents.ts` +- Modify: `web/src/utils/dates.ts`, Test: `web/src/utils/dates.test.ts` +- Modify: `web/src/widgets/FilesDrawer/documentRows.ts`, Test: `web/src/widgets/FilesDrawer/documentRows.test.ts` + +**Interfaces:** +- Produces: `interface CommentOut { id: string; text: string; author: { id: string; full_name: string }; created_at: string }`; + `getComments(documentId: string): Promise`; `postComment` типизируется `api.post`; + `formatMoscowDateTime(iso: string | null | undefined): string | null`; `DocumentRow.commentsCount: number`. + +- [ ] **Step 1: Падающие тесты** + +```ts +describe('formatMoscowDateTime', () => { + it('момент из API (UTC) — дата и время по Москве', () => { + expect(formatMoscowDateTime('2026-10-07T23:11:42.981145Z')).toBe('08.10.2026 02:11'); + expect(formatMoscowDateTime('2024-03-12T10:05:00Z')).toBe('12.03.2024 13:05'); + }); + it('пусто и мусор', () => { + expect(formatMoscowDateTime(null)).toBeNull(); + expect(formatMoscowDateTime('не дата')).toBeNull(); + }); +}); +``` + +В `documentRows.test.ts`: `buildDocumentRows([file({ documents: [doc({ comments_count: 2 })] })])[0].commentsCount === 2`; +строка файла без документов — `commentsCount === 0`; документ без поля (`{ ...doc(), comments_count: undefined } as unknown as DocumentBrief`) — 0. + +- [ ] **Step 2:** `cd web && npx vitest run src/utils/dates.test.ts src/widgets/FilesDrawer/documentRows.test.ts` — FAIL (нет функции/поля). +- [ ] **Step 3: Реализация.** `formatMoscowDateTime` — `Intl.DateTimeFormat('ru-RU', { timeZone: 'Europe/Moscow', day, month, year, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })`, разделитель «, » из `Intl` заменить пробелом. `commentsCount: d.comments_count ?? 0`, у строки файла — 0. `getComments` — `api.get(\`/documents/${id}/comments\`)`. +- [ ] **Step 4:** тот же vitest — PASS; `npx tsc --noEmit` — без ошибок. +- [ ] **Step 5: Commit** «AURORA-1683 комментарии: тип, запрос, время по Москве, счётчик строки панели». + +### Task 2: Экран документа — кнопка «Комментарии (N)» и боковая панель + +**Files:** +- Create: `web/src/widgets/DocumentComments/CommentsList.tsx` +- Create: `web/src/widgets/DocumentComments/useDocumentComments.ts` +- Create: `web/src/widgets/DocumentComments/DocumentCommentsDrawer.tsx` +- Modify: `web/src/layouts/DocumentPage.tsx:1-136` +- Delete: `web/src/widgets/DocumentReview/CommentModal.tsx` + +**Interfaces:** +- Consumes: `CommentOut`, `getComments`, `postComment`, `formatMoscowDateTime` (Task 1). +- Produces: `type CommentsState = { status: 'loading' } | { status: 'error' } | { status: 'done'; comments: CommentOut[] }`; + `CommentsList({ state, emptyText }: { state: CommentsState | null; emptyText: string })`; + `useDocumentComments(documentId: string | null): { state: CommentsState | null; load: () => void; reset: () => void }` — `load` запрашивает список, ответы устаревших запросов игнорируются (счётчик в `useRef`), `reset` сбрасывает в `null` и отменяет ожидаемый ответ; + `DocumentCommentsDrawer({ documentId, open, onClose, onAdded }: { documentId: string; open: boolean; onClose: () => void; onAdded: () => void })`. + +- [ ] **Step 1:** `CommentsList`: `null`/loading — `Spin`; error — «Не удалось загрузить комментарии» (цвет `#cf1322`); пусто — `emptyText` (цвет `#8c8c8c`); иначе на комментарий блок: ФИО жирным + время `formatMoscowDateTime` (secondary, 12px) в строке, текст ниже с `whiteSpace: 'pre-wrap', overflowWrap: 'anywhere'`; ключ — `id`. +- [ ] **Step 2:** `DocumentCommentsDrawer`: antd `Drawer` справа, `title='Комментарии к документу'`, ширина 420; при `open` — `load()`, при закрытии — `reset()`. Тело — `CommentsList` (`emptyText='Комментариев нет'`) с прокруткой, футер — `Input.TextArea` (`maxLength={4000}`, `showCount`, `autoSize={{ minRows: 3, maxRows: 8 }}`) и кнопка «Добавить» (`disabled` при пустом `trim()`, `loading` во время запроса). Успех: очистить поле, `message.success('Комментарий добавлен')`, `load()`, `onAdded()`. Ошибка: `message.error(e instanceof ApiError ? e.message : 'Не удалось добавить комментарий')`, текст остаётся. +- [ ] **Step 3:** `DocumentPage`: кнопка `` на месте «Добавить комментарий»; `DocumentCommentsDrawer` вместо `CommentModal`, `onAdded={load}` (перечитывает документ — счётчик). Удалить `CommentModal.tsx` и его импорт. +- [ ] **Step 4:** `cd web && npx tsc --noEmit && npm run lint && npx biome check src` — чисто; `npm test` — зелёный. +- [ ] **Step 5: Commit** «AURORA-1683 экран документа: список комментариев в боковой панели». + +### Task 3: Панель «Первичные документы» — счётчик и поповер + +**Files:** +- Create: `web/src/widgets/DocumentComments/CommentsPopover.tsx` +- Modify: `web/src/widgets/FilesDrawer/FilesList.tsx:136-150` (колонка `actions`) + +**Interfaces:** +- Consumes: `useDocumentComments`, `CommentsList` (Task 2), `DocumentRow.commentsCount` (Task 1). +- Produces: `CommentsPopover({ documentId, fileName, count }: { documentId: string; fileName: string; count: number })`. + +- [ ] **Step 1:** `CommentsPopover`: управляемый antd `Popover` (`trigger='click'`, `open`/`onOpenChange`), `title={\`Комментарии к документу ${fileName}\`}`; при открытии `load()`, при закрытии `reset()`. Содержимое — обёртка с `onClick`/`onKeyDown` `stopPropagation` (как в `FileWarningsPopover`), `maxWidth: 420, maxHeight: 320, overflowY: 'auto'`, `CommentsList` (`emptyText='Комментарий отсутствует'`), справа внизу `Button size='small'` «Закрыть». Триггер — `Button` с `CommentOutlined` и `{count > 0 ? count : null}`, `aria-label={\`Комментарии: ${count}\`}`, `onClick` с `stopPropagation`. +- [ ] **Step 2:** `FilesList`, колонка `actions`: `
` — `CommentsPopover` (только при `r.documentId`), затем существующая кнопка «Удалить». +- [ ] **Step 3:** `npx tsc --noEmit && npm run lint && npx biome check src && npm test` — чисто. +- [ ] **Step 4: Commit** «AURORA-1683 панель документов: счётчик и поповер комментариев». + +### Task 4: Проверка в браузере, сверка с макетом, OpenSpec + +**Files:** +- Create: `openspec/changes/document-comments-ui/tasks.md` + +- [ ] **Step 1:** Стенд по `docs/dev/qa-guide.md` (api + web из worktree). Окно 1600×1000. Сценарии спеки: добавить комментарий на экране документа → «Комментарии (1)», после F5 он виден; ошибка (например, остановить api) — текст в поле остаётся; многострочный и длинный текст без пробелов не ломает панель; в панели «Первичные документы» число у иконки, поповер со списком, клик по иконке и внутри поповера не открывает документ; документ без комментариев — «Комментарий отсутствует»; замена документа с причиной → причина в комментариях заменённого. +- [ ] **Step 2:** Скриншоты сравнить с `../docs/mockup/screens/39-01-edit-documents.png` и `41-document-check.png`; расхождения — списком в отчёт. +- [ ] **Step 3:** `tasks.md` change (группы по задачам плана, отмеченные выполненными); `openspec validate document-comments-ui --strict` — valid. +- [ ] **Step 4:** `make web-check` из корня репозитория — зелёный (вывод — в описание PR). +- [ ] **Step 5: Commit** «AURORA-1683 OpenSpec: tasks, проверки». diff --git a/openspec/changes/document-comments-ui/.openspec.yaml b/openspec/changes/document-comments-ui/.openspec.yaml new file mode 100644 index 0000000..97e3642 --- /dev/null +++ b/openspec/changes/document-comments-ui/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-27 diff --git a/openspec/changes/document-comments-ui/design.md b/openspec/changes/document-comments-ui/design.md new file mode 100644 index 0000000..95c7ed9 --- /dev/null +++ b/openspec/changes/document-comments-ui/design.md @@ -0,0 +1,64 @@ +## Context + +API комментариев к документу готов (spec `documents-api`, «Комментарии к документу»; design D-11 Среза 3b): +`GET /documents/{id}/comments` → `[{id, text, author: {id, full_name}, created_at}]` по возрастанию `created_at` +(`DOCUMENT_VIEW`), `POST …/comments {text}` 1–4000 символов после обрезки (`DOCUMENT_EDIT`) → 201 + комментарий; +`comments_count` — в `DocumentBrief` (карточка файла, панель) и `DocumentOut` (экран документа). Правки и удаления нет. + +Во фронте сейчас: `CommentModal` (экран документа, только добавление) и `postComment` в `FilesDrawer` (причина замены +документа). Макет: [39-01] — иконка комментария в строке панели, поповер «Комментарий к документу <файл>» +(«Комментарий отсутствует», «Закрыть»); [41] — кнопка «Добавить комментарий» в шапке, места под список нет. + +## Goals / Non-Goals + +Goals: комментарий виден после добавления и после перезагрузки; счётчик в панели совпадает с числом комментариев; +причина замены видна в комментариях заменённого документа. Non-goals — в proposal. + +## Decisions + +### D-1. Экран документа: кнопка «Комментарии (N)» + боковая панель +Кнопка на месте «Добавить комментарий» (иконка `CommentOutlined`, текст «Комментарии (N)», `N = doc.comments_count`). +Клик открывает antd `Drawer` справа «Комментарии к документу»: список комментариев, под ним поле ввода (до 4000 +символов, счётчик символов) и кнопка «Добавить» (неактивна при пустом тексте после обрезки). Список запрашивается при +каждом открытии панели. После успешного добавления: поле очищается, список перечитывается, экран документа перечитывает +`GET /documents/{id}` (счётчик на кнопке). Ошибка добавления — `message.error` с текстом сервера, текст в поле +сохраняется. Ошибка загрузки списка — текст «Не удалось загрузить комментарии» вместо списка. + +Почему не список под формой: экран [41] занят сканом и формой по высоте окна, блок комментариев отнимал бы место у +формы; боковая панель не меняет раскладку [41]. Вариант предложен агентом и принят владельцем в чате 27.10.2026. + +### D-2. Панель «Первичные документы»: кнопка счётчика + поповер только для чтения +В колонке действий строки документа (`documentId != null`) перед «Удалить» — кнопка с `CommentOutlined` и числом +`comments_count` (при нуле — только иконка; `aria-label` «Комментарии: N»). Клик — antd `Popover` (trigger click) +с заголовком «Комментарии к документу <имя файла>»: список комментариев (запрос при открытии) или «Комментарий +отсутствует»; кнопка «Закрыть». Клики внутри поповера и по кнопке не всплывают к `onClick` строки (иначе открывается +документ) — тот же приём, что в `FileWarningsPopover`. У строки файла без документов кнопки нет. + +### D-3. Общий список `CommentsList` +Один компонент для D-1 и D-2: на каждый комментарий — ФИО автора (жирным), дата и время по Москве +(`formatMoscowDateTime`, «dd.mm.yyyy hh:mm»), текст с сохранением переносов строк (`white-space: pre-wrap`, +`overflow-wrap: anywhere`). Состояния: загрузка (`Spin`), ошибка, пусто (текст пустоты задаёт вызывающий: «Комментариев +нет» в D-1, «Комментарий отсутствует» в D-2 — как в макете), список. + +### D-4. Счётчик в строке панели — поле `DocumentRow.commentsCount` +`buildDocumentRows` переносит `comments_count` документа (ядро без поля — 0); строка файла без документов — 0. +Панель после замены документа уже перечитывает список файлов (`reload`), поэтому счётчик заменённого документа +обновляется без доработок. + +### D-5. `CommentModal` удаляется +Добавление переезжает в панель D-1; второе место добавления на экране не нужно. + +## Risks / Trade-offs + +- Тестов компонентов в `web/` нет: покрыты чистые функции (`formatMoscowDateTime`, `buildDocumentRows`), экраны + проверяются вручную по сценариям спеки и сверкой с [39-01], [41]. +- Поповер в узкой панели: ширина ограничена 420 px, высота — 320 px с прокруткой (как `FileWarningsPopover`). + +## Decisions made without discussion + +| Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| 27.10.2026 | R-1: в поповере панели — только чтение, добавление только на экране документа | макет [39-01] показывает поповер без поля ввода; одно место добавления проще | добавить поле в поповер — небольшая правка | +| 27.10.2026 | R-2: при нуле комментариев в панели — иконка без числа | макет [39-01] показывает только иконку; «0» шумит в каждой строке | косметика | +| 27.10.2026 | R-3: формат времени «dd.mm.yyyy hh:mm» по Москве | даты в панели уже по Москве (`formatMoscowDate`), бэкенд считает даты по Москве | косметика | +| 27.10.2026 | R-4: кнопку «Добавить» не прячем без права `DOCUMENT_EDIT`, отказ 403 — сообщением | так работал `CommentModal`; доступ решает сервер (`auth/permissions.ts` — только для меню) | пользователь без права видит кнопку и получает отказ | diff --git a/openspec/changes/document-comments-ui/proposal.md b/openspec/changes/document-comments-ui/proposal.md new file mode 100644 index 0000000..7066a4e --- /dev/null +++ b/openspec/changes/document-comments-ui/proposal.md @@ -0,0 +1,53 @@ +## Why + +AURORA-1683 (arm-core #180, эпик AURORA-1353): в AURORA-1445 на экране документа [41] сделано только окно «Добавить +комментарий» — список комментариев не запрашивается, добавленный комментарий нигде не виден. В панели «Первичные +документы» [39] нет ни иконки комментария, ни счётчика. Причина замены документа пишется комментарием к заменяемому +документу (`FilesDrawer`), и её тоже негде прочитать. API ядра готов с 07.10.2026 (spec `documents-api`, требование +«Комментарии к документу»): `GET`/`POST /documents/{id}/comments`, `comments_count` в карточке файла и документа. + +## What Changes + +Репозиторий — только arm-core (`web/`); эндпоинты, бэкенд и DBML без изменений. Срез 2c (документы), правки фронта +по замечаниям спринта 3. + +- Экран документа [41]: кнопка «Добавить комментарий» заменяется кнопкой «Комментарии (N)» (`N = comments_count` + документа). Она открывает боковую панель «Комментарии к документу»: список (автор, дата и время по Москве, текст) по + возрастанию времени и поле ввода с кнопкой «Добавить». После добавления список и счётчик обновляются. +- Панель «Первичные документы» [39]: в строке документа — кнопка с иконкой комментария и числом комментариев (при + нуле — только иконка), как в макете [39-01]. По клику — поповер «Комментарии к документу <файл>» со списком только + для чтения; без комментариев — «Комментарий отсутствует», кнопка «Закрыть». +- Окно `CommentModal` удаляется — добавление переезжает в боковую панель экрана документа. +- Дельты: `document-review-ui` — ADDED «Комментарии к документу»; `primary-files-ui` — ADDED «Комментарии документа в + панели». + +Таблицы DBML v5.23 (только чтение через API): `comment` (`object_type = 'document'`), `app_user` (ФИО автора). + +## Non-goals + +- Правка и удаление комментариев — их нет в API и не будет (spec `documents-api`). +- Добавление комментария из панели «Первичные документы» — в поповере только чтение. +- Проверка права `DOCUMENT_EDIT` на фронте: кнопку «Добавить» не прячем, отказ сервера (403) показывается сообщением, + как сейчас в `CommentModal`. +- Комментарии к строкам и ячейкам формы, к консолидации — отдельные сущности, не этот change. +- Изменения бэкенда и DBML. + +## Capabilities + +### New Capabilities + +### Modified Capabilities + +- `document-review-ui`: ADDED требование «Комментарии к документу». +- `primary-files-ui`: ADDED требование «Комментарии документа в панели». + +## Impact + +- `web/src/types/api.ts` — тип `CommentOut`. +- `web/src/services/api/documents.ts` — `getComments`. +- `web/src/utils/dates.ts` — `formatMoscowDateTime` (+ тест). +- `web/src/widgets/FilesDrawer/documentRows.ts` — `commentsCount` в строке (+ тест). +- `web/src/widgets/DocumentComments/` — общий список комментариев, боковая панель экрана документа, поповер панели. +- `web/src/layouts/DocumentPage.tsx`, `web/src/widgets/FilesDrawer/FilesList.tsx`; удаляется + `web/src/widgets/DocumentReview/CommentModal.tsx`. +- Тестов компонентов в `web/` нет (vitest — только чистые функции); экраны сверяются с макетом [39-01], [41] в браузере. diff --git a/openspec/changes/document-comments-ui/specs/document-review-ui/spec.md b/openspec/changes/document-comments-ui/specs/document-review-ui/spec.md new file mode 100644 index 0000000..d8371b6 --- /dev/null +++ b/openspec/changes/document-comments-ui/specs/document-review-ui/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Комментарии к документу +Экран документа SHALL показывать в шапке кнопку «Комментарии (N)», где N — `comments_count` документа из +`GET /documents/{id}`. Кнопка SHALL открывать боковую панель «Комментарии к документу» со списком +`GET /documents/{id}/comments` (запрос при каждом открытии) по возрастанию времени: ФИО автора, дата и время по Москве +(«dd.mm.yyyy hh:mm»), текст с переносами строк; без комментариев — «Комментариев нет»; ошибка запроса — «Не удалось +загрузить комментарии». Под списком SHALL быть поле ввода (до 4000 символов) и кнопка «Добавить», неактивная при пустом +тексте после обрезки пробелов; добавление — `POST /documents/{id}/comments {text}`. После успешного добавления поле +SHALL очищаться, список — перечитываться, счётчик на кнопке — обновляться из `GET /documents/{id}`. При ошибке +добавления SHALL показываться сообщение сервера, текст в поле сохраняется. Правки и удаления комментариев нет. + +#### Scenario: Добавленный комментарий виден после перезагрузки +- **WHEN** аналитик открыл «Комментарии (0)», добавил «нет подписи руководителя» и перезагрузил страницу +- **THEN** на кнопке «Комментарии (1)», в панели — комментарий с его ФИО, датой и временем + +#### Scenario: Причина замены документа +- **WHEN** документ заменён из панели «Первичные документы» с причиной «новая редакция от клиента» +- **THEN** в комментариях заменённого документа есть «Замена документа: новая редакция от клиента» + +#### Scenario: Ошибка добавления +- **WHEN** сервер отклоняет добавление (403 или 422) +- **THEN** показано сообщение сервера, введённый текст остаётся в поле, список не меняется diff --git a/openspec/changes/document-comments-ui/specs/primary-files-ui/spec.md b/openspec/changes/document-comments-ui/specs/primary-files-ui/spec.md new file mode 100644 index 0000000..ab3a39d --- /dev/null +++ b/openspec/changes/document-comments-ui/specs/primary-files-ui/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: Комментарии документа в панели +В строке документа панели «Первичные документы» [39-01] SHALL быть кнопка с иконкой комментария и числом комментариев +документа (`documents[].comments_count`; при нуле — только иконка). Клик по кнопке SHALL открывать поповер «Комментарии +к документу <имя файла>» со списком `GET /documents/{id}/comments` (запрос при открытии; автор, дата и время по Москве, +текст) только для чтения; без комментариев — «Комментарий отсутствует»; кнопка «Закрыть» закрывает поповер. Клик по +кнопке и внутри поповера SHALL NOT открывать документ. У строки файла без документов кнопки нет. + +#### Scenario: Счётчик совпадает с числом комментариев +- **WHEN** к документу добавлены два комментария и панель перечитана +- **THEN** в строке документа у иконки число 2, в поповере — оба комментария по возрастанию времени + +#### Scenario: Документ без комментариев +- **WHEN** аналитик нажимает иконку комментария у документа без комментариев +- **THEN** в поповере «Комментарий отсутствует», экран документа не открывается From ff2232787f113027be897b24e4bd06c9f31d8cd4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 00:22:28 +0400 Subject: [PATCH 2/6] =?UTF-8?q?AURORA-1683=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8:=20=D1=82=D0=B8?= =?UTF-8?q?=D0=BF,=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81,=20=D0=B2=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D1=8F=20=D0=BF=D0=BE=20=D0=9C=D0=BE=D1=81=D0=BA?= =?UTF-8?q?=D0=B2=D0=B5,=20=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D0=BF=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/services/api/documents.ts | 7 +++++-- web/src/types/api.ts | 7 +++++++ web/src/utils/dates.test.ts | 13 ++++++++++++- web/src/utils/dates.ts | 17 +++++++++++++++++ .../widgets/FilesDrawer/documentRows.test.ts | 12 ++++++++++++ web/src/widgets/FilesDrawer/documentRows.ts | 4 ++++ 6 files changed, 57 insertions(+), 3 deletions(-) diff --git a/web/src/services/api/documents.ts b/web/src/services/api/documents.ts index 64bd52d..e5aeece 100644 --- a/web/src/services/api/documents.ts +++ b/web/src/services/api/documents.ts @@ -1,5 +1,5 @@ import { api } from '../../api/client'; -import type { DocumentOut, EntryOut, Page, StatementOut, StatementPatch } from '../../types/api'; +import type { CommentOut, DocumentOut, EntryOut, Page, StatementOut, StatementPatch } from '../../types/api'; const ENTRIES_PAGE = 2000; @@ -23,4 +23,7 @@ export async function getAllEntries(id: string): Promise { } /** Комментарий к документу: текст 1–4000 символов после обрезки пробелов (иначе 422). */ -export const postComment = (documentId: string, text: string) => api.post(`/documents/${documentId}/comments`, { text }); +export const postComment = (documentId: string, text: string) => api.post(`/documents/${documentId}/comments`, { text }); + +/** Комментарии к документу по возрастанию времени, с ФИО автора. */ +export const getComments = (documentId: string) => api.get(`/documents/${documentId}/comments`); diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 0956c4f..dbc0339 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -75,6 +75,13 @@ export interface DocumentBrief { comments_count: number; issue_counts: IssueCountsOut; } +/** Комментарий к документу (`GET /documents/{id}/comments`); правки и удаления нет. */ +export interface CommentOut { + id: string; + text: string; + author: { id: string; full_name: string }; + created_at: string; +} export type FileType = 'XLS' | 'XLSX' | 'XLSB' | 'PDF' | 'PNG' | 'JPG'; export interface FileOut { id: string; diff --git a/web/src/utils/dates.test.ts b/web/src/utils/dates.test.ts index ab6ad08..e37144f 100644 --- a/web/src/utils/dates.test.ts +++ b/web/src/utils/dates.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatMoscowDate, isIsoDate } from './dates'; +import { formatMoscowDate, formatMoscowDateTime, isIsoDate } from './dates'; describe('formatMoscowDate', () => { it('момент времени из API (UTC) — дата по Москве', () => { @@ -12,6 +12,17 @@ describe('formatMoscowDate', () => { }); }); +describe('formatMoscowDateTime', () => { + it('момент из API (UTC) — дата и время по Москве', () => { + expect(formatMoscowDateTime('2026-10-07T23:11:42.981145Z')).toBe('08.10.2026 02:11'); + expect(formatMoscowDateTime('2024-03-12T10:05:00Z')).toBe('12.03.2024 13:05'); + }); + it('пусто и мусор', () => { + expect(formatMoscowDateTime(null)).toBeNull(); + expect(formatMoscowDateTime('не дата')).toBeNull(); + }); +}); + describe('isIsoDate', () => { it('строго ГГГГ-ММ-ДД и существующая дата', () => { expect(isIsoDate('2024-12-31')).toBe(true); diff --git a/web/src/utils/dates.ts b/web/src/utils/dates.ts index 21c345d..8473c6a 100644 --- a/web/src/utils/dates.ts +++ b/web/src/utils/dates.ts @@ -7,6 +7,23 @@ export function formatMoscowDate(iso: string | null | undefined): string | null return Number.isNaN(date.getTime()) ? null : MOSCOW.format(date); } +const MOSCOW_DATE_TIME = new Intl.DateTimeFormat('ru-RU', { + timeZone: 'Europe/Moscow', + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', +}); + +/** Момент времени из API (ISO, UTC) → «dd.mm.yyyy hh:mm» по Москве; пусто/мусор → null. */ +export function formatMoscowDateTime(iso: string | null | undefined): string | null { + if (!iso) return null; + const date = new Date(iso); + return Number.isNaN(date.getTime()) ? null : MOSCOW_DATE_TIME.format(date).replace(', ', ' '); +} + /** Строго «ГГГГ-ММ-ДД» и существующая календарная дата (30 февраля — нет). */ export function isIsoDate(text: string): boolean { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text); diff --git a/web/src/widgets/FilesDrawer/documentRows.test.ts b/web/src/widgets/FilesDrawer/documentRows.test.ts index d99e3fc..74b157c 100644 --- a/web/src/widgets/FilesDrawer/documentRows.test.ts +++ b/web/src/widgets/FilesDrawer/documentRows.test.ts @@ -169,6 +169,18 @@ describe('buildDocumentRows: предупреждения', () => { const rows = buildDocumentRows([file({ documents: [doc({ issue_counts: { error: 1, info: 3 } })] })]); expect(rows[0].issueCounts).toEqual({ error: 1, info: 3 }); }); + + it('comments_count документа переносится в commentsCount', () => { + const rows = buildDocumentRows([file({ documents: [doc({ comments_count: 2 })] })]); + expect(rows[0].commentsCount).toBe(2); + }); + + it('ядро без comments_count и строка файла без документов — 0', () => { + const old = doc() as Partial; + old.comments_count = undefined; + expect(buildDocumentRows([file({ documents: [old as DocumentBrief] })])[0].commentsCount).toBe(0); + expect(buildDocumentRows([file({ status: 'PROCESSING' })])[0].commentsCount).toBe(0); + }); }); describe('rowBadge', () => { diff --git a/web/src/widgets/FilesDrawer/documentRows.ts b/web/src/widgets/FilesDrawer/documentRows.ts index 2a62265..6f9a6d5 100644 --- a/web/src/widgets/FilesDrawer/documentRows.ts +++ b/web/src/widgets/FilesDrawer/documentRows.ts @@ -27,6 +27,8 @@ export interface DocumentRow { mappingWarnings: string[]; /** счётчики issues документа; у строки файла без документов — нули */ issueCounts: IssueCountsOut; + /** число комментариев документа; у строки файла без документов — 0 */ + commentsCount: number; } /** Значок у статуса (P6-12): число предупреждений и цвет уровня; ничего нет — null. */ @@ -78,6 +80,7 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s fileWarnings, mappingWarnings, issueCounts: { error: 0, info: 0 }, + commentsCount: 0, }, ]; } @@ -105,6 +108,7 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s mappingWarnings, // ядро без v5.19 не присылает issue_counts — считаем, что замечаний нет issueCounts: d.issue_counts ?? { error: 0, info: 0 }, + commentsCount: d.comments_count ?? 0, }; }); }); From 63251b287aacf1159a1859b4d6a8645d2802f9df Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 00:23:11 +0400 Subject: [PATCH 3/6] =?UTF-8?q?AURORA-1683=20=D1=8D=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?:=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B5=D0=B2=20=D0=B2?= =?UTF-8?q?=20=D0=B1=D0=BE=D0=BA=D0=BE=D0=B2=D0=BE=D0=B9=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/layouts/DocumentPage.tsx | 6 +- .../widgets/DocumentComments/CommentsList.tsx | 25 ++++++++ .../DocumentCommentsDrawer.tsx | 63 +++++++++++++++++++ .../DocumentComments/useDocumentComments.ts | 30 +++++++++ .../widgets/DocumentReview/CommentModal.tsx | 52 --------------- 5 files changed, 121 insertions(+), 55 deletions(-) create mode 100644 web/src/widgets/DocumentComments/CommentsList.tsx create mode 100644 web/src/widgets/DocumentComments/DocumentCommentsDrawer.tsx create mode 100644 web/src/widgets/DocumentComments/useDocumentComments.ts delete mode 100644 web/src/widgets/DocumentReview/CommentModal.tsx diff --git a/web/src/layouts/DocumentPage.tsx b/web/src/layouts/DocumentPage.tsx index dda82ca..ff3a110 100644 --- a/web/src/layouts/DocumentPage.tsx +++ b/web/src/layouts/DocumentPage.tsx @@ -11,7 +11,7 @@ import { getDocument } from '../services/api/documents'; import { getFile, listLegalEntityFiles } from '../services/api/files'; import type { DocumentOut } from '../types/api'; import { documentTypeLabel, periodLabel } from '../utils/labels'; -import CommentModal from '../widgets/DocumentReview/CommentModal'; +import { DocumentCommentsDrawer } from '../widgets/DocumentComments/DocumentCommentsDrawer'; import { OsvDocument } from '../widgets/DocumentReview/OsvDocument'; import { StatementReview } from '../widgets/DocumentReview/StatementReview'; import { documentPageTitle, documentSubtitle, optionStatusBadge, statusBadge } from '../widgets/DocumentReview/reviewView'; @@ -116,7 +116,7 @@ export function DocumentPage() { />
} {kind === 'none' && }
- setCommentOpen(false)} /> + setCommentOpen(false)} onAdded={load} />
); } diff --git a/web/src/widgets/DocumentComments/CommentsList.tsx b/web/src/widgets/DocumentComments/CommentsList.tsx new file mode 100644 index 0000000..c5614ac --- /dev/null +++ b/web/src/widgets/DocumentComments/CommentsList.tsx @@ -0,0 +1,25 @@ +import { Spin, Typography } from 'antd'; +import { formatMoscowDateTime } from '../../utils/dates'; +import type { CommentsState } from './useDocumentComments'; + +/** Список комментариев документа: автор, дата и время по Москве, текст с переносами строк. */ +export function CommentsList({ state, emptyText }: { state: CommentsState | null; emptyText: string }) { + if (state === null || state.status === 'loading') return ; + if (state.status === 'error') return Не удалось загрузить комментарии; + if (state.comments.length === 0) return {emptyText}; + return ( +
+ {state.comments.map((c) => ( +
+
+ {c.author.full_name} + + {formatMoscowDateTime(c.created_at)} + +
+
{c.text}
+
+ ))} +
+ ); +} diff --git a/web/src/widgets/DocumentComments/DocumentCommentsDrawer.tsx b/web/src/widgets/DocumentComments/DocumentCommentsDrawer.tsx new file mode 100644 index 0000000..ece504e --- /dev/null +++ b/web/src/widgets/DocumentComments/DocumentCommentsDrawer.tsx @@ -0,0 +1,63 @@ +import { Button, Drawer, Input, message } from 'antd'; +import { useEffect, useState } from 'react'; +import { ApiError } from '../../api/client'; +import { postComment } from '../../services/api/documents'; +import { CommentsList } from './CommentsList'; +import { useDocumentComments } from './useDocumentComments'; + +type DocumentCommentsDrawerProps = { documentId: string; open: boolean; onClose: () => void; onAdded: () => void }; + +/** Боковая панель экрана документа: список комментариев и добавление нового (правки и удаления нет). */ +export function DocumentCommentsDrawer({ documentId, open, onClose, onAdded }: DocumentCommentsDrawerProps) { + const { state, load, reset } = useDocumentComments(documentId); + const [text, setText] = useState(''); + const [saving, setSaving] = useState(false); + + // список — при каждом открытии; при закрытии сбрасываем, чтобы не показать чужой список после смены документа + useEffect(() => { + if (open) load(); + else reset(); + }, [open, load, reset]); + + const handleAdd = async () => { + setSaving(true); + try { + await postComment(documentId, text.trim()); + message.success('Комментарий добавлен'); + setText(''); + load(); + onAdded(); + } catch (e: unknown) { + // текст в поле не трогаем: можно поправить и повторить + message.error(e instanceof ApiError ? e.message : 'Не удалось добавить комментарий'); + } finally { + setSaving(false); + } + }; + + return ( + + setText(e.target.value)} + autoSize={{ minRows: 3, maxRows: 8 }} + maxLength={4000} + showCount + placeholder='Опишите результат проверки или внесенные правки' + /> + + + }> + + + ); +} diff --git a/web/src/widgets/DocumentComments/useDocumentComments.ts b/web/src/widgets/DocumentComments/useDocumentComments.ts new file mode 100644 index 0000000..0f685d6 --- /dev/null +++ b/web/src/widgets/DocumentComments/useDocumentComments.ts @@ -0,0 +1,30 @@ +import { useCallback, useRef, useState } from 'react'; +import { getComments } from '../../services/api/documents'; +import type { CommentOut } from '../../types/api'; + +export type CommentsState = { status: 'loading' } | { status: 'error' } | { status: 'done'; comments: CommentOut[] }; + +/** Комментарии документа по запросу: `load` перечитывает список, `reset` сбрасывает его (при закрытии панели или поповера). */ +export function useDocumentComments(documentId: string | null) { + const [state, setState] = useState(null); + // номер последнего запроса: ответ устаревшего запроса или пришедший после reset игнорируется + const requestNo = useRef(0); + + const load = useCallback(() => { + requestNo.current += 1; + const mine = requestNo.current; + if (!documentId) return; + // при перечитывании после добавления старый список остаётся на экране до ответа + setState((prev) => (prev?.status === 'done' ? prev : { status: 'loading' })); + getComments(documentId) + .then((comments) => mine === requestNo.current && setState({ status: 'done', comments })) + .catch(() => mine === requestNo.current && setState({ status: 'error' })); + }, [documentId]); + + const reset = useCallback(() => { + requestNo.current += 1; + setState(null); + }, []); + + return { state, load, reset }; +} diff --git a/web/src/widgets/DocumentReview/CommentModal.tsx b/web/src/widgets/DocumentReview/CommentModal.tsx deleted file mode 100644 index bfc491c..0000000 --- a/web/src/widgets/DocumentReview/CommentModal.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Form, Input, Modal, message } from 'antd'; -import { useState } from 'react'; -import { ApiError } from '../../api/client'; -import { postComment } from '../../services/api/documents'; - -type CommentModalProps = { documentId: string; open: boolean; onClose: () => void }; - -export default function CommentModal({ documentId, open, onClose }: CommentModalProps) { - const [comment, setComment] = useState(''); - const [saving, setSaving] = useState(false); - const close = () => { - setComment(''); - onClose(); - }; - const handleOk = async () => { - setSaving(true); - try { - await postComment(documentId, comment.trim()); - message.success('Комментарий добавлен'); - close(); - } catch (e: unknown) { - // окно не закрываем: текст не теряется, можно повторить - message.error(e instanceof ApiError ? e.message : 'Не удалось добавить комментарий'); - } finally { - setSaving(false); - } - }; - return ( - -
- Комментарий}> - setComment(e.target.value)} - autoSize={{ minRows: 3 }} - maxLength={4000} - placeholder='Опишите результат проверки или внесенные правки' - /> - -
-
- ); -} From 470851ef192815c2ea2c80502232c5a815721c2a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 00:23:40 +0400 Subject: [PATCH 4/6] =?UTF-8?q?AURORA-1683=20=D0=BF=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=B2:=20=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=BF=D0=BE=D0=B2=D0=B5=D1=80=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B5?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../DocumentComments/CommentsPopover.tsx | 42 +++++++++++++++++++ web/src/widgets/FilesDrawer/FilesList.tsx | 20 +++++---- 2 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 web/src/widgets/DocumentComments/CommentsPopover.tsx diff --git a/web/src/widgets/DocumentComments/CommentsPopover.tsx b/web/src/widgets/DocumentComments/CommentsPopover.tsx new file mode 100644 index 0000000..624ae98 --- /dev/null +++ b/web/src/widgets/DocumentComments/CommentsPopover.tsx @@ -0,0 +1,42 @@ +import { CommentOutlined } from '@ant-design/icons'; +import { Button, Popover } from 'antd'; +import { useState } from 'react'; +import { CommentsList } from './CommentsList'; +import { useDocumentComments } from './useDocumentComments'; + +/** Кнопка с числом комментариев в строке панели документов [39-01]; по клику — список только для чтения. */ +export function CommentsPopover({ documentId, fileName, count }: { documentId: string; fileName: string; count: number }) { + const [open, setOpen] = useState(false); + const { state, load, reset } = useDocumentComments(documentId); + + const onOpenChange = (next: boolean) => { + setOpen(next); + if (next) load(); + else reset(); + }; + + const content = ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + style={{ width: 360, maxWidth: 420 }}> +
+ +
+
+ +
+
+ ); + + return ( + + + + ); +} diff --git a/web/src/widgets/FilesDrawer/FilesList.tsx b/web/src/widgets/FilesDrawer/FilesList.tsx index 7c74d7c..3e300d0 100644 --- a/web/src/widgets/FilesDrawer/FilesList.tsx +++ b/web/src/widgets/FilesDrawer/FilesList.tsx @@ -4,6 +4,7 @@ import type { ColumnsType } from 'antd/es/table'; import type { ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import type { CheckResult, FileOut, FileType } from '../../types/api'; +import { CommentsPopover } from '../DocumentComments/CommentsPopover'; import { FileWarningsPopover } from './FileWarningsPopover'; import { type DocumentRow, type RowStatus, buildDocumentRows, rowBadge } from './documentRows'; @@ -138,14 +139,17 @@ export function FilesList({ title: '', dataIndex: 'action', render: (_, r) => ( - - + // Поповер рисуется в портале, но события React всплывают по дереву компонентов до onClick строки таблицы и открывали + // бы документ. Обёртка вокруг всего Popover ловит клики по кнопке, заголовку, отступам и содержимому разом. + e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}> + + + + ); } diff --git a/web/src/widgets/FilesDrawer/FileWarningsPopover.tsx b/web/src/widgets/FilesDrawer/FileWarningsPopover.tsx index 8bfd2d7..264803b 100644 --- a/web/src/widgets/FilesDrawer/FileWarningsPopover.tsx +++ b/web/src/widgets/FilesDrawer/FileWarningsPopover.tsx @@ -70,11 +70,7 @@ export function FileWarningsPopover({ row, badge }: { row: DocumentRow; badge: { }; const content = ( -
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - style={{ maxWidth: 420, maxHeight: 320, overflowY: 'auto' }}> +
{row.fileWarnings.length > 0 && (
{row.fileWarnings.map((w, i) => ( @@ -104,22 +100,25 @@ export function FileWarningsPopover({ row, badge }: { row: DocumentRow; badge: { ); return ( - - - + // клики по кнопке, заголовку и содержимому поповера (он в портале, но события React всплывают по дереву компонентов) + // дошли бы до onClick строки таблицы и открыли документ — гасим их обёрткой вокруг всего Popover + e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}> + + + + ); }