feat(statement): подтверждение ячеек, дата колонки, наименование организации и подтверждение даты (#43)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
parent
d416422e73
commit
a779d2a04a
60
src/widgets/DocumentReview/ColumnHeader.tsx
Normal file
60
src/widgets/DocumentReview/ColumnHeader.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { Button, Input, Tooltip } from 'antd';
|
||||
import type { ColumnOut, IssueOut } from '../../types/api';
|
||||
import { SeverityIcon } from './IssuesPanel';
|
||||
import { columnDateEditable } from './editRules';
|
||||
import { worstSeverity } from './issuePlaces';
|
||||
import { columnTitle } from './reviewView';
|
||||
|
||||
type Props = {
|
||||
col: ColumnOut;
|
||||
issues: IssueOut[];
|
||||
editing: boolean;
|
||||
/** введённая, но не сохранённая дата колонки */
|
||||
draftDate?: string;
|
||||
error?: boolean;
|
||||
onDateChange?: (columnId: string, text: string) => void;
|
||||
/** «Подтвердить дату»: кладёт в черновик текущую дату колонки */
|
||||
onConfirmDate?: (col: ColumnOut) => void;
|
||||
};
|
||||
|
||||
/** Заголовок колонки: подпись, значок предупреждения и (в режиме правки) поле даты ГГГГ-ММ-ДД с подтверждением. */
|
||||
export function ColumnHeader({ col, issues, editing, draftDate, error = false, onDateChange, onConfirmDate }: Props) {
|
||||
const worst = worstSeverity(issues);
|
||||
const title = (
|
||||
<span>
|
||||
{worst && <SeverityIcon severity={worst} style={{ marginRight: 4 }} />}
|
||||
{columnTitle(col)}
|
||||
</span>
|
||||
);
|
||||
const withTip = worst ? (
|
||||
<Tooltip
|
||||
title={issues.map((i) => (
|
||||
<div key={i.id}>{i.message}</div>
|
||||
))}>
|
||||
{title}
|
||||
</Tooltip>
|
||||
) : (
|
||||
title
|
||||
);
|
||||
const { editable, canConfirm } = columnDateEditable(col, issues);
|
||||
if (!editing || !editable) return withTip;
|
||||
const pending = canConfirm && draftDate !== undefined && draftDate === col.period_date;
|
||||
return (
|
||||
<div>
|
||||
{withTip}
|
||||
<Input
|
||||
size='small'
|
||||
status={error ? 'error' : undefined}
|
||||
placeholder='ГГГГ-ММ-ДД'
|
||||
value={draftDate ?? col.period_date ?? ''}
|
||||
onChange={(e) => onDateChange?.(col.id, e.target.value)}
|
||||
style={{ marginTop: 4, fontWeight: 400 }}
|
||||
/>
|
||||
{canConfirm && (
|
||||
<Button size='small' style={{ marginTop: 4 }} disabled={pending} onClick={() => onConfirmDate?.(col)}>
|
||||
{pending ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -73,8 +73,8 @@ export function StatementHeaderBlock({ header, documentType, editing, draft, onD
|
||||
onChange={(e) => onDraftChange(r.field, e.target.value)}
|
||||
/>
|
||||
{r.canConfirm && onConfirmDate && (
|
||||
<Button size='small' style={{ marginTop: 4 }} onClick={onConfirmDate}>
|
||||
Подтвердить дату
|
||||
<Button size='small' style={{ marginTop: 4 }} disabled={draft.confirm_report_date === true} onClick={onConfirmDate}>
|
||||
{draft.confirm_report_date ? 'Дата подтверждена (до сохранения)' : 'Подтвердить дату'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -273,6 +273,20 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
|
||||
}
|
||||
onLineMenu={(row, key) => setDraft((prev) => ({ ...prev, lines: applyLineMenu(row, key, prev.lines) }))}
|
||||
errorKeys={errorKeys}
|
||||
fullDraft={draft}
|
||||
onConfirmToggle={(key, state) =>
|
||||
setDraft((prev) => {
|
||||
const confirms = { ...prev.confirms };
|
||||
// повторный клик по нажатой кнопке убирает подтверждение из черновика
|
||||
if (state === 'pending-confirm' || state === 'pending-unconfirm') delete confirms[key];
|
||||
else confirms[key] = state === 'confirm';
|
||||
return { ...prev, confirms };
|
||||
})
|
||||
}
|
||||
onColumnDateChange={(columnId, text) => setDraft((prev) => ({ ...prev, columns: { ...prev.columns, [columnId]: text } }))}
|
||||
onColumnDateConfirm={(col) =>
|
||||
col.period_date && setDraft((prev) => ({ ...prev, columns: { ...prev.columns, [col.id]: col.period_date as string } }))
|
||||
}
|
||||
onDraftChange={(key, text, original) =>
|
||||
setDraft((prev) => ({ ...prev, amounts: applyDraftInput(prev.amounts, key, text, original, parseAmountInput) }))
|
||||
}
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
import { Table, Tooltip, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { ColumnOut, IssueOut } from '../../types/api';
|
||||
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 { columnTitle } from './reviewView';
|
||||
import type { LineAction } from './statementDraft';
|
||||
import type { LineAction, StatementDraft } from './statementDraft';
|
||||
import { type GridRow, cellKey } from './statementGrid';
|
||||
|
||||
type Props = {
|
||||
@ -32,6 +33,11 @@ type Props = {
|
||||
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 CHILD_INDENT = 20; // отступ названия строки детализации под родителем
|
||||
@ -45,22 +51,6 @@ const isUnresolved = (r: LayoutRow) => r.kind === 'line' && r.row.lineStatus !==
|
||||
// наименование и код: красноватый (нет кода) / оранжевый (ok с needsReview) / фиолетовый («БАЛАНС»)
|
||||
const labelBackground = (r: LayoutRow) => (r.kind === 'line' ? (rowBackground(r.row) ?? (r.grand ? COLORS.grand : undefined)) : undefined);
|
||||
|
||||
function ColumnTitle({ col, issues }: { col: ColumnOut; issues: IssueOut[] }) {
|
||||
const worst = worstSeverity(issues);
|
||||
if (!worst) return <>{columnTitle(col)}</>;
|
||||
return (
|
||||
<Tooltip
|
||||
title={issues.map((i) => (
|
||||
<div key={i.id}>{i.message}</div>
|
||||
))}>
|
||||
<span>
|
||||
<SeverityIcon severity={worst} style={{ marginRight: 4 }} />
|
||||
{columnTitle(col)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatementTable({
|
||||
columns,
|
||||
layout,
|
||||
@ -75,6 +65,10 @@ export function StatementTable({
|
||||
lineErrors = {},
|
||||
onLineCodeChange,
|
||||
onLineMenu,
|
||||
fullDraft,
|
||||
onConfirmToggle,
|
||||
onColumnDateChange,
|
||||
onColumnDateConfirm,
|
||||
}: Props) {
|
||||
const span = columns.length + 2;
|
||||
// строка-заголовок раздела занимает всю ширину: первая ячейка colSpan = все колонки, остальные скрыты
|
||||
@ -156,7 +150,17 @@ export function StatementTable({
|
||||
},
|
||||
...columns.map((col) => ({
|
||||
key: col.id,
|
||||
title: <ColumnTitle col={col} issues={places.byColumn.get(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: 112,
|
||||
align: 'right' as const,
|
||||
onCell: (r: LayoutRow) =>
|
||||
@ -184,6 +188,8 @@ export function StatementTable({
|
||||
onDraftChange={onDraftChange}
|
||||
hasError={errorKeys?.has(draftKey)}
|
||||
issues={places.byCell.get(draftKey)}
|
||||
confirmState={editable && fullDraft ? cellConfirmState(r.row, value, fullDraft, draftKey) : null}
|
||||
onConfirmToggle={onConfirmToggle}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { CheckOutlined } from '@ant-design/icons';
|
||||
import { Input, Tooltip } from 'antd';
|
||||
import { CheckOutlined, UndoOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Tooltip } from 'antd';
|
||||
import type { IssueOut, ValueOut } from '../../types/api';
|
||||
import { formatAmount, parseAmountInput } from '../../utils/money';
|
||||
import { SeverityIcon } from './IssuesPanel';
|
||||
import { cellTipLines } from './cellView';
|
||||
import type { CellConfirmState } from './editRules';
|
||||
import { worstSeverity } from './issuePlaces';
|
||||
|
||||
type Props = {
|
||||
@ -17,10 +18,30 @@ type Props = {
|
||||
issues?: IssueOut[];
|
||||
/** ошибка черновика, найденная при сохранении */
|
||||
hasError?: boolean;
|
||||
/** какая кнопка подтверждения нужна ячейке (editRules.cellConfirmState) и обработчик её нажатия */
|
||||
confirmState?: CellConfirmState;
|
||||
onConfirmToggle?: (key: string, state: NonNullable<CellConfirmState>) => void;
|
||||
};
|
||||
|
||||
const CONFIRM_TITLE = {
|
||||
confirm: 'Подтвердить значение',
|
||||
unconfirm: 'Снять подтверждение',
|
||||
'pending-confirm': 'Подтверждение будет сохранено — нажмите, чтобы убрать',
|
||||
'pending-unconfirm': 'Снятие подтверждения будет сохранено — нажмите, чтобы убрать',
|
||||
} as const;
|
||||
|
||||
/** Ячейка суммы: просмотр (с серой галочкой у подтверждённой) или поле ввода. */
|
||||
export function ValueCell({ value, editable, draftKey, draft, onDraftChange, issues = [], hasError = false }: Props) {
|
||||
export function ValueCell({
|
||||
value,
|
||||
editable,
|
||||
draftKey,
|
||||
draft,
|
||||
onDraftChange,
|
||||
issues = [],
|
||||
hasError = false,
|
||||
confirmState = null,
|
||||
onConfirmToggle,
|
||||
}: Props) {
|
||||
const lines = cellTipLines(value, issues);
|
||||
const worst = worstSeverity(issues);
|
||||
// значок — в левом углу ячейки; поверх ввода и текста, не двигает суммы
|
||||
@ -39,9 +60,10 @@ export function ValueCell({ value, editable, draftKey, draft, onDraftChange, iss
|
||||
}
|
||||
const text = draft[draftKey] ?? value.amount ?? '';
|
||||
const invalid = hasError || (draftKey in draft && !parseAmountInput(text).ok);
|
||||
const pending = confirmState === 'pending-confirm' || confirmState === 'pending-unconfirm';
|
||||
return (
|
||||
<Tooltip title={invalid ? 'Неверный формат суммы' : tip}>
|
||||
<span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
||||
{corner}
|
||||
<Input
|
||||
size='small'
|
||||
@ -49,8 +71,21 @@ export function ValueCell({ value, editable, draftKey, draft, onDraftChange, iss
|
||||
prefix={value.is_confirmed ? <CheckOutlined style={{ fontSize: 10, color: '#8c8c8c' }} /> : undefined}
|
||||
value={text}
|
||||
onChange={(e) => onDraftChange(draftKey, e.target.value, value.amount)}
|
||||
style={{ textAlign: 'right' }}
|
||||
// значок записи лежит в левом углу поверх поля — освобождаем ему место, чтобы не налезал на галочку и текст
|
||||
style={{ textAlign: 'right', paddingLeft: worst ? 14 : undefined }}
|
||||
/>
|
||||
{confirmState && (
|
||||
<Tooltip title={CONFIRM_TITLE[confirmState]}>
|
||||
<Button
|
||||
size='small'
|
||||
type={pending ? 'primary' : 'default'}
|
||||
aria-pressed={pending}
|
||||
aria-label={CONFIRM_TITLE[confirmState]}
|
||||
icon={confirmState === 'unconfirm' || confirmState === 'pending-unconfirm' ? <UndoOutlined /> : <CheckOutlined />}
|
||||
onClick={() => onConfirmToggle?.(draftKey, confirmState)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
59
src/widgets/DocumentReview/editRules.test.ts
Normal file
59
src/widgets/DocumentReview/editRules.test.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IssueOut, StatementOut } from '../../types/api';
|
||||
import statementV2Json from './__fixtures__/statement-v2.json';
|
||||
import { cellConfirmState, columnDateEditable } from './editRules';
|
||||
import { emptyDraft } from './statementDraft';
|
||||
import { buildStatementGrid, cellKey } from './statementGrid';
|
||||
|
||||
const statement = statementV2Json as StatementOut;
|
||||
const { rows, columns } = buildStatementGrid(statement);
|
||||
const col = (id: string) => columns.find((c) => c.id === id) as (typeof columns)[number];
|
||||
const issue = (code: string) => statement.issues.find((i) => i.code === code) as IssueOut;
|
||||
const rowOf = (key: string) => rows.find((r) => r.key === key) as (typeof rows)[number];
|
||||
|
||||
describe('columnDateEditable', () => {
|
||||
it('колонка без даты с записью — правится, подтверждать нечего', () => {
|
||||
expect(columnDateEditable(col('c2'), [issue('COLUMN_WITHOUT_DATE')])).toEqual({ editable: true, canConfirm: false });
|
||||
});
|
||||
it('колонка с датой и без записей — не правится', () => {
|
||||
expect(columnDateEditable(col('c1'), [])).toEqual({ editable: false, canConfirm: false });
|
||||
});
|
||||
it('колонка с датой и COLUMN_DATE_UNCERTAIN — правится и подтверждается', () => {
|
||||
const uncertain = { ...issue('COLUMN_WITHOUT_DATE'), code: 'COLUMN_DATE_UNCERTAIN' } as IssueOut;
|
||||
expect(columnDateEditable(col('c1'), [uncertain])).toEqual({ editable: true, canConfirm: true });
|
||||
});
|
||||
it('посторонняя запись колонки не открывает правку', () => {
|
||||
expect(columnDateEditable(col('c1'), [issue('RELATION_FAILED')])).toEqual({ editable: false, canConfirm: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('cellConfirmState', () => {
|
||||
const row = rowOf('l-u7');
|
||||
const value = row.cells.c1;
|
||||
const key = cellKey('l-u7', 'c1');
|
||||
it('needs_review → confirm', () => {
|
||||
expect(cellConfirmState(row, value, emptyDraft(), key)).toBe('confirm');
|
||||
});
|
||||
it('подтверждение в черновике → pending-confirm', () => {
|
||||
expect(cellConfirmState(row, value, { ...emptyDraft(), confirms: { [key]: true } }, key)).toBe('pending-confirm');
|
||||
});
|
||||
it('снятие в черновике → pending-unconfirm', () => {
|
||||
expect(cellConfirmState(row, value, { ...emptyDraft(), confirms: { [key]: false } }, key)).toBe('pending-unconfirm');
|
||||
});
|
||||
it('сумма в черновике → null', () => {
|
||||
expect(cellConfirmState(row, value, { ...emptyDraft(), amounts: { [key]: '7' } }, key)).toBeNull();
|
||||
});
|
||||
it('шаблонная строка (нет id) → null', () => {
|
||||
const tpl = rowOf('code-1250');
|
||||
expect(cellConfirmState(tpl, tpl.cells.c1, emptyDraft(), cellKey('code-1250', 'c1'))).toBeNull();
|
||||
});
|
||||
it('значение без id → null', () => {
|
||||
expect(cellConfirmState(row, value && { ...value, id: null }, emptyDraft(), key)).toBeNull();
|
||||
});
|
||||
it('is_confirmed → unconfirm', () => {
|
||||
expect(cellConfirmState(row, value && { ...value, needs_review: false, is_confirmed: true }, emptyDraft(), key)).toBe('unconfirm');
|
||||
});
|
||||
it('обычная ячейка → null', () => {
|
||||
expect(cellConfirmState(row, value && { ...value, needs_review: false }, emptyDraft(), key)).toBeNull();
|
||||
});
|
||||
});
|
||||
25
src/widgets/DocumentReview/editRules.ts
Normal file
25
src/widgets/DocumentReview/editRules.ts
Normal file
@ -0,0 +1,25 @@
|
||||
// Правила правки: когда у колонки можно править дату и когда у ячейки доступно подтверждение (P6-6, P6-11).
|
||||
import type { ColumnOut, IssueOut, ValueOut } from '../../types/api';
|
||||
import type { StatementDraft } from './statementDraft';
|
||||
import type { GridRow } from './statementGrid';
|
||||
|
||||
/** Записи issues, при которых аналитик правит или подтверждает дату колонки. */
|
||||
export const COLUMN_DATE_CODES: ReadonlySet<string> = new Set(['COLUMN_WITHOUT_DATE', 'COLUMN_DATE_UNCERTAIN']);
|
||||
|
||||
export function columnDateEditable(col: ColumnOut, columnIssues: IssueOut[]): { editable: boolean; canConfirm: boolean } {
|
||||
const flagged = columnIssues.some((issue) => COLUMN_DATE_CODES.has(issue.code));
|
||||
const editable = (col.period_kind === 'none' && col.period_date === null) || flagged;
|
||||
return { editable, canConfirm: editable && col.period_date !== null && flagged };
|
||||
}
|
||||
|
||||
export type CellConfirmState = 'confirm' | 'unconfirm' | 'pending-confirm' | 'pending-unconfirm' | null;
|
||||
|
||||
/** Какая кнопка подтверждения нужна ячейке. Нужны оба `id` (у достроенных ядро ответит UNKNOWN_CELL); сумма в черновике подтверждение отменяет. */
|
||||
export function cellConfirmState(row: GridRow, value: ValueOut | undefined, draft: StatementDraft, key: string): CellConfirmState {
|
||||
if (!row.lineId || !value || value.id === null) return null;
|
||||
if (key in draft.amounts) return null;
|
||||
if (key in draft.confirms) return draft.confirms[key] ? 'pending-confirm' : 'pending-unconfirm';
|
||||
if (value.needs_review) return 'confirm';
|
||||
if (value.is_confirmed) return 'unconfirm';
|
||||
return null;
|
||||
}
|
||||
@ -55,6 +55,19 @@ describe('подтверждение ячейки', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('подтверждение ячейки без id значения', () => {
|
||||
it('пропускается молча (P6-6)', () => {
|
||||
const row = rows.find((r) => r.key === 'l-u7');
|
||||
if (!row?.cells.c1) throw new Error('нет ячейки в фикстуре');
|
||||
const noId = { ...row, cells: { ...row.cells, c1: { ...row.cells.c1, id: null } } };
|
||||
const result = buildStatementPatch(draftOf({ confirms: { [cellKey('l-u7', 'c1')]: true } }), {
|
||||
rows: rows.map((r) => (r === row ? noId : r)),
|
||||
header: statement.header,
|
||||
});
|
||||
expect(result).toEqual({ ok: true, body: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('действия строки', () => {
|
||||
it('код, расшифровка, подтверждение кода', () => {
|
||||
expect(bodyOf({ lines: { 'l-u7': { kind: 'code', code: '1250' } } })?.lines).toEqual([{ line_id: 'l-u7', line_code: '1250' }]);
|
||||
|
||||
@ -71,7 +71,8 @@ export function buildStatementPatch(
|
||||
if (key in draft.amounts) continue;
|
||||
const { rowKey, columnId } = splitKey(key);
|
||||
const row = byKey.get(rowKey);
|
||||
if (row?.lineId) values.push({ line_id: row.lineId, column_id: columnId, confirmed });
|
||||
// у достроенной ячейки (нет id значения) подтверждать нечего: ядро ответило бы UNKNOWN_CELL — пропускаем молча
|
||||
if (row?.lineId && row.cells[columnId]?.id) values.push({ line_id: row.lineId, column_id: columnId, confirmed });
|
||||
}
|
||||
|
||||
// lines: действия строк (P6-7), конфликт с суммами в шаблонную строку (P6-8)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user