Перейти к основному содержимому

@mineflow/client-react

Слой L2 клиентского SDK (ADR-0042): React-провайдер плюс набор хуков на TanStack Query поверх ядра @mineflow/client-core. Даёт чтение (concrete/refs/generic), мутации с авто-Idempotency-Key и инвалидацией кэша, write-lifecycle сменного рапорта с offline-first очередью, async-саги, live-уведомления по SSE, RBAC- и FSM-гейты для UI.

Это основной пакет для фронтенда: в 95% случаев приложение работает напрямую только с client-react (плюс @mineflow/api-zod для форм, @mineflow/api-schemas для FSM-машин и @mineflow/auth-web / @mineflow/auth-native для токенов). Пакет платформо-агностичен — те же хуки работают в web и React Native, всё платформо-специфичное инъектируется через провайдер.

Где это в общей картине

Архитектура слоёв и сквозной end-to-end пример — в обзоре SDK и в гайдах нижних слоёв @mineflow/client-core, @mineflow/api-client.

Установка

pnpm add @mineflow/client-react @mineflow/client-core @mineflow/auth-web
# peer-зависимости приложения:
pnpm add react @tanstack/react-query xstate
# опционально под формы и FSM:
pnpm add @mineflow/api-zod @mineflow/api-schemas @hookform/resolvers react-hook-form

Peer-зависимости пакета (из package.json): react ^18 || ^19, @tanstack/react-query ^5, xstate ^5. В монорепо MineFlow внутренние пакеты резолвятся как workspace:*.

1. Провайдер

Оберни приложение в MineflowProvider один раз. Провайдер строит аутентифицированный fetch (Bearer) и REST-клиент из конфига, поднимает QueryClientProvider и кладёт всё в контекст.

import Keycloak from 'keycloak-js';
import { KeycloakTokenProvider } from '@mineflow/auth-web';
import { MineflowProvider } from '@mineflow/client-react';

const keycloak = new Keycloak({
url: 'https://auth.mineflow.local',
realm: 'mineflow',
clientId: 'mineflow-web',
});
await keycloak.init({ onLoad: 'check-sso', pkceMethod: 'S256' });

// Адаптер создаётся ОДИН раз вне рендера (сравнивается по ссылке).
const tokenProvider = new KeycloakTokenProvider({ keycloak });

function App() {
return (
<MineflowProvider
baseUrl={import.meta.env.VITE_API_BASE} // origin; пути уже включают /api/v1
tokenProvider={tokenProvider}
generateId={() => crypto.randomUUID()} // RN: react-native-get-random-values
roles={tokenProvider.getRoles()} // канонические SystemRole
>
{/* ... */}
</MineflowProvider>
);
}

Полный список пропсов (MineflowProviderProps):

ПропТипНазначение
baseUrlstringOrigin API (без пути; /api/v1 уже зашит в путях из api-client).
tokenProviderTokenProviderAuth-адаптер (web — KeycloakTokenProvider, RN — ReactNativeTokenProvider).
generateIdIdGeneratorГенератор Idempotency-Key (web — crypto.randomUUID, RN — polyfill).
rolesreadonly SystemRole[]Канонические роли текущего пользователя (для useCan).
queryClientQueryClient?Внешний QueryClient; если не передан — создаётся свой.
fetchImpltypeof fetch?Базовый fetch для транспорта (REST + SSE). По умолчанию глобальный fetch.

Доступ к контексту — useMineflow() (вернёт { rest, authFetch, baseUrl, roles }) и useMineflowClient() (только REST-клиент). Вне провайдера оба бросают ошибку.

Транспорт строится один раз и не зависит от ролей

Аутентифицированный fetch + REST-клиент пересобираются только при смене baseUrl, tokenProvider, generateId или fetchImpl. Смена roles НЕ реконнектит SSE (иначе useNotificationFeed ронял бы подписку при каждом обновлении ролей). roles сравниваются по содержимому (rolesSignature), поэтому inline-литерал roles={['Foreman']} не вызывает лишних перестроений. А вот tokenProvider/generateId сравниваются по ссылке — создавай адаптер один раз вне рендера, иначе транспорт пересоберётся и стрим переподключится.

2. Чтение данных

