fix: панель документов — крестик справа, плашка без обрезки; даты по Москве; заголовки колонок формы и высота списка нарушений; решения R-7…R-12 в design.md (H-2, #3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
This commit is contained in:
Andrey Epifancev 2026-10-08 03:16:20 +04:00
parent 6f8f04a707
commit c0dfe7c6c0
11 changed files with 75 additions and 15 deletions

View File

@ -134,3 +134,14 @@ docs#58 O-1 на фронте обходится). В контуре SPA отд
- 08.10.2026 — R-3, R-4: scoped re-review однострочного фикса T5.I1 и фикса T7.I1 (чистая функция с тестами) заменены проверкой контроллера по коду. Цена ошибки: низкая. - 08.10.2026 — R-3, R-4: scoped re-review однострочного фикса T5.I1 и фикса T7.I1 (чистая функция с тестами) заменены проверкой контроллера по коду. Цена ошибки: низкая.
- 08.10.2026 — R-5: все находки финального ревью ветки исправлены одной fix-волной (384301e, 0f734e3, 67bc2c8), M-5 (тосты после 401) отложена. Цена ошибки: низкая. - 08.10.2026 — R-5: все находки финального ревью ветки исправлены одной fix-волной (384301e, 0f734e3, 67bc2c8), M-5 (тосты после 401) отложена. Цена ошибки: низкая.
- 08.10.2026 — R-6: `make start` задаёт `REACT_APP_ROOT_PATH=/` (иначе белый экран на dev-сервере, C-1). Цена ошибки: низкая — в контуре переменную задаёт платформа. - 08.10.2026 — R-6: `make start` задаёт `REACT_APP_ROOT_PATH=/` (иначе белый экран на dev-сервере, C-1). Цена ошибки: низкая — в контуре переменную задаёт платформа.
- 08.10.2026 — R-7: по замечаниям владельца (H-1, H-2) и сверке всех экранов с PNG макета (`docs/mockup/screens/`) экраны
[03], [30], [34], [39], [40], [41], [42] приведены к макету в той же ветке. Цена ошибки: низкая — только вёрстка.
- 08.10.2026 — R-8: суммы — запятая и разделители разрядов (`12 275,68`); в своде и ОСВ ноль показывается «—», в формах
ББ/ОФР — без копеек и «-» для пустых (как в макете). Цена ошибки: низкая — одна функция `formatAmount`.
- 08.10.2026 — R-9: даты загрузки и создания показываются по Москве (`Europe/Moscow`), а не по UTC-строке. Цена: низкая.
- 08.10.2026 — R-10: [34] «Дата верификации» — «—» (в `VersionBrief` нет поля, до маршрута согласования), «ГСК» — «—»
(П-3). Цена: низкая.
- 08.10.2026 — R-11: [30] вкладки Шахматка/КФО/… показаны неактивными (Срезы 5–6); фильтры свода — в заголовках колонок,
признак контрагента — в фильтре «Контрагенты». Цена: низкая.
- 08.10.2026 — R-12: [41] заголовок колонки формы без распознанной подписи — «На <дата периода>» или «Колонка N»; длинный
список нарушений — с прокруткой (120 px). Цена: низкая.

13
src/utils/dates.test.ts Normal file
View File

@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { formatMoscowDate } from './dates';
describe('formatMoscowDate', () => {
it('момент времени из API (UTC) — дата по Москве', () => {
expect(formatMoscowDate('2026-10-07T23:11:42.981145Z')).toBe('08.10.2026');
expect(formatMoscowDate('2024-03-12T10:00:00Z')).toBe('12.03.2024');
});
it('пусто и мусор', () => {
expect(formatMoscowDate(null)).toBeNull();
expect(formatMoscowDate('не дата')).toBeNull();
});
});

8
src/utils/dates.ts Normal file
View File

@ -0,0 +1,8 @@
const MOSCOW = new Intl.DateTimeFormat('ru-RU', { timeZone: 'Europe/Moscow', day: '2-digit', month: '2-digit', year: 'numeric' });
/** Момент времени из API (ISO, UTC) → «dd.mm.yyyy» по Москве (как считает даты бэкенд); пусто/мусор → null. */
export function formatMoscowDate(iso: string | null | undefined): string | null {
if (!iso) return null;
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? null : MOSCOW.format(date);
}

View File

@ -2,10 +2,11 @@ import type { TableConfig } from '../../components/GenericTable';
import { TableTag } from '../../components/TableTag/TableTag'; import { TableTag } from '../../components/TableTag/TableTag';
import type { ConsRussianStatus, QuarterEnum } from '../../types/consolidations/common_types'; import type { ConsRussianStatus, QuarterEnum } from '../../types/consolidations/common_types';
import type { ConsRow } from '../../types/consolidations/consolidations'; import type { ConsRow } from '../../types/consolidations/consolidations';
import { formatMoscowDate } from '../../utils/dates';
import { quarterShortLabel } from '../../utils/labels'; import { quarterShortLabel } from '../../utils/labels';
import { statusColorMapping } from './consts'; import { statusColorMapping } from './consts';
const ruDate = (v: unknown) => (typeof v === 'string' && v ? new Date(v).toLocaleDateString('ru') : '—'); const ruDate = (v: unknown) => (typeof v === 'string' ? (formatMoscowDate(v) ?? '—') : '—');
export const consConfigBase: TableConfig<ConsRow> = { export const consConfigBase: TableConfig<ConsRow> = {
rowKey: 'id', rowKey: 'id',

View File

@ -8,7 +8,14 @@ export const failureText = (f: FailedCheckOut) =>
type Props = { checks: ChecksOut; editable: boolean; dirty: boolean; onAccept: (comment: string) => Promise<void> }; type Props = { checks: ChecksOut; editable: boolean; dirty: boolean; onAccept: (comment: string) => Promise<void> };
const failureList = (list: FailedCheckOut[]) => list.map((f) => <div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>); // длинный список нарушений (OCR) не должен выталкивать таблицу формы за экран
const failureList = (list: FailedCheckOut[]) => (
<div style={{ maxHeight: 120, overflowY: 'auto' }}>
{list.map((f) => (
<div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>
))}
</div>
);
export function ChecksPanel({ checks, editable, dirty, onAccept }: Props) { export function ChecksPanel({ checks, editable, dirty, onAccept }: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);

View File

@ -2,6 +2,7 @@ import { Input, Table, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { ColumnOut, ValueOut } from '../../types/api'; import type { ColumnOut, ValueOut } from '../../types/api';
import { formatAmount, parseAmountInput } from '../../utils/money'; import { formatAmount, parseAmountInput } from '../../utils/money';
import { columnTitle } from './reviewView';
import { type GridRow, cellFlags, cellKey } from './statementGrid'; import { type GridRow, cellFlags, cellKey } from './statementGrid';
type Props = { type Props = {
@ -36,7 +37,7 @@ export function StatementTable({ columns, rows, editable, draft, onDraftChange }
{ key: 'code', title: 'Код', dataIndex: 'code', width: 70, align: 'center' as const }, { key: 'code', title: 'Код', dataIndex: 'code', width: 70, align: 'center' as const },
...columns.map((col) => ({ ...columns.map((col) => ({
key: col.id, key: col.id,
title: col.label ?? `Колонка ${col.column_index + 1}`, title: columnTitle(col),
width: 140, width: 140,
align: 'right' as const, align: 'right' as const,
onCell: (row: GridRow) => { onCell: (row: GridRow) => {

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { documentPageTitle, documentSubtitle, formTitle, formatReportDate, reviewActions, statusBadge } from './reviewView'; import { columnTitle, documentPageTitle, documentSubtitle, formTitle, formatReportDate, reviewActions, statusBadge } from './reviewView';
describe('formatReportDate', () => { describe('formatReportDate', () => {
it('ISO → дд.мм.гггг', () => { it('ISO → дд.мм.гггг', () => {
@ -58,3 +58,14 @@ describe('documentSubtitle', () => {
expect(documentSubtitle({ original_name: null, file_type: 'XLSX', uploaded_at: null })).toBe('XLSX'); expect(documentSubtitle({ original_name: null, file_type: 'XLSX', uploaded_at: null })).toBe('XLSX');
}); });
}); });
describe('columnTitle', () => {
const col = (label: string | null, period_date: string | null) => ({ id: 'c', column_index: 1, label, period_date, is_current: null });
it('подпись колонки, если распознана', () => {
expect(columnTitle(col('На 31 декабря 2024 г.', null))).toBe('На 31 декабря 2024 г.');
});
it('пустая подпись — дата периода, затем номер', () => {
expect(columnTitle(col(' ', '2024-12-31'))).toBe('На 31.12.2024');
expect(columnTitle(col(null, null))).toBe('Колонка 2');
});
});

View File

@ -1,4 +1,5 @@
import type { DocumentStatus, DocumentType, FileOut } from '../../types/api'; import type { ColumnOut, DocumentStatus, DocumentType, FileOut } from '../../types/api';
import { formatMoscowDate } from '../../utils/dates';
import { documentStatusLabel } from '../../utils/labels'; import { documentStatusLabel } from '../../utils/labels';
/** '2025-09-30' (или ISO с временем) → '30.09.2025'; пусто → null; нераспознанное — как есть. */ /** '2025-09-30' (или ISO с временем) → '30.09.2025'; пусто → null; нераспознанное — как есть. */
@ -33,5 +34,13 @@ export function statusBadge(status: DocumentStatus): { label: string; color: 'gr
} }
export function documentSubtitle(file: Pick<FileOut, 'original_name' | 'file_type' | 'uploaded_at'>): string { export function documentSubtitle(file: Pick<FileOut, 'original_name' | 'file_type' | 'uploaded_at'>): string {
return [file.original_name, file.file_type, formatReportDate(file.uploaded_at)].filter((p) => p).join(' · '); return [file.original_name, file.file_type, formatMoscowDate(file.uploaded_at)].filter((p) => p).join(' · ');
}
/** Заголовок колонки формы: распознанная подпись → «На <дата периода>» → «Колонка N» (OCR часто не читает шапку). */
export function columnTitle(col: Pick<ColumnOut, 'label' | 'period_date' | 'column_index'>): string {
const label = col.label?.trim();
if (label) return label;
const date = formatReportDate(col.period_date);
return date ? `На ${date}` : `Колонка ${col.column_index + 1}`;
} }

View File

@ -1,4 +1,4 @@
import { UploadOutlined } from '@ant-design/icons'; import { CloseOutlined, UploadOutlined } from '@ant-design/icons';
import { Button, Drawer, Typography } from 'antd'; import { Button, Drawer, Typography } from 'antd';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { fetchLegalEntity } from '../../services/api/directories/company_groups'; import { fetchLegalEntity } from '../../services/api/directories/company_groups';
@ -36,6 +36,8 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: ()
onClose={onClose} onClose={onClose}
destroyOnHidden destroyOnHidden
size={820} size={820}
closable={false}
extra={<Button icon={<CloseOutlined />} aria-label='Закрыть' onClick={onClose} />}
title={ title={
<div> <div>
<Typography.Text type='secondary' style={{ fontSize: 11, letterSpacing: 0.5, textTransform: 'uppercase' }}> <Typography.Text type='secondary' style={{ fontSize: 11, letterSpacing: 0.5, textTransform: 'uppercase' }}>

View File

@ -45,6 +45,8 @@ function StatusPill({ row }: { row: DocumentRow }) {
textAlign: 'center', textAlign: 'center',
fontSize: 12, fontSize: 12,
width: '100%', width: '100%',
boxSizing: 'border-box',
whiteSpace: 'nowrap',
}}> }}>
{row.statusLabel} {row.statusLabel}
</div> </div>
@ -92,7 +94,7 @@ export function FilesList({ files, loading }: { files: FileOut[]; loading: boole
</> </>
) : null, ) : null,
}, },
{ title: head('Статус'), key: 'status', width: 170, render: (_, r) => <StatusPill row={r} /> }, { title: head('Статус'), key: 'status', width: 160, render: (_, r) => <StatusPill row={r} /> },
]; ];
if (loading && files.length === 0) { if (loading && files.length === 0) {

View File

@ -1,4 +1,5 @@
import type { CheckResult, DocumentStatus, FileOut, FileType } from '../../types/api'; import type { CheckResult, DocumentStatus, FileOut, FileType } from '../../types/api';
import { formatMoscowDate } from '../../utils/dates';
import { documentStatusLabel, documentTypeLabel, periodLabel } from '../../utils/labels'; import { documentStatusLabel, documentTypeLabel, periodLabel } from '../../utils/labels';
export type RowStatus = 'success' | 'warning' | 'processing' | 'error'; export type RowStatus = 'success' | 'warning' | 'processing' | 'error';
@ -21,12 +22,6 @@ export interface DocumentRow {
const LINKABLE = new Set(['OSV', 'BALANCE', 'INCOME']); const LINKABLE = new Set(['OSV', 'BALANCE', 'INCOME']);
/** dd.mm.yyyy из ISO-строки; пусто, если даты нет. */
function formatDate(iso: string | null): string | null {
const m = iso ? /^(\d{4})-(\d{2})-(\d{2})/.exec(iso) : null;
return m ? `${m[3]}.${m[2]}.${m[1]}` : null;
}
const join = (parts: (string | null)[]) => parts.filter(Boolean).join(' · '); const join = (parts: (string | null)[]) => parts.filter(Boolean).join(' · ');
function errorReason(f: FileOut): string | null { function errorReason(f: FileOut): string | null {
@ -44,7 +39,7 @@ function documentRowStatus(status: DocumentStatus): RowStatus {
export function buildDocumentRows(files: FileOut[]): DocumentRow[] { export function buildDocumentRows(files: FileOut[]): DocumentRow[] {
return files.flatMap((f): DocumentRow[] => { return files.flatMap((f): DocumentRow[] => {
const name = f.original_name ?? 'без имени'; const name = f.original_name ?? 'без имени';
const date = formatDate(f.uploaded_at); const date = formatMoscowDate(f.uploaded_at);
if (f.documents.length === 0) { if (f.documents.length === 0) {
const failed = f.status === 'ERROR'; const failed = f.status === 'ERROR';
return [ return [