чистка кода

This commit is contained in:
gorbenko.pavel
2026-08-07 14:02:56 +05:00
parent 419aaac60a
commit 03391d7669
19 changed files with 67 additions and 37 deletions
@@ -71,8 +71,19 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
[blocks, withParallel] [blocks, withParallel]
); );
// Индекс последнего заблокированного блока — все блоки ПОСЛЕ него движимы, // Индекс последнего заблокированного блока — все блоки ПОСЛЕ него движимы,
// все блоки НА НЁМ И РАНЬШЕ — нет (см. комментарий к компоненту). // все блоки НА НЁМ И РАНЬШЕ — нет (см. комментарий к компоненту). Считаем
const lastLockedBlockIndex = lockedBlock.reduce((acc, locked, i) => (locked ? i : acc), -1); // именно НЕПРЕРЫВНЫЙ префикс заблокированных блоков с начала, а не индекс
// последнего true по всему списку: реальные данные не всегда гарантируют
// строгую непрерывность (например, более ранняя ручная правка тем же
// инструментом могла оставить решённый шаг ПОСЛЕ ещё не решённого) — а
// reduce по всему массиву в этом случае блокирует стрелки вообще у всех
// блоков до самого дальнего заблокированного, даже у тех, что реально
// редактируемы.
let lastLockedBlockIndex = -1;
for (let i = 0; i < lockedBlock.length; i++) {
if (!lockedBlock[i]) break;
lastLockedBlockIndex = i;
}
const firstMovableRowIndex = blocks[lastLockedBlockIndex + 1]?.start ?? -1; const firstMovableRowIndex = blocks[lastLockedBlockIndex + 1]?.start ?? -1;
// Фаза уже целиком решена (например, все согласующие одобрили, и заявка ушла // Фаза уже целиком решена (например, все согласующие одобрили, и заявка ушла
@@ -42,15 +42,20 @@ interface IFormState {
const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listId: '' }; const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listId: '' };
// siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
// внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде.
/** Ячейка «Сайт SharePoint» — подтягивает заголовок сайта по url, хранящемуся на бэкенде. */ /** Ячейка «Сайт SharePoint» — подтягивает заголовок сайта по url, хранящемуся на бэкенде. */
// eslint-disable-next-line @rushstack/no-new-null -- siteUrl зеркалит RequestType.siteUrl (nullable JSON-контракт бэкенда)
const SiteUrlCell: React.FC<{ client: SharePointListsClient; siteUrl: string | null }> = ({ client, siteUrl }) => { const SiteUrlCell: React.FC<{ client: SharePointListsClient; siteUrl: string | null }> = ({ client, siteUrl }) => {
const { title } = useSharePointSiteTitle(client, siteUrl); const { title } = useSharePointSiteTitle(client, siteUrl ?? undefined);
return <>{siteUrl ? formatSiteLabel(title, siteUrl) : texts.common.empty}</>; return <>{siteUrl ? formatSiteLabel(title, siteUrl) : texts.common.empty}</>;
}; };
/** Ячейка «Список SharePoint» — на бэкенде хранится id списка (устойчив к переименованию), тут подтягиваем Title для читаемости. */ /** Ячейка «Список SharePoint» — на бэкенде хранится id списка (устойчив к переименованию), тут подтягиваем Title для читаемости. */
// eslint-disable-next-line @rushstack/no-new-null -- siteUrl/listId зеркалят RequestType (nullable JSON-контракт бэкенда)
const ListIdCell: React.FC<{ client: SharePointListsClient; siteUrl: string | null; listId: string | null }> = ({ client, siteUrl, listId }) => { const ListIdCell: React.FC<{ client: SharePointListsClient; siteUrl: string | null; listId: string | null }> = ({ client, siteUrl, listId }) => {
const { title } = useSharePointListTitle(client, siteUrl, listId); const { title } = useSharePointListTitle(client, siteUrl ?? undefined, listId ?? undefined);
return <>{listId ? (title ?? listId) : texts.common.empty}</>; return <>{listId ? (title ?? listId) : texts.common.empty}</>;
}; };
@@ -104,7 +109,11 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
name: form.name.trim(), name: form.name.trim(),
pageUrl: form.pageUrl.trim(), pageUrl: form.pageUrl.trim(),
section: form.section.trim(), section: form.section.trim(),
// null, а не undefined — контракт CreateRequestTypeInput зеркалит
// nullable-поля бэкенда (см. models/requestTypes.ts).
// eslint-disable-next-line @rushstack/no-new-null
siteUrl: siteUrl || null, siteUrl: siteUrl || null,
// eslint-disable-next-line @rushstack/no-new-null
listId: listId || null listId: listId || null
}; };
const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input); const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input);
@@ -99,7 +99,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId)); const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId));
const hasListId = Boolean(requestType?.listId); const hasListId = Boolean(requestType?.listId);
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listId); const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
const fieldNameOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title })); const fieldNameOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
const resolved = useResolveRoute(api); const resolved = useResolveRoute(api);
@@ -272,7 +272,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
key: 'executor', key: 'executor',
header: texts.resolve.columnExecutor, header: texts.resolve.columnExecutor,
render: r => { render: r => {
const employee = findEmployeeByEmail(employees.items, r.executorEmail); const employee = findEmployeeByEmail(employees.items, r.executorEmail ?? undefined);
return ( return (
<div> <div>
<PrimaryCell>{employee ? employee.fio : (r.executorEmail ?? texts.common.empty)}</PrimaryCell> <PrimaryCell>{employee ? employee.fio : (r.executorEmail ?? texts.common.empty)}</PrimaryCell>
@@ -113,7 +113,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo)); const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
const submit = useAsyncSubmit(); const submit = useAsyncSubmit();
const hasListId = Boolean(requestType?.listId); const hasListId = Boolean(requestType?.listId);
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listId); const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
const fieldNameOptions = React.useMemo(() => { const fieldNameOptions = React.useMemo(() => {
const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title })); const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
@@ -10,8 +10,8 @@ export interface SharePointFieldsState {
/** Заголовки столбцов списка (site_url + list_name типа заявки) — источник значений для field_name шага. */ /** Заголовки столбцов списка (site_url + list_name типа заявки) — источник значений для field_name шага. */
export function useSharePointFields( export function useSharePointFields(
client: SharePointListsClient | undefined, client: SharePointListsClient | undefined,
siteUrl: string | undefined | null, siteUrl: string | undefined,
listTitle: string | undefined | null listTitle: string | undefined
): SharePointFieldsState { ): SharePointFieldsState {
const [items, setItems] = React.useState<SharePointListField[]>([]); const [items, setItems] = React.useState<SharePointListField[]>([]);
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
@@ -2,22 +2,22 @@ import * as React from 'react';
import { SharePointListsClient } from '../services/sharePointLists'; import { SharePointListsClient } from '../services/sharePointLists';
export interface SharePointListTitleState { export interface SharePointListTitleState {
title: string | null; title: string | undefined;
isLoading: boolean; isLoading: boolean;
} }
/** Заголовок списка SharePoint по (site_url, list_id) — для отображения «Название (id)» вместо голого GUID. */ /** Заголовок списка SharePoint по (site_url, list_id) — для отображения «Название (id)» вместо голого GUID. */
export function useSharePointListTitle( export function useSharePointListTitle(
client: SharePointListsClient | undefined, client: SharePointListsClient | undefined,
siteUrl: string | undefined | null, siteUrl: string | undefined,
listId: string | undefined | null listId: string | undefined
): SharePointListTitleState { ): SharePointListTitleState {
const [title, setTitle] = React.useState<string | null>(null); const [title, setTitle] = React.useState<string | undefined>(undefined);
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
if (!client || !siteUrl?.trim() || !listId?.trim()) { if (!client || !siteUrl?.trim() || !listId?.trim()) {
setTitle(null); setTitle(undefined);
setIsLoading(false); setIsLoading(false);
return undefined; return undefined;
} }
@@ -27,7 +27,7 @@ export function useSharePointListTitle(
client.getListTitle(siteUrl.trim(), listId.trim()) client.getListTitle(siteUrl.trim(), listId.trim())
.then(result => { if (!cancelled) setTitle(result); }) .then(result => { if (!cancelled) setTitle(result); })
.catch(() => { if (!cancelled) setTitle(null); }) .catch(() => { if (!cancelled) setTitle(undefined); })
.finally(() => { if (!cancelled) setIsLoading(false); }); .finally(() => { if (!cancelled) setIsLoading(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
@@ -2,21 +2,21 @@ import * as React from 'react';
import { SharePointListsClient } from '../services/sharePointLists'; import { SharePointListsClient } from '../services/sharePointLists';
export interface SharePointSiteTitleState { export interface SharePointSiteTitleState {
title: string | null; title: string | undefined;
isLoading: boolean; isLoading: boolean;
} }
/** Заголовок сайта SharePoint по его url — для отображения «Название — url» вместо голого url. */ /** Заголовок сайта SharePoint по его url — для отображения «Название — url» вместо голого url. */
export function useSharePointSiteTitle( export function useSharePointSiteTitle(
client: SharePointListsClient | undefined, client: SharePointListsClient | undefined,
siteUrl: string | undefined | null siteUrl: string | undefined
): SharePointSiteTitleState { ): SharePointSiteTitleState {
const [title, setTitle] = React.useState<string | null>(null); const [title, setTitle] = React.useState<string | undefined>(undefined);
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
if (!client || !siteUrl?.trim()) { if (!client || !siteUrl?.trim()) {
setTitle(null); setTitle(undefined);
setIsLoading(false); setIsLoading(false);
return undefined; return undefined;
} }
@@ -26,7 +26,7 @@ export function useSharePointSiteTitle(
client.getSiteTitle(siteUrl.trim()) client.getSiteTitle(siteUrl.trim())
.then(result => { if (!cancelled) setTitle(result); }) .then(result => { if (!cancelled) setTitle(result); })
.catch(() => { if (!cancelled) setTitle(null); }) .catch(() => { if (!cancelled) setTitle(undefined); })
.finally(() => { if (!cancelled) setIsLoading(false); }); .finally(() => { if (!cancelled) setIsLoading(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
@@ -1,5 +1,6 @@
// Справочники api.tnsu.kz (компании, отделы, проекты) в виде, удобном для UI. // Справочники api.tnsu.kz (компании, отделы, проекты) в виде, удобном для UI.
// Приведение из «сырых» PascalCase-ответов портала — в services/portalDictionary.ts. // Приведение из «сырых» PascalCase-ответов портала — в services/portalDictionary.ts.
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт портала api.tnsu.kz */
export interface Company { export interface Company {
id: string; id: string;
@@ -69,6 +69,7 @@ export interface ManagedRequestPage {
export interface ManagedApprovalRow { export interface ManagedApprovalRow {
id: number; id: number;
ticketId: number; ticketId: number;
// eslint-disable-next-line @rushstack/no-new-null -- lookup-поле SharePoint, у ghost-строки (см. handleAddApprover) значения ещё нет
approverId: number | null; approverId: number | null;
approverName: string; approverName: string;
approverEmail: string; approverEmail: string;
@@ -1,4 +1,5 @@
// DTO эндпоинта api/request-types (Application/RequestTypes/*.cs). // DTO эндпоинта api/request-types (Application/RequestTypes/*.cs).
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда */
export interface RequestType { export interface RequestType {
id: number; id: number;
@@ -1,5 +1,6 @@
// DTO эндпоинта api/template-routes/resolve // DTO эндпоинта api/template-routes/resolve
// (Application/TemplateRoutes/ResolveTemplateRouteDto.cs). // (Application/TemplateRoutes/ResolveTemplateRouteDto.cs).
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда */
import { RouteStepStatus } from './enums'; import { RouteStepStatus } from './enums';
@@ -1,5 +1,6 @@
// DTO эндпоинта api/template-routes/{routeId}/steps // DTO эндпоинта api/template-routes/{routeId}/steps
// (Application/TemplateRouteSteps/*.cs). // (Application/TemplateRouteSteps/*.cs).
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда (C# nullable-типы сериализуются в null, не в отсутствие поля) */
import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus } from './enums'; import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus } from './enums';
@@ -1,4 +1,5 @@
// DTO эндпоинта api/template-routes (Application/TemplateRoutes/*.cs). // DTO эндпоинта api/template-routes (Application/TemplateRoutes/*.cs).
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда */
export interface TemplateRoute { export interface TemplateRoute {
id: number; id: number;
@@ -54,10 +54,13 @@ const PARAM_KEYS = [
'aamtype', 'aamreq' 'aamtype', 'aamreq'
]; ];
// raw — результат URLSearchParams.get() (DOM API, возвращает string | null).
// eslint-disable-next-line @rushstack/no-new-null
function parseTab(raw: string | null): ScreenKey { function parseTab(raw: string | null): ScreenKey {
return raw && (TABS as string[]).indexOf(raw) !== -1 ? (raw as ScreenKey) : 'dashboard'; return raw && (TABS as string[]).indexOf(raw) !== -1 ? (raw as ScreenKey) : 'dashboard';
} }
// eslint-disable-next-line @rushstack/no-new-null -- raw — результат URLSearchParams.get() (DOM API)
function parseFieldValues(raw: string | null): ResolveFieldValue[] { function parseFieldValues(raw: string | null): ResolveFieldValue[] {
if (!raw) return []; if (!raw) return [];
try { try {
@@ -18,7 +18,7 @@ export class ApiClientError extends Error {
} }
} }
export type QueryParams = Record<string, string | number | boolean | undefined | null>; export type QueryParams = Record<string, string | number | boolean | undefined>;
export interface IHttpClient { export interface IHttpClient {
get<T>(path: string, query?: QueryParams): Promise<T>; get<T>(path: string, query?: QueryParams): Promise<T>;
@@ -34,7 +34,7 @@ function buildUrl(baseUrl: string, path: string, query?: QueryParams): string {
if (query) { if (query) {
Object.keys(query).forEach(key => { Object.keys(query).forEach(key => {
const value = query[key]; const value = query[key];
if (value !== undefined && value !== null && value !== '') { if (value !== undefined && value !== '') {
url.searchParams.set(key, String(value)); url.searchParams.set(key, String(value));
} }
}); });
@@ -1,3 +1,4 @@
/* eslint-disable @rushstack/no-new-null -- поля "сырых" IPortal*-интерфейсов 1:1 зеркалят nullable JSON-контракт портала api.tnsu.kz */
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http'; import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { Company, Department, Employee, Project } from '../models'; import { Company, Department, Employee, Project } from '../models';
import { ApiClientError } from './httpClient'; import { ApiClientError } from './httpClient';
@@ -51,7 +51,7 @@ export function isNumericFieldType(typeAsString: string): boolean {
} }
/** «Название — url», либо просто url, пока название ещё не подтянулось/не нашлось. */ /** «Название — url», либо просто url, пока название ещё не подтянулось/не нашлось. */
export function formatSiteLabel(title: string | null | undefined, url: string): string { export function formatSiteLabel(title: string | undefined, url: string): string {
return title ? `${title} — ${url}` : url; return title ? `${title} — ${url}` : url;
} }
@@ -79,8 +79,8 @@ export class SharePointListsClient {
// Заголовок сайта по site_url и заголовок списка по (site_url, list_id) бэкенд // Заголовок сайта по site_url и заголовок списка по (site_url, list_id) бэкенд
// не хранит (только сами url) — кэшируем на клиенте, чтобы не дёргать сайт // не хранит (только сами url) — кэшируем на клиенте, чтобы не дёргать сайт
// заново на каждый рендер строки таблицы или переоткрытие формы с тем же значением. // заново на каждый рендер строки таблицы или переоткрытие формы с тем же значением.
private readonly siteTitleCache = new Map<string, string | null>(); private readonly siteTitleCache = new Map<string, string | undefined>();
private readonly listTitleCache = new Map<string, string | null>(); private readonly listTitleCache = new Map<string, string | undefined>();
constructor(private readonly context: WebPartContext) {} constructor(private readonly context: WebPartContext) {}
@@ -90,33 +90,31 @@ export class SharePointListsClient {
} }
/** Заголовок сайта по его url — для отображения рядом с url там, где хранится только он. */ /** Заголовок сайта по его url — для отображения рядом с url там, где хранится только он. */
public async getSiteTitle(siteUrl: string): Promise<string | null> { public async getSiteTitle(siteUrl: string): Promise<string | undefined> {
const cached = this.siteTitleCache.get(siteUrl); if (this.siteTitleCache.has(siteUrl)) return this.siteTitleCache.get(siteUrl);
if (cached !== undefined) return cached;
try { try {
const web = await spfi(siteUrl).using(SPFx(this.context)).web.select('Title')() as { Title: string }; const web = await spfi(siteUrl).using(SPFx(this.context)).web.select('Title')() as { Title: string };
this.siteTitleCache.set(siteUrl, web.Title); this.siteTitleCache.set(siteUrl, web.Title);
return web.Title; return web.Title;
} catch { } catch {
this.siteTitleCache.set(siteUrl, null); this.siteTitleCache.set(siteUrl, undefined);
return null; return undefined;
} }
} }
/** Заголовок списка по (site_url, list_id — server-relative url списка) — для отображения там, где хранится только он. */ /** Заголовок списка по (site_url, list_id — server-relative url списка) — для отображения там, где хранится только он. */
public async getListTitle(siteUrl: string, listId: string): Promise<string | null> { public async getListTitle(siteUrl: string, listId: string): Promise<string | undefined> {
const key = `${siteUrl}|${listId}`; const key = `${siteUrl}|${listId}`;
const cached = this.listTitleCache.get(key); if (this.listTitleCache.has(key)) return this.listTitleCache.get(key);
if (cached !== undefined) return cached;
try { try {
const list = await clientFor(this.context, siteUrl).web.getList(listId).select('Title')() as { Title: string }; const list = await clientFor(this.context, siteUrl).web.getList(listId).select('Title')() as { Title: string };
this.listTitleCache.set(key, list.Title); this.listTitleCache.set(key, list.Title);
return list.Title; return list.Title;
} catch { } catch {
this.listTitleCache.set(key, null); this.listTitleCache.set(key, undefined);
return null; return undefined;
} }
} }
@@ -6,7 +6,7 @@ export function toEmployeeOption(employee: Employee): ISearchableOption {
return { value: employee.email, label: employee.fio, sublabel: employee.email }; return { value: employee.email, label: employee.fio, sublabel: employee.email };
} }
export function findEmployeeByEmail(employees: Employee[], email: string | null): Employee | undefined { export function findEmployeeByEmail(employees: Employee[], email: string | undefined): Employee | undefined {
if (!email) return undefined; if (!email) return undefined;
const target = email.toLowerCase(); const target = email.toLowerCase();
return employees.find(e => e.email.toLowerCase() === target); return employees.find(e => e.email.toLowerCase() === target);
@@ -3,12 +3,14 @@ import { comparisonOperatorSymbols, headTypeLabels, projectRoleLabels, texts } f
import { findEmployeeByEmail } from './employee'; import { findEmployeeByEmail } from './employee';
/** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */ /** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */
/* eslint-disable @rushstack/no-new-null -- форма зеркалит TemplateRouteStep (nullable JSON-контракт бэкенда) */
export function detectExecutorKind(step: { export function detectExecutorKind(step: {
executorEmail?: string | null; executorEmail?: string | null;
position?: string | null; position?: string | null;
projectRole?: ProjectRole | null; projectRole?: ProjectRole | null;
headType?: HeadType | null; headType?: HeadType | null;
}): StepExecutorKind { }): StepExecutorKind {
/* eslint-enable @rushstack/no-new-null */
if (step.executorEmail) return 'email'; if (step.executorEmail) return 'email';
if (step.position) return 'position'; if (step.position) return 'position';
if (step.projectRole && step.projectRole !== ProjectRole.None) return 'projectRole'; if (step.projectRole && step.projectRole !== ProjectRole.None) return 'projectRole';
@@ -24,7 +26,7 @@ export interface StepExecutorDisplay {
/** Колонка «Исполнитель» в таблице шагов шаблона маршрута. */ /** Колонка «Исполнитель» в таблице шагов шаблона маршрута. */
export function describeStepExecutor(step: TemplateRouteStep, employees: Employee[]): StepExecutorDisplay { export function describeStepExecutor(step: TemplateRouteStep, employees: Employee[]): StepExecutorDisplay {
if (step.executorEmail) { if (step.executorEmail) {
const employee = findEmployeeByEmail(employees, step.executorEmail); const employee = findEmployeeByEmail(employees, step.executorEmail ?? undefined);
return employee ? { primary: employee.fio, secondary: employee.email } : { primary: step.executorEmail }; return employee ? { primary: employee.fio, secondary: employee.email } : { primary: step.executorEmail };
} }
if (step.position) return { primary: step.position }; if (step.position) return { primary: step.position };