FSM-кнопки
Сущности MineFlow (актив, персонал, план производства, заявка на ТМЦ…) живут по конечному автомату состояний. Для каждого статуса разрешён лишь ограниченный набор пер еходов — и UI должен показывать ровно те кнопки действий, что разрешает машина в текущем статусе.
Источник истины для этих переходов — та же xstate-машина, что у бэкенда (ADR-0022 — entity = source of truth, xstate = spec). SDK re-export'ит её на фронт без копий, поэтому FSM-логика UI не дрейфует от серверной.
Этот рецепт: как из статуса сущности получить список действий и отрисовать кнопки.
Два экспорта
availableActions(machine, status)— чистая функция (@mineflow/client-react): по машине и строковому статусу возвращает массив имён переходов (string[]).useAvailableActions(machine, status)— React-хук-обёртка сuseMemoповерх той же функции.
Машины и status-энумы берутся из @mineflow/api-schemas — единственного browser/RN-safe пакета с FSM (доменные barrel'ы @mineflow/eam и т.п. тянут NestJS/Prisma и под фронт не собираются).
Базовый пример
import { assetMachine } from '@mineflow/api-schemas';
import { useAvailableActions } from '@mineflow/client-react';
const ACTION_LABELS: Record<string, string> = {
SEND_TO_MAINTENANCE: 'На ТО',
CONSERVE: 'Законсервировать',
REACTIVATE: 'Расконсервировать',
COMPLETE_MAINTENANCE: 'Завершить ТО',
DECOMMISSION: 'Списать',
};
function AssetActions({ asset }: { asset: { id: string; status: string } }) {
// имена переходов, разрешённых машиной в текущем статусе
const actions = useAvailableActions(assetMachine, asset.status);
// для 'operational' → ['SEND_TO_MAINTENANCE', 'CONSERVE', 'DECOMMISSION']
return (
<>
{actions.map((action) => (
<button key={action} onClick={() => runAction(asset, action)}>
{ACTION_LABELS[action] ?? action}
</button>
))}
</>
);
}
Без React то же самое чистой функцией — удобно для не-компонентного кода (вычисление доступности, тесты):
import { assetMachine } from '@mineflow/api-schemas';
import { availableActions } from '@mineflow/client-react';
availableActions(assetMachine, 'maintenance'); // ['COMPLETE_MAINTENANCE', 'DECOMMISSION']
availableActions(assetMachine, 'decommissioned'); // [] — терминальный статус, кнопок нет
Машина — параметр, а не вшита в хук. Берите подходящую из @mineflow/api-schemas: assetMachine, personnelMachine, productionPlanMachine, fuelSupplyRequestMachine, tmcRequestMachine и т.д. Статус — это строковый stateValue верхнего уровня машины (машины MineFlow плоские), он же значение поля status сущности из API. Полный список машин — в гайде @mineflow/api-schemas и в API-референсе.
Переходы возвращаются БЕЗ учёта guard'ов
availableActions отдаёт все имена переходов state-node, не вычисляя guard'ы (условия) xstate. Это намеренно:
- UI должен предложить действие, даже если оно требует доп. ввода. Например,
DECOMMISSIONу актива требует double-approval — но кнопку всё равно надо показать, по клику открыть форму, и только потом отправить запрос. - Финальную проверку делает бэкенд (см. ниже), guard'ы на фронте дублировали бы серверную логику и неизбежно бы с ней разошлись.
Наличие действия в списке availableActions означает «такой переход в принципе существует из этого статуса», а не «прямо сейчас точно пройдёт». Решение о фактической доступности (роль, заполненность полей, бизнес-инварианты) принимает сервер.
RBAC поверх FSM
availableActions отвечает только на вопрос «какие переходы разрешает машина статусов». Кто имеет право их выполнять — отдельная ось. Скрестите FSM с RBAC-гейтом useCan:
import { assetMachine } from '@mineflow/api-schemas';
import { useAvailableActions, useCan } from '@mineflow/client-react';
function AssetActions({ asset }: { asset: { id: string; status: string } }) {
const actions = useAvailableActions(assetMachine, asset.status);
const canManage = useCan(['Engineer', 'Mechanic']);
if (!canManage) return null;
return actions.map((action) => /* ... кнопки ... */);
}
Подробнее про роли и проверки прав — рецепт RBAC-гейтинг UI.
Имя FSM-события ≠ имя endpoint'а
Список availableActions — это имена FSM-событий (CONSERVE, COMPLETE_MAINTENANCE, …). Они не маппятся 1:1 на эндпоинты автоматически. Маппинг событие → вызов держат концентратные write-хуки в @mineflow/client-react, а не сам helper.
Для EAM-актива (ADR-0045, asset-centric) у каждого перехода статуса есть выделенный endpoint и хук:
| FSM-событие | Хук | Запрос |
|---|---|---|
CONSERVE | useConserveAsset | PATCH /eam/assets/:id/conserve (reason обяз.) |
REACTIVATE | useReactivateAsset | PATCH /eam/assets/:id/reactivate |
COMPLETE_MAINTENANCE | useCompleteMaintenance | PATCH /eam/assets/:id/complete-maintenance |
DECOMMISSION | useDecommissionAsset | POST /eam/assets/:id/decommission (double-approval) |
import { assetMachine } from '@mineflow/api-schemas';
import {
useAvailableActions,
useConserveAsset,
useReactivateAsset,
useCompleteMaintenance,
useDecommissionAsset,
} from '@mineflow/client-react';
function AssetActions({ asset }: { asset: { id: string; status: string } }) {
const actions = useAvailableActions(assetMachine, asset.status);
const conserve = useConserveAsset();
const reactivate = useReactivateAsset();
const completeMaintenance = useCompleteMaintenance();
const decommission = useDecommissionAsset();
function run(action: string) {
switch (action) {
case 'CONSERVE':
return conserve.mutate({ id: asset.id, reason: 'Сезонная консервация' });
case 'REACTIVATE':
return reactivate.mutate({ id: asset.id });
case 'COMPLETE_MAINTENANCE':
return completeMaintenance.mutate({ id: asset.id });
case 'DECOMMISSION':
// double-approval: реально открыть форму с reason + подтверждениями
return decommission.mutate({ id: asset.id, body: { /* … */ } });
// SEND_TO_MAINTENANCE у актива идёт через журнал движений, см. note ниже
}
}
return actions.map((action) => (
<button key={action} onClick={() => run(action)}>
{action}
</button>
));
}
У EAM-актива переход SEND_TO_MAINTENANCE (как и CONSERVE/REACTIVATE/COMPLETE_MAINTENANCE в исходной модели) идёт через журнал движений (POST /eam/movements), а не через прямой endpoint на /eam/assets — это by design ради аудит-журнала. То, что событие есть в availableActions, не гарантирует наличие одноимённого use<Event>Asset-хука. Сверяйтесь с гайдом @mineflow/client-react и API-референсом — какой хук дёргать на каждое событие конкретного домена.
Бэкенд — финальный арбитр перехода
availableActions управляет только видимостью кнопок. Допустимость самого перехода всегда проверяет сервер по своей машине состояний. Невалидный переход возвращает 409 с устойчивым error.code (напр. ASSET_INVALID_STATUS_TRANSITION).
Это значит:
- даже если из-за гонки (статус устарел в кэше) пользователь нажал кнопку — сервер не даст применить неверный переход;
- гейтить UI и обрабатывать отказ нужно по
error.code, а не по тексту сообщения.
import { MineflowApiError } from '@mineflow/client-core';
conserve.mutate(
{ id: asset.id, reason: 'Сезонная консервация' },
{
onError: (err) => {
if (err instanceof MineflowApiError && err.code === 'ASSET_INVALID_STATUS_TRANSITION') {
toast('Статус изменился — обновите список');
}
},
},
);
Полная обработка MineflowApiError и каталог кодов — рецепт Ошибки.
Когда переход — это сага
Часть переходов (например утверждение сменного рапорта) на бэке запускает многошаговую сагу и отвечает 202 + sagaId. Кнопку показываете так же — по availableActions, — но за вызовом стоит saga-мутация, которая поллит статус до терминала. См. рецепт Async-саги.
Итого
- Машина + статус →
availableActions/useAvailableActions→ имена переходов. - Машины и энумы — из
@mineflow/api-schemas, та же FSM, что у бэка (anti-drift, ADR-0022). - Переходы — без guard'ов: показывайте кнопку, доп. ввод собирайте в форме.
- Событие → вызов делает доменный write-хук, не helper.
- Финальную валидацию перехода делает сервер (409 +
error.code) — гейтите ошибку по коду.
Справочники: @mineflow/api-schemas · @mineflow/client-react · API-референс client-react · REST-референс.