Фронтенд для сервиса (#5)
Tests / test (push) Successful in 2m26s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/5
This commit was merged in pull request #5.
This commit is contained in:
2026-07-30 06:11:24 +00:00
parent 5476c1bab5
commit da158cfd84
114 changed files with 26045 additions and 2 deletions
@@ -0,0 +1,34 @@
import * as React from 'react';
import { ApiClientError } from '../services/httpClient';
export interface AsyncList<T> {
items: T[];
isLoading: boolean;
error?: string;
reload: () => void;
}
/**
* Загрузка списка с бэкенда: держит данные, признак загрузки и текст ошибки
* в одном месте, чтобы экраны не повторяли этот код.
*
* load обязан быть стабильным (React.useCallback) — иначе загрузка пойдёт по кругу.
*/
export function useAsyncList<T>(load: () => Promise<T[]>): AsyncList<T> {
const [items, setItems] = React.useState<T[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<string | undefined>();
const reload = React.useCallback(() => {
setIsLoading(true);
setError(undefined);
load()
.then(setItems)
.catch((e: ApiClientError) => setError(e.message))
.finally(() => setIsLoading(false));
}, [load]);
React.useEffect(() => { reload(); }, [reload]);
return { items, isLoading, error, reload };
}
@@ -0,0 +1,30 @@
import * as React from 'react';
import { ApiClientError } from '../services/httpClient';
export interface AsyncSubmit {
isBusy: boolean;
error?: string;
/** Показать ошибку без запроса — например, результат валидации формы. */
fail: (message: string) => void;
run: (action: () => Promise<void>) => void;
}
/**
* Состояние вокруг одного пишущего запроса: «выполняется» + текст ошибки.
* Используется формами и подтверждением удаления, чтобы не дублировать
* setBusy/setError/finally в каждом экране.
*/
export function useAsyncSubmit(): AsyncSubmit {
const [isBusy, setIsBusy] = React.useState(false);
const [error, setError] = React.useState<string | undefined>();
const run = (action: () => Promise<void>): void => {
setIsBusy(true);
setError(undefined);
action()
.catch((e: ApiClientError) => setError(e.message))
.finally(() => setIsBusy(false));
};
return { isBusy, error, fail: setError, run };
}
@@ -0,0 +1,40 @@
import * as React from 'react';
import { AsyncSubmit, useAsyncSubmit } from './useAsyncSubmit';
export interface DeleteTarget<T> {
/** Запись, для которой открыт диалог подтверждения. */
target?: T;
isBusy: boolean;
error?: string;
ask: (item: T) => void;
cancel: () => void;
confirm: () => void;
}
/**
* Сценарий «спросили — удалили — обновили список», одинаковый на всех экранах:
* какая запись удаляется, идёт ли запрос и что показать при ошибке.
*/
export function useDeleteTarget<T>(remove: (item: T) => Promise<void>, onDeleted: () => void): DeleteTarget<T> {
const [target, setTarget] = React.useState<T | undefined>();
const submit: AsyncSubmit = useAsyncSubmit();
const confirm = (): void => {
if (!target) return;
submit.run(() =>
remove(target).then(() => {
setTarget(undefined);
onDeleted();
})
);
};
return {
target,
isBusy: submit.isBusy,
error: submit.error,
ask: setTarget,
cancel: () => setTarget(undefined),
confirm
};
}
@@ -0,0 +1,39 @@
import * as React from 'react';
import { Company, Department } from '../models';
import { ApiClientError } from '../services/httpClient';
import { PortalDictionaryClient } from '../services/portalDictionary';
export interface PortalDictionaries {
companies: Company[];
departments: Department[];
isLoading: boolean;
error?: string;
}
/**
* Компании и отделы из 1С грузим один раз на экран: они статичны, а отделов ~700 —
* искать по ним дешевле в памяти, чем ходить на сервер на каждый символ.
*/
export function usePortalDictionaries(dictionary?: PortalDictionaryClient): PortalDictionaries {
const [companies, setCompanies] = React.useState<Company[]>([]);
const [departments, setDepartments] = React.useState<Department[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<string | undefined>();
React.useEffect(() => {
// Клиент появляется асинхронно: SPFx сначала выдаёт AAD-токен для api.tnsu.kz.
if (!dictionary) return;
setIsLoading(true);
setError(undefined);
Promise.all([dictionary.companies(), dictionary.departments()])
.then(([loadedCompanies, loadedDepartments]) => {
setCompanies(loadedCompanies);
setDepartments(loadedDepartments);
})
.catch((e: ApiClientError) => setError(e.message))
.finally(() => setIsLoading(false));
}, [dictionary]);
return { companies, departments, isLoading, error };
}
@@ -0,0 +1,34 @@
import * as React from 'react';
import { Employee } from '../models';
import { ApiClientError } from '../services/httpClient';
import { PortalDictionaryClient } from '../services/portalDictionary';
export interface PortalEmployees {
items: Employee[];
isLoading: boolean;
error?: string;
}
/**
* Справочник сотрудников api.tnsu.kz — грузим один раз на экран, чтобы искать
* исполнителя/инициатора по ФИО или email на клиенте, без запроса на каждый символ.
*/
export function usePortalEmployees(dictionary?: PortalDictionaryClient): PortalEmployees {
const [items, setItems] = React.useState<Employee[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<string | undefined>();
React.useEffect(() => {
// Клиент появляется асинхронно: SPFx сначала выдаёт AAD-токен для api.tnsu.kz.
if (!dictionary) return;
setIsLoading(true);
setError(undefined);
dictionary.employees()
.then(setItems)
.catch((e: ApiClientError) => setError(e.message))
.finally(() => setIsLoading(false));
}, [dictionary]);
return { items, isLoading, error };
}
@@ -0,0 +1,10 @@
import * as React from 'react';
import { RequestType } from '../models';
import { ApprovalRouteApi } from '../services/api';
import { AsyncList, useAsyncList } from './useAsyncList';
/** Справочник типов заявок — нужен и своему экрану, и селектам на других. */
export function useRequestTypes(api: ApprovalRouteApi): AsyncList<RequestType> {
const load = React.useCallback(() => api.requestTypes.list(), [api]);
return useAsyncList(load);
}
@@ -0,0 +1,27 @@
import * as React from 'react';
import { ResolveTemplateRouteQuery, ResolveTemplateRouteResult } from '../models';
import { ApprovalRouteApi } from '../services/api';
import { useAsyncSubmit } from './useAsyncSubmit';
export interface ResolveRoute {
result?: ResolveTemplateRouteResult;
isBusy: boolean;
error?: string;
resolve: (query: ResolveTemplateRouteQuery) => void;
fail: (message: string) => void;
}
/** Диагностический запрос «как разрешится маршрут» и его состояние. */
export function useResolveRoute(api: ApprovalRouteApi): ResolveRoute {
const [result, setResult] = React.useState<ResolveTemplateRouteResult | undefined>();
const submit = useAsyncSubmit();
const resolve = (query: ResolveTemplateRouteQuery): void => {
// Прошлый результат убираем сразу: показывать его рядом с новыми
// параметрами формы — вводить пользователя в заблуждение.
setResult(undefined);
submit.run(() => api.templateRoutes.resolve(query).then(setResult));
};
return { result, isBusy: submit.isBusy, error: submit.error, resolve, fail: submit.fail };
}
@@ -0,0 +1,17 @@
import * as React from 'react';
import { TemplateRouteStep } from '../models';
import { ApprovalRouteApi } from '../services/api';
import { AsyncList, useAsyncList } from './useAsyncList';
function byOrderNo(steps: TemplateRouteStep[]): TemplateRouteStep[] {
return [...steps].sort((a, b) => a.orderNo - b.orderNo);
}
/** Шаги шаблона маршрута, всегда в порядке выполнения. */
export function useRouteSteps(api: ApprovalRouteApi, routeId: number): AsyncList<TemplateRouteStep> {
const load = React.useCallback(
() => api.templateRouteSteps.list(routeId).then(byOrderNo),
[api, routeId]
);
return useAsyncList(load);
}
@@ -0,0 +1,10 @@
import * as React from 'react';
import { TemplateRoute } from '../models';
import { ApprovalRouteApi } from '../services/api';
import { AsyncList, useAsyncList } from './useAsyncList';
/** Список маршрутов, при необходимости отфильтрованный по типу заявки. */
export function useTemplateRoutes(api: ApprovalRouteApi, requestTypeId?: number): AsyncList<TemplateRoute> {
const load = React.useCallback(() => api.templateRoutes.list(requestTypeId), [api, requestTypeId]);
return useAsyncList(load);
}