arm-web/src/widgets/DocumentReview/StatementTable.tsx
Andrey Epifancev a3199d636e fix(arm-web): вёрстка экрана проверки формы — ширины колонок, значки в ячейке, шапка, ссылка кнопкой (#43)
T11.1–T11.4, T11.6–T11.8: короткая подпись кнопки даты, фиксированная раскладка таблиц, код без переноса, значок уровня внутри ячейки, подсказка на всю ячейку, отступ child-строки, ссылка «Разберите строки без кода» — Button type=link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 20:06:02 +04:00

218 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Table, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { ColumnOut } from '../../types/api';
import { CodeCell } from './CodeCell';
import { ColumnHeader } from './ColumnHeader';
import { SeverityIcon } from './IssuesPanel';
import { LineMenuButton } from './LineMenuButton';
import { ValueCell } from './ValueCell';
import { COLORS, rowBackground, valueBackground } from './cellView';
import type { CodeOption } from './codeSuggestions';
import { type CellConfirmState, cellConfirmState } from './editRules';
import type { LayoutRow } from './formLayout';
import { type IssuePlaces, worstSeverity } from './issuePlaces';
import type { LineMenuKey } from './lineMenu';
import type { LineAction, StatementDraft } from './statementDraft';
import { type GridRow, cellKey } from './statementGrid';
type Props = {
columns: ColumnOut[];
layout: LayoutRow[];
editable: boolean;
draft: Record<string, string>;
onDraftChange: (key: string, text: string, originalAmount: string | null) => void;
/** ключи ячеек/строк/колонок с ошибкой черновика (красная рамка) */
errorKeys?: Set<string>;
/** предупреждения и нарушения у мест таблицы */
places: IssuePlaces;
/** строка, которую нужно подсветить рамкой после перехода «Показать» */
highlightKey?: string | null;
/** действия над строками из черновика, подсказки кода и ошибки полей кода (lineId → текст) */
lineActions?: Record<string, LineAction>;
codeOptions?: CodeOption[];
lineErrors?: Record<string, string>;
onLineCodeChange?: (row: GridRow, code: string) => void;
onLineMenu?: (row: GridRow, key: LineMenuKey) => void;
/** весь черновик: подтверждения ячеек и даты колонок (суммы — в `draft` выше) */
fullDraft?: StatementDraft;
onConfirmToggle?: (key: string, state: NonNullable<CellConfirmState>) => void;
onColumnDateChange?: (columnId: string, text: string) => void;
onColumnDateConfirm?: (col: ColumnOut) => void;
};
const ICON_WIDTH = 18; // значок уровня с отступом: у ребёнка вычитается из отступа, чтобы текст не съезжал
const CHILD_INDENT = 20; // отступ названия строки детализации под родителем
function lineWeight(r: LayoutRow): number | undefined {
if (r.kind !== 'line') return undefined;
if (r.grand) return 700;
return r.total ? 600 : undefined;
}
const isUnresolved = (r: LayoutRow) => r.kind === 'line' && (r.row.lineStatus === 'unreadable' || r.row.lineStatus === 'missing');
// наименование и код: красноватый (нет кода) / оранжевый (ok с needsReview) / фиолетовый («БАЛАНС»)
const labelBackground = (r: LayoutRow) => (r.kind === 'line' ? (rowBackground(r.row) ?? (r.grand ? COLORS.grand : undefined)) : undefined);
export function StatementTable({
columns,
layout,
editable,
draft,
onDraftChange,
errorKeys,
places,
highlightKey = null,
lineActions = {},
codeOptions = [],
lineErrors = {},
onLineCodeChange,
onLineMenu,
fullDraft,
onConfirmToggle,
onColumnDateChange,
onColumnDateConfirm,
}: Props) {
const span = columns.length + 2;
// строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты
const sectionSpan = (r: LayoutRow, first: boolean) => (r.kind === 'section' ? { colSpan: first ? span : 0 } : {});
const tableColumns: ColumnsType<LayoutRow> = [
{
key: 'name',
title: 'Наименование показателя',
onCell: (r) => ({
...sectionSpan(r, true),
style:
r.kind === 'section'
? { background: COLORS.section, textAlign: 'center', fontWeight: r.level === 1 ? 700 : 600 }
: { background: labelBackground(r) },
}),
render: (_: unknown, r: LayoutRow) => {
if (r.kind === 'section') return r.title;
const rowIssues = places.byRow.get(r.key) ?? [];
const rowWorst = worstSeverity(rowIssues);
const text = (
<span style={{ fontWeight: lineWeight(r), display: 'inline-block' }}>
{r.displayName || '—'}
{r.secondaryName && (
<Typography.Text type='secondary' style={{ display: 'block', fontWeight: 400, fontSize: 12 }}>
{r.secondaryName}
</Typography.Text>
)}
</span>
);
const namedText = r.fullName ? <Tooltip title={`Распознано: ${r.fullName}`}>{text}</Tooltip> : text;
const named = editable ? (
<>
{namedText}
<LineMenuButton
row={r.row}
action={r.row.lineId ? lineActions[r.row.lineId] : undefined}
onSelect={(key) => onLineMenu?.(r.row, key)}
/>
</>
) : (
namedText
);
// отступ ребёнка — на общей обёртке: значок уровня остаётся внутри отступа и не сдвигает текст (T11.8)
const indent = r.child ? CHILD_INDENT : undefined;
if (!rowWorst) return <div style={{ paddingLeft: indent }}>{named}</div>;
return (
<div style={{ paddingLeft: indent === undefined ? undefined : indent - ICON_WIDTH, display: 'flex', alignItems: 'center' }}>
<Tooltip
title={rowIssues.map((i) => (
<div key={i.id}>{i.message}</div>
))}>
<span>
<SeverityIcon severity={rowWorst} style={{ marginRight: 4 }} />
</span>
</Tooltip>
{named}
</div>
);
},
},
{
key: 'code',
title: 'Код',
width: editable ? 160 : 84,
align: 'center' as const,
onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: labelBackground(r), whiteSpace: 'nowrap' } }),
render: (_: unknown, r: LayoutRow) => {
if (r.kind !== 'line') return null;
const lineId = r.row.lineId;
const editing =
editable && lineId
? {
action: lineActions[lineId],
options: codeOptions,
error: lineErrors[lineId],
onCodeChange: (code: string) => onLineCodeChange?.(r.row, code),
}
: undefined;
return <CodeCell row={r.row} fontWeight={lineWeight(r)} editing={editing} />;
},
},
...columns.map((col) => ({
key: col.id,
title: (
<ColumnHeader
col={col}
issues={places.byColumn.get(col.id) ?? []}
editing={editable}
draftDate={fullDraft?.columns[col.id]}
error={errorKeys?.has(col.id)}
onDateChange={onColumnDateChange}
onConfirmDate={onColumnDateConfirm}
/>
),
width: 128,
align: 'right' as const,
onCell: (r: LayoutRow) =>
r.kind === 'section'
? sectionSpan(r, false)
: {
style: {
background: isUnresolved(r)
? rowBackground(r.row)
: (valueBackground(r.row.cells[col.id]) ?? (r.grand ? COLORS.grand : undefined)),
fontWeight: lineWeight(r),
},
},
render: (_: unknown, r: LayoutRow) => {
if (r.kind === 'section') return null;
const value = r.row.cells[col.id];
if (!value) return null;
const draftKey = cellKey(r.row.key, col.id);
return (
<ValueCell
value={value}
editable={editable}
draftKey={draftKey}
draft={draft}
onDraftChange={onDraftChange}
hasError={errorKeys?.has(draftKey)}
issues={places.byCell.get(draftKey)}
confirmState={editable && fullDraft ? cellConfirmState(r.row, value, fullDraft, draftKey) : null}
onConfirmToggle={onConfirmToggle}
/>
);
},
})),
];
return (
<Table<LayoutRow>
size='small'
tableLayout='fixed'
bordered
rowKey='key'
columns={tableColumns}
dataSource={layout}
pagination={false}
onRow={(r) =>
r.kind === 'line'
? { id: `row-${r.key}`, style: r.key === highlightKey ? { outline: '2px solid #1677ff', outlineOffset: -2 } : undefined }
: {}
}
/>
);
}