Concrete-хуки (типобезопасные)

Тривиальные list/detail/sub-хуки генерятся из OpenAPI (см. «Кодген хуков») и типобезопасны по query-параметрам и ответу. Все возвращают стандартный UseQueryResult TanStack Query (data, isLoading, error, refetch, …).

import {
useAssets,
useAsset,
usePersonnel,
usePerson,
useBrigades,
useBrigade,
useBrigadeMembers,
useWatches,
useWatch,
useTimesheet,
useShiftReports,
useShiftReport,
useShiftReportEntries,
} from '@mineflow/client-react';

// EAM
const { data, isLoading, error } = useAssets({ status: 'operational', limit: 50 });
const { data: asset } = useAsset(id);

// HR
const { data: people } = usePersonnel({ limit: 100 });
const { data: person } = usePerson(personId); // не запустится при пустом id
const { data: members } = useBrigadeMembers(brigadeId);
const { data: timesheet } = useTimesheet({ dateFrom: '2026-06-01', dateTo: '2026-06-30' });

// PRD
const { data: reports } = useShiftReports({ shiftDateFrom: '2026-06-01', status: 'submitted' });
const { data: report } = useShiftReport(id); // parent: FSM-поля без line-items
const { data: entries } = useShiftReportEntries(id); // 7 коллекций line-items одним вызовом

Списки возвращают { items, nextCursor } (cursor-пагинация). Detail/sub-хуки рапорта, сотрудника, бригады и вахты гейтятся enabled: id.length > 0 — не запускаются при пустом id.

warning
Серверный лимит и useApiQueryAll

useTimesheet требует dateFrom/dateTo (обязательны на бэке). Серверный предел страницы — limit ≤ 200. Для пикеров на участке с >200 единицами есть useAssetsAll / usePersonnelAll — они сами гонят курсор до конца через useApiQueryAll, передавать нужно только фильтры (objectId/status), без cursor/limit.

Подробности cursor-пагинации — в рецепте «Пагинация».

Справочники (refs-kit)

Долгоживущие lookup'ы для picker'ов (ADR-0014), читаемые всеми ролями:

import { useProductionObjects, usePositions, useAssetClasses } from '@mineflow/client-react';

const { data: objects } = useProductionObjects(); // участки; activeOnly=true по умолчанию
const { data: positions } = usePositions(); // должности HR
const { data: classes } = useAssetClasses(); // классы техники EAM

Generic-хуки

Под любой эндпоинт, которого ещё нет в concrete-наборе. useApiQuery даёт механику (кэш, контекст), сам запрос типизируется через замыкание fn(client):

import { useApiQuery } from '@mineflow/client-react';
import { unwrap } from '@mineflow/client-core';

const q = useApiQuery(['eam', 'maintenance'], async (c) =>
unwrap(await c.GET('/api/v1/eam/maintenance', { params: { query: {} } })),
);

useApiQueryAll(queryKey, fetchPage, options?) — то же, но стягивает все страницы курсора в один массив (fetchPage(client, cursor) => { items, nextCursor }; maxPages — предохранитель от битого nextCursor).

3. Мутации (write)

Idempotency-Key проставляется автоматически на каждый write (его генерит fetch-слой из generateId) — руками передавать не нужно. Concrete-мутации на успехе инвалидируют связанный раздел кэша.

import {
useRegisterAsset,
useTransferAsset,
useDecommissionAsset,
useConserveAsset,
useReactivateAsset,
useCompleteMaintenance,
} from '@mineflow/client-react';

const register = useRegisterAsset();
register.mutate({ inventoryNumber: 'BG-001', name: '...', assetClassId, currentObjectId /* … */ });

const transfer = useTransferAsset();
transfer.mutate({ id, body: { toObjectId, reason } }); // reason — опционально

const decommission = useDecommissionAsset(); // синхронная (200), НЕ сага
decommission.mutate({ id, body: { coApprovedBy, reason } }); // double-approval

// FSM-переходы актива (ADR-0045): dedicated PATCH-эндпоинты, журнал движений пишется реактивно
useConserveAsset().mutate({ id, reason }); // operational → conserved (reason обязателен)
useReactivateAsset().mutate({ id }); // conserved → operational
useCompleteMaintenance().mutate({ id }); // maintenance → operational

