полное имя вместо email
This commit is contained in:
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user