diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx index 2ed6500..6dc875e 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx @@ -71,8 +71,19 @@ export const ManageRequestsPhaseTable: React.FC [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; // Фаза уже целиком решена (например, все согласующие одобрили, и заявка ушла diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx index e72fb83..f1678a3 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypesScreen.tsx @@ -42,15 +42,20 @@ interface IFormState { const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listId: '' }; +// siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда +// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined, +// внутренний код (хуки/кэш SharePointListsClient) с null не работает нигде. /** Ячейка «Сайт 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 { title } = useSharePointSiteTitle(client, siteUrl); + const { title } = useSharePointSiteTitle(client, siteUrl ?? undefined); return <>{siteUrl ? formatSiteLabel(title, siteUrl) : texts.common.empty}; }; /** Ячейка «Список 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 { title } = useSharePointListTitle(client, siteUrl, listId); + const { title } = useSharePointListTitle(client, siteUrl ?? undefined, listId ?? undefined); return <>{listId ? (title ?? listId) : texts.common.empty}; }; @@ -104,7 +109,11 @@ export const RequestTypesScreen: React.FC = ({ api, sh name: form.name.trim(), pageUrl: form.pageUrl.trim(), section: form.section.trim(), + // null, а не undefined — контракт CreateRequestTypeInput зеркалит + // nullable-поля бэкенда (см. models/requestTypes.ts). + // eslint-disable-next-line @rushstack/no-new-null siteUrl: siteUrl || null, + // eslint-disable-next-line @rushstack/no-new-null listId: listId || null }; const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input); diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx index 25b342f..278a627 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx @@ -99,7 +99,7 @@ export const ResolveScreen: React.FC = ({ api, dictionary, const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId)); 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 resolved = useResolveRoute(api); @@ -272,7 +272,7 @@ export const ResolveScreen: React.FC = ({ api, dictionary, key: 'executor', header: texts.resolve.columnExecutor, render: r => { - const employee = findEmployeeByEmail(employees.items, r.executorEmail); + const employee = findEmployeeByEmail(employees.items, r.executorEmail ?? undefined); return (
{employee ? employee.fio : (r.executorEmail ?? texts.common.empty)} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx index 522fd86..e20aec4 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx @@ -113,7 +113,7 @@ export const RouteStepFormModal: React.FC = ({ const [form, setForm] = React.useState(() => toFormState(editing, nextOrderNo)); const submit = useAsyncSubmit(); 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 options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title })); diff --git a/frontend/src/webparts/approvalAdmin/hooks/useSharePointFields.ts b/frontend/src/webparts/approvalAdmin/hooks/useSharePointFields.ts index fd8f125..f3b2f7f 100644 --- a/frontend/src/webparts/approvalAdmin/hooks/useSharePointFields.ts +++ b/frontend/src/webparts/approvalAdmin/hooks/useSharePointFields.ts @@ -10,8 +10,8 @@ export interface SharePointFieldsState { /** Заголовки столбцов списка (site_url + list_name типа заявки) — источник значений для field_name шага. */ export function useSharePointFields( client: SharePointListsClient | undefined, - siteUrl: string | undefined | null, - listTitle: string | undefined | null + siteUrl: string | undefined, + listTitle: string | undefined ): SharePointFieldsState { const [items, setItems] = React.useState([]); const [isLoading, setIsLoading] = React.useState(false); diff --git a/frontend/src/webparts/approvalAdmin/hooks/useSharePointListTitle.ts b/frontend/src/webparts/approvalAdmin/hooks/useSharePointListTitle.ts index 81c3160..2d09ec1 100644 --- a/frontend/src/webparts/approvalAdmin/hooks/useSharePointListTitle.ts +++ b/frontend/src/webparts/approvalAdmin/hooks/useSharePointListTitle.ts @@ -2,22 +2,22 @@ import * as React from 'react'; import { SharePointListsClient } from '../services/sharePointLists'; export interface SharePointListTitleState { - title: string | null; + title: string | undefined; isLoading: boolean; } /** Заголовок списка SharePoint по (site_url, list_id) — для отображения «Название (id)» вместо голого GUID. */ export function useSharePointListTitle( client: SharePointListsClient | undefined, - siteUrl: string | undefined | null, - listId: string | undefined | null + siteUrl: string | undefined, + listId: string | undefined ): SharePointListTitleState { - const [title, setTitle] = React.useState(null); + const [title, setTitle] = React.useState(undefined); const [isLoading, setIsLoading] = React.useState(false); React.useEffect(() => { if (!client || !siteUrl?.trim() || !listId?.trim()) { - setTitle(null); + setTitle(undefined); setIsLoading(false); return undefined; } @@ -27,7 +27,7 @@ export function useSharePointListTitle( client.getListTitle(siteUrl.trim(), listId.trim()) .then(result => { if (!cancelled) setTitle(result); }) - .catch(() => { if (!cancelled) setTitle(null); }) + .catch(() => { if (!cancelled) setTitle(undefined); }) .finally(() => { if (!cancelled) setIsLoading(false); }); return () => { cancelled = true; }; diff --git a/frontend/src/webparts/approvalAdmin/hooks/useSharePointSiteTitle.ts b/frontend/src/webparts/approvalAdmin/hooks/useSharePointSiteTitle.ts index 266e02a..1a91c2b 100644 --- a/frontend/src/webparts/approvalAdmin/hooks/useSharePointSiteTitle.ts +++ b/frontend/src/webparts/approvalAdmin/hooks/useSharePointSiteTitle.ts @@ -2,21 +2,21 @@ import * as React from 'react'; import { SharePointListsClient } from '../services/sharePointLists'; export interface SharePointSiteTitleState { - title: string | null; + title: string | undefined; isLoading: boolean; } /** Заголовок сайта SharePoint по его url — для отображения «Название — url» вместо голого url. */ export function useSharePointSiteTitle( client: SharePointListsClient | undefined, - siteUrl: string | undefined | null + siteUrl: string | undefined ): SharePointSiteTitleState { - const [title, setTitle] = React.useState(null); + const [title, setTitle] = React.useState(undefined); const [isLoading, setIsLoading] = React.useState(false); React.useEffect(() => { if (!client || !siteUrl?.trim()) { - setTitle(null); + setTitle(undefined); setIsLoading(false); return undefined; } @@ -26,7 +26,7 @@ export function useSharePointSiteTitle( client.getSiteTitle(siteUrl.trim()) .then(result => { if (!cancelled) setTitle(result); }) - .catch(() => { if (!cancelled) setTitle(null); }) + .catch(() => { if (!cancelled) setTitle(undefined); }) .finally(() => { if (!cancelled) setIsLoading(false); }); return () => { cancelled = true; }; diff --git a/frontend/src/webparts/approvalAdmin/models/dictionaries.ts b/frontend/src/webparts/approvalAdmin/models/dictionaries.ts index 9278960..d81fe99 100644 --- a/frontend/src/webparts/approvalAdmin/models/dictionaries.ts +++ b/frontend/src/webparts/approvalAdmin/models/dictionaries.ts @@ -1,5 +1,6 @@ // Справочники api.tnsu.kz (компании, отделы, проекты) в виде, удобном для UI. // Приведение из «сырых» PascalCase-ответов портала — в services/portalDictionary.ts. +/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт портала api.tnsu.kz */ export interface Company { id: string; diff --git a/frontend/src/webparts/approvalAdmin/models/requestManage.ts b/frontend/src/webparts/approvalAdmin/models/requestManage.ts index 1e49920..ee9dda8 100644 --- a/frontend/src/webparts/approvalAdmin/models/requestManage.ts +++ b/frontend/src/webparts/approvalAdmin/models/requestManage.ts @@ -69,6 +69,7 @@ export interface ManagedRequestPage { export interface ManagedApprovalRow { id: number; ticketId: number; + // eslint-disable-next-line @rushstack/no-new-null -- lookup-поле SharePoint, у ghost-строки (см. handleAddApprover) значения ещё нет approverId: number | null; approverName: string; approverEmail: string; diff --git a/frontend/src/webparts/approvalAdmin/models/requestTypes.ts b/frontend/src/webparts/approvalAdmin/models/requestTypes.ts index 5acb5cd..6700000 100644 --- a/frontend/src/webparts/approvalAdmin/models/requestTypes.ts +++ b/frontend/src/webparts/approvalAdmin/models/requestTypes.ts @@ -1,4 +1,5 @@ // DTO эндпоинта api/request-types (Application/RequestTypes/*.cs). +/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда */ export interface RequestType { id: number; diff --git a/frontend/src/webparts/approvalAdmin/models/resolve.ts b/frontend/src/webparts/approvalAdmin/models/resolve.ts index 3608fd1..1070f61 100644 --- a/frontend/src/webparts/approvalAdmin/models/resolve.ts +++ b/frontend/src/webparts/approvalAdmin/models/resolve.ts @@ -1,5 +1,6 @@ // DTO эндпоинта api/template-routes/resolve // (Application/TemplateRoutes/ResolveTemplateRouteDto.cs). +/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда */ import { RouteStepStatus } from './enums'; diff --git a/frontend/src/webparts/approvalAdmin/models/templateRouteSteps.ts b/frontend/src/webparts/approvalAdmin/models/templateRouteSteps.ts index 07aa361..e569aca 100644 --- a/frontend/src/webparts/approvalAdmin/models/templateRouteSteps.ts +++ b/frontend/src/webparts/approvalAdmin/models/templateRouteSteps.ts @@ -1,5 +1,6 @@ // DTO эндпоинта api/template-routes/{routeId}/steps // (Application/TemplateRouteSteps/*.cs). +/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда (C# nullable-типы сериализуются в null, не в отсутствие поля) */ import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus } from './enums'; diff --git a/frontend/src/webparts/approvalAdmin/models/templateRoutes.ts b/frontend/src/webparts/approvalAdmin/models/templateRoutes.ts index 5447dcc..d4393d6 100644 --- a/frontend/src/webparts/approvalAdmin/models/templateRoutes.ts +++ b/frontend/src/webparts/approvalAdmin/models/templateRoutes.ts @@ -1,4 +1,5 @@ // DTO эндпоинта api/template-routes (Application/TemplateRoutes/*.cs). +/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда */ export interface TemplateRoute { id: number; diff --git a/frontend/src/webparts/approvalAdmin/routing.ts b/frontend/src/webparts/approvalAdmin/routing.ts index 7cc4b40..b968a64 100644 --- a/frontend/src/webparts/approvalAdmin/routing.ts +++ b/frontend/src/webparts/approvalAdmin/routing.ts @@ -54,10 +54,13 @@ const PARAM_KEYS = [ 'aamtype', 'aamreq' ]; +// raw — результат URLSearchParams.get() (DOM API, возвращает string | null). +// eslint-disable-next-line @rushstack/no-new-null function parseTab(raw: string | null): ScreenKey { 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[] { if (!raw) return []; try { diff --git a/frontend/src/webparts/approvalAdmin/services/httpClient.ts b/frontend/src/webparts/approvalAdmin/services/httpClient.ts index 5d82e60..6fcacca 100644 --- a/frontend/src/webparts/approvalAdmin/services/httpClient.ts +++ b/frontend/src/webparts/approvalAdmin/services/httpClient.ts @@ -18,7 +18,7 @@ export class ApiClientError extends Error { } } -export type QueryParams = Record; +export type QueryParams = Record; export interface IHttpClient { get(path: string, query?: QueryParams): Promise; @@ -34,7 +34,7 @@ function buildUrl(baseUrl: string, path: string, query?: QueryParams): string { if (query) { Object.keys(query).forEach(key => { const value = query[key]; - if (value !== undefined && value !== null && value !== '') { + if (value !== undefined && value !== '') { url.searchParams.set(key, String(value)); } }); diff --git a/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts b/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts index 8122f19..b0d8bb7 100644 --- a/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts +++ b/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts @@ -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 { Company, Department, Employee, Project } from '../models'; import { ApiClientError } from './httpClient'; diff --git a/frontend/src/webparts/approvalAdmin/services/sharePointLists.ts b/frontend/src/webparts/approvalAdmin/services/sharePointLists.ts index 65a9174..cb6fa55 100644 --- a/frontend/src/webparts/approvalAdmin/services/sharePointLists.ts +++ b/frontend/src/webparts/approvalAdmin/services/sharePointLists.ts @@ -51,7 +51,7 @@ export function isNumericFieldType(typeAsString: string): boolean { } /** «Название — 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; } @@ -79,8 +79,8 @@ export class SharePointListsClient { // Заголовок сайта по site_url и заголовок списка по (site_url, list_id) бэкенд // не хранит (только сами url) — кэшируем на клиенте, чтобы не дёргать сайт // заново на каждый рендер строки таблицы или переоткрытие формы с тем же значением. - private readonly siteTitleCache = new Map(); - private readonly listTitleCache = new Map(); + private readonly siteTitleCache = new Map(); + private readonly listTitleCache = new Map(); constructor(private readonly context: WebPartContext) {} @@ -90,33 +90,31 @@ export class SharePointListsClient { } /** Заголовок сайта по его url — для отображения рядом с url там, где хранится только он. */ - public async getSiteTitle(siteUrl: string): Promise { - const cached = this.siteTitleCache.get(siteUrl); - if (cached !== undefined) return cached; + public async getSiteTitle(siteUrl: string): Promise { + if (this.siteTitleCache.has(siteUrl)) return this.siteTitleCache.get(siteUrl); try { const web = await spfi(siteUrl).using(SPFx(this.context)).web.select('Title')() as { Title: string }; this.siteTitleCache.set(siteUrl, web.Title); return web.Title; } catch { - this.siteTitleCache.set(siteUrl, null); - return null; + this.siteTitleCache.set(siteUrl, undefined); + return undefined; } } /** Заголовок списка по (site_url, list_id — server-relative url списка) — для отображения там, где хранится только он. */ - public async getListTitle(siteUrl: string, listId: string): Promise { + public async getListTitle(siteUrl: string, listId: string): Promise { const key = `${siteUrl}|${listId}`; - const cached = this.listTitleCache.get(key); - if (cached !== undefined) return cached; + if (this.listTitleCache.has(key)) return this.listTitleCache.get(key); try { const list = await clientFor(this.context, siteUrl).web.getList(listId).select('Title')() as { Title: string }; this.listTitleCache.set(key, list.Title); return list.Title; } catch { - this.listTitleCache.set(key, null); - return null; + this.listTitleCache.set(key, undefined); + return undefined; } } diff --git a/frontend/src/webparts/approvalAdmin/utils/employee.ts b/frontend/src/webparts/approvalAdmin/utils/employee.ts index 6df6244..07fd96e 100644 --- a/frontend/src/webparts/approvalAdmin/utils/employee.ts +++ b/frontend/src/webparts/approvalAdmin/utils/employee.ts @@ -6,7 +6,7 @@ export function toEmployeeOption(employee: Employee): ISearchableOption { 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; const target = email.toLowerCase(); return employees.find(e => e.email.toLowerCase() === target); diff --git a/frontend/src/webparts/approvalAdmin/utils/stepExecutor.ts b/frontend/src/webparts/approvalAdmin/utils/stepExecutor.ts index a94ba2d..d2e3d0f 100644 --- a/frontend/src/webparts/approvalAdmin/utils/stepExecutor.ts +++ b/frontend/src/webparts/approvalAdmin/utils/stepExecutor.ts @@ -3,12 +3,14 @@ import { comparisonOperatorSymbols, headTypeLabels, projectRoleLabels, texts } f import { findEmployeeByEmail } from './employee'; /** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */ +/* eslint-disable @rushstack/no-new-null -- форма зеркалит TemplateRouteStep (nullable JSON-контракт бэкенда) */ export function detectExecutorKind(step: { executorEmail?: string | null; position?: string | null; projectRole?: ProjectRole | null; headType?: HeadType | null; }): StepExecutorKind { + /* eslint-enable @rushstack/no-new-null */ if (step.executorEmail) return 'email'; if (step.position) return 'position'; if (step.projectRole && step.projectRole !== ProjectRole.None) return 'projectRole'; @@ -24,7 +26,7 @@ export interface StepExecutorDisplay { /** Колонка «Исполнитель» в таблице шагов шаблона маршрута. */ export function describeStepExecutor(step: TemplateRouteStep, employees: Employee[]): StepExecutorDisplay { 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 }; } if (step.position) return { primary: step.position };