Generic-мутации

На дженериках построены все concrete write-хуки. useDomainMutation(invalidateKey, fn, options?) инвалидирует раздел на успехе:

import { useDomainMutation, queryKeys } from '@mineflow/client-react';
import { unwrap } from '@mineflow/client-core';

const m = useDomainMutation(queryKeys.personnel.all, async (c, vars: { id: string }) =>
unwrap(
await c.PATCH('/api/v1/hr/personnel/{id}/start-watch', {
params: { path: { id: vars.id }, header: { 'Idempotency-Key': '' } },
}),
),
);

Третий аргумент { mutationKey, scope } (DomainMutationOptions) делает запись offline-устойчивой — см. §5.

useApiMutation(fn) — то же без авто-инвалидации (когда сам управляешь кэшем).

Placeholder для Idempotency-Key через дженерик

При write через generic-мутацию передавай header: { 'Idempotency-Key': '' } — это пустой плейсхолдер под required header-param OpenAPI; реальный ключ подставит fetch-слой (makeAuthenticatedFetch).

Карта кодов ошибок и гейтинг UI по error.code — в рецепте «Ошибки». Шаблон форм с Zod — в рецепте «Формы».

4. Сменные рапорты PRD: write-lifecycle

Полный цикл авторинга рапорта (ADR-0049): создать черновик → добавить строки 7 коллекций → подать/отклонить. Все write-хуки рапорта offline-устойчивы (общий scope, стабильный Idempotency-Key, пауза вместо падения — детали в §5).

import {
useCreateShiftReport,
useAddShiftPersonnel,
useAddShiftAssetUsage,
useAddDrillingEntry,
useAddBlastingEntry,
useAddFuelEntry,
useAddDowntimeEvent,
useAddTmcUsage,
useSubmitShiftReport,
useRejectShiftReport,
} from '@mineflow/client-react';

// idempotencyKey генерь при энкью и клади в vars — он переживёт рестарт/replay.
const create = useCreateShiftReport();
create.mutate({ body: { objectId, shiftDate, shift }, idempotencyKey });

const addPersonnel = useAddShiftPersonnel();
addPersonnel.mutate({ id: reportId, body: { personnelId, workedHours }, idempotencyKey });

const addAssetUsage = useAddShiftAssetUsage();
// возвращает id, на который ссылаются drilling/fuel (anchor)

const submit = useSubmitShiftReport(); // draft → submitted, тело пустое
submit.mutate({ id: reportId, idempotencyKey });

const reject = useRejectShiftReport(); // submitted → rejected (reason ≥5), без саги
reject.mutate({ id: reportId, body: { reason }, idempotencyKey });

Остальные add-хуки симметричны: useAddDrillingEntry, useAddBlastingEntry, useAddFuelEntry, useAddDowntimeEvent, useAddTmcUsage — все принимают { id, body, idempotencyKey } (AddEntryVars<B>).

approve и reject-after-approve — саги (поллят статус, требуют сети), см. §6.

Anchor-зависимости строк

drilling/fuel ссылаются на assetUsageId, возвращаемый useAddShiftAssetUsage, поэтому добавляй asset-usage до них. FIFO-replay offline-очереди сохраняет порядок энкью (см. §5).

5. Offline-first запись и outbox (ADR-0049)

Полевой пользователь работает без стабильной сети. Write-операции рапорта офлайн паузятся (не падают), очередь персистится и доигрывается строго по порядку при реконнекте/после рестарта. Вся механика собрана в объекте shiftReportDomain — инстанциация фабрики createOfflineDomain.

Три кита устойчивости:

  1. Стабильный Idempotency-Key в vars — переживает рестарт и переиспользуется на каждой попытке; бэк дедуплицирует повтор.
  2. Общий scope — TanStack сериализует мутации с одним scope.id строго FIFO (create→add→submit не гоняются конкурентно).
  3. networkMode: 'online' (дефолт TanStack) — офлайн мутация паузится, mutationFn не вызывается; авто-resume по onlineManager.

Включение (внутри провайдера)

