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
This commit is contained in:
Andrey Epifancev 2026-10-18 20:06:02 +04:00
parent 12fcea7560
commit a3199d636e
5 changed files with 62 additions and 32 deletions

View File

@ -51,9 +51,18 @@ export function ColumnHeader({ col, issues, editing, draftDate, error = false, o
style={{ marginTop: 4, fontWeight: 400 }} style={{ marginTop: 4, fontWeight: 400 }}
/> />
{canConfirm && ( {canConfirm && (
<Button size='small' style={{ marginTop: 4 }} disabled={pending} onClick={() => onConfirmDate?.(col)}> // короткая подпись, чтобы кнопка помещалась в колонку 112 px и ширина не прыгала (T11.1); длинный текст — в подсказке
{pending ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'} <Tooltip title={pending ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}>
</Button> <Button
size='small'
block
style={{ marginTop: 4, paddingInline: 0, fontSize: 12 }}
disabled={pending}
aria-label={pending ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}
onClick={() => onConfirmDate?.(col)}>
{pending ? 'Подтверждено' : 'Подтвердить'}
</Button>
</Tooltip>
)} )}
</div> </div>
); );

View File

@ -45,7 +45,12 @@ export function StatementHeaderBlock({ header, documentType, editing, draft, onD
const reportDate = formatReportDate(header.report_date.value); const reportDate = formatReportDate(header.report_date.value);
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<table style={{ alignSelf: 'flex-end', borderCollapse: 'collapse', fontSize: 13 }}> <table style={{ width: '100%', tableLayout: 'fixed', borderCollapse: 'collapse', fontSize: 13 }}>
{/* фиксированная раскладка: ширина блока не зависит от содержимого и подписи кнопки (T11.6) */}
<colgroup>
<col style={{ width: 170 }} />
<col />
</colgroup>
<tbody> <tbody>
{headerFields(header, byField).map((r) => ( {headerFields(header, byField).map((r) => (
<tr key={r.field}> <tr key={r.field}>
@ -54,7 +59,6 @@ export function StatementHeaderBlock({ header, documentType, editing, draft, onD
style={{ style={{
...cell, ...cell,
fontWeight: 600, fontWeight: 600,
minWidth: r.field === 'organization_name' ? 320 : 160,
background: fieldBackground(r.modified, r.attention), background: fieldBackground(r.modified, r.attention),
}}> }}>
{r.issues.length > 0 && ( {r.issues.length > 0 && (
@ -74,9 +78,16 @@ export function StatementHeaderBlock({ header, documentType, editing, draft, onD
onChange={(e) => onDraftChange(r.field, e.target.value)} onChange={(e) => onDraftChange(r.field, e.target.value)}
/> />
{r.canConfirm && onConfirmDate && ( {r.canConfirm && onConfirmDate && (
<Button size='small' style={{ marginTop: 4 }} disabled={draft.confirm_report_date === true} onClick={onConfirmDate}> <Tooltip title={draft.confirm_report_date ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}>
{draft.confirm_report_date ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'} <Button
</Button> size='small'
style={{ marginTop: 4 }}
disabled={draft.confirm_report_date === true}
aria-label={draft.confirm_report_date ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}
onClick={onConfirmDate}>
{draft.confirm_report_date ? 'Подтверждено' : 'Подтвердить'}
</Button>
</Tooltip>
)} )}
</> </>
) : ( ) : (

View File

@ -208,7 +208,9 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
confirmDescription = confirmFailures.map((f) => <div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>); confirmDescription = confirmFailures.map((f) => <div key={`${f.column_index}-${f.relation}`}>{failureText(f)}</div>);
} else if (confirmLines.length > 0) { } else if (confirmLines.length > 0) {
confirmDescription = ( confirmDescription = (
<Typography.Link onClick={() => focusRow(confirmLines[0].line_id)}>{unresolvedHint(confirmLines.length)}</Typography.Link> <Button type='link' style={{ paddingInline: 0 }} onClick={() => focusRow(confirmLines[0].line_id)}>
{unresolvedHint(confirmLines.length)}
</Button>
); );
} else if (duplicateId) { } else if (duplicateId) {
confirmDescription = ( confirmDescription = (
@ -242,9 +244,6 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
onClick={confirm}> onClick={confirm}>
Подтвердить данные Подтвердить данные
</Button> </Button>
{unresolved.length > 0 && (
<Typography.Link onClick={() => focusRow(unresolved[0].key)}>{unresolvedHint(unresolved.length)}</Typography.Link>
)}
</Space> </Space>
); );
@ -259,6 +258,12 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
<div style={{ height: '100%', paddingLeft: 6 }}> <div style={{ height: '100%', paddingLeft: 6 }}>
<ReviewCard title='Результат распознавания' extra={cardActions}> <ReviewCard title='Результат распознавания' extra={cardActions}>
<Space orientation='vertical' size={12} style={{ width: '100%' }}> <Space orientation='vertical' size={12} style={{ width: '100%' }}>
{unresolved.length > 0 && (
// настоящая кнопка: достижима Tab, срабатывает по Enter; отдельной строкой, чтобы не ломать заголовок (T11.7)
<Button type='link' style={{ paddingInline: 0 }} onClick={() => focusRow(unresolved[0].key)}>
{unresolvedHint(unresolved.length)}
</Button>
)}
<StatementHeaderBlock <StatementHeaderBlock
header={statement.header} header={statement.header}
documentType={statement.document.document_type} documentType={statement.document.document_type}

View File

@ -40,6 +40,7 @@ type Props = {
onColumnDateConfirm?: (col: ColumnOut) => void; onColumnDateConfirm?: (col: ColumnOut) => void;
}; };
const ICON_WIDTH = 18; // значок уровня с отступом: у ребёнка вычитается из отступа, чтобы текст не съезжал
const CHILD_INDENT = 20; // отступ названия строки детализации под родителем const CHILD_INDENT = 20; // отступ названия строки детализации под родителем
function lineWeight(r: LayoutRow): number | undefined { function lineWeight(r: LayoutRow): number | undefined {
@ -89,7 +90,7 @@ export function StatementTable({
const rowIssues = places.byRow.get(r.key) ?? []; const rowIssues = places.byRow.get(r.key) ?? [];
const rowWorst = worstSeverity(rowIssues); const rowWorst = worstSeverity(rowIssues);
const text = ( const text = (
<span style={{ fontWeight: lineWeight(r), paddingLeft: r.child ? CHILD_INDENT : undefined, display: 'inline-block' }}> <span style={{ fontWeight: lineWeight(r), display: 'inline-block' }}>
{r.displayName || '—'} {r.displayName || '—'}
{r.secondaryName && ( {r.secondaryName && (
<Typography.Text type='secondary' style={{ display: 'block', fontWeight: 400, fontSize: 12 }}> <Typography.Text type='secondary' style={{ display: 'block', fontWeight: 400, fontSize: 12 }}>
@ -111,9 +112,11 @@ export function StatementTable({
) : ( ) : (
namedText namedText
); );
if (!rowWorst) return named; // отступ ребёнка — на общей обёртке: значок уровня остаётся внутри отступа и не сдвигает текст (T11.8)
const indent = r.child ? CHILD_INDENT : undefined;
if (!rowWorst) return <div style={{ paddingLeft: indent }}>{named}</div>;
return ( return (
<> <div style={{ paddingLeft: indent === undefined ? undefined : indent - ICON_WIDTH, display: 'flex', alignItems: 'center' }}>
<Tooltip <Tooltip
title={rowIssues.map((i) => ( title={rowIssues.map((i) => (
<div key={i.id}>{i.message}</div> <div key={i.id}>{i.message}</div>
@ -123,16 +126,16 @@ export function StatementTable({
</span> </span>
</Tooltip> </Tooltip>
{named} {named}
</> </div>
); );
}, },
}, },
{ {
key: 'code', key: 'code',
title: 'Код', title: 'Код',
width: editable ? 160 : 60, width: editable ? 160 : 84,
align: 'center' as const, align: 'center' as const,
onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: labelBackground(r) } }), onCell: (r) => (r.kind === 'section' ? sectionSpan(r, false) : { style: { background: labelBackground(r), whiteSpace: 'nowrap' } }),
render: (_: unknown, r: LayoutRow) => { render: (_: unknown, r: LayoutRow) => {
if (r.kind !== 'line') return null; if (r.kind !== 'line') return null;
const lineId = r.row.lineId; const lineId = r.row.lineId;
@ -161,7 +164,7 @@ export function StatementTable({
onConfirmDate={onColumnDateConfirm} onConfirmDate={onColumnDateConfirm}
/> />
), ),
width: 112, width: 128,
align: 'right' as const, align: 'right' as const,
onCell: (r: LayoutRow) => onCell: (r: LayoutRow) =>
r.kind === 'section' r.kind === 'section'
@ -198,6 +201,7 @@ export function StatementTable({
return ( return (
<Table<LayoutRow> <Table<LayoutRow>
size='small' size='small'
tableLayout='fixed'
bordered bordered
rowKey='key' rowKey='key'
columns={tableColumns} columns={tableColumns}

View File

@ -1,9 +1,9 @@
import { CheckOutlined, UndoOutlined } from '@ant-design/icons'; import { CheckOutlined, UndoOutlined } from '@ant-design/icons';
import { Button, Input, Tooltip } from 'antd'; import { Button, Input, Tooltip, Typography } from 'antd';
import type { IssueOut, ValueOut } from '../../types/api'; import type { IssueOut, ValueOut } from '../../types/api';
import { formatAmount, parseAmountInput } from '../../utils/money'; import { parseAmountInput } from '../../utils/money';
import { SeverityIcon } from './IssuesPanel'; import { SeverityIcon } from './IssuesPanel';
import { cellTipLines } from './cellView'; import { cellTipLines, valueCellText } from './cellView';
import type { CellConfirmState } from './editRules'; import type { CellConfirmState } from './editRules';
import { worstSeverity } from './issuePlaces'; import { worstSeverity } from './issuePlaces';
@ -51,17 +51,19 @@ export function ValueCell({
}: Props) { }: Props) {
const lines = cellTipLines(value, issues); const lines = cellTipLines(value, issues);
const worst = worstSeverity(issues); const worst = worstSeverity(issues);
// значок — в левом углу ячейки; поверх ввода и текста, не двигает суммы const shown = valueCellText(value);
const corner = worst && <SeverityIcon severity={worst} style={{ position: 'absolute', top: 1, left: 2, fontSize: 10 }} />; // значок уровня — внутри ячейки, слева от суммы, по центру по вертикали (T11.3)
const corner = worst && <SeverityIcon severity={worst} style={{ fontSize: 11, flex: 'none' }} />;
const tip = lines.length > 0 ? lines.map((line, i) => <div key={`${i}-${line}`}>{line}</div>) : undefined; const tip = lines.length > 0 ? lines.map((line, i) => <div key={`${i}-${line}`}>{line}</div>) : undefined;
if (!editable) { if (!editable) {
return ( return (
// подсказка на всей ячейке: блок на всю ширину колонки (T11.4)
<Tooltip title={tip}> <Tooltip title={tip}>
<span> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 4, minHeight: 22 }}>
{corner} {corner}
{value.is_confirmed && <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c', marginRight: 4 }} />} {value.is_confirmed && <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c' }} />}
{formatAmount(value.amount, { fraction: 'auto', empty: '-' })} {shown.muted ? <Typography.Text type='secondary'>{shown.text}</Typography.Text> : shown.text}
</span> </div>
</Tooltip> </Tooltip>
); );
} }
@ -70,7 +72,7 @@ export function ValueCell({
const pending = confirmState === 'pending-confirm' || confirmState === 'pending-unconfirm'; const pending = confirmState === 'pending-confirm' || confirmState === 'pending-unconfirm';
return ( return (
<Tooltip title={invalid ? 'Неверный формат суммы' : tip}> <Tooltip title={invalid ? 'Неверный формат суммы' : tip}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, width: '100%' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4, width: '100%' }}>
{corner} {corner}
<Input <Input
size='small' size='small'
@ -78,8 +80,7 @@ export function ValueCell({
prefix={value.is_confirmed ? <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c' }} /> : undefined} prefix={value.is_confirmed ? <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c' }} /> : undefined}
value={text} value={text}
onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)} onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)}
// значок записи лежит в левом углу поверх поля — освобождаем ему место, чтобы не налезал на галочку и текст style={{ textAlign: 'right', minWidth: 0, flex: 1 }}
style={{ textAlign: 'right', paddingLeft: worst ? 14 : undefined }}
/> />
{confirmState && ( {confirmState && (
<Tooltip title={CONFIRM_TITLE[confirmState]}> <Tooltip title={CONFIRM_TITLE[confirmState]}>
@ -93,7 +94,7 @@ export function ValueCell({
/> />
</Tooltip> </Tooltip>
)} )}
</span> </div>
</Tooltip> </Tooltip>
); );
} }