feat(web): решения по строкам и отмена корректировки (#206)
Флажок «Учитывать в КФО» и меню строк (перенести, убрать, вернуть к автоматике) отправляют allocate; «Отменить корректировку» открывает окно с обязательным комментарием и вызывает revoke. Ошибки — уведомление с текстом сервера, 409 — ещё перечитывание таблицы. Подсказки режима только чтения вынесены в readOnly.ts, подсказка «Недоступно» убрана (T4.14). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
14693d8dce
commit
14cceb80be
@ -188,3 +188,8 @@
|
||||
| 28.10.2026 | R-7: флажки «Учитывать в КФО» и меню строк неактивны всегда (`actionsEnabled=false`) до Task 5–6, подсказка «только чтение» подключена | бриф Task 4: решения по строкам — в Task 5 | забыть включить флаг в Task 5 |
|
||||
| 28.10.2026 | R-8: ошибка загрузки показывается уведомлением с «Повторить» (по спеке) и кнопкой на месте таблицы | после закрытия уведомления аналитик не остаётся без выхода | две кнопки повтора |
|
||||
| 28.10.2026 | R-9: «Итого» показывается только при наличии видимых агрегатов (при «Нераспределенные записи» его нет) | сценарий спеки | показать «Итого» с пустыми значениями |
|
||||
| 28.10.2026 | R-10: `ROW_ACTIONS_READY`/`actionsEnabled` удалены; контролы строк неактивны при `!editable` (с подсказкой `readOnlyHint`) и на время запроса (`busy`, без подсказки) | включение R-7 в Task 5; блокировка на время запроса не даёт отправить два решения подряд | аналитик не видит, почему контрол замер, пока идёт запрос (виден спиннер таблицы) |
|
||||
| 28.10.2026 | R-11: любая ошибка 409 при решении/отмене — уведомление с текстом сервера и `reload()` (`needsReload`) | 409 = версия закрылась, блокировку забрали, корректировку уже изменили/отозвали: локальная таблица устарела | лишний запрос при 409 без смысла для таблицы |
|
||||
| 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; фронт не выдумывает правил | при рассинхроне справочника правил пункт неактивен |
|
||||
|
||||
|
||||
@ -3,40 +3,26 @@ import { App, Button, ConfigProvider, Spin } from 'antd';
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ApiError } from '../../api/client';
|
||||
import { useVisibleColumns } from '../../components/GenericTable/useVisibleColumns';
|
||||
import { exportCorrectionTable } from '../../services/api/corrections';
|
||||
import { allocate, exportCorrectionTable, revokeCorrection } from '../../services/api/corrections';
|
||||
import type { VersionCard } from '../../types/consolidations/version_members';
|
||||
import type { CorrectionTableOut, ReadOnlyReason } from '../../types/corrections';
|
||||
import type { AllocationIn, CorrectionAggregateOut } from '../../types/corrections';
|
||||
import { saveBlob, xlsxFilename } from '../../utils/download';
|
||||
import { useNotificationApi } from '../../utils/useNotification';
|
||||
import { useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay';
|
||||
import { CorrectionTable, type RowActions } from './CorrectionTable';
|
||||
import { CorrectionToolbar } from './CorrectionToolbar';
|
||||
import { RevokeModal } from './RevokeModal';
|
||||
import { COLUMN_DEFS, type ColKey, type ColShape } from './columns';
|
||||
import { EMPTY_FILTERS, type TableFilters, buildRows, filterTable, lineOptions, visibleTotals } from './tableModel';
|
||||
import { needsReload, readOnlyHint } from './readOnly';
|
||||
import { EMPTY_FILTERS, type TableFilters, buildRows, filterTable, lineOptions, toAggregateKey, visibleTotals } from './tableModel';
|
||||
import { useCorrectionTable } from './useCorrectionTable';
|
||||
|
||||
/** Подсказки для режима «только чтение» (дословно из спеки). */
|
||||
const READ_ONLY_HINTS: Record<ReadOnlyReason, string> = {
|
||||
VERSION_NOT_EDITABLE: 'Версия закрыта для изменений',
|
||||
LOCK_REQUIRED: 'Возьмите консолидацию в работу',
|
||||
LOCKED_BY_OTHER: 'Консолидацию редактирует другой пользователь',
|
||||
};
|
||||
|
||||
/** Подсказка «почему только чтение»; правки открыты или причина не названа — `null`. */
|
||||
function readOnlyHint(t: CorrectionTableOut): string | null {
|
||||
if (t.editable || !t.read_only_reason) return null;
|
||||
return READ_ONLY_HINTS[t.read_only_reason];
|
||||
}
|
||||
|
||||
/** Решения по строкам и окно корректировки подключаются в Task 5–6: тогда флаг становится true (одна правка). */
|
||||
const ROW_ACTIONS_READY = false;
|
||||
|
||||
const LOAD_ERROR_KEY = 'corrections-load-error';
|
||||
|
||||
type Props = { versionId: string; card?: VersionCard | null };
|
||||
|
||||
export function CorrectionContent({ versionId, card = null }: Props) {
|
||||
const { table, loading, error, reload } = useCorrectionTable(versionId);
|
||||
const { table, loading, error, reload, replace, replaceAggregate } = useCorrectionTable(versionId);
|
||||
const { notification } = App.useApp();
|
||||
const notify = useNotificationApi();
|
||||
// по умолчанию — «тыс. руб.», 2 знака, как в дизайне
|
||||
@ -46,6 +32,9 @@ export function CorrectionContent({ versionId, card = null }: Props) {
|
||||
const { visibleKeys, setVisibleKeys } = useVisibleColumns<ColShape>({ rowKey: 'include', columns: COLUMN_DEFS });
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
// идёт запрос решения по строке / отмены: на это время контролы строк неактивны, чтобы не слать два запроса подряд
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [revoking, setRevoking] = useState<CorrectionAggregateOut | null>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ошибка загрузки — уведомление с текстом сервера и кнопкой «Повторить»
|
||||
@ -117,11 +106,45 @@ export function CorrectionContent({ versionId, card = null }: Props) {
|
||||
return next;
|
||||
});
|
||||
|
||||
// заглушки: окно корректировки и решения по строкам подключают следующие задачи плана
|
||||
// ошибка правки: текст сервера в уведомлении; 409 (версию закрыли, блокировку забрали, корректировку уже изменили) — ещё перечитываем таблицу
|
||||
const fail = (e: unknown, fallback: string) => {
|
||||
notify.error({ description: e instanceof ApiError ? e.message : fallback });
|
||||
if (needsReload(e)) reload();
|
||||
};
|
||||
|
||||
// решение по строкам: сервер отвечает всей таблицей (в ней меняются и агрегаты, и нераспределённые) — подменяем её целиком
|
||||
const onAllocate = async (body: AllocationIn) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
replace(await allocate(versionId, body));
|
||||
} catch (e) {
|
||||
fail(e, 'Не удалось сохранить решение по строке');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// отмена корректировки: ответ — один агрегат, подменяем только его
|
||||
const onRevoke = async (comment: string) => {
|
||||
if (!revoking) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
replaceAggregate(await revokeCorrection(versionId, toAggregateKey(revoking), { comment }));
|
||||
setRevoking(null);
|
||||
} catch (e) {
|
||||
// 409 — данные устарели, окно закрываем (таблица перечитается); иная ошибка — окно остаётся, можно повторить
|
||||
if (needsReload(e)) setRevoking(null);
|
||||
fail(e, 'Не удалось отменить корректировку');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// «Корректировать» остаётся заглушкой до Task 6
|
||||
const actions: RowActions = {
|
||||
onCorrect: () => notify.info({ description: 'Окно корректировки будет в следующей версии экрана' }),
|
||||
onRevoke: () => notify.info({ description: 'Отмена корректировки будет в следующей версии экрана' }),
|
||||
onAllocate: () => notify.info({ description: 'Решения по строкам будут в следующей версии экрана' }),
|
||||
onRevoke: setRevoking,
|
||||
onAllocate,
|
||||
};
|
||||
|
||||
const noData = table !== null && table.aggregates.length === 0 && table.unallocated.length === 0;
|
||||
@ -130,7 +153,7 @@ export function CorrectionContent({ versionId, card = null }: Props) {
|
||||
let body: ReactNode;
|
||||
if (table) {
|
||||
body = (
|
||||
<Spin spinning={loading}>
|
||||
<Spin spinning={loading || busy}>
|
||||
<CorrectionTable
|
||||
rows={rows}
|
||||
totals={totals}
|
||||
@ -139,8 +162,8 @@ export function CorrectionContent({ versionId, card = null }: Props) {
|
||||
onToggle={toggle}
|
||||
rules={table.rules}
|
||||
editable={table.editable}
|
||||
readOnlyHint={readOnlyHint(table)}
|
||||
actionsEnabled={ROW_ACTIONS_READY && table.editable}
|
||||
readOnlyHint={table.editable ? null : readOnlyHint(table.read_only_reason)}
|
||||
busy={busy}
|
||||
actions={actions}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
@ -199,6 +222,13 @@ export function CorrectionContent({ versionId, card = null }: Props) {
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
/>
|
||||
{body}
|
||||
<RevokeModal
|
||||
open={revoking !== null}
|
||||
subtitle={revoking ? `${revoking.company_1.name} — ${revoking.company_2.name}` : ''}
|
||||
saving={busy}
|
||||
onCancel={() => setRevoking(null)}
|
||||
onSubmit={onRevoke}
|
||||
/>
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@ -8,6 +8,7 @@ import { type ReactNode, useMemo } from 'react';
|
||||
import type { AllocationIn, CorrectionAggregateOut, CorrectionLineOut, CorrectionRuleOut } from '../../types/corrections';
|
||||
import { HeaderFilter, SelectFilter, TextFilter } from './HeaderFilter';
|
||||
import { COLUMN_DEFS, type ColKey } from './columns';
|
||||
import { allocationMenu } from './readOnly';
|
||||
import { type DeviationFilter, type TableFilters, type TableRow, articleCodes, ruleLabel } from './tableModel';
|
||||
import type { visibleTotals } from './tableModel';
|
||||
|
||||
@ -30,8 +31,8 @@ type Props = {
|
||||
editable: boolean;
|
||||
/** Подсказка, почему правки закрыты; `null` — открыты. */
|
||||
readOnlyHint: string | null;
|
||||
/** Выключатель флажков и меню строк: пока не подключены решения по строкам — false. */
|
||||
actionsEnabled: boolean;
|
||||
/** Запрос решения уже идёт: контролы строк на это время неактивны (без подсказки). */
|
||||
busy: boolean;
|
||||
actions: RowActions;
|
||||
filters: TableFilters;
|
||||
onFiltersChange: (next: TableFilters) => void;
|
||||
@ -57,8 +58,6 @@ const BG_CHANGED = '#fff6e5';
|
||||
/** «394.27» → «394,27%»; строка не переводится в число. */
|
||||
const percentText = (p: string | null): string => (p === null ? '—' : `${p.replace('.', ',')}%`);
|
||||
|
||||
const UNAVAILABLE_HINT = 'Недоступно в этой версии экрана';
|
||||
|
||||
const showLine = (line: string): string => articleCodes(line).join(' / ');
|
||||
|
||||
const sideSuffix = (row: TableRow) => {
|
||||
@ -69,8 +68,8 @@ const sideSuffix = (row: TableRow) => {
|
||||
};
|
||||
|
||||
export function CorrectionTable(props: Props) {
|
||||
const { rows, totals, format, expanded, onToggle, rules, editable, readOnlyHint, actionsEnabled, actions, filters, onFiltersChange } = props;
|
||||
const canAct = editable && actionsEnabled;
|
||||
const { rows, totals, format, expanded, onToggle, rules, editable, readOnlyHint, busy, actions, filters, onFiltersChange } = props;
|
||||
const canAct = editable && !busy;
|
||||
const ruleById = useMemo(() => new Map(rules.map((r) => [r.id, r])), [rules]);
|
||||
|
||||
const rowBg = (row: TableRow): string | undefined => {
|
||||
@ -79,38 +78,25 @@ export function CorrectionTable(props: Props) {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// меню решений по строке ОСВ / нераспределённой строке
|
||||
const entryMenu = (row: Extract<TableRow, { kind: 'entry' | 'unallocated' }>): MenuProps['items'] => {
|
||||
const e = row.entry;
|
||||
const ruleItems = e.candidate_rule_ids.flatMap((id) => {
|
||||
const r = ruleById.get(id);
|
||||
return r ? [{ key: `assign:${id}`, label: ruleLabel(r) }] : [];
|
||||
// меню решений по строке ОСВ / нераспределённой строке: пункты даёт allocationMenu, здесь только переводим в формат antd
|
||||
const entryMenu = (row: Extract<TableRow, { kind: 'entry' | 'unallocated' }>): MenuProps['items'] =>
|
||||
allocationMenu(row, rules).map((item) => {
|
||||
if (item.key !== 'MOVE') return { key: item.key, label: item.label, disabled: item.disabled };
|
||||
const children = row.entry.candidate_rule_ids.flatMap((id) => {
|
||||
const r = ruleById.get(id);
|
||||
return r ? [{ key: `ASSIGN:${id}`, label: ruleLabel(r) }] : [];
|
||||
});
|
||||
return { key: 'MOVE', label: item.label, disabled: item.disabled, children };
|
||||
});
|
||||
const items: NonNullable<MenuProps['items']> = [
|
||||
{
|
||||
key: 'assign',
|
||||
label: row.kind === 'entry' ? 'Перенести в правило…' : 'Отнести к правилу…',
|
||||
disabled: ruleItems.length === 0,
|
||||
children: ruleItems,
|
||||
},
|
||||
];
|
||||
if (row.kind === 'entry') items.push({ key: 'unassign', label: 'Убрать из правила' });
|
||||
if ((row.kind === 'entry' && row.entry.decision === 'ASSIGN') || (row.kind === 'unallocated' && row.entry.reason === 'UNASSIGNED')) {
|
||||
items.push({ key: 'reset', label: 'Вернуть к автоматике' });
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
const onEntryMenu = (row: Extract<TableRow, { kind: 'entry' | 'unallocated' }>, key: string) => {
|
||||
const base = { statement_type: 'balance' as const, entry_ids: row.entry.entry_ids };
|
||||
if (key.startsWith('assign:')) actions.onAllocate({ ...base, action: 'ASSIGN', rule_id: key.slice('assign:'.length) });
|
||||
else if (key === 'unassign') actions.onAllocate({ ...base, action: 'UNASSIGN' });
|
||||
else if (key === 'reset') actions.onAllocate({ ...base, action: 'RESET' });
|
||||
if (key.startsWith('ASSIGN:')) actions.onAllocate({ ...base, action: 'ASSIGN', rule_id: key.slice('ASSIGN:'.length) });
|
||||
else if (key === 'UNASSIGN' || key === 'RESET') actions.onAllocate({ ...base, action: key });
|
||||
};
|
||||
|
||||
// почему контрол неактивен: сервер закрыл правки — его причина; иначе действие ещё не подключено в этой версии экрана
|
||||
const inactiveHint = readOnlyHint ?? (actionsEnabled ? null : UNAVAILABLE_HINT);
|
||||
const withHint = (node: ReactNode, hint: string | null = inactiveHint) =>
|
||||
// почему контрол неактивен: подсказка нужна только когда сервер закрыл правки (editable = false); пока идёт запрос — без неё
|
||||
const withHint = (node: ReactNode, hint: string | null = readOnlyHint) =>
|
||||
hint ? (
|
||||
<Tooltip title={hint}>
|
||||
<span>{node}</span>
|
||||
@ -140,7 +126,6 @@ export function CorrectionTable(props: Props) {
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>,
|
||||
readOnlyHint,
|
||||
);
|
||||
case 'd_entity':
|
||||
return a.company_1.name;
|
||||
@ -192,11 +177,12 @@ export function CorrectionTable(props: Props) {
|
||||
{withHint(
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
disabled={!canAct}
|
||||
menu={{
|
||||
items: [{ key: 'revoke', label: 'Отменить корректировку', disabled: !canAct || !a.correction }],
|
||||
items: [{ key: 'revoke', label: 'Отменить корректировку', disabled: !a.correction }],
|
||||
onClick: () => actions.onRevoke(a),
|
||||
}}>
|
||||
<Button size='small' type='text' icon={<MoreOutlined />} aria-label='Действия' />
|
||||
<Button size='small' type='text' icon={<MoreOutlined />} aria-label='Действия' disabled={!canAct} />
|
||||
</Dropdown>,
|
||||
)}
|
||||
</span>
|
||||
|
||||
48
web/src/widgets/CorrectionTable/RevokeModal.tsx
Normal file
48
web/src/widgets/CorrectionTable/RevokeModal.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
// Окно «Отменить корректировку»: комментарий обязателен, пустой — «Сохранить» неактивна.
|
||||
import { Input, Modal } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
/** Подпись агрегата в заголовке окна (кто с кем). */
|
||||
subtitle: string;
|
||||
/** Запрос уходит: кнопка в процессе, окно не закрывается. */
|
||||
saving: boolean;
|
||||
onCancel(): void;
|
||||
onSubmit(comment: string): void;
|
||||
};
|
||||
|
||||
export function RevokeModal({ open, subtitle, saving, onCancel, onSubmit }: Props) {
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
// при каждом открытии — чистое поле
|
||||
useEffect(() => {
|
||||
if (open) setComment('');
|
||||
}, [open]);
|
||||
|
||||
const trimmed = comment.trim();
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title='Отменить корректировку'
|
||||
okText='Сохранить'
|
||||
cancelText='Отменить'
|
||||
okButtonProps={{ disabled: trimmed === '' }}
|
||||
confirmLoading={saving}
|
||||
mask={{ closable: false }}
|
||||
onCancel={saving ? undefined : onCancel}
|
||||
onOk={() => onSubmit(trimmed)}
|
||||
destroyOnHidden>
|
||||
<div style={{ marginBottom: 8, color: '#667085' }}>{subtitle}</div>
|
||||
<Input.TextArea
|
||||
autoFocus
|
||||
rows={4}
|
||||
value={comment}
|
||||
maxLength={2000}
|
||||
placeholder='Комментарий к отмене (обязателен)'
|
||||
aria-label='Комментарий к отмене корректировки'
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
86
web/src/widgets/CorrectionTable/readOnly.test.ts
Normal file
86
web/src/widgets/CorrectionTable/readOnly.test.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ApiError } from '../../api/client';
|
||||
import { makeMockTable } from './mockData';
|
||||
import { allocationMenu, needsReload, readOnlyHint } from './readOnly';
|
||||
import type { TableRow } from './tableModel';
|
||||
|
||||
describe('readOnlyHint', () => {
|
||||
it('тексты причин', () => {
|
||||
expect(readOnlyHint('LOCK_REQUIRED')).toBe('Возьмите консолидацию в работу');
|
||||
expect(readOnlyHint('VERSION_NOT_EDITABLE')).toBe('Версия закрыта для изменений');
|
||||
expect(readOnlyHint('LOCKED_BY_OTHER')).toBe('Консолидацию редактирует другой пользователь');
|
||||
});
|
||||
it('причины нет — подсказки нет', () => {
|
||||
expect(readOnlyHint(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('allocationMenu', () => {
|
||||
const t = makeMockTable();
|
||||
const firstEntry = () => {
|
||||
for (const a of t.aggregates) for (const e of [...a.side_1.entries, ...a.side_2.entries]) return e;
|
||||
throw new Error('нет строк ОСВ');
|
||||
};
|
||||
const entryRow = (patch: Partial<ReturnType<typeof firstEntry>>): TableRow => ({
|
||||
kind: 'entry',
|
||||
key: 'k',
|
||||
parentKey: 'p',
|
||||
side: 1,
|
||||
entry: { ...firstEntry(), ...patch },
|
||||
});
|
||||
const unallocRow = (patch: Partial<(typeof t.unallocated)[number]>): TableRow => ({
|
||||
kind: 'unallocated',
|
||||
key: 'u',
|
||||
entry: { ...t.unallocated[0], ...patch },
|
||||
});
|
||||
const keys = (row: TableRow) => allocationMenu(row, t.rules).map((i) => i.key);
|
||||
|
||||
it('у строки агрегата с автоматическим решением нет «Вернуть к автоматике»', () => {
|
||||
const items = allocationMenu(entryRow({ decision: 'AUTO', candidate_rule_ids: [t.rules[0].id] }), t.rules);
|
||||
expect(items.map((i) => i.label)).not.toContain('Вернуть к автоматике');
|
||||
expect(items.map((i) => i.key)).toEqual(['MOVE', 'UNASSIGN']);
|
||||
});
|
||||
|
||||
it('у строки с ручным переносом «Вернуть к автоматике» есть', () => {
|
||||
expect(keys(entryRow({ decision: 'ASSIGN' }))).toContain('RESET');
|
||||
});
|
||||
|
||||
it('пустой список правил-кандидатов — «Перенести в правило…» неактивен', () => {
|
||||
const move = allocationMenu(entryRow({ candidate_rule_ids: [] }), t.rules).find((i) => i.key === 'MOVE');
|
||||
expect(move?.label).toBe('Перенести в правило…');
|
||||
expect(move?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('кандидат, которого нет в справочнике правил, не считается', () => {
|
||||
const move = allocationMenu(entryRow({ candidate_rule_ids: ['нет-такого'] }), t.rules).find((i) => i.key === 'MOVE');
|
||||
expect(move?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('у нераспределённой с reason = UNASSIGNED есть «Вернуть к автоматике», «Убрать из правила» нет', () => {
|
||||
const items = allocationMenu(unallocRow({ reason: 'UNASSIGNED', candidate_rule_ids: [t.rules[0].id] }), t.rules);
|
||||
expect(items.map((i) => i.key)).toEqual(['MOVE', 'RESET']);
|
||||
expect(items[0].label).toBe('Отнести к правилу…');
|
||||
expect(items[0].disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('у нераспределённой с MULTIPLE_RULES возврата к автоматике нет', () => {
|
||||
expect(keys(unallocRow({ reason: 'MULTIPLE_RULES' }))).toEqual(['MOVE']);
|
||||
});
|
||||
|
||||
it('у агрегата меню строки пусто', () => {
|
||||
const agg: TableRow = { kind: 'aggregate', key: 'a', aggregate: t.aggregates[0], include: 'Да' };
|
||||
expect(allocationMenu(agg, t.rules)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsReload', () => {
|
||||
it('409 — перечитать таблицу', () => {
|
||||
expect(needsReload(new ApiError(409, 'LOCK_REQUIRED', 'x'))).toBe(true);
|
||||
expect(needsReload(new ApiError(409, 'CORRECTION_VERSION_CONFLICT', 'x'))).toBe(true);
|
||||
});
|
||||
it('422, сеть и мусор — нет', () => {
|
||||
expect(needsReload(new ApiError(422, 'COMMENT_REQUIRED', 'x'))).toBe(false);
|
||||
expect(needsReload(new Error('сеть'))).toBe(false);
|
||||
expect(needsReload(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
42
web/src/widgets/CorrectionTable/readOnly.ts
Normal file
42
web/src/widgets/CorrectionTable/readOnly.ts
Normal file
@ -0,0 +1,42 @@
|
||||
// Чистые функции для режима «только чтение» и меню решений по строке (без React — тестируются отдельно).
|
||||
import type { AllocationAction, CorrectionRuleOut, ReadOnlyReason } from '../../types/corrections';
|
||||
import type { TableRow } from './tableModel';
|
||||
|
||||
/** Подсказки для режима «только чтение» (дословно из спеки). */
|
||||
const READ_ONLY_HINTS: Record<ReadOnlyReason, string> = {
|
||||
VERSION_NOT_EDITABLE: 'Версия закрыта для изменений',
|
||||
LOCK_REQUIRED: 'Возьмите консолидацию в работу',
|
||||
LOCKED_BY_OTHER: 'Консолидацию редактирует другой пользователь',
|
||||
};
|
||||
|
||||
/** Подсказка «почему только чтение»; причины нет — `null`. */
|
||||
export function readOnlyHint(reason: ReadOnlyReason | null): string | null {
|
||||
return reason ? READ_ONLY_HINTS[reason] : null;
|
||||
}
|
||||
|
||||
export type AllocationMenuItem = { key: AllocationAction | 'MOVE'; label: string; disabled: boolean };
|
||||
|
||||
/**
|
||||
* Пункты меню решений по строке ОСВ или нераспределённой строке.
|
||||
* 'MOVE' — не действие сервера, а «выбрать правило»: потом уходит как ASSIGN с rule_id.
|
||||
*/
|
||||
export function allocationMenu(row: TableRow, rules: CorrectionRuleOut[]): AllocationMenuItem[] {
|
||||
if (row.kind === 'aggregate') return [];
|
||||
const known = new Set(rules.map((r) => r.id));
|
||||
const hasCandidates = row.entry.candidate_rule_ids.some((id) => known.has(id));
|
||||
const items: AllocationMenuItem[] = [
|
||||
{ key: 'MOVE', label: row.kind === 'entry' ? 'Перенести в правило…' : 'Отнести к правилу…', disabled: !hasCandidates },
|
||||
];
|
||||
if (row.kind === 'entry') items.push({ key: 'UNASSIGN', label: 'Убрать из правила', disabled: false });
|
||||
const manual = row.kind === 'entry' ? row.entry.decision === 'ASSIGN' : row.entry.reason === 'UNASSIGNED';
|
||||
if (manual) items.push({ key: 'RESET', label: 'Вернуть к автоматике', disabled: false });
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ошибка правки, после которой таблицу надо перечитать: любой 409 — версия закрылась, блокировку забрали,
|
||||
* корректировку уже изменили или отозвали (`CORRECTION_VERSION_CONFLICT`, `CORRECTION_NOT_APPLIED`).
|
||||
*/
|
||||
export function needsReload(e: { status?: number } | unknown): boolean {
|
||||
return typeof e === 'object' && e !== null && (e as { status?: number }).status === 409;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user