Нужен и QueryClient, и REST-клиент. registerDefaults восстанавливает mutation defaults, чтобы мутации, поднятые из персиста (у них только key + variables, без mutationFn), знали, как себя проиграть. Звать до resumePausedMutations:

import { shiftReportDomain, useMineflowClient } from '@mineflow/client-react';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';

function OfflineGate({ children }) {
const queryClient = useQueryClient();
const client = useMineflowClient();
useEffect(() => {
shiftReportDomain.registerDefaults(queryClient, client);
}, [queryClient, client]);
// + persistQueryClient* — эталон в apps/mobile/src/app/providers/offline-gate.tsx
return children;
}

Индикатор и ретрай в UI

import { shiftReportDomain, useIsOnline } from '@mineflow/client-react';

function OutboxBanner() {
const online = useIsOnline();
const { paused, pending, error, total } = shiftReportDomain.useOutbox(); // сводка очереди
const retry = shiftReportDomain.useRetryFailed(); // повтор упавших (тот же Idempotency-Key)
if (online && total === 0) return null;
return <Banner onPress={error > 0 ? retry : undefined}>{/* … */}</Banner>;
}

useOutbox() возвращает OutboxStatus: { paused, pending, error, total }. useRetryFailed() re-execute'ит только упавшие (errored) мутации с прежними variablesresumePausedMutations поднимает только paused, поэтому errored нужен явный повтор.

Свой offline-домен под другой агрегат

createOfflineDomain({ scope, invalidateKey, mutationKeyPrefix, ops, mutationKeys }) вернёт такой же объект с registerDefaults / useOutbox / useRetryFailed плюс ops / mutationKeys / scope для live-хуков (как это сделано для рапортов поверх useDomainMutation).

Полный сценарий offline-очереди — в рецепте «Offline».

6. Async-саги

Часть POST'ов возвращает 202 + sagaId; хук поллит /sagas/{id}/status до терминального состояния и резолвится финальным SagaStatussteps[]).

import { useApproveShiftReport, useRejectShiftReportAfterApprove } from '@mineflow/client-react';

const approve = useApproveShiftReport(); // центральная 6-шаговая approve-сага
approve.mutate({ id });
// approve.isPending — пока идёт сага; approve.data — финальный SagaStatus

const reverse = useRejectShiftReportAfterApprove(); // approved → rejected (только CEO), reason ≥10
reverse.mutate({ id, body: { reason } });
warning
Терминальный откат саги — это onError, не onSuccess

Если сага завершилась откатом (failed/compensated), это не успех. useSagaMutation бросает SagaFailedError (с полем .status: SagaStatus), поэтому сработает onError, а не onSuccess. Иначе UI показал бы «утверждено» по факту провалившейся саги. Чистый детектор — assertSagaSucceeded(status).

Своя сага

import { useSagaMutation } from '@mineflow/client-react';
import { unwrap } from '@mineflow/client-core';

const m = useSagaMutation(async (c, vars: { id: string }) => {
const res = unwrap(await c.POST('/api/v1/eam/sagas/asset-decommission', { /* ... */ }));
return { sagaId: res.sagaId };
});

Второй аргумент { pollOptions } пробрасывается в pollSaga (интервал/таймаут). Подробнее про polling и компенсацию — в рецепте «Саги».

7. Уведомления (live SSE)

import { useNotifications, useNotificationFeed } from '@mineflow/client-react';

// backlog (REST, cursor-пагинация, новые сверху)
const { data } = useNotifications({ limit: 50 });

// live-фид: новые уведомления префиксятся в кэш useNotifications (дедуп по id)
useNotificationFeed({
onNotification: (n) => toast(`${n.severity}: ${n.title}`),
reconnectDelayMs: 1000, // база backoff; реальная задержка растёт экспоненциально + jitter
});

Тип уведомления (Notification) — { id, eventType, severity, title, body, targetRoles, targetUserId, resourceType, resourceId, createdAt }. Сервер сам фильтрует фид по организации и роли/пользователю. SSE авторизуется fetch-stream'ом с Bearer (EventSource не умеет Authorization) через authFetch из провайдера.

React Native: нужен потоковый fetch

Дефолтный RN-fetch не отдаёт response.body (ReadableStream), и SSE-фид не заработает. Передай в провайдер потоковый fetchImpl (например expo/fetch). Детали транспорта SSE — в @mineflow/client-core.

