полное имя вместо email

This commit is contained in:
gorbenko.pavel
2026-07-29 17:33:05 +05:00
parent df3558b63a
commit dffbb81a20
10 changed files with 170 additions and 34 deletions
@@ -47,6 +47,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, currentUse
api={api} api={api}
route={selectedRoute} route={selectedRoute}
requestTypeName={selectedRequestTypeName} requestTypeName={selectedRequestTypeName}
dictionary={dictionary}
onBack={() => setScreen('routes')} onBack={() => setScreen('routes')}
/> />
)} )}
@@ -6,6 +6,8 @@ import { Project, RouteStepStatus } from '../../models';
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts'; import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useResolveRoute } from '../../hooks/useResolveRoute'; import { useResolveRoute } from '../../hooks/useResolveRoute';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
import { import {
Alert, Alert,
AsyncSearchableSelect, AsyncSearchableSelect,
@@ -18,8 +20,8 @@ import {
PageHeader, PageHeader,
Panel, Panel,
PrimaryCell, PrimaryCell,
Select, SearchableSelect,
TextInput Select
} from '../ui'; } from '../ui';
export interface IResolveScreenProps { export interface IResolveScreenProps {
@@ -41,6 +43,7 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
const [status, setStatus] = React.useState<RouteStepStatus>(RouteStepStatus.Approval); const [status, setStatus] = React.useState<RouteStepStatus>(RouteStepStatus.Approval);
const resolved = useResolveRoute(api); const resolved = useResolveRoute(api);
const employees = usePortalEmployees(dictionary);
const searchProjects = React.useCallback( const searchProjects = React.useCallback(
(query: string, page: number) => { (query: string, page: number) => {
@@ -87,7 +90,17 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
label={texts.resolve.userEmailLabel} label={texts.resolve.userEmailLabel}
hint={currentUserEmail ? texts.resolve.userEmailHint(currentUserEmail) : texts.resolve.userEmailHintUnknown} hint={currentUserEmail ? texts.resolve.userEmailHint(currentUserEmail) : texts.resolve.userEmailHintUnknown}
> >
<TextInput value={userEmail} onChange={e => setUserEmail(e.target.value)} placeholder={texts.resolve.userEmailPlaceholder} /> <SearchableSelect
options={employees.items.map(toEmployeeOption)}
value={userEmail}
loading={employees.isLoading}
allowClear
clearLabel={texts.resolve.userEmailClear}
placeholder={texts.resolve.userEmailPlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty}
onChange={setUserEmail}
/>
</Field> </Field>
<Field label={texts.resolve.projectLabel} hint={texts.resolve.projectHint}> <Field label={texts.resolve.projectLabel} hint={texts.resolve.projectHint}>
<AsyncSearchableSelect<Project> <AsyncSearchableSelect<Project>
@@ -133,12 +146,16 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
{ {
key: 'executor', key: 'executor',
header: texts.resolve.columnExecutor, header: texts.resolve.columnExecutor,
render: r => ( render: r => {
<div> const employee = findEmployeeByEmail(employees.items, r.executorEmail);
<PrimaryCell>{r.executorEmail ?? texts.common.empty}</PrimaryCell> return (
{r.warning && <CellNote kind="warn">{r.warning}</CellNote>} <div>
</div> <PrimaryCell>{employee ? employee.fio : (r.executorEmail ?? texts.common.empty)}</PrimaryCell>
) {employee && <CellNote>{employee.email}</CellNote>}
{r.warning && <CellNote kind="warn">{r.warning}</CellNote>}
</div>
);
}
}, },
{ {
key: 'position', key: 'position',
@@ -1,5 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { import {
Employee,
HeadType, HeadType,
ProjectRole, ProjectRole,
RouteStepStatus, RouteStepStatus,
@@ -9,9 +10,10 @@ import {
} from '../../models'; } from '../../models';
import { headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts'; import { headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
import { detectExecutorKind } from '../../utils/stepExecutor'; import { detectExecutorKind } from '../../utils/stepExecutor';
import { toEmployeeOption } from '../../utils/employee';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { ApprovalRouteApi } from '../../services/api'; 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 { export interface IRouteStepFormModalProps {
api: ApprovalRouteApi; api: ApprovalRouteApi;
@@ -19,6 +21,8 @@ export interface IRouteStepFormModalProps {
/** Undefined — создание нового шага, иначе редактирование существующего. */ /** Undefined — создание нового шага, иначе редактирование существующего. */
editing?: TemplateRouteStep; editing?: TemplateRouteStep;
nextOrderNo: number; nextOrderNo: number;
employees: Employee[];
employeesLoading: boolean;
onSaved: (savedStep: TemplateRouteStep) => void; onSaved: (savedStep: TemplateRouteStep) => void;
onClose: () => void; onClose: () => void;
} }
@@ -71,7 +75,16 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
} }
/** Форма создания/редактирования шага маршрута — вынесена из RouteStepsScreen. */ /** Форма создания/редактирования шага маршрута — вынесена из RouteStepsScreen. */
export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({ api, route, editing, nextOrderNo, onSaved, onClose }) => { export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
api,
route,
editing,
nextOrderNo,
employees,
employeesLoading,
onSaved,
onClose
}) => {
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo)); const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
const submit = useAsyncSubmit(); const submit = useAsyncSubmit();
@@ -82,7 +95,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({ api, ro
return; return;
} }
if (form.kind === 'email' && !form.executorEmail.trim()) { if (form.kind === 'email' && !form.executorEmail.trim()) {
submit.fail(texts.routeSteps.validationEmail); submit.fail(texts.routeSteps.validationExecutor);
return; return;
} }
if (form.kind === 'position' && !form.position.trim()) { if (form.kind === 'position' && !form.position.trim()) {
@@ -157,11 +170,15 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({ api, ro
</Field> </Field>
{form.kind === 'email' && ( {form.kind === 'email' && (
<Field label={texts.routeSteps.emailLabel} required full> <Field label={texts.routeSteps.executorLabel} required full>
<TextInput <SearchableSelect
options={employees.map(toEmployeeOption)}
value={form.executorEmail} value={form.executorEmail}
onChange={e => setForm({ ...form, executorEmail: e.target.value })} loading={employeesLoading}
placeholder={texts.routeSteps.emailPlaceholder} placeholder={texts.routeSteps.executorPlaceholder}
searchPlaceholder={texts.select.employeeSearchPlaceholder}
emptyMessage={texts.select.employeesEmpty}
onChange={executorEmail => setForm({ ...form, executorEmail })}
/> />
</Field> </Field>
)} )}
@@ -1,17 +1,20 @@
import * as React from 'react'; import * as React from 'react';
import { RouteStepFormModal } from './RouteStepFormModal'; import { RouteStepFormModal } from './RouteStepFormModal';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models'; import { RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts'; import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts';
import { describeStepExecutor } from '../../utils/stepExecutor'; import { describeStepExecutor } from '../../utils/stepExecutor';
import { useRouteSteps } from '../../hooks/useRouteSteps'; import { useRouteSteps } from '../../hooks/useRouteSteps';
import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { import {
ActionsCell, ActionsCell,
Alert, Alert,
Badge, Badge,
Breadcrumbs, Breadcrumbs,
Button, Button,
CellNote,
ConfirmDialog, ConfirmDialog,
DataTable, DataTable,
IconButton, IconButton,
@@ -23,11 +26,14 @@ export interface IRouteStepsScreenProps {
api: ApprovalRouteApi; api: ApprovalRouteApi;
route: TemplateRoute; route: TemplateRoute;
requestTypeName: string; requestTypeName: string;
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
dictionary?: PortalDictionaryClient;
onBack: () => void; onBack: () => void;
} }
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestTypeName, onBack }) => { export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestTypeName, dictionary, onBack }) => {
const steps = useRouteSteps(api, route.id); const steps = useRouteSteps(api, route.id);
const employees = usePortalEmployees(dictionary);
const [savedWarning, setSavedWarning] = React.useState<string | undefined>(); const [savedWarning, setSavedWarning] = React.useState<string | undefined>();
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>(); const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
@@ -86,7 +92,15 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
{ {
key: 'executor', key: 'executor',
header: texts.routeSteps.columnExecutor, header: texts.routeSteps.columnExecutor,
render: r => <PrimaryCell>{describeStepExecutor(r)}</PrimaryCell> render: r => {
const executor = describeStepExecutor(r, employees.items);
return (
<div>
<PrimaryCell>{executor.primary}</PrimaryCell>
{executor.secondary && <CellNote>{executor.secondary}</CellNote>}
</div>
);
}
}, },
{ {
key: 'status', key: 'status',
@@ -128,6 +142,8 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
route={route} route={route}
editing={editing} editing={editing}
nextOrderNo={nextOrderNo} nextOrderNo={nextOrderNo}
employees={employees.items}
employeesLoading={employees.isLoading}
onSaved={handleSaved} onSaved={handleSaved}
onClose={() => setShowForm(false)} onClose={() => setShowForm(false)}
/> />
@@ -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 };
}
@@ -22,3 +22,10 @@ export interface Project {
code: string | null; code: string | null;
status: string | null; status: string | null;
} }
// api.tnsu.kz/Employee/All. Ключ для полей executorEmail/userEmail на нашем
// бэкенде — email, но вводить его руками неудобно, поэтому в UI ищем по ФИО.
export interface Employee {
fio: string;
email: string;
}
@@ -1,5 +1,5 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http'; import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { Company, Department, Project } from '../models'; import { Company, Department, Employee, Project } from '../models';
import { ApiClientError } from './httpClient'; import { ApiClientError } from './httpClient';
// Справочники берём напрямую из api.tnsu.kz — тем же AAD-токеном, что и наш API: // Справочники берём напрямую из api.tnsu.kz — тем же AAD-токеном, что и наш API:
@@ -45,6 +45,14 @@ export interface IProjectSearchResult {
hasMore: boolean; hasMore: boolean;
} }
// GET /Employee/All — уже camelCase, как /Project (не PascalCase, как /Dictionary/*).
// Строка на каждого сотрудника в каждом его отделе, поэтому один email может
// повторяться несколько раз; сотрудники без email в подборе исполнителя бесполезны.
interface IPortalEmployee {
fio: string;
email: string | null;
}
async function getJson<T>(client: AadHttpClient, baseUrl: string, path: string): Promise<T> { async function getJson<T>(client: AadHttpClient, baseUrl: string, path: string): Promise<T> {
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
const url = new URL(path.replace(/^\//, ''), normalizedBase).toString(); const url = new URL(path.replace(/^\//, ''), normalizedBase).toString();
@@ -93,4 +101,14 @@ export class PortalDictionaryClient {
hasMore: raw.page < raw.totalPages hasMore: raw.page < raw.totalPages
}; };
} }
public async employees(): Promise<Employee[]> {
const raw = await getJson<IPortalEmployee[]>(this.client, this.baseUrl, '/Employee/All');
const byEmail = new Map<string, Employee>();
(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());
}
} }
+10 -7
View File
@@ -172,13 +172,13 @@ export const texts = {
statusLabel: 'Статус шага', statusLabel: 'Статус шага',
kindLabel: 'Кто исполнитель', kindLabel: 'Кто исполнитель',
kindHint: 'Должно быть заполнено ровно одно из полей.', kindHint: 'Должно быть заполнено ровно одно из полей.',
kindEmail: 'Конкретный сотрудник (email)', kindEmail: 'Конкретный сотрудник',
kindPosition: 'По должности в компании инициатора', kindPosition: 'По должности в компании инициатора',
kindProjectRole: 'По роли в проекте', kindProjectRole: 'По роли в проекте',
kindProjectRoleDisabled: 'Недоступно: маршрут не учитывает проект (по проекту = нет).', kindProjectRoleDisabled: 'Недоступно: маршрут не учитывает проект (по проекту = нет).',
kindHeadType: 'По типу руководителя', kindHeadType: 'По типу руководителя',
emailLabel: 'Email исполнителя', executorLabel: 'Сотрудник',
emailPlaceholder: 'user@tnsu.kz', executorPlaceholder: 'Выберите сотрудника',
positionLabel: 'Должность', positionLabel: 'Должность',
positionHint: 'Ищется в справочнике api.tnsu.kz по компании инициатора.', positionHint: 'Ищется в справочнике api.tnsu.kz по компании инициатора.',
positionPlaceholder: 'Главный инженер', positionPlaceholder: 'Главный инженер',
@@ -187,7 +187,7 @@ export const texts = {
reassignLabel: 'Переназначение', reassignLabel: 'Переназначение',
reassignCheckbox: 'Разрешить переназначать другим согласующим', reassignCheckbox: 'Разрешить переназначать другим согласующим',
validationOrderNo: 'Порядковый номер шага обязателен.', validationOrderNo: 'Порядковый номер шага обязателен.',
validationEmail: 'Укажите email исполнителя.', validationExecutor: 'Выберите сотрудника-исполнителя.',
validationPosition: 'Укажите должность.', validationPosition: 'Укажите должность.',
deleteTitle: 'Удалить шаг?', deleteTitle: 'Удалить шаг?',
deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.` deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.`
@@ -199,10 +199,11 @@ export const texts = {
title: 'Проверка разрешения маршрута', title: 'Проверка разрешения маршрута',
requestTypeLabel: 'Тип заявки', requestTypeLabel: 'Тип заявки',
requestTypePlaceholder: 'Выберите тип заявки', requestTypePlaceholder: 'Выберите тип заявки',
userEmailLabel: 'Email инициатора', userEmailLabel: 'Инициатор',
userEmailHint: (currentUserEmail: string): string => `Пусто = текущий пользователь (${currentUserEmail}).`, userEmailHint: (currentUserEmail: string): string => `Пусто = текущий пользователь (${currentUserEmail}).`,
userEmailHintUnknown: 'Пусто = текущий пользователь из токена.', userEmailHintUnknown: 'Пусто = текущий пользователь из токена.',
userEmailPlaceholder: 'user@tnsu.kz', userEmailPlaceholder: 'Выберите сотрудника',
userEmailClear: 'Текущий пользователь',
projectLabel: 'Проект', projectLabel: 'Проект',
projectHint: 'Заполните, если маршрут зависит от проекта (ByProject = true).', projectHint: 'Заполните, если маршрут зависит от проекта (ByProject = true).',
projectPlaceholder: 'Не выбран', projectPlaceholder: 'Не выбран',
@@ -235,6 +236,8 @@ export const texts = {
notSelected: 'Не выбрано', notSelected: 'Не выбрано',
missingInDictionary: 'Нет в справочнике', missingInDictionary: 'Нет в справочнике',
loadingDictionary: 'Загрузка справочника...', loadingDictionary: 'Загрузка справочника...',
searching: 'Поиск...' searching: 'Поиск...',
employeeSearchPlaceholder: 'Поиск по ФИО или email...',
employeesEmpty: 'Сотрудники не найдены в справочнике'
} }
}; };
@@ -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);
}
@@ -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 { headTypeLabels, projectRoleLabels, texts } from '../texts';
import { findEmployeeByEmail } from './employee';
/** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */ /** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */
export function detectExecutorKind(step: { export function detectExecutorKind(step: {
@@ -14,15 +15,24 @@ export function detectExecutorKind(step: {
return 'headType'; return 'headType';
} }
/** Одна строка для колонки «Исполнитель» в таблице шагов шаблона маршрута. */ export interface StepExecutorDisplay {
export function describeStepExecutor(step: TemplateRouteStep): string { primary: string;
if (step.executorEmail) return step.executorEmail; /** Email под ФИО — только когда исполнитель задан конкретным сотрудником и тот найден в справочнике. */
if (step.position) return step.position; 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) { 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) { 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 };
} }