feat(web): окно «Корректировка операции» с черновиком (#206)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 21:48:12 +04:00
parent c2cbbf5dfd
commit c559dd321c
7 changed files with 732 additions and 3 deletions

View File

@ -193,3 +193,9 @@
| 28.10.2026 | R-12: после `allocate` таблица заменяется ответом целиком (`replace`), после `revoke` подменяется один агрегат (`replaceAggregate`); окно отмены при не-409 ошибке остаётся открытым, при 409 закрывается | `allocate` меняет и нераспределённые, `revoke` отдаёт агрегат; при сетевой ошибке комментарий не должен пропасть | после revoke нераспределённые не обновляются (по контракту revoke их не трогает) |
| 28.10.2026 | R-13: меню строки вынесено в чистую `allocationMenu` (ключ `MOVE` — «выбрать правило», уходит как `ASSIGN`); правило-кандидат, которого нет в `rules`, пункт «в правило» не включает | тестируемость без React; фронт не выдумывает правил | при рассинхроне справочника правил пункт неактивен |
| 28.10.2026 | R-14: окно получило пропс `onReload` сверх сигнатуры плана и рендерится только пока открыто (`key` = ключ агрегата) | при `CORRECTION_VERSION_CONFLICT`/409/404 окну нужно перечитать таблицу; черновик читается один раз при открытии | окно без `onReload` не обновит таблицу при конфликте |
| 28.10.2026 | R-15: черновик пишется только при `editable` и если состояние отличается от исходного (суммы — копейками); вернули к исходному — черновик удаляется; при конфликте редакций правки остаются в окне, черновик сохраняется с прежним `baseVersionNo`, плашка «Корректировку изменили после ваших правок» | в режиме чтения черновик бессмыслен; совпавший с исходным — мусор в `localStorage` | при ручном возврате к исходному плашка восстановления пропадает |
| 28.10.2026 | R-16: ключ строки статьи в окне — устойчивый числовой `id` из счётчика компонента (в `EditorState` ids нет); наружу (черновик, PUT) уходит `EditorState` без id | ключ-индекс при удалении строки переносил бы введённый текст на соседнюю | id не попадают в черновик, после перезагрузки выдаются заново |
| 28.10.2026 | R-17: после `UNASSIGN`/`ASSIGN` в окне суммы статей без своих правок (не dirty) пересобираются из нового агрегата (`initialEditorState`), при правках — остаются как есть | у агрегата без корректировки статья предлагается с суммой стороны, она изменилась; затирать ввод аналитика нельзя | при правках статьи не подстраиваются под новые суммы сторон — их видно по строке контроля |
| 28.10.2026 | R-18: суммы в окне показываются в рублях (`1 000 000,00`) независимо от единиц панели, а в полях ввода — как есть (`1000000.00`, пробелы запрещены R-4); у строки контроля без процента (`percent = null`, одна сторона 0) процент не пишется, у равенства — «= 0,00» | единицы панели — про таблицу; дизайн показывает «(100%)» там, где процент к меньшей стороне не определён, фронт его не выдумывает | расхождение с дизайном в тексте контроля |
| 28.10.2026 | R-19: агрегат исчез из ответа — окно открыто, стороны «—», «Сохранить» неактивна, пояснение в плашке; ключ PUT берётся из ответа, поэтому без агрегата сохранить нельзя | Review Focus плана | — |

View File

@ -0,0 +1,47 @@
// Выбор нераспределённой строки для «Добавить строку ОСВ»: клик по строке отправляет ASSIGN (в окне, через onPick).
import { Modal, Table } from 'antd';
import type { UnallocatedEntryOut } from '../../types/corrections';
import { formatRub } from './editorView';
type Props = {
open: boolean;
sideLabel: 'Дт' | 'Кт';
article: string;
candidates: UnallocatedEntryOut[];
busy: boolean;
onPick(entry: UnallocatedEntryOut): void;
onClose(): void;
};
export function AllocationPicker({ open, sideLabel, article, candidates, busy, onPick, onClose }: Props) {
return (
<Modal
open={open}
title={`Нераспределённые записи · ${sideLabel}`}
footer={null}
width={760}
getContainer={() => (document.fullscreenElement as HTMLElement) || document.body}
onCancel={onClose}
destroyOnHidden>
<Table
size='small'
pagination={false}
rowKey={(u) => u.entry_ids.join(',')}
dataSource={candidates}
locale={{ emptyText: 'Нет подходящих нераспределённых записей' }}
onRow={(u) => ({
onClick: () => {
if (!busy) onPick(u);
},
style: { cursor: busy ? 'default' : 'pointer' },
})}
columns={[
{ title: 'Контрагент', dataIndex: ['counterparty', 'name'] },
{ title: 'Счёт', dataIndex: 'account_code', width: 120 },
{ title: 'Строка баланса', width: 140, render: () => article },
{ title: 'Сумма', dataIndex: 'amount', align: 'right', width: 160, render: (v: string) => formatRub(v) },
]}
/>
</Modal>
);
}

View File

@ -9,12 +9,13 @@ import type { AllocationIn, CorrectionAggregateOut } from '../../types/correctio
import { saveBlob, xlsxFilename } from '../../utils/download';
import { useNotificationApi } from '../../utils/useNotification';
import { useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay';
import { CorrectionEditorModal } from './CorrectionEditorModal';
import { CorrectionTable, type RowActions } from './CorrectionTable';
import { CorrectionToolbar } from './CorrectionToolbar';
import { RevokeModal } from './RevokeModal';
import { COLUMN_DEFS, type ColKey, type ColShape } from './columns';
import { errorOutcome, needsReload, readOnlyHint } from './readOnly';
import { EMPTY_FILTERS, type TableFilters, buildRows, filterTable, lineOptions, toAggregateKey, visibleTotals } from './tableModel';
import { EMPTY_FILTERS, type TableFilters, aggregateKey, buildRows, filterTable, lineOptions, toAggregateKey, visibleTotals } from './tableModel';
import { useCorrectionTable } from './useCorrectionTable';
const LOAD_ERROR_KEY = 'corrections-load-error';
@ -37,6 +38,8 @@ export function CorrectionContent({ versionId, card = null }: Props) {
// ref, а не состояние: setBusy применяется после перерисовки, а два клика могут прийти до неё — ref меняется мгновенно
const inflight = useRef(false);
const [revoking, setRevoking] = useState<CorrectionAggregateOut | null>(null);
// ключ агрегата, окно корректировки которого открыто
const [editing, setEditing] = useState<string | null>(null);
const boxRef = useRef<HTMLDivElement>(null);
// ошибка загрузки — уведомление с текстом сервера и кнопкой «Повторить»
@ -148,9 +151,8 @@ export function CorrectionContent({ versionId, card = null }: Props) {
}
};
// «Корректировать» остаётся заглушкой до Task 6
const actions: RowActions = {
onCorrect: () => notify.info({ description: 'Окно корректировки будет в следующей версии экрана' }),
onCorrect: (a) => setEditing(aggregateKey(a)),
onRevoke: setRevoking,
onAllocate,
};
@ -230,6 +232,19 @@ export function CorrectionContent({ versionId, card = null }: Props) {
onToggleFullscreen={toggleFullscreen}
/>
{body}
{editing !== null && table && (
<CorrectionEditorModal
key={editing}
versionId={versionId}
table={table}
aggregateKey={editing}
editable={table.editable}
onClose={() => setEditing(null)}
onTable={replace}
onAggregate={replaceAggregate}
onReload={reload}
/>
)}
<RevokeModal
open={revoking !== null}
subtitle={revoking ? `${revoking.company_1.name} — ${revoking.company_2.name}` : ''}

View File

@ -0,0 +1,335 @@
// Окно «Корректировка операции»: исходные строки ОСВ и распределение корректировки по двум сторонам агрегата, живой контроль,
// комментарий. Правки статей и комментария пишутся в черновик (localStorage); действия со строками ОСВ уходят на сервер сразу.
import { Alert, App, Button, Input, Modal } from 'antd';
import { useMemo, useRef, useState } from 'react';
import { useCurrentUser } from '../../auth/MeContext';
import { allocate, saveCorrection } from '../../services/api/corrections';
import type { CorrectionAggregateOut, CorrectionEntryOut, CorrectionTableOut, Side, UnallocatedEntryOut } from '../../types/corrections';
import { AllocationPicker } from './AllocationPicker';
import { ControlRow, DistributionBlock, type KeyedLine, OsvBlock, SideHeader } from './EditorSide';
import { type Draft, clearDraft, draftKey, loadDraft, saveDraft } from './draftStorage';
import { type EditorState, initialEditorState, isDirty, liveControl, pickerCandidates, toCorrectionIn, validateLines } from './editorModel';
import { DISCARD_QUESTION, controlText, draftNotice, saveErrorOutcome } from './editorView';
import { COMMENT_MAX_LENGTH, errorOutcome, readOnlyHint } from './readOnly';
import { articleCodes, findAggregate, toAggregateKey } from './tableModel';
type Props = {
versionId: string;
table: CorrectionTableOut;
/** Ключ агрегата из `aggregateKey(...)`. */
aggregateKey: string;
editable: boolean;
onClose(): void;
/** После действия со строкой сервер отвечает всей таблицей. */
onTable(t: CorrectionTableOut): void;
/** После сохранения сервер отвечает одним агрегатом. */
onAggregate(a: CorrectionAggregateOut): void;
/** Данные устарели (конфликт редакций, 409/404): перечитать таблицу. */
onReload(): void;
};
type KeyedState = { debit: KeyedLine[]; credit: KeyedLine[]; comment: string };
type SideNo = 1 | 2;
const EMPTY: EditorState = { debit: [], credit: [], comment: '' };
const container = () => (document.fullscreenElement as HTMLElement) || document.body;
/** Сторона в тексте: у правила сторона 1 — `side_1` (Д/К), сторона 2 — противоположная. */
const sideText = (s: Side): 'Дт' | 'Кт' => (s === 'D' ? 'Дт' : 'Кт');
export function CorrectionEditorModal({ versionId, table, aggregateKey, editable, onClose, onTable, onAggregate, onReload }: Props) {
const user = useCurrentUser();
const { modal } = App.useApp();
const aggregate = findAggregate(table, aggregateKey);
const key = useMemo(() => draftKey(user.id, versionId, aggregateKey), [user.id, versionId, aggregateKey]);
// Счётчик id строк: у «Распределения» нет собственных id, а ключ-индекс при удалении строки путал бы поля соседей.
const nextId = useRef(1);
const withIds = (s: EditorState): KeyedState => ({
comment: s.comment,
debit: s.debit.map((l) => ({ ...l, id: nextId.current++ })),
credit: s.credit.map((l) => ({ ...l, id: nextId.current++ })),
});
const plain = (s: KeyedState): EditorState => ({
comment: s.comment,
debit: s.debit.map(({ line_code, amount }) => ({ line_code, amount })),
credit: s.credit.map(({ line_code, amount }) => ({ line_code, amount })),
});
const initial = useMemo(() => (aggregate ? initialEditorState(aggregate) : EMPTY), [aggregate]);
// Начальное состояние: черновик (если не только чтение и он отличается от исходного) или исходные данные агрегата.
const [start] = useState(() => {
const draft: Draft | null = editable && aggregate ? loadDraft(key) : null;
const useDraft = draft !== null && isDirty(initial, draft.state);
return {
state: withIds(useDraft && draft ? draft.state : initial),
savedAt: useDraft && draft ? draft.savedAt : null,
base: useDraft && draft ? draft.baseVersionNo : (aggregate?.correction?.version_no ?? null),
};
});
const [state, setState] = useState<KeyedState>(start.state);
// savedAt — время черновика, пока плашка видна; base — редакция корректировки, от которой начаты правки
const [savedAt, setSavedAt] = useState<string | null>(start.savedAt);
const [base, setBase] = useState<number | null>(start.base);
const [saving, setSaving] = useState(false);
const [allocating, setAllocating] = useState(false);
const inflight = useRef(false); // ref: два клика приходят раньше, чем применится setSaving
const confirming = useRef(false);
const [commentError, setCommentError] = useState<string | null>(null);
const [lineError, setLineError] = useState<{ lineCode: string; message: string } | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [pickerSide, setPickerSide] = useState<SideNo | null>(null);
const stateRef = useRef(state);
stateRef.current = state;
const busy = saving || allocating;
const dirty = editable && isDirty(initial, plain(state));
/** Любая правка статей/комментария: в состояние и в черновик (совпало с исходным — черновик удаляем). */
const commit = (next: KeyedState) => {
setState(next);
if (!editable || !aggregate) return;
const p = plain(next);
if (isDirty(initial, p)) saveDraft(key, { state: p, savedAt: new Date().toISOString(), baseVersionNo: base });
else {
clearDraft(key);
setSavedAt(null);
}
};
const editLines = (side: SideNo, fn: (lines: KeyedLine[]) => KeyedLine[]) => {
setLineError(null);
setFormError(null);
const name = side === 1 ? 'debit' : 'credit';
commit({ ...state, [name]: fn(state[name]) });
};
const resetDraft = () => {
clearDraft(key);
setState(withIds(initial));
setSavedAt(null);
setBase(aggregate?.correction?.version_no ?? null);
setCommentError(null);
setLineError(null);
setFormError(null);
};
const requestClose = () => {
if (saving || confirming.current) return;
if (!dirty) {
onClose();
return;
}
confirming.current = true;
modal.confirm({
title: DISCARD_QUESTION,
okText: 'Закрыть',
cancelText: 'Остаться',
getContainer: container,
onOk: () => {
clearDraft(key);
onClose();
},
afterClose: () => {
confirming.current = false;
},
});
};
// Действие со строкой ОСВ: сразу на сервер, таблица подменяется ответом, окно перечитывает агрегат из неё.
const runAllocate = async (body: Parameters<typeof allocate>[1]): Promise<boolean> => {
if (inflight.current) return false;
inflight.current = true;
setAllocating(true);
setFormError(null);
try {
const t = await allocate(versionId, body);
onTable(t);
// без своих правок суммы по умолчанию следуют за новыми суммами сторон
const fresh = findAggregate(t, aggregateKey);
if (fresh && !isDirty(initial, plain(stateRef.current))) setState(withIds(initialEditorState(fresh)));
return true;
} catch (e) {
const out = errorOutcome(e, 'Не удалось выполнить действие со строкой');
setFormError(out.message);
if (out.reload) onReload();
return false;
} finally {
inflight.current = false;
setAllocating(false);
}
};
const unassign = (e: CorrectionEntryOut) => runAllocate({ statement_type: 'balance', entry_ids: e.entry_ids, action: 'UNASSIGN' });
const assign = async (u: UnallocatedEntryOut) => {
if (!aggregate) return;
const ok = await runAllocate({ statement_type: 'balance', entry_ids: u.entry_ids, action: 'ASSIGN', rule_id: aggregate.rule_id });
if (ok) setPickerSide(null);
};
const validation = validateLines(plain(state));
const save = async () => {
if (!aggregate || inflight.current || !validation.ok) return;
inflight.current = true;
setSaving(true);
setFormError(null);
setCommentError(null);
setLineError(null);
try {
const out = await saveCorrection(versionId, toAggregateKey(aggregate), toCorrectionIn(validation.body, aggregate.correction?.version_no ?? null));
clearDraft(key);
onAggregate(out);
onClose();
} catch (e) {
const err = saveErrorOutcome(e);
if (err.kind === 'comment') setCommentError(err.message);
else if (err.kind === 'line' && err.lineCode) setLineError({ lineCode: err.lineCode, message: err.message });
else setFormError(err.message);
if (err.kind === 'conflict') {
// правки остаются в окне; плашка «Корректировку изменили после ваших правок» (base остаётся прежним)
const p = plain(stateRef.current);
const at = new Date().toISOString();
saveDraft(key, { state: p, savedAt: at, baseVersionNo: base });
setSavedAt(at);
}
if (err.reload) onReload();
} finally {
inflight.current = false;
setSaving(false);
}
};
const lineErrors = (side: SideNo) => (i: number) => {
const name = side === 1 ? 'debit' : 'credit';
const errs = validation.ok ? {} : validation.errors;
const line = state[name][i];
return {
line_code: errs[`${name}.${i}.line_code`] ?? (lineError && line && line.line_code.trim() === lineError.lineCode ? lineError.message : undefined),
amount: errs[`${name}.${i}.amount`],
};
};
const rule = aggregate ? table.rules.find((r) => r.id === aggregate.rule_id) : undefined;
const sideLabels: Record<SideNo, 'Дт' | 'Кт'> = rule
? { 1: sideText(rule.side_1), 2: sideText(rule.side_1 === 'D' ? 'K' : 'D') }
: { 1: 'Дт', 2: 'Кт' };
const sideData = (n: SideNo) => {
const side = aggregate ? (n === 1 ? aggregate.side_1 : aggregate.side_2) : null;
const company = aggregate ? (n === 1 ? aggregate.company_1.name : aggregate.company_2.name) : null;
return {
company,
article: side ? articleCodes(side.article_line).join(' / ') : '',
codes: side ? articleCodes(side.article_line) : [],
entries: side ? side.entries : [],
};
};
const s1 = sideData(1);
const s2 = sideData(2);
const control = aggregate ? liveControl(aggregate, plain(state)) : null;
const source = control ? controlText(control.sourceDiff) : null;
const allocation = control ? controlText(control.allocationDiff) : null;
const notice = draftNotice(savedAt, base, aggregate);
const hint = editable ? null : readOnlyHint(table.read_only_reason);
return (
<Modal
open
title='Корректировка операции'
width={1100}
getContainer={container}
mask={{ closable: false }}
onCancel={requestClose}
destroyOnHidden
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={requestClose} disabled={saving}>
{editable ? 'Отменить' : 'Закрыть'}
</Button>
{editable && (
<Button type='primary' loading={saving} disabled={!aggregate || !validation.ok || allocating} onClick={save}>
Сохранить корректировки
</Button>
)}
</div>
}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{hint && <Alert type='info' showIcon title={hint} />}
{!aggregate && <Alert type='warning' showIcon title='Этого агрегата больше нет в таблице: строки перенесены в другие правила' />}
{notice && (
<Alert
type={notice.kind === 'stale' ? 'warning' : 'info'}
showIcon
title={notice.text}
action={
editable ? (
<Button size='small' onClick={resetDraft}>
Сбросить
</Button>
) : undefined
}
/>
)}
{formError && <Alert type='error' showIcon title={formError} />}
<div style={{ background: '#f7faf8', borderRadius: 8, padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<SideHeader company={s1.company} sideLabel={sideLabels[1]} article={s1.article} />
<SideHeader company={s2.company} sideLabel={sideLabels[2]} article={s2.article} />
<OsvBlock article={s1.article} entries={s1.entries} editable={editable} busy={busy} onPick={() => setPickerSide(1)} onUnassign={unassign} />
<OsvBlock article={s2.article} entries={s2.entries} editable={editable} busy={busy} onPick={() => setPickerSide(2)} onUnassign={unassign} />
</div>
<ControlRow {...(source ?? { left: '—', middle: '—', right: '—', red: false })} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
{([1, 2] as const).map((n) => (
<DistributionBlock
key={n}
articleCodes={n === 1 ? s1.codes : s2.codes}
lines={n === 1 ? state.debit : state.credit}
lineErrors={lineErrors(n)}
editable={editable}
onAddLine={() => editLines(n, (ls) => [...ls, { id: nextId.current++, line_code: '', amount: '' }])}
onRemoveLine={(id) => editLines(n, (ls) => ls.filter((l) => l.id !== id))}
onChangeLine={(id, patch) => editLines(n, (ls) => ls.map((l) => (l.id === id ? { ...l, ...patch } : l)))}
/>
))}
</div>
<ControlRow {...(allocation ?? { left: '—', middle: '—', right: '—', red: false })} />
{!validation.ok && validation.errors.lines && <div style={{ color: '#d92d20' }}>{validation.errors.lines}</div>}
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>Комментарий к корректировке</div>
<Input.TextArea
rows={3}
value={state.comment}
disabled={!editable}
maxLength={COMMENT_MAX_LENGTH}
status={commentError ? 'error' : undefined}
placeholder='Укажите причину корректировки'
aria-label='Комментарий к корректировке'
onChange={(e) => {
setCommentError(null);
commit({ ...state, comment: e.target.value });
}}
/>
{commentError && <div style={{ color: '#d92d20', fontSize: 12 }}>{commentError}</div>}
</div>
</div>
{pickerSide !== null && aggregate && (
<AllocationPicker
open
sideLabel={sideLabels[pickerSide]}
article={pickerSide === 1 ? s1.article : s2.article}
candidates={pickerCandidates(table, aggregate, pickerSide)}
busy={allocating}
onPick={assign}
onClose={() => setPickerSide(null)}
/>
)}
</Modal>
);
}

View File

@ -0,0 +1,177 @@
// Блоки окна «Корректировка операции»: шапка стороны, «Исходные данные ОСВ», «Распределение корректировки», строка контроля.
// Вынесены из CorrectionEditorModal.tsx, чтобы окно не разрасталось: здесь только вёрстка, состояние и запросы — в окне.
import { CloseOutlined, PlusOutlined } from '@ant-design/icons';
import { AutoComplete, Button, Input } from 'antd';
import type { CSSProperties } from 'react';
import type { CorrectionEntryOut } from '../../types/corrections';
import { formatRub } from './editorView';
/** Строка распределения с устойчивым ключом: при удалении строки React не путает поля соседних строк. */
export type KeyedLine = { id: number; line_code: string; amount: string };
const CARD: CSSProperties = { background: '#fff', border: '1px solid #e4e7ec', borderRadius: 8, padding: 12 };
const TH: CSSProperties = { textAlign: 'left', fontWeight: 500, color: '#667085', fontSize: 12, padding: '6px 8px' };
const TD: CSSProperties = { padding: '6px 8px', borderTop: '1px solid #f2f4f7' };
type LineErrors = (index: number) => { line_code?: string; amount?: string };
/** Шапка стороны: компания, Дт/Кт и статья правила. */
export function SideHeader({ company, sideLabel, article }: { company: string | null; sideLabel: 'Дт' | 'Кт'; article: string }) {
return (
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>{company ?? '—'}</div>
<div style={{ marginTop: 4 }}>
<span style={{ fontWeight: 600 }}>{sideLabel}</span> <span style={{ fontWeight: 600, fontSize: 16 }}>{article || '—'}</span>
</div>
</div>
);
}
type OsvProps = {
article: string;
entries: CorrectionEntryOut[];
editable: boolean;
/** Идёт запрос: кнопки действий неактивны. */
busy: boolean;
onPick(): void;
onUnassign(entry: CorrectionEntryOut): void;
};
/** «Исходные данные ОСВ»: строки стороны агрегата; «×» — убрать из правила, «Добавить строку ОСВ» — выбор из нераспределённых. */
export function OsvBlock(p: OsvProps) {
const canEdit = p.editable && !p.busy;
return (
<div style={CARD}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<b>Исходные данные ОСВ</b>
{p.editable && (
<Button size='small' icon={<PlusOutlined />} disabled={!canEdit} onClick={p.onPick}>
Добавить строку ОСВ
</Button>
)}
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 8 }}>
<thead>
<tr>
<th style={TH}>Счёт</th>
<th style={TH}>Строка баланса</th>
<th style={{ ...TH, textAlign: 'right' }}>Сумма</th>
{p.editable && <th style={{ ...TH, width: 32 }} />}
</tr>
</thead>
<tbody>
{p.entries.length === 0 && (
<tr>
<td colSpan={4} style={{ ...TD, color: '#667085' }}>
Нет исходных строк ОСВ для этой операции.
</td>
</tr>
)}
{p.entries.map((e) => (
<tr key={e.entry_ids.join(',')}>
<td style={TD}>{e.account_code}</td>
<td style={TD}>{p.article}</td>
<td style={{ ...TD, textAlign: 'right' }}>{formatRub(e.amount)}</td>
{p.editable && (
<td style={TD}>
<Button
type='text'
size='small'
icon={<CloseOutlined />}
disabled={!canEdit}
aria-label={`Убрать из правила: ${e.account_code}`}
onClick={() => p.onUnassign(e)}
/>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
type DistProps = {
articleCodes: string[];
lines: KeyedLine[];
lineErrors: LineErrors;
editable: boolean;
onAddLine(): void;
onRemoveLine(id: number): void;
onChangeLine(id: number, patch: Partial<Pick<KeyedLine, 'line_code' | 'amount'>>): void;
};
/** Блок «Распределение корректировки» стороны — отдельная карточка под контрольной строкой исходных данных. */
export function DistributionBlock(p: DistProps) {
return (
<div style={CARD}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<b>Распределение корректировки</b>
{p.editable && (
<Button size='small' icon={<PlusOutlined />} onClick={p.onAddLine}>
Добавить строку
</Button>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.6fr 32px', gap: 8, marginTop: 8, alignItems: 'start' }}>
<span style={TH}>Строка баланса</span>
<span style={{ ...TH, textAlign: 'right' }}>Сумма</span>
<span />
{p.lines.map((line, i) => {
const err = p.lineErrors(i);
return <LineRow key={line.id} {...p} line={line} err={err} />;
})}
</div>
</div>
);
}
type RowProps = Pick<DistProps, 'articleCodes' | 'editable' | 'onRemoveLine' | 'onChangeLine'> & {
line: KeyedLine;
err: { line_code?: string; amount?: string };
};
function LineRow({ line, err, articleCodes, editable, onRemoveLine, onChangeLine }: RowProps) {
return (
<>
<div>
<AutoComplete
value={line.line_code}
options={articleCodes.map((c) => ({ value: c }))}
disabled={!editable}
style={{ width: '100%' }}
onChange={(v) => onChangeLine(line.id, { line_code: v })}>
<Input maxLength={4} status={err.line_code ? 'error' : undefined} aria-label='Строка баланса' />
</AutoComplete>
{err.line_code && <div style={{ color: '#d92d20', fontSize: 12 }}>{err.line_code}</div>}
</div>
<div>
<Input
value={line.amount}
disabled={!editable}
inputMode='decimal'
status={err.amount ? 'error' : undefined}
style={{ textAlign: 'right' }}
aria-label='Сумма статьи'
onChange={(e) => onChangeLine(line.id, { amount: e.target.value })}
/>
{err.amount && <div style={{ color: '#d92d20', fontSize: 12 }}>{err.amount}</div>}
</div>
<div>
{editable && <Button type='text' icon={<CloseOutlined />} aria-label='Удалить статью' onClick={() => onRemoveLine(line.id)} />}
</div>
</>
);
}
/** Строка контроля: сумма слева, «≠ разница (процент)» посередине (красная при превышении порога), сумма справа. */
export function ControlRow({ left, middle, right, red }: { left: string; middle: string; right: string; red: boolean }) {
return (
<div style={{ ...CARD, display: 'grid', gridTemplateColumns: '1fr auto 1fr', alignItems: 'center', padding: '10px 12px', fontWeight: 600 }}>
<span>{left}</span>
<span style={{ color: red ? '#d92d20' : undefined }}>{middle}</span>
<span style={{ textAlign: 'right' }}>{right}</span>
</div>
);
}

View File

@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { ApiError } from '../../api/client';
import type { CorrectionAggregateOut } from '../../types/corrections';
import { CONFLICT_MESSAGE, controlText, draftNotice, formatRub, saveErrorOutcome, timeHHMM } from './editorView';
const err = (status: number, code: string, detail: Record<string, unknown> = {}) =>
new ApiError(status, code, `msg ${code}`, { detail: { code, message: `msg ${code}`, ...detail } });
const agg = (versionNo: number | null) => ({ correction: versionNo === null ? null : { version_no: versionNo } }) as unknown as CorrectionAggregateOut;
describe('saveErrorOutcome', () => {
it('COMMENT_REQUIRED — у комментария, без перечитывания', () => {
expect(saveErrorOutcome(err(422, 'COMMENT_REQUIRED'))).toMatchObject({ kind: 'comment', reload: false });
});
it('INVALID_LINE_CODE — у статьи detail.line_code', () => {
expect(saveErrorOutcome(err(422, 'INVALID_LINE_CODE', { line_code: '9999' }))).toMatchObject({ kind: 'line', lineCode: '9999', reload: false });
});
it('INVALID_LINE_CODE без line_code в теле — статья не определена, окно не падает', () => {
expect(saveErrorOutcome(err(422, 'INVALID_LINE_CODE'))).toMatchObject({ kind: 'line', lineCode: null });
});
it('CORRECTION_VERSION_CONFLICT — фиксированный текст и перечитывание', () => {
expect(saveErrorOutcome(err(409, 'CORRECTION_VERSION_CONFLICT'))).toEqual({ kind: 'conflict', message: CONFLICT_MESSAGE, lineCode: null, reload: true });
});
it('прочий 409 — текст сервера и перечитывание; 500 — без перечитывания', () => {
expect(saveErrorOutcome(err(409, 'LOCKED_BY_OTHER'))).toMatchObject({ kind: 'other', message: 'msg LOCKED_BY_OTHER', reload: true });
expect(saveErrorOutcome(err(500, 'X'))).toMatchObject({ kind: 'other', reload: false });
});
it('не ApiError — запасной текст', () => {
expect(saveErrorOutcome(new Error('boom'))).toMatchObject({ kind: 'other', message: 'Не удалось сохранить корректировку' });
});
});
describe('draftNotice', () => {
const at = new Date(2026, 9, 28, 14, 5).toISOString();
it('нет черновика — плашки нет', () => expect(draftNotice(null, 1, agg(1))).toBeNull());
it('версия совпала — «Восстановлены…» со временем', () => {
expect(draftNotice(at, 1, agg(1))).toEqual({ kind: 'restored', text: 'Восстановлены несохранённые правки от 14:05' });
});
it('версия другая — «Корректировку изменили после ваших правок»', () => {
expect(draftNotice(at, 2, agg(3))?.kind).toBe('stale');
expect(draftNotice(at, null, agg(1))?.kind).toBe('stale');
});
it('корректировки нет и в черновике null — не устарел; агрегата нет — восстановлен', () => {
expect(draftNotice(at, null, agg(null))?.kind).toBe('restored');
expect(draftNotice(at, 3, null)?.kind).toBe('restored');
});
it('timeHHMM дополняет нулями', () => expect(timeHHMM(new Date(2026, 0, 1, 3, 7).toISOString())).toBe('03:07'));
});
describe('controlText', () => {
it('расхождение — красное «≠ разница (процент)»', () => {
const t = controlText({ left: '1000000.00', right: '202320.00', difference: '797680.00', percent: '394.27', red: true });
expect(t.middle).toBe('≠ 797 680,00 (394,27%)');
expect(t.left).toBe('1 000 000,00');
expect(t.red).toBe(true);
});
it('равенство и пустые суммы', () => {
const t = controlText({ left: null, right: null, difference: '0.00', percent: null, red: false });
expect(t).toEqual({ left: '0,00', middle: '= 0,00', right: '0,00', red: false });
});
it('одна сторона пуста — без процента', () => {
expect(controlText({ left: '5.00', right: null, difference: '5.00', percent: null, red: true }).middle).toBe('≠ 5,00');
});
});
describe('formatRub', () => {
it('рубли с двумя знаками, пусто — прочерк', () => {
expect(formatRub('1234.5')).toBe('1 234,50');
expect(formatRub(null)).toBe('—');
});
});

View File

@ -0,0 +1,78 @@
// Чистые куски окна «Корректировка операции» без React: тексты ошибок сохранения, плашки черновика, строки контроля.
import { ApiError } from '../../api/client';
import type { CorrectionAggregateOut } from '../../types/corrections';
import { formatScaledAmount } from '../../utils/money';
import type { Draft } from './draftStorage';
import type { Diff } from './editorModel';
import { errorOutcome } from './readOnly';
export const CONFLICT_MESSAGE = 'Корректировку уже изменили, данные обновлены';
export const COMMENT_REQUIRED_MESSAGE = 'Комментарий обязателен';
export const DISCARD_QUESTION = 'Есть несохранённые изменения. Закрыть без сохранения?';
/** Сумма в рублях с двумя знаками («1 000 000,00»); окно показывает рубли независимо от единиц панели таблицы. */
export const formatRub = (value: string | null): string => formatScaledAmount(value, { scale: 0, precision: 2 });
/** «394.27» → «394,27%»; строка в число не переводится. */
export const percentText = (p: string): string => `${p.replace('.', ',')}%`;
export type ControlText = { left: string; middle: string; right: string; red: boolean };
/** Строка контроля: суммы сторон и «≠ разница (процент)»; суммы нет — «0,00», как в дизайне. */
export function controlText(d: Diff): ControlText {
const diff = d.difference ?? '0.00';
const equal = diff === '0.00';
const pct = d.percent === null ? '' : ` (${percentText(d.percent)})`;
return {
left: formatRub(d.left ?? '0.00'),
middle: equal ? '= 0,00' : `≠ ${formatRub(diff)}${pct}`,
right: formatRub(d.right ?? '0.00'),
red: d.red,
};
}
export type SaveErrorKind = 'comment' | 'line' | 'conflict' | 'other';
export type SaveError = { kind: SaveErrorKind; message: string; lineCode: string | null; reload: boolean };
function detailOf(e: unknown): Record<string, unknown> | null {
if (!(e instanceof ApiError)) return null;
const body = e.body;
if (typeof body !== 'object' || body === null) return null;
const detail = (body as Record<string, unknown>).detail;
return typeof detail === 'object' && detail !== null && !Array.isArray(detail) ? (detail as Record<string, unknown>) : null;
}
/** Куда показать ошибку PUT: у комментария, у статьи (`detail.line_code`), конфликт редакций (с перечитыванием) или общим текстом. */
export function saveErrorOutcome(e: unknown): SaveError {
const code = e instanceof ApiError ? e.code : undefined;
if (code === 'COMMENT_REQUIRED') return { kind: 'comment', message: COMMENT_REQUIRED_MESSAGE, lineCode: null, reload: false };
if (code === 'CORRECTION_VERSION_CONFLICT') return { kind: 'conflict', message: CONFLICT_MESSAGE, lineCode: null, reload: true };
if (code === 'INVALID_LINE_CODE') {
const raw = detailOf(e)?.line_code;
const lineCode = typeof raw === 'string' ? raw : null;
const message = e instanceof ApiError && e.message ? e.message : 'Такой статьи нет в форме компании';
return { kind: 'line', message, lineCode, reload: false };
}
const out = errorOutcome(e, 'Не удалось сохранить корректировку');
return { kind: 'other', message: out.message, lineCode: null, reload: out.reload };
}
/** «ЧЧ:ММ» местного времени из ISO-строки. */
export function timeHHMM(iso: string): string {
const d = new Date(iso);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
export type DraftNotice = { kind: 'restored' | 'stale'; text: string };
/**
* Плашка над окном: правки восстановлены из черновика; если корректировку после начала правок изменили (`baseVersionNo`
* не совпадает с действующей) — отдельный текст. Нет черновика (`savedAt = null`) — плашки нет.
*/
export function draftNotice(savedAt: string | null, baseVersionNo: number | null, a: CorrectionAggregateOut | null): DraftNotice | null {
if (savedAt === null) return null;
if (a && baseVersionNo !== (a.correction?.version_no ?? null)) return { kind: 'stale', text: 'Корректировку изменили после ваших правок' };
return { kind: 'restored', text: `Восстановлены несохранённые правки от ${timeHHMM(savedAt)}` };
}
export type { Draft };