From dffbb81a20d672daa6cf2df252b499a1400fc333 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 29 Jul 2026 17:33:05 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D0=B5=20=D0=B8?= =?UTF-8?q?=D0=BC=D1=8F=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20email?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../approvalAdmin/components/app/AdminApp.tsx | 1 + .../components/screens/ResolveScreen.tsx | 35 ++++++++++++++----- .../components/screens/RouteStepFormModal.tsx | 31 ++++++++++++---- .../components/screens/RouteStepsScreen.tsx | 20 +++++++++-- .../approvalAdmin/hooks/usePortalEmployees.ts | 34 ++++++++++++++++++ .../approvalAdmin/models/dictionaries.ts | 7 ++++ .../services/portalDictionary.ts | 20 ++++++++++- frontend/src/webparts/approvalAdmin/texts.ts | 17 +++++---- .../webparts/approvalAdmin/utils/employee.ts | 13 +++++++ .../approvalAdmin/utils/stepExecutor.ts | 26 +++++++++----- 10 files changed, 170 insertions(+), 34 deletions(-) create mode 100644 frontend/src/webparts/approvalAdmin/hooks/usePortalEmployees.ts create mode 100644 frontend/src/webparts/approvalAdmin/utils/employee.ts diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index 857e417..514922f 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -47,6 +47,7 @@ export const AdminApp: React.FC = ({ api, dictionary, currentUse api={api} route={selectedRoute} requestTypeName={selectedRequestTypeName} + dictionary={dictionary} onBack={() => setScreen('routes')} /> )} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx index 4279103..6955232 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ResolveScreen.tsx @@ -6,6 +6,8 @@ import { Project, RouteStepStatus } from '../../models'; import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts'; import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useResolveRoute } from '../../hooks/useResolveRoute'; +import { usePortalEmployees } from '../../hooks/usePortalEmployees'; +import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee'; import { Alert, AsyncSearchableSelect, @@ -18,8 +20,8 @@ import { PageHeader, Panel, PrimaryCell, - Select, - TextInput + SearchableSelect, + Select } from '../ui'; export interface IResolveScreenProps { @@ -41,6 +43,7 @@ export const ResolveScreen: React.FC = ({ api, dictionary, const [status, setStatus] = React.useState(RouteStepStatus.Approval); const resolved = useResolveRoute(api); + const employees = usePortalEmployees(dictionary); const searchProjects = React.useCallback( (query: string, page: number) => { @@ -87,7 +90,17 @@ export const ResolveScreen: React.FC = ({ api, dictionary, label={texts.resolve.userEmailLabel} hint={currentUserEmail ? texts.resolve.userEmailHint(currentUserEmail) : texts.resolve.userEmailHintUnknown} > - setUserEmail(e.target.value)} placeholder={texts.resolve.userEmailPlaceholder} /> + @@ -133,12 +146,16 @@ export const ResolveScreen: React.FC = ({ api, dictionary, { key: 'executor', header: texts.resolve.columnExecutor, - render: r => ( -
- {r.executorEmail ?? texts.common.empty} - {r.warning && {r.warning}} -
- ) + render: r => { + const employee = findEmployeeByEmail(employees.items, r.executorEmail); + return ( +
+ {employee ? employee.fio : (r.executorEmail ?? texts.common.empty)} + {employee && {employee.email}} + {r.warning && {r.warning}} +
+ ); + } }, { key: 'position', diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx index 8173cef..8b51da7 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { + Employee, HeadType, ProjectRole, RouteStepStatus, @@ -9,9 +10,10 @@ import { } from '../../models'; import { headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts'; import { detectExecutorKind } from '../../utils/stepExecutor'; +import { toEmployeeOption } from '../../utils/employee'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { ApprovalRouteApi } from '../../services/api'; -import { Checkbox, Field, FormGrid, FormModal, RadioGroup, Select, TextInput } from '../ui'; +import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui'; export interface IRouteStepFormModalProps { api: ApprovalRouteApi; @@ -19,6 +21,8 @@ export interface IRouteStepFormModalProps { /** Undefined — создание нового шага, иначе редактирование существующего. */ editing?: TemplateRouteStep; nextOrderNo: number; + employees: Employee[]; + employeesLoading: boolean; onSaved: (savedStep: TemplateRouteStep) => void; onClose: () => void; } @@ -71,7 +75,16 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number } /** Форма создания/редактирования шага маршрута — вынесена из RouteStepsScreen. */ -export const RouteStepFormModal: React.FC = ({ api, route, editing, nextOrderNo, onSaved, onClose }) => { +export const RouteStepFormModal: React.FC = ({ + api, + route, + editing, + nextOrderNo, + employees, + employeesLoading, + onSaved, + onClose +}) => { const [form, setForm] = React.useState(() => toFormState(editing, nextOrderNo)); const submit = useAsyncSubmit(); @@ -82,7 +95,7 @@ export const RouteStepFormModal: React.FC = ({ api, ro return; } if (form.kind === 'email' && !form.executorEmail.trim()) { - submit.fail(texts.routeSteps.validationEmail); + submit.fail(texts.routeSteps.validationExecutor); return; } if (form.kind === 'position' && !form.position.trim()) { @@ -157,11 +170,15 @@ export const RouteStepFormModal: React.FC = ({ api, ro
{form.kind === 'email' && ( - - + setForm({ ...form, executorEmail: e.target.value })} - placeholder={texts.routeSteps.emailPlaceholder} + loading={employeesLoading} + placeholder={texts.routeSteps.executorPlaceholder} + searchPlaceholder={texts.select.employeeSearchPlaceholder} + emptyMessage={texts.select.employeesEmpty} + onChange={executorEmail => setForm({ ...form, executorEmail })} /> )} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx index fdaffeb..087f8bd 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepsScreen.tsx @@ -1,17 +1,20 @@ import * as React from 'react'; import { RouteStepFormModal } from './RouteStepFormModal'; import { ApprovalRouteApi } from '../../services/api'; +import { PortalDictionaryClient } from '../../services/portalDictionary'; import { RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models'; import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts'; import { describeStepExecutor } from '../../utils/stepExecutor'; import { useRouteSteps } from '../../hooks/useRouteSteps'; import { useDeleteTarget } from '../../hooks/useDeleteTarget'; +import { usePortalEmployees } from '../../hooks/usePortalEmployees'; import { ActionsCell, Alert, Badge, Breadcrumbs, Button, + CellNote, ConfirmDialog, DataTable, IconButton, @@ -23,11 +26,14 @@ export interface IRouteStepsScreenProps { api: ApprovalRouteApi; route: TemplateRoute; requestTypeName: string; + /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ + dictionary?: PortalDictionaryClient; onBack: () => void; } -export const RouteStepsScreen: React.FC = ({ api, route, requestTypeName, onBack }) => { +export const RouteStepsScreen: React.FC = ({ api, route, requestTypeName, dictionary, onBack }) => { const steps = useRouteSteps(api, route.id); + const employees = usePortalEmployees(dictionary); const [savedWarning, setSavedWarning] = React.useState(); const [editing, setEditing] = React.useState(); @@ -86,7 +92,15 @@ export const RouteStepsScreen: React.FC = ({ api, route, { key: 'executor', header: texts.routeSteps.columnExecutor, - render: r => {describeStepExecutor(r)} + render: r => { + const executor = describeStepExecutor(r, employees.items); + return ( +
+ {executor.primary} + {executor.secondary && {executor.secondary}} +
+ ); + } }, { key: 'status', @@ -128,6 +142,8 @@ export const RouteStepsScreen: React.FC = ({ api, route, route={route} editing={editing} nextOrderNo={nextOrderNo} + employees={employees.items} + employeesLoading={employees.isLoading} onSaved={handleSaved} onClose={() => setShowForm(false)} /> diff --git a/frontend/src/webparts/approvalAdmin/hooks/usePortalEmployees.ts b/frontend/src/webparts/approvalAdmin/hooks/usePortalEmployees.ts new file mode 100644 index 0000000..8ca949b --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/hooks/usePortalEmployees.ts @@ -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([]); + const [isLoading, setIsLoading] = React.useState(true); + const [error, setError] = React.useState(); + + 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 }; +} diff --git a/frontend/src/webparts/approvalAdmin/models/dictionaries.ts b/frontend/src/webparts/approvalAdmin/models/dictionaries.ts index 764659f..6ecaa14 100644 --- a/frontend/src/webparts/approvalAdmin/models/dictionaries.ts +++ b/frontend/src/webparts/approvalAdmin/models/dictionaries.ts @@ -22,3 +22,10 @@ export interface Project { code: string | null; status: string | null; } + +// api.tnsu.kz/Employee/All. Ключ для полей executorEmail/userEmail на нашем +// бэкенде — email, но вводить его руками неудобно, поэтому в UI ищем по ФИО. +export interface Employee { + fio: string; + email: string; +} diff --git a/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts b/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts index 55afd08..55de139 100644 --- a/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts +++ b/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts @@ -1,5 +1,5 @@ import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http'; -import { Company, Department, Project } from '../models'; +import { Company, Department, Employee, Project } from '../models'; import { ApiClientError } from './httpClient'; // Справочники берём напрямую из api.tnsu.kz — тем же AAD-токеном, что и наш API: @@ -45,6 +45,14 @@ export interface IProjectSearchResult { hasMore: boolean; } +// GET /Employee/All — уже camelCase, как /Project (не PascalCase, как /Dictionary/*). +// Строка на каждого сотрудника в каждом его отделе, поэтому один email может +// повторяться несколько раз; сотрудники без email в подборе исполнителя бесполезны. +interface IPortalEmployee { + fio: string; + email: string | null; +} + async function getJson(client: AadHttpClient, baseUrl: string, path: string): Promise { const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; const url = new URL(path.replace(/^\//, ''), normalizedBase).toString(); @@ -93,4 +101,14 @@ export class PortalDictionaryClient { hasMore: raw.page < raw.totalPages }; } + + public async employees(): Promise { + const raw = await getJson(this.client, this.baseUrl, '/Employee/All'); + + const byEmail = new Map(); + (raw ?? []).forEach(x => { + if (x.email && !byEmail.has(x.email)) byEmail.set(x.email, { fio: x.fio, email: x.email }); + }); + return Array.from(byEmail.values()); + } } diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index 3122a40..b7a80dc 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -172,13 +172,13 @@ export const texts = { statusLabel: 'Статус шага', kindLabel: 'Кто исполнитель', kindHint: 'Должно быть заполнено ровно одно из полей.', - kindEmail: 'Конкретный сотрудник (email)', + kindEmail: 'Конкретный сотрудник', kindPosition: 'По должности в компании инициатора', kindProjectRole: 'По роли в проекте', kindProjectRoleDisabled: 'Недоступно: маршрут не учитывает проект (по проекту = нет).', kindHeadType: 'По типу руководителя', - emailLabel: 'Email исполнителя', - emailPlaceholder: 'user@tnsu.kz', + executorLabel: 'Сотрудник', + executorPlaceholder: 'Выберите сотрудника', positionLabel: 'Должность', positionHint: 'Ищется в справочнике api.tnsu.kz по компании инициатора.', positionPlaceholder: 'Главный инженер', @@ -187,7 +187,7 @@ export const texts = { reassignLabel: 'Переназначение', reassignCheckbox: 'Разрешить переназначать другим согласующим', validationOrderNo: 'Порядковый номер шага обязателен.', - validationEmail: 'Укажите email исполнителя.', + validationExecutor: 'Выберите сотрудника-исполнителя.', validationPosition: 'Укажите должность.', deleteTitle: 'Удалить шаг?', deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.` @@ -199,10 +199,11 @@ export const texts = { title: 'Проверка разрешения маршрута', requestTypeLabel: 'Тип заявки', requestTypePlaceholder: 'Выберите тип заявки', - userEmailLabel: 'Email инициатора', + userEmailLabel: 'Инициатор', userEmailHint: (currentUserEmail: string): string => `Пусто = текущий пользователь (${currentUserEmail}).`, userEmailHintUnknown: 'Пусто = текущий пользователь из токена.', - userEmailPlaceholder: 'user@tnsu.kz', + userEmailPlaceholder: 'Выберите сотрудника', + userEmailClear: 'Текущий пользователь', projectLabel: 'Проект', projectHint: 'Заполните, если маршрут зависит от проекта (ByProject = true).', projectPlaceholder: 'Не выбран', @@ -235,6 +236,8 @@ export const texts = { notSelected: 'Не выбрано', missingInDictionary: 'Нет в справочнике', loadingDictionary: 'Загрузка справочника...', - searching: 'Поиск...' + searching: 'Поиск...', + employeeSearchPlaceholder: 'Поиск по ФИО или email...', + employeesEmpty: 'Сотрудники не найдены в справочнике' } }; diff --git a/frontend/src/webparts/approvalAdmin/utils/employee.ts b/frontend/src/webparts/approvalAdmin/utils/employee.ts new file mode 100644 index 0000000..6df6244 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/utils/employee.ts @@ -0,0 +1,13 @@ +import { Employee } from '../models'; +import { ISearchableOption } from '../components/ui'; + +/** Опция для SearchableSelect: ФИО — основная строка, email — маленькая подпись под ней. */ +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 { + 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 f03daa2..36b61e2 100644 --- a/frontend/src/webparts/approvalAdmin/utils/stepExecutor.ts +++ b/frontend/src/webparts/approvalAdmin/utils/stepExecutor.ts @@ -1,5 +1,6 @@ -import { HeadType, ProjectRole, StepExecutorKind, TemplateRouteStep } from '../models'; +import { Employee, HeadType, ProjectRole, StepExecutorKind, TemplateRouteStep } from '../models'; import { headTypeLabels, projectRoleLabels, texts } from '../texts'; +import { findEmployeeByEmail } from './employee'; /** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */ export function detectExecutorKind(step: { @@ -14,15 +15,24 @@ export function detectExecutorKind(step: { return 'headType'; } -/** Одна строка для колонки «Исполнитель» в таблице шагов шаблона маршрута. */ -export function describeStepExecutor(step: TemplateRouteStep): string { - if (step.executorEmail) return step.executorEmail; - if (step.position) return step.position; +export interface StepExecutorDisplay { + primary: string; + /** Email под ФИО — только когда исполнитель задан конкретным сотрудником и тот найден в справочнике. */ + secondary?: string; +} + +/** Колонка «Исполнитель» в таблице шагов шаблона маршрута. */ +export function describeStepExecutor(step: TemplateRouteStep, employees: Employee[]): StepExecutorDisplay { + if (step.executorEmail) { + const employee = findEmployeeByEmail(employees, step.executorEmail); + return employee ? { primary: employee.fio, secondary: employee.email } : { primary: step.executorEmail }; + } + if (step.position) return { primary: step.position }; if (step.projectRole && step.projectRole !== ProjectRole.None) { - return texts.routeSteps.executorRole(projectRoleLabels[step.projectRole]); + return { primary: texts.routeSteps.executorRole(projectRoleLabels[step.projectRole]) }; } if (step.headType && step.headType !== HeadType.None) { - return texts.routeSteps.executorHead(headTypeLabels[step.headType]); + return { primary: texts.routeSteps.executorHead(headTypeLabels[step.headType]) }; } - return texts.common.empty; + return { primary: texts.common.empty }; }