Полный сценарий realtime — в рецепте «SSE».

8. RBAC-гейт

import { useCan } from '@mineflow/client-react';

function ApproveButton() {
const canApprove = useCan(['CEO', 'Engineer']); // одна из ролей у пользователя?
return canApprove ? <button>Утвердить</button> : null;
}

useCan(required) проверяет, есть ли у текущего пользователя (roles из провайдера) хотя бы одна из требуемых ролей. Пустой массив = доступно любому аутентифицированному. Чистая (не-хук) версия — can(userRoles, required).

Это только UI-гейт

Бэк всё равно проверяет роли и object-scope и вернёт 403. useCan лишь прячет недоступные элементы интерфейса. Object-scope / multi-tenancy делает сервер по JWT — org_id в URL не передаётся.

Полная матрица ролей и паттерны object-scope — в рецепте «RBAC».

9. FSM-кнопки

import { useAvailableActions } from '@mineflow/client-react';
import { assetMachine } from '@mineflow/api-schemas';

const actions = useAvailableActions(assetMachine, asset.status);
// ['SEND_TO_MAINTENANCE', 'CONSERVE', 'DECOMMISSION'] — что показать кнопками

useAvailableActions(machine, stateValue) возвращает имена доступных переходов из текущего состояния без учёта guard'ов — UI должен предлагать действие, даже если оно требует доп. ввода (например DECOMMISSION с double-approval); бэк сам валидирует переход (409). Чистая версия — availableActions(machine, stateValue).

Машины (assetMachine и т.д.) — из @mineflow/api-schemas. Маппинг «событие FSM → write-хук» держат concrete-мутации (например CONSERVEuseConserveAsset), а не этот helper. Подробнее — в рецепте «FSM».

10. Формы

Валидация — Zod-схемами из @mineflow/api-zod + react-hook-form:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { zCreateAssetDto } from '@mineflow/api-zod';
import { useRegisterAsset } from '@mineflow/client-react';

function RegisterAssetForm() {
const form = useForm({ resolver: zodResolver(zCreateAssetDto) });
const register = useRegisterAsset();
return <form onSubmit={form.handleSubmit((v) => register.mutate(v))}>{/* … */}</form>;
}

Полный шаблон форм (включая динамические entry-формы рапорта) — в рецепте «Формы».

11. Ключи кэша (queryKeys)

Единый источник ключей TanStack Query для всех хуков — для ручного invalidate/prefetch/setQueryData из приложения:

import { queryKeys } from '@mineflow/client-react';

queryKeys.assets.all; // ['eam','assets'] — префикс списка И детали
queryKeys.assets.list({ status: 'operational' });
queryKeys.assets.detail(id);
queryKeys.shiftReports.entries(id);
queryKeys.refs.assetClasses;

queryClient.invalidateQueries({ queryKey: queryKeys.assets.all }); // накрывает списки + детали

Доступные группы: assets, personnel, brigades, watches, timesheet, shiftReports, notifications, documents, refs. Инвариант (покрыт тестом): all — префикс list(...)/detail(...), поэтому инвалидация по all накрывает весь раздел. Тип — QueryKeys.

12. Документы и вложения (presigned)

Загрузка файлов (фото рапорта и т.д.) — трёхшаговый presigned-flow (ADR-0051): initiate → бинарный PUT напрямую в storage → confirm. Бинарный PUT платформо-специфичен (web — fetch+Blob, RN — expo-file-system), поэтому SDK даёт операции, а сам аплоад оркеструет приложение. Операции — documentOps.initiate / confirm / getDownloadUrl / listByEntity.

Для чтения есть готовые хуки:

import { useDocumentsByEntity, useDocumentDownloadUrl } from '@mineflow/client-react';

const { data: docs } = useDocumentsByEntity('shift-report', reportId); // кросс-девайс список
const { data } = useDocumentDownloadUrl(docId); // presigned GET URL для просмотра
Не делите Idempotency-Key между initiate и confirm

Идемпотентность хэширует method:path:body, поэтому initiate и confirm (разные path) не могут делить один ключ — иначе второй вызов получит 409. documentOps генерят свежий ключ per-call. Полный сценарий аплоада — в рецепте «React Native».

