feat: добавил поле организация во все заявки, а также расширил компонет выбора даты для ui-kit

This commit is contained in:
Тимур Городецкий
2026-08-25 11:43:57 +05:00
parent a146877327
commit 3b4ee93b48
55 changed files with 641 additions and 143 deletions
@@ -17,19 +17,46 @@ async function client(context: WebPartContext): Promise<AadHttpClient> {
return context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
}
// Ретрай-политика на сетевые сбои — тот же принцип и те же по порядку величины
// значения, что и у RETRY в shared/pnp/pnpjs.ts (там для PnPjs/SharePoint).
const NETWORK_RETRY_ATTEMPTS = 2;
const NETWORK_RETRY_INTERVAL_MS = 1000;
/**
* Мягкий ретрай на СЕТЕВЫЕ сбои (fetch не долетел вовсе — TypeError «Failed to
* fetch»/«NetworkError», не HTTP-статус). Тот же принцип, что и
* BrowserFetchWithRetry у PnPjs-клиента (shared/pnp/pnpjs.ts) — там ретрай уже
* есть, а этот внешний API его был лишён, из-за чего случайная заминка сети или
* холодный старт бэкенда сразу превращались в необратимое «Failed to fetch» в
* UI матрицы. HTTP-ошибки (404/401/500 — throw Error ниже по коду) НЕ ретраим:
* повтор того же запроса их не лечит, только тянет время до сообщения об ошибке.
*/
async function withNetworkRetry<T>(fn: () => Promise<T>): Promise<T> {
for (let attempt = 0; ; attempt += 1) {
try {
return await fn();
} catch (e) {
if (attempt >= NETWORK_RETRY_ATTEMPTS || !(e instanceof TypeError)) throw e;
await new Promise((resolve) => setTimeout(resolve, NETWORK_RETRY_INTERVAL_MS * (attempt + 1)));
}
}
}
// GET /api/request-types — список типов заявок (id + имя). Пригодится, чтобы
// сопоставить форму с RequestTypeId, если он неизвестен заранее.
export async function getRequestTypes(context: WebPartContext): Promise<IRequestType[]> {
const c = await client(context);
const response: HttpClientResponse = await c.get(
`${getApprovalApiBaseUrl()}/api/request-types`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`request-types: ${response.status} ${response.statusText}`);
}
const body: IApiResponse<IRequestType[]> = await response.json();
return body.data ?? [];
return withNetworkRetry(async () => {
const c = await client(context);
const response: HttpClientResponse = await c.get(
`${getApprovalApiBaseUrl()}/api/request-types`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`request-types: ${response.status} ${response.statusText}`);
}
const body: IApiResponse<IRequestType[]> = await response.json();
return body.data ?? [];
});
}
/**
@@ -43,16 +70,18 @@ export async function getRequestTypes(context: WebPartContext): Promise<IRequest
* Типы без настроенного списка SharePoint в ответ не попадают.
*/
export async function getListPartitions(context: WebPartContext): Promise<IRequestTypeListSeries[]> {
const c = await client(context);
const response: HttpClientResponse = await c.get(
`${getApprovalApiBaseUrl()}/api/request-types/lists`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`request-types/lists: ${response.status} ${response.statusText}`);
}
const body: IApiResponse<IRequestTypeListSeries[]> = await response.json();
return body.data ?? [];
return withNetworkRetry(async () => {
const c = await client(context);
const response: HttpClientResponse = await c.get(
`${getApprovalApiBaseUrl()}/api/request-types/lists`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`request-types/lists: ${response.status} ${response.statusText}`);
}
const body: IApiResponse<IRequestTypeListSeries[]> = await response.json();
return body.data ?? [];
});
}
/**
@@ -67,16 +96,18 @@ export async function getCurrentListPartition(
context: WebPartContext,
requestTypeId: number
): Promise<ICurrentListPartition> {
const c = await client(context);
const response: HttpClientResponse = await c.get(
`${getApprovalApiBaseUrl()}/api/request-types/${requestTypeId}/lists/current`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`request-types/${requestTypeId}/lists/current: ${response.status} ${response.statusText}`);
}
const body: IApiResponse<ICurrentListPartition> = await response.json();
return body.data;
return withNetworkRetry(async () => {
const c = await client(context);
const response: HttpClientResponse = await c.get(
`${getApprovalApiBaseUrl()}/api/request-types/${requestTypeId}/lists/current`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`request-types/${requestTypeId}/lists/current: ${response.status} ${response.statusText}`);
}
const body: IApiResponse<ICurrentListPartition> = await response.json();
return body.data;
});
}
// Кеш на уровне МОДУЛЯ (не useRef/useState) — список типов заявок админ меняет
@@ -138,30 +169,32 @@ export async function resolveRoute(
// eslint-disable-next-line @rushstack/no-new-null -- см. departmentId
projectId: string | null = null
): Promise<IResolvedRoute> {
const c = await client(context);
const url = `${getApprovalApiBaseUrl()}/api/template-routes/resolve`;
return withNetworkRetry(async () => {
const c = await client(context);
const url = `${getApprovalApiBaseUrl()}/api/template-routes/resolve`;
// Эндпоинт сменился с GET (query) на POST (JSON-тело).
// projectId (uuid проекта) — для форм, где согласующий зависит от проекта
// (напр. «руководитель проекта-источника» в СЗ на перемещение ТМЦ): бэкенд по
// нему подставляет исполнителя роли. Формы без привязки к проекту шлют null.
const response: HttpClientResponse = await c.post(url, AadHttpClient.configurations.v1, {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
requestTypeId,
userEmail,
projectId: projectId || null,
status,
fieldValues,
departmentId: departmentId || null
})
// Эндпоинт сменился с GET (query) на POST (JSON-тело).
// projectId (uuid проекта) — для форм, где согласующий зависит от проекта
// (напр. «руководитель проекта-источника» в СЗ на перемещение ТМЦ): бэкенд по
// нему подставляет исполнителя роли. Формы без привязки к проекту шлют null.
const response: HttpClientResponse = await c.post(url, AadHttpClient.configurations.v1, {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
requestTypeId,
userEmail,
projectId: projectId || null,
status,
fieldValues,
departmentId: departmentId || null
})
});
if (!response.ok) {
throw new Error(`resolve: ${response.status} ${response.statusText}`);
}
const body: IApiResponse<IResolvedRoute> = await response.json();
return body.data;
});
if (!response.ok) {
throw new Error(`resolve: ${response.status} ${response.statusText}`);
}
const body: IApiResponse<IResolvedRoute> = await response.json();
return body.data;
}
@@ -40,7 +40,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
waiting = false,
waitingText = '',
}) => {
const { rows, loading, error, editable, blocks } = editor;
const { rows, loading, error, editable, blocks, retry } = editor;
// Подпись «доп. согласующий»/«доп. исполнитель» — из roleLabel, а не
// хардкод: иначе блок «Исполнение» (roleLabel="Исполнитель") показывал бы
@@ -113,7 +113,14 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
<div className={styles.matrixTitle}>{title}</div>
{waiting && <div className={styles.matrixNote}>{waitingText}</div>}
{!waiting && loading && <div className={styles.matrixNote}>Загрузка маршрута…</div>}
{!waiting && !loading && error && <div className={styles.matrixNote}>Маршрут недоступен: {error}</div>}
{!waiting && !loading && error && (
<div className={styles.matrixNote}>
Маршрут недоступен: {error}{' '}
<button type="button" className={styles.retryBtn} onClick={retry}>
Повторить
</button>
</div>
)}
{!waiting && !loading && !error && rows.length === 0 && (
<div className={styles.matrixNote}>{emptyText}</div>
)}
@@ -91,6 +91,23 @@
color: $gray-500;
}
// Инлайновая ссылка-кнопка повтора запроса рядом с текстом ошибки — тот же
// приём, что у .email в InitiatorLine: не отдельная кнопка с рамкой, а
// подчёркиваемая ссылка внутри строки заметки.
.retryBtn {
padding: 0;
border: none;
background: none;
font: inherit;
color: $navy-700;
text-decoration: underline;
cursor: pointer;
&:hover {
color: $gold-600;
}
}
// Обёртка таблицы: на промежуточных ширинах — горизонтальный скролл вместо
// сплющивания колонок. На телефоне таблица становится карточками (медиазапрос ниже).
.matrixScroll {
@@ -7,6 +7,8 @@ export interface IUseMatrixEditor {
loading: boolean;
error: string | undefined;
editable: boolean;
/** Перезапросить маршрут ещё раз (после сетевой ошибки) без смены reloadKey. */
retry: () => void;
addApprover: (user: ISiteUser) => void;
/** Переместить ШАГ целиком (вместе со всеми параллельными строками) выше/ниже. */
moveBlockUp: (index: number) => void;
@@ -155,6 +157,10 @@ export function useMatrixEditor(
const [rows, setRows] = useState<IMatrixRow[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | undefined>(undefined);
// Ручной повтор после ошибки (см. кнопку «Повторить» в ApprovalMatrix) — свой
// счётчик, а не reloadKey: тот приходит от вызывающего и не должен знать о
// повторных попытках, это внутренняя механика самого хука.
const [retrySeq, setRetrySeq] = useState<number>(0);
useEffect(() => {
if (!ready) return;
@@ -175,9 +181,11 @@ export function useMatrixEditor(
return () => {
active = false;
};
// load — стабильная ссылка от вызывающего (useCallback); перезагрузка по reloadKey/ready.
// load — стабильная ссылка от вызывающего (useCallback); перезагрузка по reloadKey/ready/retrySeq.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reloadKey, ready]);
}, [reloadKey, ready, retrySeq]);
const retry = (): void => setRetrySeq((n) => n + 1);
const appendUser = (user: ISiteUser, position: string): void => {
setRows((prev) => {
@@ -235,6 +243,7 @@ export function useMatrixEditor(
loading,
error,
editable,
retry,
addApprover,
moveBlockUp,
moveBlockDown,