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-7: флажки «Учитывать в КФО» и меню строк неактивны всегда (`actionsEnabled=false`) до Task 5–6, подсказка «только чтение» подключена | бриф Task 4: решения по строкам — в Task 5 | забыть включить флаг в Task 5 |
|
||||||
| 28.10.2026 | R-8: ошибка загрузки показывается уведомлением с «Повторить» (по спеке) и кнопкой на месте таблицы | после закрытия уведомления аналитик не остаётся без выхода | две кнопки повтора |
|
| 28.10.2026 | R-8: ошибка загрузки показывается уведомлением с «Повторить» (по спеке) и кнопкой на месте таблицы | после закрытия уведомления аналитик не остаётся без выхода | две кнопки повтора |
|
||||||
| 28.10.2026 | R-9: «Итого» показывается только при наличии видимых агрегатов (при «Нераспределенные записи» его нет) | сценарий спеки | показать «Итого» с пустыми значениями |
|
| 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 { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { ApiError } from '../../api/client';
|
import { ApiError } from '../../api/client';
|
||||||
import { useVisibleColumns } from '../../components/GenericTable/useVisibleColumns';
|
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 { 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 { 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 { CorrectionTable, type RowActions } from './CorrectionTable';
|
import { CorrectionTable, type RowActions } from './CorrectionTable';
|
||||||
import { CorrectionToolbar } from './CorrectionToolbar';
|
import { CorrectionToolbar } from './CorrectionToolbar';
|
||||||
|
import { RevokeModal } from './RevokeModal';
|
||||||
import { COLUMN_DEFS, type ColKey, type ColShape } from './columns';
|
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';
|
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';
|
const LOAD_ERROR_KEY = 'corrections-load-error';
|
||||||
|
|
||||||
type Props = { versionId: string; card?: VersionCard | null };
|
type Props = { versionId: string; card?: VersionCard | null };
|
||||||
|
|
||||||
export function CorrectionContent({ versionId, card = null }: Props) {
|
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 { notification } = App.useApp();
|
||||||
const notify = useNotificationApi();
|
const notify = useNotificationApi();
|
||||||
// по умолчанию — «тыс. руб.», 2 знака, как в дизайне
|
// по умолчанию — «тыс. руб.», 2 знака, как в дизайне
|
||||||
@ -46,6 +32,9 @@ export function CorrectionContent({ versionId, card = null }: Props) {
|
|||||||
const { visibleKeys, setVisibleKeys } = useVisibleColumns<ColShape>({ rowKey: 'include', columns: COLUMN_DEFS });
|
const { visibleKeys, setVisibleKeys } = useVisibleColumns<ColShape>({ rowKey: 'include', columns: COLUMN_DEFS });
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
// идёт запрос решения по строке / отмены: на это время контролы строк неактивны, чтобы не слать два запроса подряд
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [revoking, setRevoking] = useState<CorrectionAggregateOut | null>(null);
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// ошибка загрузки — уведомление с текстом сервера и кнопкой «Повторить»
|
// ошибка загрузки — уведомление с текстом сервера и кнопкой «Повторить»
|
||||||
@ -117,11 +106,45 @@ export function CorrectionContent({ versionId, card = null }: Props) {
|
|||||||
return next;
|
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 = {
|
const actions: RowActions = {
|
||||||
onCorrect: () => notify.info({ description: 'Окно корректировки будет в следующей версии экрана' }),
|
onCorrect: () => notify.info({ description: 'Окно корректировки будет в следующей версии экрана' }),
|
||||||
onRevoke: () => notify.info({ description: 'Отмена корректировки будет в следующей версии экрана' }),
|
onRevoke: setRevoking,
|
||||||
onAllocate: () => notify.info({ description: 'Решения по строкам будут в следующей версии экрана' }),
|
onAllocate,
|
||||||
};
|
};
|
||||||
|
|
||||||
const noData = table !== null && table.aggregates.length === 0 && table.unallocated.length === 0;
|
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;
|
let body: ReactNode;
|
||||||
if (table) {
|
if (table) {
|
||||||
body = (
|
body = (
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading || busy}>
|
||||||
<CorrectionTable
|
<CorrectionTable
|
||||||
rows={rows}
|
rows={rows}
|
||||||
totals={totals}
|
totals={totals}
|
||||||
@ -139,8 +162,8 @@ export function CorrectionContent({ versionId, card = null }: Props) {
|
|||||||
onToggle={toggle}
|
onToggle={toggle}
|
||||||
rules={table.rules}
|
rules={table.rules}
|
||||||
editable={table.editable}
|
editable={table.editable}
|
||||||
readOnlyHint={readOnlyHint(table)}
|
readOnlyHint={table.editable ? null : readOnlyHint(table.read_only_reason)}
|
||||||
actionsEnabled={ROW_ACTIONS_READY && table.editable}
|
busy={busy}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onFiltersChange={setFilters}
|
onFiltersChange={setFilters}
|
||||||
@ -199,6 +222,13 @@ export function CorrectionContent({ versionId, card = null }: Props) {
|
|||||||
onToggleFullscreen={toggleFullscreen}
|
onToggleFullscreen={toggleFullscreen}
|
||||||
/>
|
/>
|
||||||
{body}
|
{body}
|
||||||
|
<RevokeModal
|
||||||
|
open={revoking !== null}
|
||||||
|
subtitle={revoking ? `${revoking.company_1.name} — ${revoking.company_2.name}` : ''}
|
||||||
|
saving={busy}
|
||||||
|
onCancel={() => setRevoking(null)}
|
||||||
|
onSubmit={onRevoke}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { type ReactNode, useMemo } from 'react';
|
|||||||
import type { AllocationIn, CorrectionAggregateOut, CorrectionLineOut, CorrectionRuleOut } from '../../types/corrections';
|
import type { AllocationIn, CorrectionAggregateOut, CorrectionLineOut, CorrectionRuleOut } from '../../types/corrections';
|
||||||
import { HeaderFilter, SelectFilter, TextFilter } from './HeaderFilter';
|
import { HeaderFilter, SelectFilter, TextFilter } from './HeaderFilter';
|
||||||
import { COLUMN_DEFS, type ColKey } from './columns';
|
import { COLUMN_DEFS, type ColKey } from './columns';
|
||||||
|
import { allocationMenu } from './readOnly';
|
||||||
import { type DeviationFilter, type TableFilters, type TableRow, articleCodes, ruleLabel } from './tableModel';
|
import { type DeviationFilter, type TableFilters, type TableRow, articleCodes, ruleLabel } from './tableModel';
|
||||||
import type { visibleTotals } from './tableModel';
|
import type { visibleTotals } from './tableModel';
|
||||||
|
|
||||||
@ -30,8 +31,8 @@ type Props = {
|
|||||||
editable: boolean;
|
editable: boolean;
|
||||||
/** Подсказка, почему правки закрыты; `null` — открыты. */
|
/** Подсказка, почему правки закрыты; `null` — открыты. */
|
||||||
readOnlyHint: string | null;
|
readOnlyHint: string | null;
|
||||||
/** Выключатель флажков и меню строк: пока не подключены решения по строкам — false. */
|
/** Запрос решения уже идёт: контролы строк на это время неактивны (без подсказки). */
|
||||||
actionsEnabled: boolean;
|
busy: boolean;
|
||||||
actions: RowActions;
|
actions: RowActions;
|
||||||
filters: TableFilters;
|
filters: TableFilters;
|
||||||
onFiltersChange: (next: TableFilters) => void;
|
onFiltersChange: (next: TableFilters) => void;
|
||||||
@ -57,8 +58,6 @@ const BG_CHANGED = '#fff6e5';
|
|||||||
/** «394.27» → «394,27%»; строка не переводится в число. */
|
/** «394.27» → «394,27%»; строка не переводится в число. */
|
||||||
const percentText = (p: string | null): string => (p === null ? '—' : `${p.replace('.', ',')}%`);
|
const percentText = (p: string | null): string => (p === null ? '—' : `${p.replace('.', ',')}%`);
|
||||||
|
|
||||||
const UNAVAILABLE_HINT = 'Недоступно в этой версии экрана';
|
|
||||||
|
|
||||||
const showLine = (line: string): string => articleCodes(line).join(' / ');
|
const showLine = (line: string): string => articleCodes(line).join(' / ');
|
||||||
|
|
||||||
const sideSuffix = (row: TableRow) => {
|
const sideSuffix = (row: TableRow) => {
|
||||||
@ -69,8 +68,8 @@ const sideSuffix = (row: TableRow) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function CorrectionTable(props: Props) {
|
export function CorrectionTable(props: Props) {
|
||||||
const { rows, totals, format, expanded, onToggle, rules, editable, readOnlyHint, actionsEnabled, actions, filters, onFiltersChange } = props;
|
const { rows, totals, format, expanded, onToggle, rules, editable, readOnlyHint, busy, actions, filters, onFiltersChange } = props;
|
||||||
const canAct = editable && actionsEnabled;
|
const canAct = editable && !busy;
|
||||||
const ruleById = useMemo(() => new Map(rules.map((r) => [r.id, r])), [rules]);
|
const ruleById = useMemo(() => new Map(rules.map((r) => [r.id, r])), [rules]);
|
||||||
|
|
||||||
const rowBg = (row: TableRow): string | undefined => {
|
const rowBg = (row: TableRow): string | undefined => {
|
||||||
@ -79,38 +78,25 @@ export function CorrectionTable(props: Props) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
// меню решений по строке ОСВ / нераспределённой строке
|
// меню решений по строке ОСВ / нераспределённой строке: пункты даёт allocationMenu, здесь только переводим в формат antd
|
||||||
const entryMenu = (row: Extract<TableRow, { kind: 'entry' | 'unallocated' }>): MenuProps['items'] => {
|
const entryMenu = (row: Extract<TableRow, { kind: 'entry' | 'unallocated' }>): MenuProps['items'] =>
|
||||||
const e = row.entry;
|
allocationMenu(row, rules).map((item) => {
|
||||||
const ruleItems = e.candidate_rule_ids.flatMap((id) => {
|
if (item.key !== 'MOVE') return { key: item.key, label: item.label, disabled: item.disabled };
|
||||||
const r = ruleById.get(id);
|
const children = row.entry.candidate_rule_ids.flatMap((id) => {
|
||||||
return r ? [{ key: `assign:${id}`, label: ruleLabel(r) }] : [];
|
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 onEntryMenu = (row: Extract<TableRow, { kind: 'entry' | 'unallocated' }>, key: string) => {
|
||||||
const base = { statement_type: 'balance' as const, entry_ids: row.entry.entry_ids };
|
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) });
|
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 === 'UNASSIGN' || key === 'RESET') actions.onAllocate({ ...base, action: key });
|
||||||
else if (key === 'reset') actions.onAllocate({ ...base, action: 'RESET' });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// почему контрол неактивен: сервер закрыл правки — его причина; иначе действие ещё не подключено в этой версии экрана
|
// почему контрол неактивен: подсказка нужна только когда сервер закрыл правки (editable = false); пока идёт запрос — без неё
|
||||||
const inactiveHint = readOnlyHint ?? (actionsEnabled ? null : UNAVAILABLE_HINT);
|
const withHint = (node: ReactNode, hint: string | null = readOnlyHint) =>
|
||||||
const withHint = (node: ReactNode, hint: string | null = inactiveHint) =>
|
|
||||||
hint ? (
|
hint ? (
|
||||||
<Tooltip title={hint}>
|
<Tooltip title={hint}>
|
||||||
<span>{node}</span>
|
<span>{node}</span>
|
||||||
@ -140,7 +126,6 @@ export function CorrectionTable(props: Props) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</span>,
|
</span>,
|
||||||
readOnlyHint,
|
|
||||||
);
|
);
|
||||||
case 'd_entity':
|
case 'd_entity':
|
||||||
return a.company_1.name;
|
return a.company_1.name;
|
||||||
@ -192,11 +177,12 @@ export function CorrectionTable(props: Props) {
|
|||||||
{withHint(
|
{withHint(
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
|
disabled={!canAct}
|
||||||
menu={{
|
menu={{
|
||||||
items: [{ key: 'revoke', label: 'Отменить корректировку', disabled: !canAct || !a.correction }],
|
items: [{ key: 'revoke', label: 'Отменить корректировку', disabled: !a.correction }],
|
||||||
onClick: () => actions.onRevoke(a),
|
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>,
|
</Dropdown>,
|
||||||
)}
|
)}
|
||||||
</span>
|
</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