AURORA-1541: таблица корректировки — подключена к реальному API (#206) #215
@ -199,3 +199,4 @@
|
||||
| 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 плана | — |
|
||||
| 28.10.2026 | R-20: в окне нет названий статей рядом с кодом («дт 1230 Дебиторская задолженность» в дизайне) | в ответе API названий статей нет, а фронт их не выдумывает | добавить поле `article_name` в контракт и показать |
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// Выбор нераспределённой строки для «Добавить строку ОСВ»: клик по строке отправляет ASSIGN (в окне, через onPick).
|
||||
import { Modal, Table } from 'antd';
|
||||
import { Button, Modal, Table } from 'antd';
|
||||
import type { UnallocatedEntryOut } from '../../types/corrections';
|
||||
import { formatRub } from './editorView';
|
||||
|
||||
@ -40,6 +40,23 @@ export function AllocationPicker({ open, sideLabel, article, candidates, busy, o
|
||||
{ title: 'Счёт', dataIndex: 'account_code', width: 120 },
|
||||
{ title: 'Строка баланса', width: 140, render: () => article },
|
||||
{ title: 'Сумма', dataIndex: 'amount', align: 'right', width: 160, render: (v: string) => formatRub(v) },
|
||||
{
|
||||
// кнопка — путь с клавиатуры (Tab, Enter); клик по строке остаётся для мыши
|
||||
title: '',
|
||||
width: 110,
|
||||
render: (_: unknown, u: UnallocatedEntryOut) => (
|
||||
<Button
|
||||
size='small'
|
||||
disabled={busy}
|
||||
aria-label={`Выбрать: ${u.account_code}, ${formatRub(u.amount)}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPick(u);
|
||||
}}>
|
||||
Выбрать
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
// Окно «Корректировка операции»: исходные строки ОСВ и распределение корректировки по двум сторонам агрегата, живой контроль,
|
||||
// комментарий. Правки статей и комментария пишутся в черновик (localStorage); действия со строками ОСВ уходят на сервер сразу.
|
||||
import { Alert, App, Button, Input, Modal } from 'antd';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, 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 { type EditorState, initialEditorState, isDirty, liveControl, pickerCandidates, rebaseState, 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';
|
||||
@ -68,7 +68,13 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
|
||||
base: useDraft && draft ? draft.baseVersionNo : (aggregate?.correction?.version_no ?? null),
|
||||
};
|
||||
});
|
||||
const [state, setState] = useState<KeyedState>(start.state);
|
||||
const [state, setStateRaw] = useState<KeyedState>(start.state);
|
||||
// ref обновляется там же, где состояние (а не при рендере): после await обработчик читает свежие правки, а не замыкание
|
||||
const stateRef = useRef(state);
|
||||
const setState = (next: KeyedState) => {
|
||||
stateRef.current = next;
|
||||
setStateRaw(next);
|
||||
};
|
||||
// savedAt — время черновика, пока плашка видна; base — редакция корректировки, от которой начаты правки
|
||||
const [savedAt, setSavedAt] = useState<string | null>(start.savedAt);
|
||||
const [base, setBase] = useState<number | null>(start.base);
|
||||
@ -81,16 +87,26 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
|
||||
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 prevInitial = useRef(initial);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: реагируем только на смену исходного состояния агрегата
|
||||
useEffect(() => {
|
||||
if (prevInitial.current === initial) return;
|
||||
const next = rebaseState(prevInitial.current, initial, plain(stateRef.current));
|
||||
prevInitial.current = initial;
|
||||
if (next) setState(withIds(next));
|
||||
}, [initial]);
|
||||
|
||||
// без агрегата окно только на просмотр: правок и «несохранённого» нет
|
||||
const edit = editable && aggregate !== null;
|
||||
const dirty = edit && isDirty(initial, plain(state));
|
||||
|
||||
/** Любая правка статей/комментария: в состояние и в черновик (совпало с исходным — черновик удаляем). */
|
||||
const commit = (next: KeyedState) => {
|
||||
setState(next);
|
||||
if (!editable || !aggregate) return;
|
||||
if (!edit || !aggregate) return;
|
||||
const p = plain(next);
|
||||
if (isDirty(initial, p)) saveDraft(key, { state: p, savedAt: new Date().toISOString(), baseVersionNo: base });
|
||||
else {
|
||||
@ -146,9 +162,6 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
|
||||
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, 'Не удалось выполнить действие со строкой');
|
||||
@ -171,7 +184,7 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
|
||||
const validation = validateLines(plain(state));
|
||||
|
||||
const save = async () => {
|
||||
if (!aggregate || inflight.current || !validation.ok) return;
|
||||
if (!edit || !aggregate || inflight.current || !validation.ok) return;
|
||||
inflight.current = true;
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
@ -246,9 +259,9 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button onClick={requestClose} disabled={saving}>
|
||||
{editable ? 'Отменить' : 'Закрыть'}
|
||||
{edit ? 'Отменить' : 'Закрыть'}
|
||||
</Button>
|
||||
{editable && (
|
||||
{edit && (
|
||||
<Button type='primary' loading={saving} disabled={!aggregate || !validation.ok || allocating} onClick={save}>
|
||||
Сохранить корректировки
|
||||
</Button>
|
||||
@ -256,15 +269,16 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
|
||||
</div>
|
||||
}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{hint && <Alert type='info' showIcon title={hint} />}
|
||||
{!aggregate && <Alert type='warning' showIcon title='Этого агрегата больше нет в таблице: строки перенесены в другие правила' />}
|
||||
{hint && <Alert type='info' role='alert' showIcon title={hint} />}
|
||||
{!aggregate && <Alert type='warning' role='alert' showIcon title='Этого агрегата больше нет в таблице: строки перенесены в другие правила' />}
|
||||
{notice && (
|
||||
<Alert
|
||||
type={notice.kind === 'stale' ? 'warning' : 'info'}
|
||||
role='alert'
|
||||
showIcon
|
||||
title={notice.text}
|
||||
action={
|
||||
editable ? (
|
||||
edit ? (
|
||||
<Button size='small' onClick={resetDraft}>
|
||||
Сбросить
|
||||
</Button>
|
||||
@ -272,14 +286,14 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{formError && <Alert type='error' showIcon title={formError} />}
|
||||
{formError && <Alert type='error' role='alert' 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} />
|
||||
<OsvBlock article={s1.article} entries={s1.entries} editable={edit} busy={busy} onPick={() => setPickerSide(1)} onUnassign={unassign} />
|
||||
<OsvBlock article={s2.article} entries={s2.entries} editable={edit} busy={busy} onPick={() => setPickerSide(2)} onUnassign={unassign} />
|
||||
</div>
|
||||
<ControlRow {...(source ?? { left: '—', middle: '—', right: '—', red: false })} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
@ -289,7 +303,8 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
|
||||
articleCodes={n === 1 ? s1.codes : s2.codes}
|
||||
lines={n === 1 ? state.debit : state.credit}
|
||||
lineErrors={lineErrors(n)}
|
||||
editable={editable}
|
||||
editable={edit}
|
||||
sideName={n === 1 ? 'дебет' : 'кредит'}
|
||||
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)))}
|
||||
@ -297,7 +312,11 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
|
||||
))}
|
||||
</div>
|
||||
<ControlRow {...(allocation ?? { left: '—', middle: '—', right: '—', red: false })} />
|
||||
{!validation.ok && validation.errors.lines && <div style={{ color: '#d92d20' }}>{validation.errors.lines}</div>}
|
||||
{!validation.ok && validation.errors.lines && (
|
||||
<div role='alert' style={{ color: '#d92d20' }}>
|
||||
{validation.errors.lines}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -305,17 +324,22 @@ export function CorrectionEditorModal({ versionId, table, aggregateKey, editable
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={state.comment}
|
||||
disabled={!editable}
|
||||
disabled={!edit}
|
||||
maxLength={COMMENT_MAX_LENGTH}
|
||||
status={commentError ? 'error' : undefined}
|
||||
placeholder='Укажите причину корректировки'
|
||||
aria-label='Комментарий к корректировке'
|
||||
aria-describedby={commentError ? 'correction-comment-error' : undefined}
|
||||
onChange={(e) => {
|
||||
setCommentError(null);
|
||||
commit({ ...state, comment: e.target.value });
|
||||
}}
|
||||
/>
|
||||
{commentError && <div style={{ color: '#d92d20', fontSize: 12 }}>{commentError}</div>}
|
||||
{commentError && (
|
||||
<div id='correction-comment-error' role='alert' style={{ color: '#d92d20', fontSize: 12 }}>
|
||||
{commentError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -97,6 +97,8 @@ type DistProps = {
|
||||
lines: KeyedLine[];
|
||||
lineErrors: LineErrors;
|
||||
editable: boolean;
|
||||
/** Сторона в подписях для скринридера: «дебет» / «кредит». */
|
||||
sideName: string;
|
||||
onAddLine(): void;
|
||||
onRemoveLine(id: number): void;
|
||||
onChangeLine(id: number, patch: Partial<Pick<KeyedLine, 'line_code' | 'amount'>>): void;
|
||||
@ -120,7 +122,7 @@ export function DistributionBlock(p: DistProps) {
|
||||
<span />
|
||||
{p.lines.map((line, i) => {
|
||||
const err = p.lineErrors(i);
|
||||
return <LineRow key={line.id} {...p} line={line} err={err} />;
|
||||
return <LineRow key={line.id} {...p} line={line} index={i} err={err} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@ -129,10 +131,16 @@ export function DistributionBlock(p: DistProps) {
|
||||
|
||||
type RowProps = Pick<DistProps, 'articleCodes' | 'editable' | 'onRemoveLine' | 'onChangeLine'> & {
|
||||
line: KeyedLine;
|
||||
/** Номер строки в стороне с нуля. */
|
||||
index: number;
|
||||
sideName: string;
|
||||
err: { line_code?: string; amount?: string };
|
||||
};
|
||||
|
||||
function LineRow({ line, err, articleCodes, editable, onRemoveLine, onChangeLine }: RowProps) {
|
||||
function LineRow({ line, index, sideName, err, articleCodes, editable, onRemoveLine, onChangeLine }: RowProps) {
|
||||
const where = `${sideName}, ${index + 1}`;
|
||||
const codeErrId = `line-${line.id}-code-error`;
|
||||
const amountErrId = `line-${line.id}-amount-error`;
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
@ -142,9 +150,13 @@ function LineRow({ line, err, articleCodes, editable, onRemoveLine, onChangeLine
|
||||
disabled={!editable}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => onChangeLine(line.id, { line_code: v })}>
|
||||
<Input maxLength={4} status={err.line_code ? 'error' : undefined} aria-label='Строка баланса' />
|
||||
<Input maxLength={4} status={err.line_code ? 'error' : undefined} aria-label={`Строка баланса, ${where}`} aria-describedby={err.line_code ? codeErrId : undefined} />
|
||||
</AutoComplete>
|
||||
{err.line_code && <div style={{ color: '#d92d20', fontSize: 12 }}>{err.line_code}</div>}
|
||||
{err.line_code && (
|
||||
<div id={codeErrId} style={{ color: '#d92d20', fontSize: 12 }}>
|
||||
{err.line_code}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
@ -153,13 +165,18 @@ function LineRow({ line, err, articleCodes, editable, onRemoveLine, onChangeLine
|
||||
inputMode='decimal'
|
||||
status={err.amount ? 'error' : undefined}
|
||||
style={{ textAlign: 'right' }}
|
||||
aria-label='Сумма статьи'
|
||||
aria-label={`Сумма статьи, ${where}`}
|
||||
aria-describedby={err.amount ? amountErrId : undefined}
|
||||
onChange={(e) => onChangeLine(line.id, { amount: e.target.value })}
|
||||
/>
|
||||
{err.amount && <div style={{ color: '#d92d20', fontSize: 12 }}>{err.amount}</div>}
|
||||
{err.amount && (
|
||||
<div id={amountErrId} style={{ color: '#d92d20', fontSize: 12 }}>
|
||||
{err.amount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{editable && <Button type='text' icon={<CloseOutlined />} aria-label='Удалить статью' onClick={() => onRemoveLine(line.id)} />}
|
||||
{editable && <Button type='text' icon={<CloseOutlined />} aria-label={`Удалить статью, ${where}`} onClick={() => onRemoveLine(line.id)} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@ -168,7 +185,7 @@ function LineRow({ line, err, articleCodes, editable, onRemoveLine, onChangeLine
|
||||
/** Строка контроля: сумма слева, «≠ разница (процент)» посередине (красная при превышении порога), сумма справа. */
|
||||
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 }}>
|
||||
<div aria-live='polite' 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>
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
isDirty,
|
||||
liveControl,
|
||||
pickerCandidates,
|
||||
rebaseState,
|
||||
toCorrectionIn,
|
||||
validateLines,
|
||||
} from './editorModel';
|
||||
@ -230,3 +231,18 @@ describe('pickerCandidates', () => {
|
||||
expect(aggregateKey(a)).toBe('r6|c-ept|c-ils');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rebaseState', () => {
|
||||
const st = (amount: string, comment = ''): EditorState => ({ debit: [{ line_code: '1230', amount }], credit: [], comment });
|
||||
it('аналитик не правил — окно берёт новое исходное состояние', () => {
|
||||
expect(rebaseState(st('100.00'), st('250.00'), st('100.00'))).toEqual(st('250.00'));
|
||||
});
|
||||
it('аналитик правил — ввод не затирается', () => {
|
||||
expect(rebaseState(st('100.00'), st('250.00'), st('999.00'))).toBeNull();
|
||||
expect(rebaseState(st('100.00'), st('250.00'), st('100.00', 'комментарий'))).toBeNull();
|
||||
});
|
||||
it('исходное не изменилось или совпало с текущим — пересобирать нечего', () => {
|
||||
expect(rebaseState(st('100.00'), st('100.00'), st('100.00'))).toBeNull();
|
||||
expect(rebaseState(st('100.00'), st('250.00'), st('250,00'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -138,6 +138,16 @@ export function isDirty(initial: EditorState, current: EditorState): boolean {
|
||||
return !sameLines(initial.debit, current.debit) || !sameLines(initial.credit, current.credit) || initial.comment !== current.comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Состояние окна после смены исходного состояния агрегата (перечитали таблицу, строка ОСВ перенесена): если аналитик
|
||||
* ничего не правил относительно прежнего `prev`, окно показывает новое `next` (вернём его); правил — ввод не трогаем (`null`).
|
||||
* `null` и когда пересобирать нечего (новое состояние совпало с текущим).
|
||||
*/
|
||||
export function rebaseState(prev: EditorState, next: EditorState, current: EditorState): EditorState | null {
|
||||
if (isDirty(prev, current)) return null;
|
||||
return isDirty(next, current) ? next : null;
|
||||
}
|
||||
|
||||
const opposite = (s: Side): Side => (s === 'D' ? 'K' : 'D');
|
||||
|
||||
/** Нераспределённые строки для «Добавить строку ОСВ»: владелец и контрагент — из пары, сторона — по правилу, правило — в кандидатах. */
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import { ApiError } from '../../api/client';
|
||||
import type { CorrectionAggregateOut } from '../../types/corrections';
|
||||
import { formatScaledAmount } from '../../utils/money';
|
||||
import type { Draft } from './draftStorage';
|
||||
import { type Draft, isStale } from './draftStorage';
|
||||
import type { Diff } from './editorModel';
|
||||
import { errorOutcome } from './readOnly';
|
||||
|
||||
@ -71,7 +71,7 @@ export type DraftNotice = { kind: 'restored' | 'stale'; text: string };
|
||||
*/
|
||||
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: 'Корректировку изменили после ваших правок' };
|
||||
if (a && isStale({ state: { debit: [], credit: [], comment: '' }, savedAt, baseVersionNo }, a)) return { kind: 'stale', text: 'Корректировку изменили после ваших правок' };
|
||||
return { kind: 'restored', text: `Восстановлены несохранённые правки от ${timeHHMM(savedAt)}` };
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user