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:
parent
6f8f04a707
commit
c0dfe7c6c0
@ -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
13
src/utils/dates.test.ts
Normal 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
8
src/utils/dates.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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',
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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' }}>
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 [
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user