Справочник ключевых экспортов

ЭкспортНазначение
MineflowProviderкорневой провайдер (строит REST + authFetch + QueryClient)
useMineflow / useMineflowClientдоступ к контексту / REST-клиенту
useApiQuery / useApiQueryAllдженерик-чтение (одна страница / весь курсор)
useApiMutation / useDomainMutation / useSagaMutationдженерик-write / write+инвалидация / сага
useAssets / useAsset / useAssetsAllчтение EAM
usePersonnel / usePerson / usePersonnelAll / useBrigades / useBrigade / useBrigadeMembers / useWatches / useWatch / useTimesheetчтение HR
useShiftReports / useShiftReport / useShiftReportEntriesчтение PRD
useProductionObjects / usePositions / useAssetClassesсправочники (refs-kit)
useRegisterAsset / useTransferAsset / useDecommissionAssetмутации EAM
useConserveAsset / useReactivateAsset / useCompleteMaintenanceFSM-переходы актива
useCreateShiftReport / useSubmitShiftReport / useRejectShiftReportwrite-lifecycle рапорта (offline-ready)
useAddShiftPersonnel / useAddShiftAssetUsage / useAddDrillingEntry / useAddBlastingEntry / useAddFuelEntry / useAddDowntimeEvent / useAddTmcUsageстроки рапорта (offline-ready)
useApproveShiftReport / useRejectShiftReportAfterApproveapprove / сторно-саги PRD
shiftReportDomain / createOfflineDomain / useIsOnlineoffline-домен (replay/outbox/retry) + сеть
useNotifications / useNotificationFeedbacklog / live SSE
useCan / canRBAC-гейт
useAvailableActions / availableActionsFSM-действия
useDocumentsByEntity / useDocumentDownloadUrl / documentOpsдокументы/вложения (presigned)
queryKeysключи кэша для ручного invalidate/prefetch
SagaFailedError / assertSagaSucceededдетектор терминального отказа саги

Полный список (включая типы вроде Notification, CreateShiftReportBody, OutboxStatus, MineflowProviderProps) — в полном API-референсе из кода → /api/client-react/.

Кодген хуков (для мейнтейнеров)

Тривиальные read-хуки (list/detail/sub) не пишутся руками — генерятся из apps/api/openapi.json:

pnpm gen:hooks # standalone; также входит в pnpm openapi:client

Раскладка src/domain-hooks/:

  • <area>.generated.tsAUTO-GENERATED тривиальные read'ы (eam/hr/prd);
  • <area>.ts — рукописные write/саги/offline; ре-экспортит свой .generated;
  • refs.ts — справочники (bespoke-ключи, рукописные);
  • index.ts — barrel области.

Чтобы добавить read-хуки нового агрегата (например scm/fuel): строка в манифесте HOOKS в tools/scripts/gen-domain-hooks.ts, группа ключей в src/query-keys.ts, затем pnpm gen:hooks. Сложные хуки (write с инвалидацией, саги, offline) пишутся руками в <area>.ts на дженериках useDomainMutation/useSagaMutation.

Anti-drift

Генератор сверяет каждый путь с живой OpenAPI-спекой — переименование/удаление эндпоинта на бэке роняет pnpm gen:hooks (fail-loud, без частичной записи), а не молча выдаёт битый хук. Тот же PR валит CI-гейт.

Подробнее про генерацию типов и хуков — в рецепте «Codegen».

React Native

Хуки platform-agnostic — TanStack Query работает в RN без изменений. Платформо-специфичное инъектируется через провайдер:

  • tokenProviderReactNativeTokenProvider из @mineflow/auth-native (react-native-app-auth);
  • generateId — polyfill react-native-get-random-values;
  • fetchImpl — потоковый fetch (expo/fetch) для SSE (useNotificationFeed), иначе фид не получит response.body.

Эталон offline-оркестрации (registerDefaults + persistQueryClient*) — apps/mobile/src/app/providers/offline-gate.tsx. UI пишется на RN-примитивах. Полный гайд по RN-интеграции (включая бинарный аплоад документов) — в рецепте «React Native».

Ссылки