feat(web): окно «Корректировка операции» с черновиком (#206)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c2cbbf5dfd
commit
c559dd321c
@ -193,3 +193,9 @@
|
|||||||
| 28.10.2026 | R-12: после `allocate` таблица заменяется ответом целиком (`replace`), после `revoke` подменяется один агрегат (`replaceAggregate`); окно отмены при не-409 ошибке остаётся открытым, при 409 закрывается | `allocate` меняет и нераспределённые, `revoke` отдаёт агрегат; при сетевой ошибке комментарий не должен пропасть | после revoke нераспределённые не обновляются (по контракту revoke их не трогает) |
|
| 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-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 плана | — |
|
||||||
|
|||||||
47
web/src/widgets/CorrectionTable/AllocationPicker.tsx
Normal file
47
web/src/widgets/CorrectionTable/AllocationPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -9,12 +9,13 @@ import type { AllocationIn, CorrectionAggregateOut } from '../../types/correctio
|
|||||||
import { saveBlob, xlsxFilename } from '../../utils/download';
|
import { saveBlob, xlsxFilename } from '../../utils/download';
|
||||||
import { useNotificationApi } from '../../utils/useNotification';
|
import { useNotificationApi } from '../../utils/useNotification';
|
||||||
import { useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay';
|
import { useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay';
|
||||||
|
import { CorrectionEditorModal } from './CorrectionEditorModal';
|
||||||
import { CorrectionTable, type RowActions } from './CorrectionTable';
|
import { CorrectionTable, type RowActions } from './CorrectionTable';
|
||||||
import { CorrectionToolbar } from './CorrectionToolbar';
|
import { CorrectionToolbar } from './CorrectionToolbar';
|
||||||
import { RevokeModal } from './RevokeModal';
|
import { RevokeModal } from './RevokeModal';
|
||||||
import { COLUMN_DEFS, type ColKey, type ColShape } from './columns';
|
import { COLUMN_DEFS, type ColKey, type ColShape } from './columns';
|
||||||
import { errorOutcome, needsReload, readOnlyHint } from './readOnly';
|
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';
|
import { useCorrectionTable } from './useCorrectionTable';
|
||||||
|
|
||||||
const LOAD_ERROR_KEY = 'corrections-load-error';
|
const LOAD_ERROR_KEY = 'corrections-load-error';
|
||||||
@ -37,6 +38,8 @@ export function CorrectionContent({ versionId, card = null }: Props) {
|
|||||||
// ref, а не состояние: setBusy применяется после перерисовки, а два клика могут прийти до неё — ref меняется мгновенно
|
// ref, а не состояние: setBusy применяется после перерисовки, а два клика могут прийти до неё — ref меняется мгновенно
|
||||||
const inflight = useRef(false);
|
const inflight = useRef(false);
|
||||||
const [revoking, setRevoking] = useState<CorrectionAggregateOut | null>(null);
|
const [revoking, setRevoking] = useState<CorrectionAggregateOut | null>(null);
|
||||||
|
// ключ агрегата, окно корректировки которого открыто
|
||||||
|
const [editing, setEditing] = useState<string | null>(null);
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// ошибка загрузки — уведомление с текстом сервера и кнопкой «Повторить»
|
// ошибка загрузки — уведомление с текстом сервера и кнопкой «Повторить»
|
||||||
@ -148,9 +151,8 @@ export function CorrectionContent({ versionId, card = null }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// «Корректировать» остаётся заглушкой до Task 6
|
|
||||||
const actions: RowActions = {
|
const actions: RowActions = {
|
||||||
onCorrect: () => notify.info({ description: 'Окно корректировки будет в следующей версии экрана' }),
|
onCorrect: (a) => setEditing(aggregateKey(a)),
|
||||||
onRevoke: setRevoking,
|
onRevoke: setRevoking,
|
||||||
onAllocate,
|
onAllocate,
|
||||||
};
|
};
|
||||||
@ -230,6 +232,19 @@ export function CorrectionContent({ versionId, card = null }: Props) {
|
|||||||
onToggleFullscreen={toggleFullscreen}
|
onToggleFullscreen={toggleFullscreen}
|
||||||
/>
|
/>
|
||||||
{body}
|
{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
|
<RevokeModal
|
||||||
open={revoking !== null}
|
open={revoking !== null}
|
||||||
subtitle={revoking ? `${revoking.company_1.name} — ${revoking.company_2.name}` : ''}
|
subtitle={revoking ? `${revoking.company_1.name} — ${revoking.company_2.name}` : ''}
|
||||||
|
|||||||
335
web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx
Normal file
335
web/src/widgets/CorrectionTable/CorrectionEditorModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
web/src/widgets/CorrectionTable/EditorSide.tsx
Normal file
177
web/src/widgets/CorrectionTable/EditorSide.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
web/src/widgets/CorrectionTable/editorView.test.ts
Normal file
71
web/src/widgets/CorrectionTable/editorView.test.ts
Normal 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('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
78
web/src/widgets/CorrectionTable/editorView.ts
Normal file
78
web/src/widgets/CorrectionTable/editorView.ts
Normal 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 };
|
||||||
Loading…
x
Reference in New Issue
Block a user