diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md index 0f5d0ac..7ea97b3 100644 --- a/openspec/changes/corrections-table-ui/design.md +++ b/openspec/changes/corrections-table-ui/design.md @@ -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 плана | — | diff --git a/web/src/widgets/CorrectionTable/AllocationPicker.tsx b/web/src/widgets/CorrectionTable/AllocationPicker.tsx new file mode 100644 index 0000000..f8fb6f8 --- /dev/null +++ b/web/src/widgets/CorrectionTable/AllocationPicker.tsx @@ -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 ( + (document.fullscreenElement as HTMLElement) || document.body} + onCancel={onClose} + destroyOnHidden> + 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) }, + ]} + /> + + ); +} diff --git a/web/src/widgets/CorrectionTable/CorrectionContent.tsx b/web/src/widgets/CorrectionTable/CorrectionContent.tsx index 9b495dc..1d0b8e0 100644 --- a/web/src/widgets/CorrectionTable/CorrectionContent.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionContent.tsx @@ -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(null); + // ключ агрегата, окно корректировки которого открыто + const [editing, setEditing] = useState(null); const boxRef = useRef(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 && ( + setEditing(null)} + onTable={replace} + onAggregate={replaceAggregate} + onReload={reload} + /> + )} (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(start.state); + // savedAt — время черновика, пока плашка видна; base — редакция корректировки, от которой начаты правки + const [savedAt, setSavedAt] = useState(start.savedAt); + const [base, setBase] = useState(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(null); + const [lineError, setLineError] = useState<{ lineCode: string; message: string } | null>(null); + const [formError, setFormError] = useState(null); + const [pickerSide, setPickerSide] = useState(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[1]): Promise => { + 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 = 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 ( + + + {editable && ( + + )} + + }> +
+ {hint && } + {!aggregate && } + {notice && ( + + Сбросить + + ) : undefined + } + /> + )} + {formError && } + +
+
+ + + setPickerSide(1)} onUnassign={unassign} /> + setPickerSide(2)} onUnassign={unassign} /> +
+ +
+ {([1, 2] as const).map((n) => ( + 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)))} + /> + ))} +
+ + {!validation.ok && validation.errors.lines &&
{validation.errors.lines}
} +
+ +
+
Комментарий к корректировке
+ { + setCommentError(null); + commit({ ...state, comment: e.target.value }); + }} + /> + {commentError &&
{commentError}
} +
+
+ + {pickerSide !== null && aggregate && ( + setPickerSide(null)} + /> + )} +
+ ); +} diff --git a/web/src/widgets/CorrectionTable/EditorSide.tsx b/web/src/widgets/CorrectionTable/EditorSide.tsx new file mode 100644 index 0000000..ecee983 --- /dev/null +++ b/web/src/widgets/CorrectionTable/EditorSide.tsx @@ -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 ( +
+
{company ?? '—'}
+
+ {sideLabel} {article || '—'} +
+
+ ); +} + +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 ( +
+
+ Исходные данные ОСВ + {p.editable && ( + + )} +
+
+ + + + + + {p.editable && + + + {p.entries.length === 0 && ( + + + + )} + {p.entries.map((e) => ( + + + + + {p.editable && ( + + )} + + ))} + +
СчётСтрока балансаСумма} +
+ Нет исходных строк ОСВ для этой операции. +
{e.account_code}{p.article}{formatRub(e.amount)} +
+ + ); +} + +type DistProps = { + articleCodes: string[]; + lines: KeyedLine[]; + lineErrors: LineErrors; + editable: boolean; + onAddLine(): void; + onRemoveLine(id: number): void; + onChangeLine(id: number, patch: Partial>): void; +}; + +/** Блок «Распределение корректировки» стороны — отдельная карточка под контрольной строкой исходных данных. */ +export function DistributionBlock(p: DistProps) { + return ( +
+
+ Распределение корректировки + {p.editable && ( + + )} +
+
+ Строка баланса + Сумма + + {p.lines.map((line, i) => { + const err = p.lineErrors(i); + return ; + })} +
+
+ ); +} + +type RowProps = Pick & { + line: KeyedLine; + err: { line_code?: string; amount?: string }; +}; + +function LineRow({ line, err, articleCodes, editable, onRemoveLine, onChangeLine }: RowProps) { + return ( + <> +
+ ({ value: c }))} + disabled={!editable} + style={{ width: '100%' }} + onChange={(v) => onChangeLine(line.id, { line_code: v })}> + + + {err.line_code &&
{err.line_code}
} +
+
+ onChangeLine(line.id, { amount: e.target.value })} + /> + {err.amount &&
{err.amount}
} +
+
+ {editable &&
+ + ); +} + +/** Строка контроля: сумма слева, «≠ разница (процент)» посередине (красная при превышении порога), сумма справа. */ +export function ControlRow({ left, middle, right, red }: { left: string; middle: string; right: string; red: boolean }) { + return ( +
+ {left} + {middle} + {right} +
+ ); +} diff --git a/web/src/widgets/CorrectionTable/editorView.test.ts b/web/src/widgets/CorrectionTable/editorView.test.ts new file mode 100644 index 0000000..d5344c0 --- /dev/null +++ b/web/src/widgets/CorrectionTable/editorView.test.ts @@ -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 = {}) => + 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('—'); + }); +}); diff --git a/web/src/widgets/CorrectionTable/editorView.ts b/web/src/widgets/CorrectionTable/editorView.ts new file mode 100644 index 0000000..65f478a --- /dev/null +++ b/web/src/widgets/CorrectionTable/editorView.ts @@ -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 | null { + if (!(e instanceof ApiError)) return null; + const body = e.body; + if (typeof body !== 'object' || body === null) return null; + const detail = (body as Record).detail; + return typeof detail === 'object' && detail !== null && !Array.isArray(detail) ? (detail as Record) : 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 };