Merge remote-tracking branch 'origin/main' into archive

# Conflicts:
#	src/webparts/requestPortal/RequestPortalWebPart.ts
#	src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx
#	src/webparts/requestPortal/forms/Clearance/submit.ts
This commit is contained in:
gorbenko.pavel
2026-08-18 14:16:45 +05:00
45 changed files with 1787 additions and 265 deletions
+7
View File
@@ -295,6 +295,13 @@ module.exports = {
'@microsoft/spfx/no-require-ensure': 2,
}
},
{
// Файлы локализации SPFx (loc/*.js) — это AMD-модули: их подключает
// загрузчик страницы через define(), а не бандлер. Без объявления среды
// no-undef считает define необъявленной переменной.
files: ['**/loc/*.js'],
env: { amd: true }
},
{
// For unit tests, we can be a little bit less strict. The settings below revise the
// defaults specified in the extended configurations, as well as above.
@@ -3,27 +3,19 @@
"id": "caca4ff9-050c-4643-936c-d0f29a0f9532",
"alias": "RequestPortalWebPart",
"componentType": "WebPart",
// The "*" signifies that the version should be taken from the package.json
"version": "*",
"manifestVersion": 2,
// If true, the component can only be installed on sites where Custom Script is allowed.
// Components that allow authors to embed arbitrary script code should set this to true.
// https://support.office.com/en-us/article/Turn-scripting-capabilities-on-or-off-1f2c515f-5d7e-448a-9fd7-835da935584f
"requiresCustomScript": false,
"supportedHosts": ["SharePointWebPart", "TeamsPersonalApp", "TeamsTab", "SharePointFullPage"],
"supportsThemeVariants": true,
"supportsFullBleed": true,
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Advanced
"groupId": "5c03119e-3074-46fd-976b-c60198311f70",
"group": { "default": "Advanced" },
"title": { "default": "request-portal" },
"description": { "default": "request-portal description" },
"officeFabricIconFontName": "Page",
"properties": {
"description": "request-portal"
}
"properties": {}
}]
}
@@ -1,11 +1,7 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
type IPropertyPaneConfiguration,
PropertyPaneDropdown,
PropertyPaneTextField
} from '@microsoft/sp-property-pane';
import { type IPropertyPaneConfiguration, PropertyPaneDropdown } from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { IReadonlyTheme } from '@microsoft/sp-component-base';
@@ -14,15 +10,22 @@ import RequestPortal from './components/RequestPortal';
import { IRequestPortalProps } from './components/IRequestPortalProps';
import { initSP } from './shared/pnp/pnpjs';
import { installErrorLogging } from './shared/diagnostics';
import { setImpersonatedEmail } from './shared/impersonation';
import { setImpersonatedIdentity } from './shared/impersonation';
import { APPROVAL_API_BASE_URL_LOCAL, APPROVAL_API_BASE_URL_PROD, setApprovalApiBaseUrl } from './shared/config';
import { initListPartitions } from './shared/approval/listPartitions';
import { ErrorOverlay } from './shared/ui';
import { PropertyPaneDebugUser } from './propertyPane/PropertyPaneDebugUser';
export interface IRequestPortalWebPartProps {
description: string;
/** Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто. */
debugUserEmail: string;
/**
* Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто.
* Одно поле на оба случая: e-mail, введённый напрямую, хранится как есть;
* ФИО, найденное поиском по имени (см. propertyPane/DebugUserField), после
* резолва заменяется собой же на найденный e-mail — отдельное поле под
* «сырой» ввод не нужно, DebugUserField ведёт его в своём локальном состоянии.
*/
debugUserQuery: string;
debugUserDisplayName: string;
/** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */
approvalApiBaseUrl: string;
}
@@ -35,7 +38,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
public render(): void {
// Именно здесь, а не в onInit: свойство меняется в панели без перезапуска
// веб-части, и render вызывается на каждое такое изменение.
setImpersonatedEmail(this.properties.debugUserEmail);
setImpersonatedIdentity(this.properties.debugUserQuery, this.properties.debugUserDisplayName);
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
@@ -43,7 +46,11 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
ErrorOverlay,
undefined,
React.createElement<IRequestPortalProps>(RequestPortal, {
description: this.properties.description,
// key на подменённом e-mail: смена подмены в property pane должна
// пересобрать раздел заново — иначе счётчики в боковом меню и открытые
// таблицы остались бы от прежнего пользователя (их эффекты завязаны на
// монтирование/context, а не на identity подмены).
key: this.properties.debugUserQuery || '__self__',
isDarkTheme: this._isDarkTheme,
environmentMessage: this._environmentMessage,
hasTeamsContext: !!this.context.sdks.microsoftTeams,
@@ -80,7 +87,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
if (!!this.context.sdks.microsoftTeams) { // running in Teams, office.com or Outlook
return this.context.sdks.microsoftTeams.teamsJs.app.getContext()
.then(context => {
let environmentMessage: string = '';
let environmentMessage: string;
switch (context.app.host.name) {
case 'Office': // running in Office
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOffice : strings.AppOfficeEnvironment;
@@ -140,13 +147,29 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
{
groupName: strings.BasicGroupName,
groupFields: [
PropertyPaneTextField('description', {
label: strings.DescriptionFieldLabel
}),
PropertyPaneTextField('debugUserEmail', {
label: strings.DebugUserEmailFieldLabel,
description: strings.DebugUserEmailFieldDescription,
placeholder: 'employee@tnsu.kz'
PropertyPaneDebugUser('debugUserQuery', {
label: strings.DebugUserFieldLabel,
description: strings.DebugUserFieldDescription,
context: this.context,
initialQuery: this.properties.debugUserQuery,
onResolved: (query, email, displayName) => {
// Сохраняем ТОЛЬКО e-mail (введённый напрямую или найденный по
// ФИО) — свойство одно и то же используется как e-mail для
// подмены (см. render() выше), так что писать в него сырое ФИО
// нельзя. Пустой query — поле очистили, подмену выключаем.
// А вот «email пуст, но query не пуст» — ФИО ещё не нашлось
// (недописано/не существует): ничего не трогаем, иначе неудачный
// промежуточный поиск молча выключил бы уже работающую подмену.
if (email) {
this.properties.debugUserQuery = email;
this.properties.debugUserDisplayName = displayName;
this.render();
} else if (!query) {
this.properties.debugUserQuery = '';
this.properties.debugUserDisplayName = '';
this.render();
}
}
})
]
},
@@ -1,7 +1,6 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
export interface IRequestPortalProps {
description: string;
isDarkTheme: boolean;
environmentMessage: string;
hasTeamsContext: boolean;
@@ -50,15 +50,26 @@
// Раскладка: боковой рейл (side) на всю высоту + основная область (main).
.layout {
display: flex;
align-items: stretch; // рейл тянется на высоту контента
align-items: flex-start; // рейл больше НЕ тянется по контенту — см. .side
width: 100%;
min-height: 78vh; // «присутствие» рейла даже когда контента мало
}
// Рейл-колонка: сам Sidebar тянется по высоте (flex-стретч).
// Рейл-колонка: раньше тянулась по высоте .main (align-items: stretch), из-за
// чего высота рейла плавала вместе с содержимым соседней колонки — например, у
// вкладки «Инициированные мной» появляется доп. фильтр по статусу, .main
// становится чуть выше, и градиентный фон Sidebar (посчитанный от своей же
// высоты) заметно менял оттенок. Прибиваем рейл к высоте окна (sticky + 100vh):
// теперь он не зависит от того, что показано рядом. На мобильном брейкпоинте
// сам Sidebar уходит в position:fixed (см. @tnsu/ui-kit) — там эти правила уже
// не действуют.
.side {
flex: 0 0 auto;
display: flex;
position: sticky;
top: 0;
align-self: flex-start;
height: 100vh;
}
.main {
@@ -20,7 +20,7 @@ import {
OwnTab,
} from '../shared/routing';
import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents';
import { getImpersonatedEmail } from '../shared/impersonation';
import { getImpersonatedEmail, getImpersonatedDisplayName } from '../shared/impersonation';
import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges';
import { Drawer } from './Drawer/Drawer';
import { CategoryTabs } from './CategoryTabs/CategoryTabs';
@@ -62,6 +62,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
// (setImpersonatedEmail в render) до создания этого элемента, и меняется оно
// только вместе с новым render всей веб-части.
const impersonatedEmail = getImpersonatedEmail();
const impersonatedDisplayName = getImpersonatedDisplayName();
const route = useRoute();
const [navCollapsed, setNavCollapsed] = React.useState<boolean>(false);
@@ -133,7 +134,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
// загрузки в таблице не перезапустится сам. Шлём событие обновления —
// таблица на него подписана и перечитает данные фоном, не убирая с экрана
// текущие строки (см. RequestsTableView/SearchView).
if (nav === route.nav && route.mode === 'submit') emitRequestsChanged();
if (nav === route.nav && route.mode === 'submit') emitRequestsChanged(true);
// Всё восстанавливаемое состояние раздела передаём ЯВНО, из запомненного.
// parseRoute подставил бы его и сам, но тогда оно осталось бы только в
// состоянии и не попало бы в адрес: ссылка на раздел вела бы на первую
@@ -281,8 +282,11 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
закрыть: она исчезает только когда свойство веб-части очищено. */}
{impersonatedEmail && (
<div className={styles.impersonationBar}>
Включена отладочная подмена: портал работает от имени <strong>{impersonatedEmail}</strong>.
Очистите поле в свойствах веб-части, чтобы вернуться к своей учётной записи.
Включена отладочная подмена: портал работает от имени{' '}
<strong>
{impersonatedDisplayName ? `${impersonatedDisplayName} (${impersonatedEmail})` : impersonatedEmail}
</strong>
. Очистите поле в свойствах веб-части, чтобы вернуться к своей учётной записи.
</div>
)}
<div className={styles.layout}>
@@ -416,7 +416,14 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
};
load().catch(() => undefined); // ошибки уже обработаны внутри load через setError
const unsubscribe = onRequestsChanged(() => {
const unsubscribe = onRequestsChanged((force) => {
// Явный повторный клик по уже открытому пункту меню — пользователь ждёт
// видимого обновления, а не молчаливой подмены данных за спиной. Прежние
// строки прячем сразу; свежие подставит load().
if (force) {
setRows(undefined);
setLoadedKey('');
}
load().catch(() => undefined);
});
// Пока вкладка браузера скрыта, обновляться незачем — при возврате к ней
@@ -200,7 +200,8 @@ export const SearchView: React.FC<ISearchViewProps> = ({
};
load().catch(() => undefined);
const unsubscribe = onRequestsChanged(() => {
const unsubscribe = onRequestsChanged((force) => {
if (force) setRows(undefined);
load().catch(() => undefined);
});
const timer = window.setInterval(() => {
@@ -21,6 +21,7 @@ import {
REQUEST_STATUS,
isEditableStatus,
IApproverToWrite,
APPROVAL_PHASE,
findUnassignedStep
} from '../../shared/approval';
import { IRequestFormProps } from '../../models/types';
@@ -121,17 +122,42 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
const loadMatrix = useCallback(
() =>
editId
? approval.getMatrixForRequest(editId)
? approval
.getMatrixForRequest(editId)
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution))
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, [
{ fieldName: 'Position', value: data.Position }
]),
[context, approval, editId, data.Employee, data.Position, requestTypeId]
);
const matrixReady = !!editId || (!!data.Employee && !!requestTypeId);
const matrix = useMatrixEditor(
loadMatrix,
`${data.Employee}-${editId ?? 'new'}-${data.Position}`,
!readOnly,
!!editId || (!!data.Employee && !!requestTypeId),
matrixReady,
(login) => approval.getJobTitle(login)
);
// Фаза исполнения: тот же маршрут (условие «IsManager» по data.Position — см.
// комментарий у loadMatrix выше), только 'Execution' вместо 'Approval'. У
// открытой на редактирование заявки — строки уже записаны, читаем как есть.
const loadExecutors = useCallback(
() =>
editId
? approval
.getMatrixForRequest(editId)
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution))
: getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Execution', null, [
{ fieldName: 'Position', value: data.Position }
]),
[context, approval, editId, data.Employee, data.Position, requestTypeId]
);
const executors = useMatrixEditor(
loadExecutors,
`e-${data.Employee}-${editId ?? 'new'}-${data.Position}`,
!readOnly,
matrixReady,
(login) => approval.getJobTitle(login)
);
@@ -168,6 +194,7 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
rtId,
data,
toApprovers(matrix.rows),
toApprovers(executors.rows),
status,
editListId
);
@@ -203,6 +230,10 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
if (unassigned) {
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`;
}
const unassignedExecutor = findUnassignedStep(executors.rows);
if (unassignedExecutor) {
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`;
}
return undefined;
};
@@ -249,6 +280,18 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
{/* Блок исполнителя показываем, только когда по маршруту реально есть
исполнители (условие «IsManager» и т.п. могло не назначить ни одного). */}
{matrixReady && !executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение (после согласования)"
roleLabel="Исполнитель"
emptyText="Исполнители не заданы по маршруту."
/>
)}
<SubmitActions
submit={submit}
isValid={isValid}
@@ -64,13 +64,31 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
// Матрица маршрута из фактических строк заявки (read-only), раздельно по
// фазам: согласование и исполнение — два отдельных блока в UI (см. ниже).
// decisionSeq в reloadKey: после решения строки на сервере меняются —
// перечитываем матрицу.
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
const loadApprovers = useCallback(
() =>
approval
.getMatrixForRequest(itemId)
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)),
[approval, itemId]
);
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
approval.getJobTitle(l)
);
const loadExecutors = useCallback(
() =>
approval
.getMatrixForRequest(itemId)
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)),
[approval, itemId]
);
const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) =>
approval.getJobTitle(l)
);
useEffect(() => {
let alive = true;
@@ -210,6 +228,16 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
{!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение"
roleLabel="Исполнитель"
emptyText="Исполнители не назначены."
/>
)}
</div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
@@ -2,9 +2,9 @@ import * as React from 'react';
import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField } from '../../../shared/ui';
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
// Должность/подразделение подтягиваются автоматически при выборе сотрудника
// (см. ClearanceForm.handleEmployeeChange — SharePoint User Profile) —
// свободный ввод здесь намеренно убран, чтобы не расходиться с профилем.
// Подразделение подтягивается автоматически при выборе сотрудника (см.
// ClearanceForm.handleEmployeeChange — SharePoint User Profile) — свободный
// ввод здесь намеренно убран, чтобы не расходиться с профилем.
const NOOP = (): void => undefined;
interface IRequestSectionProps extends ISectionProps {
@@ -32,12 +32,16 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
placeholder={EMPLOYEE_PLACEHOLDER}
/>
</FormField>
{/* Подтягивается из профиля при выборе сотрудника, но поле остаётся
редактируемым: если профиль не синхронизирован (SPS-JobTitle пуст),
HR должен суметь ввести должность вручную — иначе маршрут
согласования (условие «IsManager» резолвится именно по ней) не
соберётся, и список исполнителей ниже останется пустым. */}
<FormField label="Должность" htmlFor="Position" required>
<TextInput
id="Position"
value={data.Position}
onChange={NOOP}
readOnly
onChange={(v) => set('Position', v)}
placeholder="Подставится автоматически по выбранному сотруднику"
/>
</FormField>
@@ -90,9 +90,8 @@ export interface ISaveResult {
}
/**
* Сохранить обходной лист: содержимое + маршрут согласования.
* Сохранить обходной лист: содержимое + маршрут (согласование + исполнение).
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
* Фазы исполнения у обходного листа нет — writeRoute вызывается с пустым списком исполнителей.
* Возвращает Id заявки (+ код при создании).
*
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadClearanceRequest). Имеет смысл
@@ -105,6 +104,7 @@ export async function saveClearanceRequest(
requestTypeId: number,
data: IFormData,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string,
listId?: string
): Promise<ISaveResult> {
@@ -126,6 +126,6 @@ export async function saveClearanceRequest(
await list.items.getById(id).update({ RequestUniqueCode: code });
}
await writeRoute(clearanceApprovalService(existingId ? listId : undefined), id, approvers, [], status);
await writeRoute(clearanceApprovalService(existingId ? listId : undefined), id, approvers, executors, status);
return { id, code };
}
@@ -123,9 +123,9 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
(login) => approval.getJobTitle(login)
);
const isValid = !!(data.Responsible && data.DepartmentName.trim() && data.Position.trim());
const isValid = !!(data.Responsible && data.DepartmentName.trim() && data.Position.trim() && data.Residence);
const validate = (): string | undefined => {
if (!isValid) return 'Заполните обязательные поля: ответственный за подачу, название отдела, должность.';
if (!isValid) return 'Заполните обязательные поля: ответственный за подачу, название отдела, должность, проживание.';
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
@@ -1,11 +1,28 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { ICompany } from '../../shared/dictionary';
import { dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
import { FORM_ID, IFormData, EMPTY_DATA, ILanguageLevel } from './model';
/** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */
function parseJsonArray<T>(raw: unknown): T[] {
if (typeof raw !== 'string' || raw.trim() === '') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as T[]) : [];
} catch {
return [];
}
}
// Категории полей — определяют, как читать значение из элемента списка.
const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager'];
const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker'];
const BOOL_FIELDS: (keyof IFormData)[] = ['IsRegistered', 'IsApproved', 'CompletionMark', 'MaterialResponsibility'];
const BOOL_FIELDS: (keyof IFormData)[] = [
'MaterialResponsibility',
'PieceworkPay',
'ReferralProgram',
'BusinessTrips'
];
const DATE_FIELDS: (keyof IFormData)[] = ['SubmitDate', 'ClosingDeadline'];
type PersonValue = { EMail?: string } | undefined;
@@ -30,21 +47,39 @@ export async function loadSelectionRequest(itemId: number, listId?: string): Pro
const isPerson = (k: keyof IFormData): boolean =>
PERSON_FIELDS.indexOf(k) !== -1 || MULTI_PERSON_FIELDS.indexOf(k) !== -1;
// Скалярные поля — по имени; поля-люди — вложенный EMail (+ expand).
const scalarSelects = keys.filter((k) => !isPerson(k));
// Скалярные поля — по имени; поля-люди — вложенный EMail (+ expand);
// Companies/Languages/Programs хранятся в Note-колонках *Json (не одноимёнными колонками).
const jsonKeys: (keyof IFormData)[] = ['Companies', 'Languages', 'Programs', 'WorkplaceProvision'];
const scalarSelects = keys.filter((k) => !isPerson(k) && jsonKeys.indexOf(k) === -1);
const personSelects = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS].map((k) => `${k}/EMail`);
const expands = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS];
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list
.items.getById(itemId)
.select(...scalarSelects, 'RequestStatus', 'RequestUniqueCode', ...personSelects)
.select(
...scalarSelects,
'CompaniesJson',
'LanguagesJson',
'ProgramsJson',
'WorkplaceProvisionJson',
'RequestStatus',
'RequestUniqueCode',
...personSelects
)
.expand(...expands)()) as Record<string, unknown>;
const data: IFormData = { ...EMPTY_DATA };
const writable = data as unknown as Record<string, unknown>;
writable.Companies = parseJsonArray<ICompany>(item.CompaniesJson);
writable.Languages = parseJsonArray<ILanguageLevel>(item.LanguagesJson);
writable.Programs = parseJsonArray<string>(item.ProgramsJson);
writable.WorkplaceProvision = parseJsonArray<string>(item.WorkplaceProvisionJson);
keys.forEach((key) => {
if (jsonKeys.indexOf(key) !== -1) {
return; // уже разобрали из *Json
}
if (PERSON_FIELDS.indexOf(key) !== -1) {
writable[key] = (item[key] as PersonValue)?.EMail || '';
} else if (MULTI_PERSON_FIELDS.indexOf(key) !== -1) {
@@ -1,5 +1,6 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { ISelectOption } from '../../shared/ui';
import { ICompany } from '../../shared/dictionary';
import { RequestTypeId } from '../../models/types';
/**
@@ -18,47 +19,47 @@ export interface IFormData {
SubmitDate: string;
ClosingDeadline: string;
OpenReason: string;
IsRegistered: boolean;
IsApproved: boolean;
CompletionMark: boolean;
OpenReasonDetails: string;
Companies: ICompany[];
ObjectProject: string;
ObjectProjectId: string;
ObjectProjectCode: string;
// Отдел
DepartmentName: string;
DepartmentHeadcount: string;
DepartmentGoals: string;
RelatedDepartments: string;
OpenVacanciesCount: string;
// Вакансия
Position: string;
ClassifierCode: string;
EmploymentType: string;
MainFunction: string;
AdditionalFunction: string;
JobFunction: string;
Competencies: string;
PersonalTraits: string;
Education: string;
WorkExperience: string;
AdditionalEducation: string;
EvaluationCriteria: string;
PcSkills: string;
ForeignLanguages: string;
Programs: string[];
Languages: ILanguageLevel[];
Residence: string;
ResidenceOther: string;
DrivingLicense: string;
// Здоровье / командировки
HealthRestrictions: string;
BusinessTrips: string;
// Командировки
BusinessTrips: boolean;
BusinessTripsDetails: string;
BusinessTripsDetailsOther: string;
// Условия работы
RateProbation: string;
RateAfterProbation: string;
Awards: string;
Bonuses: string;
CompensationPackage: string;
PieceworkPay: boolean;
WorkSchedule: string;
Location: string;
WorkScheduleOther: string;
WorkHours: string;
WorkHoursOther: string;
MaterialResponsibility: boolean;
WorkplaceProvision: string;
WorkplaceProvision: string[];
ProposedMentor: string;
DirectManager: string;
DevelopmentPrograms: string;
DesiredTraining: string;
ReferralProgram: boolean;
HiringDecisionMaker: string[];
}
@@ -77,43 +78,43 @@ export const EMPTY_DATA: IFormData = {
SubmitDate: '',
ClosingDeadline: '',
OpenReason: '',
IsRegistered: false,
IsApproved: false,
CompletionMark: false,
OpenReasonDetails: '',
Companies: [],
ObjectProject: '',
ObjectProjectId: '',
ObjectProjectCode: '',
DepartmentName: '',
DepartmentHeadcount: '',
DepartmentGoals: '',
RelatedDepartments: '',
OpenVacanciesCount: '',
Position: '',
ClassifierCode: '',
EmploymentType: '',
MainFunction: '',
AdditionalFunction: '',
JobFunction: '',
Competencies: '',
PersonalTraits: '',
Education: '',
WorkExperience: '',
AdditionalEducation: '',
EvaluationCriteria: '',
PcSkills: '',
ForeignLanguages: '',
Programs: [],
Languages: [],
Residence: '',
ResidenceOther: '',
DrivingLicense: '',
HealthRestrictions: '',
BusinessTrips: '',
BusinessTrips: false,
BusinessTripsDetails: '',
BusinessTripsDetailsOther: '',
RateProbation: '',
RateAfterProbation: '',
Awards: '',
Bonuses: '',
CompensationPackage: '',
PieceworkPay: false,
WorkSchedule: '',
Location: '',
WorkScheduleOther: '',
WorkHours: '',
WorkHoursOther: '',
MaterialResponsibility: false,
WorkplaceProvision: '',
WorkplaceProvision: [],
ProposedMentor: '',
DirectManager: '',
DevelopmentPrograms: '',
DesiredTraining: '',
ReferralProgram: false,
HiringDecisionMaker: []
};
@@ -123,5 +124,127 @@ export const EMPLOYMENT_TYPE_OPTIONS: ISelectOption[] = [
{ value: 'Временная', label: 'Временная' }
];
/** Значение-триггер «Другое»: при нём показываем поле ручного ввода. */
export const OTHER_VALUE = 'Другое';
/** Причины открытия вакансии, требующие ФИО сотрудника (для условного поля). */
export const OPEN_REASON_REPLACE = 'Замена уволенного сотрудника';
export const OPEN_REASON_TRANSFER = 'Перевод сотрудника';
export const OPEN_REASON_NEW = 'Новая штатная единица';
/** Варианты «Причина открытия вакансии» — совпадают с choices колонки OpenReason. */
export const OPEN_REASON_OPTIONS: ISelectOption[] = [
{ value: OPEN_REASON_REPLACE, label: OPEN_REASON_REPLACE },
{ value: OPEN_REASON_TRANSFER, label: OPEN_REASON_TRANSFER },
{ value: OPEN_REASON_NEW, label: OPEN_REASON_NEW },
{ value: OTHER_VALUE, label: OTHER_VALUE }
];
/** Варианты «Проживание» — совпадают с choices колонки Residence. */
export const RESIDENCE_OPTIONS: ISelectOption[] = [
{ value: 'Общежитие компании', label: 'Общежитие компании' },
{ value: 'За счёт компании', label: 'За счёт компании' },
{ value: 'За счёт кандидата', label: 'За счёт кандидата' },
{ value: OTHER_VALUE, label: OTHER_VALUE }
];
/** Владение одним языком: язык + требуемый уровень. Хранится JSON-массивом. */
export interface ILanguageLevel {
language: string;
level: string;
}
/** Иностранные языки, актуальные для Казахстана (фикс. набор строк таблицы). */
export const LANGUAGES: string[] = ['Казахский', 'Русский', 'Английский', 'Китайский', 'Турецкий'];
/** Шкала уровней владения языком. */
export const LANGUAGE_LEVEL_OPTIONS: ISelectOption[] = [
{ value: 'Начинающий', label: 'Начинающий' },
{ value: 'Базовый', label: 'Базовый' },
{ value: 'Средний', label: 'Средний' },
{ value: 'Продвинутый', label: 'Продвинутый' },
{ value: 'Свободный', label: 'Свободный' }
];
/**
* Программы (ПО) — ВРЕМЕННЫЙ список, пока нет справочника в API. Используется в
* мультивыборе «Необходимые знания в программах». Меняется здесь одним местом.
*/
export const SOFTWARE_OPTIONS: ISelectOption[] = [
{ value: '1С:Предприятие', label: '1С:Предприятие' },
{ value: 'MS Word', label: 'MS Word' },
{ value: 'MS Excel', label: 'MS Excel' },
{ value: 'MS PowerPoint', label: 'MS PowerPoint' },
{ value: 'MS Outlook', label: 'MS Outlook' },
{ value: 'MS Project', label: 'MS Project' },
{ value: 'AutoCAD', label: 'AutoCAD' },
{ value: 'Revit', label: 'Revit' },
{ value: 'ArchiCAD', label: 'ArchiCAD' },
{ value: 'Компас-3D', label: 'Компас-3D' },
{ value: 'Primavera', label: 'Primavera' },
{ value: 'SAP', label: 'SAP' },
{ value: 'Adobe Photoshop', label: 'Adobe Photoshop' }
];
/** Категории «Обеспечение рабочего места» (+ «Другое» через allowOther). */
export const WORKPLACE_PROVISION_OPTIONS: ISelectOption[] = [
{ value: 'Оргтехника', label: 'Оргтехника' },
{ value: 'СИЗ', label: 'СИЗ' },
{ value: 'Программное обеспечение', label: 'Программное обеспечение' }
];
/** Варианты «Командировки — продолжительность/частота» — совпадают с choices BusinessTripsDetails. */
export const BUSINESS_TRIP_OPTIONS: ISelectOption[] = [
{ value: 'Редко (1–2 раза в год)', label: 'Редко (1–2 раза в год)' },
{ value: 'Ежеквартально', label: 'Ежеквартально' },
{ value: 'Ежемесячно', label: 'Ежемесячно' },
{ value: 'Еженедельно', label: 'Еженедельно' },
{ value: 'Постоянные разъезды', label: 'Постоянные разъезды' },
{ value: 'Вахтовый метод', label: 'Вахтовый метод' },
{ value: OTHER_VALUE, label: OTHER_VALUE }
];
/** Варианты «Образование» (РК) — совпадают с choices колонки Education. */
export const EDUCATION_OPTIONS: ISelectOption[] = [
{ value: 'Среднее', label: 'Среднее' },
{ value: 'Техническое и профессиональное (ТиПО)', label: 'Техническое и профессиональное (ТиПО)' },
{ value: 'Незаконченное высшее', label: 'Незаконченное высшее' },
{ value: 'Высшее — бакалавриат', label: 'Высшее — бакалавриат' },
{ value: 'Высшее — магистратура', label: 'Высшее — магистратура' },
{ value: 'Послевузовское (докторантура / PhD)', label: 'Послевузовское (докторантура / PhD)' }
];
/** Варианты «Опыт работы» — совпадают с choices колонки WorkExperience. */
export const WORK_EXPERIENCE_OPTIONS: ISelectOption[] = [
{ value: 'Без опыта', label: 'Без опыта' },
{ value: 'До 3 лет', label: 'До 3 лет' },
{ value: 'От 3 до 5 лет', label: 'От 3 до 5 лет' },
{ value: 'От 5 лет и выше', label: 'От 5 лет и выше' }
];
/** Варианты «Режим работы» — совпадают с choices колонки WorkSchedule. */
export const WORK_SCHEDULE_OPTIONS: ISelectOption[] = [
{ value: '5/2', label: '5/2' },
{ value: 'Вахтовый 30/30', label: 'Вахтовый 30/30' },
{ value: '20/10', label: '20/10' },
{ value: '30/15', label: '30/15' },
{ value: '22/8', label: '22/8' },
{ value: '15/15', label: '15/15' },
{ value: '6/1', label: '6/1' },
{ value: '1/2', label: '1/2' },
{ value: '2/2', label: '2/2' },
{ value: OTHER_VALUE, label: OTHER_VALUE }
];
/** Варианты «Время работы» — совпадают с choices колонки WorkHours. */
export const WORK_HOURS_OPTIONS: ISelectOption[] = [
{ value: '8-5', label: '8-5' },
{ value: '9-6', label: '9-6' },
{ value: '7-7', label: '7-7' },
{ value: '8-8', label: '8-8' },
{ value: '9-9', label: '9-9' },
{ value: OTHER_VALUE, label: OTHER_VALUE }
];
/** Плейсхолдер для полей-людей. */
export const PERSON_PLACEHOLDER = 'Начните вводить имя сотрудника…';
@@ -5,49 +5,49 @@
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "Responsible", "title": "Ответственный за подачу заявки", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
{ "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true },
{ "internalName": "IsRegistered", "title": "Зарегистрирована", "type": "Boolean" },
{ "internalName": "IsApproved", "title": "Одобрена", "type": "Boolean" },
{ "internalName": "OpenReason", "title": "Причина открытия вакансии", "type": "Note" },
{ "internalName": "OpenReason", "title": "Причина открытия вакансии", "type": "Choice", "choices": ["Замена уволенного сотрудника", "Перевод сотрудника", "Новая штатная единица", "Другое"] },
{ "internalName": "OpenReasonDetails", "title": "Причина открытия — ФИО / уточнение", "type": "Text" },
{ "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" },
{ "internalName": "ObjectProject", "title": "Объект/Проект", "type": "Text" },
{ "internalName": "ObjectProjectId", "title": "Объект/Проект — Id", "type": "Text" },
{ "internalName": "ObjectProjectCode", "title": "Объект/Проект — код", "type": "Text" },
{ "internalName": "ClosingDeadline", "title": "Крайний срок закрытия", "type": "DateTime", "dateOnly": true },
{ "internalName": "CompletionMark", "title": "Отметка о выполнении", "type": "Boolean" },
{ "internalName": "DepartmentName", "title": "Название отдела/департамента", "type": "Text", "required": true },
{ "internalName": "DepartmentHeadcount", "title": "Количество сотрудников в отделе", "type": "Number" },
{ "internalName": "DepartmentGoals", "title": "Цели, стоящие перед отделом", "type": "Note" },
{ "internalName": "RelatedDepartments", "title": "Смежные отделы по бизнес-процессам", "type": "Note" },
{ "internalName": "OpenVacanciesCount", "title": "Количество открытых вакансий", "type": "Number" },
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
{ "internalName": "ClassifierCode", "title": "Код по классификатору", "type": "Text" },
{ "internalName": "EmploymentType", "title": "Характер занятости", "type": "Choice", "choices": ["Постоянная", "Временная"] },
{ "internalName": "MainFunction", "title": "Основной функционал", "type": "Note" },
{ "internalName": "AdditionalFunction", "title": "Дополнительный функционал", "type": "Note" },
{ "internalName": "JobFunction", "title": "Функционал", "type": "Note" },
{ "internalName": "Competencies", "title": "Компетенции", "type": "Note" },
{ "internalName": "PersonalTraits", "title": "Личностные характеристики", "type": "Note" },
{ "internalName": "Education", "title": "Образование", "type": "Text" },
{ "internalName": "WorkExperience", "title": "Опыт работы", "type": "Note" },
{ "internalName": "AdditionalEducation", "title": "Дополнительное образование", "type": "Note" },
{ "internalName": "EvaluationCriteria", "title": "Критерии оценки работы", "type": "Note" },
{ "internalName": "PcSkills", "title": "Уровень владения ПК и программами", "type": "Note" },
{ "internalName": "ForeignLanguages", "title": "Иностранные языки (уровень владения)", "type": "Note" },
{ "internalName": "Residence", "title": "Проживание", "type": "Text" },
{ "internalName": "Education", "title": "Образование", "type": "Choice", "choices": ["Среднее", "Техническое и профессиональное (ТиПО)", "Незаконченное высшее", "Высшее — бакалавриат", "Высшее — магистратура", "Послевузовское (докторантура / PhD)"] },
{ "internalName": "WorkExperience", "title": "Опыт работы", "type": "Choice", "choices": ["Без опыта", "До 3 лет", "От 3 до 5 лет", "От 5 лет и выше"] },
{ "internalName": "ProgramsJson", "title": "Необходимые знания в программах (JSON)", "type": "Note" },
{ "internalName": "LanguagesJson", "title": "Иностранные языки (JSON)", "type": "Note" },
{ "internalName": "Residence", "title": "Проживание", "type": "Choice", "required": true, "choices": ["Общежитие компании", "За счёт компании", "За счёт кандидата", "Другое"] },
{ "internalName": "ResidenceOther", "title": "Проживание (другое)", "type": "Text" },
{ "internalName": "DrivingLicense", "title": "Водительские права, стаж вождения", "type": "Text" },
{ "internalName": "HealthRestrictions", "title": "Приемлемые противопоказания (здоровье)", "type": "Note" },
{ "internalName": "BusinessTrips", "title": "Командировки (есть/нет и продолжительность)", "type": "Note" },
{ "internalName": "BusinessTrips", "title": "Командировки", "type": "Boolean" },
{ "internalName": "BusinessTripsDetails", "title": "Командировки — продолжительность / частота", "type": "Choice", "choices": ["Редко (1–2 раза в год)", "Ежеквартально", "Ежемесячно", "Еженедельно", "Постоянные разъезды", "Вахтовый метод", "Другое"] },
{ "internalName": "BusinessTripsDetailsOther", "title": "Командировки — частота (другое)", "type": "Text" },
{ "internalName": "RateProbation", "title": "Ставка на ИС", "type": "Number", "decimals": 2 },
{ "internalName": "RateAfterProbation", "title": "Ставка после ИС", "type": "Number", "decimals": 2 },
{ "internalName": "Awards", "title": "Премии", "type": "Note" },
{ "internalName": "Bonuses", "title": "Бонусы", "type": "Note" },
{ "internalName": "CompensationPackage", "title": "Компенсационный пакет", "type": "Note" },
{ "internalName": "WorkSchedule", "title": "Режим работы", "type": "Text" },
{ "internalName": "Location", "title": "Локация", "type": "Text" },
{ "internalName": "PieceworkPay", "title": "Сдельная оплата труда", "type": "Boolean" },
{ "internalName": "WorkSchedule", "title": "Режим работы", "type": "Choice", "choices": ["5/2", "Вахтовый 30/30", "20/10", "30/15", "22/8", "15/15", "6/1", "1/2", "2/2", "Другое"] },
{ "internalName": "WorkScheduleOther", "title": "Режим работы (другое)", "type": "Text" },
{ "internalName": "WorkHours", "title": "Время работы", "type": "Choice", "choices": ["8-5", "9-6", "7-7", "8-8", "9-9", "Другое"] },
{ "internalName": "WorkHoursOther", "title": "Время работы (другое)", "type": "Text" },
{ "internalName": "MaterialResponsibility", "title": "Материальная ответственность", "type": "Boolean" },
{ "internalName": "WorkplaceProvision", "title": "Обеспечение рабочего места", "type": "Note" },
{ "internalName": "WorkplaceProvisionJson", "title": "Обеспечение рабочего места (JSON)", "type": "Note" },
{ "internalName": "ProposedMentor", "title": "Предполагаемый наставник", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "DirectManager", "title": "Непосредственный руководитель", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "DevelopmentPrograms", "title": "Программы развития и обучения, периодичность", "type": "Note" },
{ "internalName": "DesiredTraining", "title": "Желаемое доп. обучение при выходе на работу", "type": "Note" },
{ "internalName": "ReferralProgram", "title": "Реферальная программа", "type": "Boolean" },
{ "internalName": "HiringDecisionMaker", "title": "Кто принимает решение о приеме (собеседования)", "type": "User", "userSelectionMode": "PeopleOnly", "allowMultiple": true }
]
}
@@ -1,10 +1,24 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, Toggle, PeopleField } from '../../../shared/ui';
import { ISectionProps, PERSON_PLACEHOLDER } from '../model';
import { CollapseSection, FormField, TextInput, Select, PeopleField } from '../../../shared/ui';
import { CompanyPickerMulti, Project1CPicker } from '../../../shared/dictionary';
import {
ISectionProps,
PERSON_PLACEHOLDER,
OPEN_REASON_OPTIONS,
OPEN_REASON_REPLACE,
OPEN_REASON_TRANSFER,
OPEN_REASON_NEW
} from '../model';
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context }) => (
<CollapseSection title="Заявка" icon="clipboard">
<FormField label="Ответственный за подачу заявки" required full>
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context }) => {
const reason = data.OpenReason;
const showReasonDetails = reason !== '' && reason !== OPEN_REASON_NEW;
const reasonDetailsLabel =
reason === OPEN_REASON_REPLACE || reason === OPEN_REASON_TRANSFER ? 'ФИО сотрудника' : 'Уточните причину';
return (
<CollapseSection title="1. Заявка" icon="clipboard">
<FormField label="1.1 Ответственный за подачу заявки" required full>
<PeopleField
context={context}
value={data.Responsible}
@@ -12,10 +26,10 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
placeholder={PERSON_PLACEHOLDER}
/>
</FormField>
<FormField label="Дата подачи заявки" htmlFor="SubmitDate">
<FormField label="1.2 Дата подачи заявки" htmlFor="SubmitDate">
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} />
</FormField>
<FormField label="Крайний срок закрытия" htmlFor="ClosingDeadline">
<FormField label="1.3 Крайний срок закрытия" htmlFor="ClosingDeadline">
<TextInput
id="ClosingDeadline"
type="date"
@@ -23,17 +37,49 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
onChange={(v) => set('ClosingDeadline', v)}
/>
</FormField>
<FormField label="Причина открытия вакансии" htmlFor="OpenReason" full>
<TextArea id="OpenReason" value={data.OpenReason} onChange={(v) => set('OpenReason', v)} placeholder="..." />
<FormField label="1.4 Причина открытия вакансии" htmlFor="OpenReason">
<Select
id="OpenReason"
value={data.OpenReason}
onChange={(v) => {
set('OpenReason', v);
if (v === '' || v === OPEN_REASON_NEW) set('OpenReasonDetails', '');
}}
options={OPEN_REASON_OPTIONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Зарегистрирована">
<Toggle checked={data.IsRegistered} onChange={(v) => set('IsRegistered', v)} onLabel="Да" />
{showReasonDetails && (
<FormField label={reasonDetailsLabel} htmlFor="OpenReasonDetails">
<TextInput
id="OpenReasonDetails"
value={data.OpenReasonDetails}
onChange={(v) => set('OpenReasonDetails', v)}
/>
</FormField>
<FormField label="Одобрена">
<Toggle checked={data.IsApproved} onChange={(v) => set('IsApproved', v)} onLabel="Да" />
)}
<FormField label="1.5 Компания" htmlFor="Companies" full>
<CompanyPickerMulti
context={context}
id="Companies"
value={data.Companies}
onChange={(v) => set('Companies', v)}
placeholder="Начните вводить название компании…"
/>
</FormField>
<FormField label="Отметка о выполнении">
<Toggle checked={data.CompletionMark} onChange={(v) => set('CompletionMark', v)} onLabel="Да" />
<FormField label="1.6 Объект/Проект" htmlFor="ObjectProject" full>
<Project1CPicker
context={context}
id="ObjectProject"
value={data.ObjectProject}
onChange={(name, id, code) => {
set('ObjectProject', name);
set('ObjectProjectId', id);
set('ObjectProjectCode', code);
}}
placeholder="Начните вводить название или код проекта…"
/>
</FormField>
</CollapseSection>
);
};
@@ -1,45 +1,97 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, MoneyInput, Toggle, PeopleField, PeopleFieldMulti } from '../../../shared/ui';
import { ISectionProps, PERSON_PLACEHOLDER } from '../model';
import { CollapseSection, FormField, TextInput, TextArea, MoneyInput, Toggle, Select, PeopleField, PeopleFieldMulti } from '../../../shared/ui';
import { MultiChoiceField } from './MultiChoiceField';
import {
ISectionProps,
PERSON_PLACEHOLDER,
OTHER_VALUE,
WORK_SCHEDULE_OPTIONS,
WORK_HOURS_OPTIONS,
WORKPLACE_PROVISION_OPTIONS
} from '../model';
export const ConditionsSection: React.FC<ISectionProps> = ({ data, set, context }) => (
<CollapseSection title="Условия работы" icon="chart">
<FormField label="Ставка на ИС" htmlFor="RateProbation">
<CollapseSection title="5. Условия работы" icon="chart">
<FormField label="5.1 Ставка на ИС" htmlFor="RateProbation">
<MoneyInput id="RateProbation" value={data.RateProbation} onChange={(v) => set('RateProbation', v)} />
</FormField>
<FormField label="Ставка после ИС" htmlFor="RateAfterProbation">
<FormField label="5.2 Ставка после ИС" htmlFor="RateAfterProbation">
<MoneyInput
id="RateAfterProbation"
value={data.RateAfterProbation}
onChange={(v) => set('RateAfterProbation', v)}
/>
</FormField>
<FormField label="Премии" htmlFor="Awards" full>
<FormField label="5.3 Премии" htmlFor="Awards" full>
<TextArea id="Awards" value={data.Awards} onChange={(v) => set('Awards', v)} />
</FormField>
<FormField label="Бонусы" htmlFor="Bonuses" full>
<FormField label="5.4 Бонусы" htmlFor="Bonuses" full>
<TextArea id="Bonuses" value={data.Bonuses} onChange={(v) => set('Bonuses', v)} />
</FormField>
<FormField label="Компенсационный пакет" htmlFor="CompensationPackage" full>
<FormField label="5.5 Компенсационный пакет" htmlFor="CompensationPackage" full>
<TextArea id="CompensationPackage" value={data.CompensationPackage} onChange={(v) => set('CompensationPackage', v)} />
</FormField>
<FormField label="Режим работы" htmlFor="WorkSchedule">
<TextInput id="WorkSchedule" value={data.WorkSchedule} onChange={(v) => set('WorkSchedule', v)} />
<FormField label="5.6 Сдельная оплата труда">
<Toggle checked={data.PieceworkPay} onChange={(v) => set('PieceworkPay', v)} onLabel="Да" />
</FormField>
<FormField label="Локация" htmlFor="Location">
<TextInput id="Location" value={data.Location} onChange={(v) => set('Location', v)} />
<FormField label="5.7 Режим работы" htmlFor="WorkSchedule">
<Select
id="WorkSchedule"
value={data.WorkSchedule}
onChange={(v) => {
set('WorkSchedule', v);
if (v !== OTHER_VALUE) set('WorkScheduleOther', '');
}}
options={WORK_SCHEDULE_OPTIONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Материальная ответственность">
{data.WorkSchedule === OTHER_VALUE && (
<FormField label="Режим работы (другое)" htmlFor="WorkScheduleOther">
<TextInput
id="WorkScheduleOther"
value={data.WorkScheduleOther}
onChange={(v) => set('WorkScheduleOther', v)}
/>
</FormField>
)}
<FormField label="5.8 Время работы" htmlFor="WorkHours">
<Select
id="WorkHours"
value={data.WorkHours}
onChange={(v) => {
set('WorkHours', v);
if (v !== OTHER_VALUE) set('WorkHoursOther', '');
}}
options={WORK_HOURS_OPTIONS}
placeholder="— выберите —"
/>
</FormField>
{data.WorkHours === OTHER_VALUE && (
<FormField label="Время работы (другое)" htmlFor="WorkHoursOther">
<TextInput id="WorkHoursOther" value={data.WorkHoursOther} onChange={(v) => set('WorkHoursOther', v)} />
</FormField>
)}
<FormField label="5.9 Материальная ответственность">
<Toggle
checked={data.MaterialResponsibility}
onChange={(v) => set('MaterialResponsibility', v)}
onLabel="Да"
/>
</FormField>
<FormField label="Обеспечение рабочего места" htmlFor="WorkplaceProvision" full>
<TextArea id="WorkplaceProvision" value={data.WorkplaceProvision} onChange={(v) => set('WorkplaceProvision', v)} />
<FormField label="5.10 Обеспечение рабочего места" full>
<MultiChoiceField
value={data.WorkplaceProvision}
onChange={(v) => set('WorkplaceProvision', v)}
options={WORKPLACE_PROVISION_OPTIONS}
allowOther
placeholder="Выберите категорию"
/>
</FormField>
<FormField label="Предполагаемый наставник">
<FormField label="5.11 Реферальная программа">
<Toggle checked={data.ReferralProgram} onChange={(v) => set('ReferralProgram', v)} onLabel="Да" />
</FormField>
<FormField label="5.12 Предполагаемый наставник">
<PeopleField
context={context}
value={data.ProposedMentor}
@@ -47,7 +99,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({ data, set, context
placeholder={PERSON_PLACEHOLDER}
/>
</FormField>
<FormField label="Непосредственный руководитель">
<FormField label="5.13 Непосредственный руководитель">
<PeopleField
context={context}
value={data.DirectManager}
@@ -55,13 +107,7 @@ export const ConditionsSection: React.FC<ISectionProps> = ({ data, set, context
placeholder={PERSON_PLACEHOLDER}
/>
</FormField>
<FormField label="Программы развития и обучения, периодичность" htmlFor="DevelopmentPrograms" full>
<TextArea id="DevelopmentPrograms" value={data.DevelopmentPrograms} onChange={(v) => set('DevelopmentPrograms', v)} />
</FormField>
<FormField label="Желаемое доп. обучение при выходе на работу" htmlFor="DesiredTraining" full>
<TextArea id="DesiredTraining" value={data.DesiredTraining} onChange={(v) => set('DesiredTraining', v)} />
</FormField>
<FormField label="Кто принимает решение о приеме (собеседования)" full>
<FormField label="5.14 Кто принимает решение о приеме (собеседования)" full>
<PeopleFieldMulti
context={context}
value={data.HiringDecisionMaker}
@@ -1,28 +1,18 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea } from '../../../shared/ui';
import { CollapseSection, FormField, TextInput } from '../../../shared/ui';
import { ISectionProps } from '../model';
export const DepartmentSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Отдел / Подразделение" icon="building">
<FormField label="Название отдела/департамента" htmlFor="DepartmentName" required full>
<CollapseSection title="2. Отдел / Подразделение" icon="building">
<FormField label="2.1 Название отдела/департамента" htmlFor="DepartmentName" required full>
<TextInput id="DepartmentName" value={data.DepartmentName} onChange={(v) => set('DepartmentName', v)} />
</FormField>
<FormField label="Количество сотрудников в отделе" htmlFor="DepartmentHeadcount">
<FormField label="2.2 Количество открытых вакансий" htmlFor="OpenVacanciesCount">
<TextInput
id="DepartmentHeadcount"
id="OpenVacanciesCount"
type="number"
value={data.DepartmentHeadcount}
onChange={(v) => set('DepartmentHeadcount', v)}
/>
</FormField>
<FormField label="Цели, стоящие перед отделом" htmlFor="DepartmentGoals" full>
<TextArea id="DepartmentGoals" value={data.DepartmentGoals} onChange={(v) => set('DepartmentGoals', v)} />
</FormField>
<FormField label="Смежные отделы по бизнес-процессам" htmlFor="RelatedDepartments" full>
<TextArea
id="RelatedDepartments"
value={data.RelatedDepartments}
onChange={(v) => set('RelatedDepartments', v)}
value={data.OpenVacanciesCount}
onChange={(v) => set('OpenVacanciesCount', v !== '' && Number(v) < 0 ? '0' : v)}
/>
</FormField>
</CollapseSection>
@@ -1,18 +1,34 @@
import * as React from 'react';
import { CollapseSection, FormField, TextArea } from '../../../shared/ui';
import { ISectionProps } from '../model';
import { CollapseSection, FormField, TextInput, Select, Toggle } from '../../../shared/ui';
import { ISectionProps, BUSINESS_TRIP_OPTIONS, OTHER_VALUE } from '../model';
export const HealthTripsSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Здоровье и командировки" icon="send">
<FormField label="Приемлемые противопоказания (здоровье)" htmlFor="HealthRestrictions" full>
<TextArea
id="HealthRestrictions"
value={data.HealthRestrictions}
onChange={(v) => set('HealthRestrictions', v)}
<CollapseSection title="4. Командировки" icon="send">
<FormField label="4.1 Командировки">
<Toggle checked={data.BusinessTrips} onChange={(v) => set('BusinessTrips', v)} onLabel="Да" />
</FormField>
{data.BusinessTrips && (
<FormField label="Продолжительность / частота" htmlFor="BusinessTripsDetails">
<Select
id="BusinessTripsDetails"
value={data.BusinessTripsDetails}
onChange={(v) => {
set('BusinessTripsDetails', v);
if (v !== OTHER_VALUE) set('BusinessTripsDetailsOther', '');
}}
options={BUSINESS_TRIP_OPTIONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Командировки (есть/нет и продолжительность)" htmlFor="BusinessTrips" full>
<TextArea id="BusinessTrips" value={data.BusinessTrips} onChange={(v) => set('BusinessTrips', v)} />
)}
{data.BusinessTrips && data.BusinessTripsDetails === OTHER_VALUE && (
<FormField label="Продолжительность / частота (другое)" htmlFor="BusinessTripsDetailsOther">
<TextInput
id="BusinessTripsDetailsOther"
value={data.BusinessTripsDetailsOther}
onChange={(v) => set('BusinessTripsDetailsOther', v)}
/>
</FormField>
)}
</CollapseSection>
);
@@ -0,0 +1,70 @@
@import '../../../../../styles/design-tokens';
.list {
display: flex;
flex-direction: column;
gap: $sp-1;
margin-bottom: $sp-2;
}
.row {
display: flex;
align-items: center;
gap: $sp-2;
padding: 8px 12px;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $navy-50;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
}
.lang {
font-weight: 600;
}
.dash {
color: $gray-400;
}
.level {
color: $gray-700;
}
.remove {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: auto;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: $gray-500;
font-size: 16px;
line-height: 1;
cursor: pointer;
transition: .15s;
&:hover {
background: $gray-200;
color: $gray-900;
}
}
.add {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-2;
}
// Оба селекта тянутся; на узких экранах переносятся и занимают всю ширину.
.addField {
flex: 1 1 180px;
min-width: 0;
}
@@ -0,0 +1,79 @@
import * as React from 'react';
import { useState } from 'react';
import styles from './LanguagesField.module.scss';
import { Select, Button, useFormReadOnly, ISelectOption } from '../../../shared/ui';
import { ILanguageLevel, LANGUAGES, LANGUAGE_LEVEL_OPTIONS } from '../model';
export interface ILanguagesFieldProps {
value: ILanguageLevel[];
onChange: (langs: ILanguageLevel[]) => void;
}
/**
* «Иностранные языки»: строка добавления (язык + уровень → «Добавить») и список
* выбранных. Показываются только добавленные языки; уже выбранные исключаются из
* списка для добавления.
*/
export const LanguagesField: React.FC<ILanguagesFieldProps> = ({ value, onChange }) => {
const readOnly = useFormReadOnly();
const [draftLang, setDraftLang] = useState<string>('');
const [draftLevel, setDraftLevel] = useState<string>('');
const used = new Set(value.map((l) => l.language));
const langOptions: ISelectOption[] = LANGUAGES.filter((l) => !used.has(l)).map((l) => ({ value: l, label: l }));
const add = (): void => {
if (!draftLang || !draftLevel) return;
onChange([...value, { language: draftLang, level: draftLevel }]);
setDraftLang('');
setDraftLevel('');
};
const remove = (lang: string): void => onChange(value.filter((l) => l.language !== lang));
return (
<div>
{value.length > 0 && (
<div className={styles.list}>
{value.map((l) => (
<div key={l.language} className={styles.row}>
<span className={styles.lang}>{l.language}</span>
<span className={styles.dash}>—</span>
<span className={styles.level}>{l.level}</span>
{!readOnly && (
<button
type="button"
className={styles.remove}
aria-label={`Убрать ${l.language}`}
title="Убрать"
onClick={() => remove(l.language)}
>
×
</button>
)}
</div>
))}
</div>
)}
{!readOnly && langOptions.length > 0 && (
<div className={styles.add}>
<div className={styles.addField}>
<Select value={draftLang} onChange={setDraftLang} options={langOptions} placeholder="Выберите язык" />
</div>
<div className={styles.addField}>
<Select
value={draftLevel}
onChange={setDraftLevel}
options={LANGUAGE_LEVEL_OPTIONS}
placeholder="Уровень"
/>
</div>
<Button type="button" variant="secondary" onClick={add} disabled={!draftLang || !draftLevel}>
Добавить
</Button>
</div>
)}
</div>
);
};
@@ -0,0 +1,96 @@
import * as React from 'react';
import { useState } from 'react';
import styles from './LanguagesField.module.scss';
import { Select, TextInput, Button, useFormReadOnly, ISelectOption } from '../../../shared/ui';
import { OTHER_VALUE } from '../model';
export interface IMultiChoiceFieldProps {
value: string[];
onChange: (values: string[]) => void;
options: ISelectOption[];
/** Разрешить «Другое» с ручным вводом произвольного значения. */
allowOther?: boolean;
placeholder?: string;
}
/**
* Мультивыбор из фиксированного списка: выбор значения → «Добавить», выбранные —
* чипами с удалением. Уже выбранные исключаются из списка. allowOther добавляет
* пункт «Другое» с полем ручного ввода. Переиспользует стили LanguagesField.
*/
export const MultiChoiceField: React.FC<IMultiChoiceFieldProps> = ({
value,
onChange,
options,
allowOther,
placeholder
}) => {
const readOnly = useFormReadOnly();
const [draft, setDraft] = useState<string>('');
const [draftOther, setDraftOther] = useState<string>('');
const selected = new Set(value);
const available = options.filter((o) => !selected.has(o.value));
const addOptions: ISelectOption[] = allowOther ? [...available, { value: OTHER_VALUE, label: OTHER_VALUE }] : available;
const isOther = draft === OTHER_VALUE;
const pending = isOther ? draftOther.trim() : draft;
const add = (): void => {
if (!pending || selected.has(pending)) return;
onChange([...value, pending]);
setDraft('');
setDraftOther('');
};
const remove = (v: string): void => onChange(value.filter((x) => x !== v));
return (
<div>
{value.length > 0 && (
<div className={styles.list}>
{value.map((v) => (
<div key={v} className={styles.row}>
<span className={styles.lang}>{v}</span>
{!readOnly && (
<button
type="button"
className={styles.remove}
aria-label={`Убрать ${v}`}
title="Убрать"
onClick={() => remove(v)}
>
×
</button>
)}
</div>
))}
</div>
)}
{!readOnly && addOptions.length > 0 && (
<div className={styles.add}>
<div className={styles.addField}>
<Select
value={draft}
onChange={(v) => {
setDraft(v);
if (v !== OTHER_VALUE) setDraftOther('');
}}
options={addOptions}
placeholder={placeholder ?? '— выберите —'}
/>
</div>
{isOther && (
<div className={styles.addField}>
<TextInput value={draftOther} onChange={setDraftOther} placeholder="Введите значение" />
</div>
)}
<Button type="button" variant="secondary" onClick={add} disabled={!pending}>
Добавить
</Button>
</div>
)}
</div>
);
};
@@ -1,16 +1,23 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../shared/ui';
import { ISectionProps, EMPLOYMENT_TYPE_OPTIONS } from '../model';
import { LanguagesField } from './LanguagesField';
import { MultiChoiceField } from './MultiChoiceField';
import {
ISectionProps,
EMPLOYMENT_TYPE_OPTIONS,
EDUCATION_OPTIONS,
WORK_EXPERIENCE_OPTIONS,
RESIDENCE_OPTIONS,
SOFTWARE_OPTIONS,
OTHER_VALUE
} from '../model';
export const VacancySection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Вакансия" icon="document">
<FormField label="Должность" htmlFor="Position" required full>
<CollapseSection title="3. Вакансия" icon="document">
<FormField label="3.1 Должность" htmlFor="Position" required full>
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormField>
<FormField label="Код по классификатору" htmlFor="ClassifierCode">
<TextInput id="ClassifierCode" value={data.ClassifierCode} onChange={(v) => set('ClassifierCode', v)} />
</FormField>
<FormField label="Характер занятости" htmlFor="EmploymentType">
<FormField label="3.2 Характер занятости" htmlFor="EmploymentType">
<Select
id="EmploymentType"
value={data.EmploymentType}
@@ -19,40 +26,63 @@ export const VacancySection: React.FC<ISectionProps> = ({ data, set }) => (
placeholder="— выберите —"
/>
</FormField>
<FormField label="Основной функционал" htmlFor="MainFunction" full>
<TextArea id="MainFunction" value={data.MainFunction} onChange={(v) => set('MainFunction', v)} />
<FormField label="3.3 Функционал" htmlFor="JobFunction" full>
<TextArea id="JobFunction" value={data.JobFunction} onChange={(v) => set('JobFunction', v)} />
</FormField>
<FormField label="Дополнительный функционал" htmlFor="AdditionalFunction" full>
<TextArea id="AdditionalFunction" value={data.AdditionalFunction} onChange={(v) => set('AdditionalFunction', v)} />
</FormField>
<FormField label="Компетенции" htmlFor="Competencies" full>
<FormField label="3.4 Компетенции" htmlFor="Competencies" full>
<TextArea id="Competencies" value={data.Competencies} onChange={(v) => set('Competencies', v)} />
</FormField>
<FormField label="Личностные характеристики" htmlFor="PersonalTraits" full>
<FormField label="3.5 Личностные характеристики" htmlFor="PersonalTraits" full>
<TextArea id="PersonalTraits" value={data.PersonalTraits} onChange={(v) => set('PersonalTraits', v)} />
</FormField>
<FormField label="Образование" htmlFor="Education">
<TextInput id="Education" value={data.Education} onChange={(v) => set('Education', v)} />
<FormField label="3.6 Образование" htmlFor="Education">
<Select
id="Education"
value={data.Education}
onChange={(v) => set('Education', v)}
options={EDUCATION_OPTIONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Опыт работы" htmlFor="WorkExperience" full>
<TextArea id="WorkExperience" value={data.WorkExperience} onChange={(v) => set('WorkExperience', v)} />
<FormField label="3.7 Опыт работы" htmlFor="WorkExperience">
<Select
id="WorkExperience"
value={data.WorkExperience}
onChange={(v) => set('WorkExperience', v)}
options={WORK_EXPERIENCE_OPTIONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Дополнительное образование" htmlFor="AdditionalEducation" full>
<TextArea id="AdditionalEducation" value={data.AdditionalEducation} onChange={(v) => set('AdditionalEducation', v)} />
<FormField label="3.8 Необходимые знания в программах" full>
<MultiChoiceField
value={data.Programs}
onChange={(v) => set('Programs', v)}
options={SOFTWARE_OPTIONS}
allowOther
placeholder="Выберите программу"
/>
</FormField>
<FormField label="Критерии оценки работы" htmlFor="EvaluationCriteria" full>
<TextArea id="EvaluationCriteria" value={data.EvaluationCriteria} onChange={(v) => set('EvaluationCriteria', v)} />
<FormField label="3.9 Иностранные языки" full>
<LanguagesField value={data.Languages} onChange={(v) => set('Languages', v)} />
</FormField>
<FormField label="Уровень владения ПК и программами" htmlFor="PcSkills" full>
<TextArea id="PcSkills" value={data.PcSkills} onChange={(v) => set('PcSkills', v)} />
<FormField label="3.10 Проживание" htmlFor="Residence" required>
<Select
id="Residence"
value={data.Residence}
onChange={(v) => {
set('Residence', v);
if (v !== OTHER_VALUE) set('ResidenceOther', '');
}}
options={RESIDENCE_OPTIONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Иностранные языки (уровень владения)" htmlFor="ForeignLanguages" full>
<TextArea id="ForeignLanguages" value={data.ForeignLanguages} onChange={(v) => set('ForeignLanguages', v)} />
{data.Residence === OTHER_VALUE && (
<FormField label="Проживание (другое)" htmlFor="ResidenceOther">
<TextInput id="ResidenceOther" value={data.ResidenceOther} onChange={(v) => set('ResidenceOther', v)} />
</FormField>
<FormField label="Проживание" htmlFor="Residence">
<TextInput id="Residence" value={data.Residence} onChange={(v) => set('Residence', v)} />
</FormField>
<FormField label="Водительские права, стаж вождения" htmlFor="DrivingLicense">
)}
<FormField label="3.11 Водительские права, стаж вождения" htmlFor="DrivingLicense">
<TextInput id="DrivingLicense" value={data.DrivingLicense} onChange={(v) => set('DrivingLicense', v)} />
</FormField>
</CollapseSection>
@@ -9,7 +9,7 @@ const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'Di
// Поля-люди (несколько): логины → массив Id в колонку <Field>Id.
const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker'];
// Числовые поля: пустая строка → null, иначе Number().
const NUMBER_FIELDS: (keyof IFormData)[] = ['DepartmentHeadcount', 'RateProbation', 'RateAfterProbation'];
const NUMBER_FIELDS: (keyof IFormData)[] = ['OpenVacanciesCount', 'RateProbation', 'RateAfterProbation'];
/**
* Экземпляр сервиса списка согласования этой формы.
@@ -38,6 +38,26 @@ function buildScalarBody(data: IFormData): Record<string, unknown> {
if (PERSON_FIELDS.indexOf(key) !== -1 || MULTI_PERSON_FIELDS.indexOf(key) !== -1) {
return;
}
if (key === 'Companies') {
// Мультивыбор компаний не разворачивается в колонки — сериализуем в Note.
item.CompaniesJson = data.Companies.length > 0 ? JSON.stringify(data.Companies) : null;
return;
}
if (key === 'Languages') {
// Таблица «язык + уровень» — сериализуем в Note.
item.LanguagesJson = data.Languages.length > 0 ? JSON.stringify(data.Languages) : null;
return;
}
if (key === 'Programs') {
// Мультивыбор программ — сериализуем в Note.
item.ProgramsJson = data.Programs.length > 0 ? JSON.stringify(data.Programs) : null;
return;
}
if (key === 'WorkplaceProvision') {
// Мультивыбор категорий обеспечения — сериализуем в Note.
item.WorkplaceProvisionJson = data.WorkplaceProvision.length > 0 ? JSON.stringify(data.WorkplaceProvision) : null;
return;
}
const value = data[key];
if (typeof value === 'boolean') {
item[key] = value;
+2 -3
View File
@@ -2,9 +2,8 @@ define([], function() {
return {
"PropertyPaneDescription": "Description",
"BasicGroupName": "Group Name",
"DescriptionFieldLabel": "Description Field",
"DebugUserEmailFieldLabel": "Отладка: работать от имени сотрудника",
"DebugUserEmailFieldDescription": "Только для отладки маршрутов. Портал будет считать текущим пользователем указанный e-mail: по нему планируется маршрут, отбираются «свои» заявки и очередь согласования. Прав это не даёт — запись в списки всё равно идёт под вашей учёткой. На боевой странице оставьте пустым.",
"DebugUserFieldLabel": "Отладка: работать от имени сотрудника",
"DebugUserFieldDescription": "Только для отладки маршрутов. Введите фамилию и имя сотрудника (можно в обратном порядке, отчество не обязательно) — портал найдёт его e-mail сам, найденное отобразится над полем. Можно ввести e-mail напрямую. По этому e-mail планируется маршрут, отбираются «свои» заявки и очередь согласования. Прав это не даёт — запись в списки всё равно идёт под вашей учёткой. На боевой странице оставьте пустым.",
"AppLocalEnvironmentSharePoint": "The app is running on your local environment as SharePoint web part",
"AppLocalEnvironmentTeams": "The app is running on your local environment as Microsoft Teams app",
"AppLocalEnvironmentOffice": "The app is running on your local environment in office.com",
+2 -3
View File
@@ -1,9 +1,8 @@
declare interface IRequestPortalWebPartStrings {
PropertyPaneDescription: string;
BasicGroupName: string;
DescriptionFieldLabel: string;
DebugUserEmailFieldLabel: string;
DebugUserEmailFieldDescription: string;
DebugUserFieldLabel: string;
DebugUserFieldDescription: string;
AppLocalEnvironmentSharePoint: string;
AppLocalEnvironmentTeams: string;
AppLocalEnvironmentOffice: string;
@@ -0,0 +1,104 @@
import * as React from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { resolveDebugUser } from '../shared/impersonation';
export interface IDebugUserFieldProps {
label: string;
description: string;
context: WebPartContext;
initialQuery: string;
/** Зовётся при каждом изменении: query — как введено, email/displayName — итог поиска ('' если не найдено). */
onResolved: (query: string, email: string, displayName: string) => void;
}
type Status = 'idle' | 'loading' | 'found' | 'notfound' | 'email';
const DEBOUNCE_MS = 400;
/**
* Поле свойств веб-части: ввод «Фамилия Имя [Отчество]» (в любом порядке) или
* e-mail напрямую. Сам резолвит e-mail через SharePoint people search — см.
* shared/impersonation.ts (resolveDebugUser) — и показывает найденное тут же,
* чтобы не гадать, кого именно подставили.
*/
export const DebugUserField: React.FC<IDebugUserFieldProps> = ({ label, description, context, initialQuery, onResolved }) => {
const [query, setQuery] = React.useState(initialQuery || '');
const [status, setStatus] = React.useState<Status>('idle');
const [found, setFound] = React.useState<{ email: string; displayName: string } | undefined>(undefined);
React.useEffect(() => {
const text = query.trim();
if (!text) {
setStatus('idle');
setFound(undefined);
onResolved('', '', '');
return;
}
// E-mail введён напрямую — используем его как есть, но всё равно ищем ФИО
// (для красной полосы в интерфейсе и для проверки, что такой сотрудник есть).
const isEmail = text.indexOf('@') >= 0;
let cancelled = false;
setStatus(isEmail ? 'email' : 'loading');
if (isEmail) setFound(undefined);
const timer = window.setTimeout(() => {
resolveDebugUser(context, text)
.then((hit) => {
if (cancelled) return;
if (isEmail) {
setFound(hit);
onResolved(text, text, hit ? hit.displayName : '');
} else if (hit) {
setStatus('found');
setFound(hit);
onResolved(text, hit.email, hit.displayName);
} else {
setStatus('notfound');
setFound(undefined);
onResolved(text, '', '');
}
})
.catch(() => {
if (cancelled) return;
if (isEmail) {
onResolved(text, text, '');
} else {
setStatus('notfound');
setFound(undefined);
onResolved(text, '', '');
}
});
}, DEBOUNCE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
return (
<div>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>{label}</div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Иванов Иван Иванович, Иван Иванов или e-mail"
style={{ width: '100%', boxSizing: 'border-box', padding: '5px 8px', fontSize: 14 }}
/>
<div style={{ marginTop: 4, fontSize: 12, lineHeight: 1.4, color: '#a4262c', minHeight: 16 }}>
{status === 'loading' && 'Ищем сотрудника…'}
{status === 'found' && found && `Найдено: ${found.displayName} — ${found.email}`}
{status === 'notfound' && 'Сотрудник не найден — подмена работать не будет'}
{status === 'email' &&
(found
? `Используется как e-mail напрямую: ${query.trim()} (${found.displayName})`
: `Используется как e-mail напрямую: ${query.trim()}`)}
</div>
<div style={{ marginTop: 6, fontSize: 12, lineHeight: 1.4, color: '#605e5c' }}>{description}</div>
</div>
);
};
@@ -0,0 +1,59 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import {
IPropertyPaneField,
PropertyPaneFieldType,
IPropertyPaneCustomFieldProps
} from '@microsoft/sp-property-pane';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { DebugUserField } from './DebugUserField';
export interface IPropertyPaneDebugUserProps {
label: string;
description: string;
context: WebPartContext;
initialQuery: string;
onResolved: (query: string, email: string, displayName: string) => void;
}
class PropertyPaneDebugUserBuilder
implements IPropertyPaneField<IPropertyPaneCustomFieldProps & IPropertyPaneDebugUserProps> {
public type: PropertyPaneFieldType = PropertyPaneFieldType.Custom;
public targetProperty: string;
public properties: IPropertyPaneCustomFieldProps & IPropertyPaneDebugUserProps;
constructor(targetProperty: string, properties: IPropertyPaneDebugUserProps) {
this.targetProperty = targetProperty;
this.properties = {
key: targetProperty,
onRender: this.onRender.bind(this),
onDispose: this.onDispose.bind(this),
...properties
};
}
private onRender(elem: HTMLElement): void {
ReactDom.render(
<DebugUserField
label={this.properties.label}
description={this.properties.description}
context={this.properties.context}
initialQuery={this.properties.initialQuery}
onResolved={this.properties.onResolved}
/>,
elem
);
}
private onDispose(elem: HTMLElement): void {
ReactDom.unmountComponentAtNode(elem);
}
}
/** Кастомное поле property pane: ФИО/e-mail сотрудника с резолвом в e-mail на лету. */
export function PropertyPaneDebugUser(
targetProperty: string,
properties: IPropertyPaneDebugUserProps
): IPropertyPaneField<IPropertyPaneCustomFieldProps & IPropertyPaneDebugUserProps> {
return new PropertyPaneDebugUserBuilder(targetProperty, properties);
}
@@ -129,6 +129,7 @@ export class ApprovalListService {
toWrite.forEach(({ a, order }, i) => {
// void: промис от add() на batched-инстансе нарочно не ждём поштучно —
// он разрешится вместе со всеми остальными после execute() ниже.
// eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error
void batchedList.items.add({
Title: a.position || a.email,
[`${FIELDS.approver}Id`]: approverIds[i],
@@ -51,7 +51,10 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
// подсвечивается пол-таблицы и непонятно, что именно переехало. Запоминаем
// ключи блока ДО перестановки: после неё индексы уже другие.
const [activeKeys, setActiveKeys] = React.useState<string[]>([]);
const registerRow = useFlipRows(activeKeys, rowKeys);
// Обёртка таблицы: её высоту useFlipRows анимирует вместе с переездом строк,
// чтобы пикер и следующая карточка не подпрыгивали при удалении шага.
const scrollRef = React.useRef<HTMLDivElement>(null);
const registerRow = useFlipRows(activeKeys, rowKeys, scrollRef);
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
@@ -110,7 +113,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
<div className={styles.matrixNote}>{emptyText}</div>
)}
{!waiting && !loading && !error && rows.length > 0 && (
<div className={styles.matrixScroll}>
<div className={styles.matrixScroll} ref={scrollRef}>
<table className={styles.matrix}>
<thead>
<tr>
@@ -95,6 +95,19 @@
// сплющивания колонок. На телефоне таблица становится карточками (медиазапрос ниже).
.matrixScroll {
overflow-x: auto;
// overflow-y ОБЯЗАН быть задан явно. По спецификации, если одна ось получает
// прокрутку, вторая с `visible` вычисляется в `auto` — то есть один
// overflow-x: auto включал прокрутку и по вертикали тоже. А анимация
// перестановки (см. ui/useFlipRows) стартует с translateY на высоту строки:
// при удалении строки нижние сначала отрисованы СМЕЩЁННЫМИ ВНИЗ, вылезают за
// нижнюю границу контейнера — на кадр появляется вертикальная полоса
// прокрутки, из-за неё контейнер сужается, текст в колонках перевёрстывается,
// строки съезжают, а через 380 мс всё это откатывается назад. Отсюда и
// дрожание интерфейса на каждое удаление позиции.
//
// hidden, а не clip: `clip` не создаёт scroll container, и тогда вертикальный
// вылет анимации утёк бы наружу, прокручивая уже саму страницу.
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
@@ -22,8 +22,9 @@ import * as React from 'react';
// центра разрывало блок визуально. Плюс строка становилась шире контейнера
// и .matrixScroll (overflow-x: auto) на время анимации показывал полосу
// горизонтальной прокрутки.
// • пружинистый перелёт (cubic-bezier с >1): при overflow-x: auto вторая ось
// тоже становится auto, и вылет за край таблицы строку бы обрезало.
// • пружинистый перелёт (cubic-bezier с >1): вылет за край .matrixScroll
// обрезается (там overflow-y: hidden — см. approval.module.scss), так что
// перелёт был бы виден только как срезанная строка.
// • подсветка ВСЕХ переехавших строк: при обмене двух шагов двигаются оба, и
// жёлтым заливало пол-таблицы. Подсвечиваем только тот блок, который двигал
// пользователь (activeKeys).
@@ -108,10 +109,16 @@ function measureTops(nodes: Map<string, HTMLTableRowElement>): Map<string, numbe
* @param rowKeys ключи строк в ТЕКУЩЕМ порядке — единственный сигнал, по
* которому решаем, действительно ли что-то переставили (см. ниже, почему
* геометрии для этого недостаточно).
* @param containerRef прокручиваемая обёртка таблицы (.matrixScroll). Её высоту
* анимируем в такт переезду строк: без этого React схлопывает высоту таблицы
* мгновенно, а строки едут 380 мс — и всё, что стоит НИЖЕ маршрута (пикер
* «Добавить согласующего», следующая карточка), рывком подпрыгивает на высоту
* удалённой строки. Необязателен: без него анимируются только строки.
*/
export function useFlipRows(
activeKeys: string[],
rowKeys: string[]
rowKeys: string[],
containerRef?: React.RefObject<HTMLElement>
// eslint-disable-next-line @rushstack/no-new-null -- null здесь диктует React: ref-колбэк получает именно null при отвязке узла
): (key: string) => (el: HTMLTableRowElement | null) => void {
const nodes = React.useRef(new Map<string, HTMLTableRowElement>());
@@ -121,6 +128,10 @@ export function useFlipRows(
const prevTops = React.useRef(new Map<string, number>());
// Порядок строк на прошлый коммит — см. проверку orderChanged ниже.
const prevRowKeys = React.useRef<string[]>([]);
// Высота обёртки на прошлый коммит. Нужна именно запомненная: layout-эффект
// работает УЖЕ ПОСЛЕ коммита, и к этому моменту высота на экране новая —
// «откуда ехать» из текущей раскладки не узнать.
const prevHeight = React.useRef<number | undefined>(undefined);
// Свои анимации держим сами, чтобы отменять ИМЕННО их: el.getAnimations()
// вернул бы заодно чужие (CSS-переходы и т.п.).
const running = React.useRef<Animation[]>([]);
@@ -156,10 +167,13 @@ export function useFlipRows(
rowKeys.length !== prevRowKeys.current.length || rowKeys.some((k, i) => k !== prevRowKeys.current[i]);
prevRowKeys.current = rowKeys;
const container = containerRef ? containerRef.current : null;
if (!orderChanged) {
// Порядок тот же — просто обновляем базовую геометрию для следующего
// ДЕЙСТВИТЕЛЬНОГО сравнения, без единой анимации на этом проходе.
prevTops.current = measureTops(nodes.current);
prevHeight.current = container ? container.getBoundingClientRect().height : undefined;
return;
}
@@ -167,6 +181,10 @@ export function useFlipRows(
// она в этот момент ещё летит, и новая анимация обязана стартовать от её
// текущего положения на экране, иначе будет рывок.
const visualTops = measureTops(nodes.current);
// То же и для высоты обёртки: если анимация высоты ещё идёт, здесь мы
// получим её промежуточное значение (после cancel ниже высота схлопнется к
// натуральной, и «откуда ехать» было бы уже не восстановить).
const visualHeight = container ? container.getBoundingClientRect().height : undefined;
running.current.forEach((a) => a.cancel());
running.current = [];
@@ -195,7 +213,38 @@ export function useFlipRows(
prevTops.current = current;
if (prefersReducedMotion() || (moved.length === 0 && entered.length === 0)) return;
// Высота: «куда» — натуральная (анимации уже отменены). «Откуда» — то, что
// было видно: промежуточное значение недоигранной анимации, если она шла
// (visualHeight разошёлся с натуральным), иначе высота прошлого коммита.
const heightTo = container ? container.getBoundingClientRect().height : undefined;
const heightFrom =
visualHeight !== undefined && heightTo !== undefined && Math.abs(visualHeight - heightTo) >= MIN_SHIFT_PX
? visualHeight
: prevHeight.current;
prevHeight.current = heightTo;
const heightChanged =
!!container &&
heightFrom !== undefined &&
heightTo !== undefined &&
Math.abs(heightFrom - heightTo) >= MIN_SHIFT_PX;
// heightChanged в условии — отдельным слагаемым: при удалении ПОСЛЕДНЕЙ
// строки ехать некому (moved и entered пусты), но высота всё равно
// меняется, и без анимации нижняя карточка снова подпрыгнула бы.
if (prefersReducedMotion() || (moved.length === 0 && entered.length === 0 && !heightChanged)) return;
if (heightChanged && container) {
// Та же длительность и easing, что у переезда строк: нижняя граница
// обёртки должна идти ровно за последней строкой, иначе рассинхрон видно.
// Обрезку лишнего на время анимации берёт на себя overflow-y: hidden
// (см. .matrixScroll в approval.module.scss).
running.current.push(
container.animate([{ height: `${heightFrom as number}px` }, { height: `${heightTo as number}px` }], {
duration: MOVE_MS,
easing: MOVE_EASING,
})
);
}
moved.forEach(({ el, key, shift }) => {
const isActive = activeKeys.indexOf(key) !== -1;
+5 -4
View File
@@ -23,10 +23,11 @@ export const APPROVAL_API_BASE_URL_PROD = 'https://api.tnsu.kz/approval-route';
export const APPROVAL_API_BASE_URL_LOCAL = 'http://localhost:5080';
// Настраивается свойством веб-части approvalApiBaseUrl (селект в панели свойств,
// см. RequestPortalWebPart.render) — тот же приём, что и у debugUserEmail/
// setImpersonatedEmail: модульный синглтон, а не проброс параметром через все
// вызовы approvalRoute.service.ts, потому что эти вызовы разбросаны по формам и
// не имеют общего входа, через который можно было бы прокинуть контекст.
// см. RequestPortalWebPart.render) — тот же приём, что и у debugUserQuery/
// setImpersonatedIdentity (shared/impersonation.ts): модульный синглтон, а не
// проброс параметром через все вызовы approvalRoute.service.ts, потому что эти
// вызовы разбросаны по формам и не имеют общего входа, через который можно
// было бы прокинуть контекст.
let _approvalApiBaseUrl = APPROVAL_API_BASE_URL_PROD;
export function setApprovalApiBaseUrl(url: string): void {
@@ -0,0 +1,31 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
import { ICompany } from './models/ICompany';
// GET /Dictionary/Companies — справочник компаний, ответ — плоский массив
// { Id, Name } (без обёртки, как /Dictionary/Departments).
async function fetchCompanies(context: WebPartContext): Promise<ICompany[]> {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const response: HttpClientResponse = await client.get(
`${DICTIONARY_API_BASE_URL}/Dictionary/Companies`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`Dictionary/Companies: ${response.status} ${response.statusText}`);
}
return response.json();
}
// Справочник в рамках сессии не меняется — грузим один раз и отдаём тот же
// промис повторным вызовам (как в departments.service).
let cache: Promise<ICompany[]> | undefined;
export function getCompanies(context: WebPartContext): Promise<ICompany[]> {
if (!cache) {
cache = fetchCompanies(context).catch((e) => {
cache = undefined;
throw e;
});
}
return cache;
}
@@ -6,3 +6,11 @@ export { getProjects } from './projects.service';
export type { IProject } from './models/IProject';
export { ProjectPicker } from './ui/ProjectPicker';
export type { IProjectPickerProps } from './ui/ProjectPicker';
export { getCompanies } from './companies.service';
export type { ICompany } from './models/ICompany';
export { CompanyPickerMulti } from './ui/CompanyPickerMulti';
export type { ICompanyPickerMultiProps } from './ui/CompanyPickerMulti';
export { getProjects1C } from './projects1c.service';
export type { IProject1C } from './models/IProject1C';
export { Project1CPicker } from './ui/Project1CPicker';
export type { IProject1CPickerProps } from './ui/Project1CPicker';
@@ -0,0 +1,8 @@
// GET https://api.tnsu.kz/Dictionary/Companies → плоский массив { Id, Name }
// (PascalCase, без обёртки — как /Dictionary/Departments).
export interface ICompany {
/** GUID компании — стабильный идентификатор. */
Id: string;
/** Наименование (напр. «ТОО AFL Engineering») — то, что видит пользователь. */
Name: string;
}
@@ -0,0 +1,11 @@
// GET https://api.tnsu.kz/Dictionary/Projects1C → плоский массив
// { Id, Code, ProjectName } (PascalCase, без обёртки — как /Dictionary/Companies).
// Это отдельный справочник 1С-проектов; не путать с /Project (approval-route).
export interface IProject1C {
/** GUID проекта — стабильный идентификатор. */
Id: string;
/** Код проекта (напр. «БК0000069»). */
Code: string;
/** Наименование проекта — то, что видит пользователь. */
ProjectName: string;
}
@@ -0,0 +1,31 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
import { IProject1C } from './models/IProject1C';
// GET /Dictionary/Projects1C — справочник проектов 1С, ответ — плоский массив
// { Id, Code, ProjectName } (без обёртки, как /Dictionary/Companies).
async function fetchProjects1C(context: WebPartContext): Promise<IProject1C[]> {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const response: HttpClientResponse = await client.get(
`${DICTIONARY_API_BASE_URL}/Dictionary/Projects1C`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`Dictionary/Projects1C: ${response.status} ${response.statusText}`);
}
return response.json();
}
// Справочник в рамках сессии не меняется — грузим один раз и отдаём тот же
// промис повторным вызовам (как в companies/departments.service).
let cache: Promise<IProject1C[]> | undefined;
export function getProjects1C(context: WebPartContext): Promise<IProject1C[]> {
if (!cache) {
cache = fetchProjects1C(context).catch((e) => {
cache = undefined;
throw e;
});
}
return cache;
}
@@ -0,0 +1,104 @@
@import '../../../../../styles/design-tokens';
.wrap {
position: relative;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: $sp-1;
margin-bottom: $sp-1;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 5px 6px 5px 10px;
border: 1px solid $gray-200;
border-radius: 999px;
background: $navy-50;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
word-break: break-word;
}
.chipRemove {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: $gray-500;
font-size: 15px;
line-height: 1;
cursor: pointer;
transition: .15s;
&:hover {
background: $gray-200;
color: $gray-900;
}
}
.field {
position: relative;
}
.input {
box-sizing: border-box;
width: 100%;
padding: 11px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-900;
transition: .18s;
@include input-focus-gold;
&::placeholder {
color: $gray-400;
}
}
.list {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin-top: $sp-1;
max-height: 260px;
overflow-y: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-sm;
box-shadow: $sh-2;
}
.item {
display: block;
width: 100%;
padding: 9px 12px;
border: 0;
background: transparent;
text-align: left;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
cursor: pointer;
&:hover {
background: $navy-50;
}
}
@@ -0,0 +1,129 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './CompanyPickerMulti.module.scss';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { getCompanies } from '../companies.service';
import { ICompany } from '../models/ICompany';
export interface ICompanyPickerMultiProps {
context: WebPartContext;
id?: string;
/** Выбранные компании (Id + Name). */
value: ICompany[];
onChange: (companies: ICompany[]) => void;
placeholder?: string;
/** Только чтение (режим согласующего). */
readOnly?: boolean;
}
const MAX_SUGGESTIONS = 30;
/**
* Мультивыбор компаний из справочника (GET /Dictionary/Companies): ввод текста
* фильтрует список, выбранные показываются чипами. Хранит массив { Id, Name }.
*/
export const CompanyPickerMulti: React.FC<ICompanyPickerMultiProps> = ({
context,
id,
value,
onChange,
placeholder,
readOnly
}) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [companies, setCompanies] = React.useState<ICompany[]>([]);
const [query, setQuery] = React.useState<string>('');
const [open, setOpen] = React.useState<boolean>(false);
React.useEffect(() => {
let alive = true;
getCompanies(context)
.then((list) => {
if (alive) setCompanies(list);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [context]);
const selectedIds = React.useMemo(() => new Set(value.map((c) => c.Id)), [value]);
const suggestions = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return companies
.filter((c) => !selectedIds.has(c.Id) && c.Name.toLowerCase().indexOf(q) !== -1)
.slice(0, MAX_SUGGESTIONS);
}, [companies, query, selectedIds]);
const add = (company: ICompany): void => {
onChange([...value, { Id: company.Id, Name: company.Name }]);
setQuery('');
setOpen(false);
};
const remove = (companyId: string): void => {
onChange(value.filter((c) => c.Id !== companyId));
};
return (
<div className={styles.wrap}>
{value.length > 0 && (
<div className={styles.chips}>
{value.map((c) => (
<span key={c.Id} className={styles.chip}>
{c.Name}
{!effectiveReadOnly && (
<button
type="button"
className={styles.chipRemove}
aria-label={`Убрать ${c.Name}`}
title="Убрать"
onClick={() => remove(c.Id)}
>
×
</button>
)}
</span>
))}
</div>
)}
{!effectiveReadOnly && (
<div className={styles.field}>
<input
id={id}
className={styles.input}
value={query}
placeholder={placeholder}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
{open && suggestions.length > 0 && (
<div className={styles.list}>
{suggestions.map((c) => (
<button
key={c.Id}
type="button"
className={styles.item}
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick.
onMouseDown={(e) => e.preventDefault()}
onClick={() => add(c)}
>
{c.Name}
</button>
))}
</div>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,90 @@
@import '../../../../../styles/design-tokens';
.wrap {
position: relative;
}
.input {
box-sizing: border-box;
width: 100%;
// справа место под крестик-очистку
padding: 11px 36px 11px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-900;
transition: .18s;
@include input-focus-gold;
@include readonly-field(':read-only');
&::placeholder {
color: $gray-400;
}
}
.clear {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-400;
font-size: 18px;
line-height: 1;
cursor: pointer;
transition: .15s;
&:hover {
background: $gray-100;
color: $gray-700;
}
}
.list {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin-top: $sp-1;
max-height: 260px;
overflow-y: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-sm;
box-shadow: $sh-2;
}
.item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 9px 12px;
border: 0;
background: transparent;
text-align: left;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
cursor: pointer;
&:hover {
background: $navy-50;
}
}
.meta {
font-size: $fs-caption;
color: $gray-500;
}
@@ -0,0 +1,118 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './Project1CPicker.module.scss';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { getProjects1C } from '../projects1c.service';
import { IProject1C } from '../models/IProject1C';
export interface IProject1CPickerProps {
context: WebPartContext;
id?: string;
/** Текущее наименование проекта (то, что видно в поле). */
value: string;
/**
* name — введённый/выбранный текст; id — GUID проекта ('' при ручном вводе);
* code — код проекта ('' при ручном вводе). При выборе из списка приходят все три.
*/
onChange: (name: string, id: string, code: string) => void;
placeholder?: string;
/** Только чтение (режим согласующего). */
readOnly?: boolean;
}
const MAX_SUGGESTIONS = 30;
/** Поле выбора проекта из справочника 1С (GET /Dictionary/Projects1C): ввод текста фильтрует список по названию/коду. */
export const Project1CPicker: React.FC<IProject1CPickerProps> = ({
context,
id,
value,
onChange,
placeholder,
readOnly
}) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [projects, setProjects] = React.useState<IProject1C[]>([]);
const [open, setOpen] = React.useState<boolean>(false);
React.useEffect(() => {
let alive = true;
getProjects1C(context)
.then((list) => {
if (alive) setProjects(list);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [context]);
const suggestions = React.useMemo(() => {
const query = value.trim().toLowerCase();
if (!query) return [];
return projects
.filter(
(p) =>
p.ProjectName.toLowerCase().indexOf(query) !== -1 ||
(p.Code && p.Code.toLowerCase().indexOf(query) !== -1)
)
.slice(0, MAX_SUGGESTIONS);
}, [projects, value]);
const handlePick = (project: IProject1C): void => {
onChange(project.ProjectName, project.Id, project.Code);
setOpen(false);
};
return (
<div className={styles.wrap}>
<input
id={id}
className={styles.input}
value={value}
placeholder={placeholder}
readOnly={effectiveReadOnly}
onChange={(e) => {
onChange(e.target.value, '', '');
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
{!effectiveReadOnly && value.length > 0 && (
<button
type="button"
className={styles.clear}
aria-label="Очистить"
title="Очистить"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange('', '', '');
setOpen(false);
}}
>
×
</button>
)}
{!effectiveReadOnly && open && suggestions.length > 0 && (
<div className={styles.list}>
{suggestions.map((p) => (
<button
key={p.Id}
type="button"
className={styles.item}
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick.
onMouseDown={(e) => e.preventDefault()}
onClick={() => handlePick(p)}
>
{p.ProjectName}
<span className={styles.meta}>{p.Code}</span>
</button>
))}
</div>
)}
</div>
);
};
@@ -1,4 +1,5 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { SPHttpClient } from '@microsoft/sp-http';
/**
* ОТЛАДОЧНАЯ подмена «текущего пользователя»: портал ведёт себя так, будто им
@@ -22,10 +23,12 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
*/
let impersonatedEmail = '';
let impersonatedDisplayName = '';
/** Вызывается веб-частью на каждый render — свойство могли поменять в панели. */
export function setImpersonatedEmail(email: string | undefined): void {
export function setImpersonatedIdentity(email: string | undefined, displayName?: string): void {
impersonatedEmail = (email || '').trim();
impersonatedDisplayName = (displayName || '').trim();
}
/** Заданная подмена ('' — подмены нет). Для предупреждающей полосы в интерфейсе. */
@@ -33,7 +36,89 @@ export function getImpersonatedEmail(): string {
return impersonatedEmail;
}
/** ФИО подменённого сотрудника, если его удалось найти поиском по имени ('' — для e-mail, введённого напрямую). */
export function getImpersonatedDisplayName(): string {
return impersonatedDisplayName;
}
/** Email, под которым портал работает: подменённый, если задан, иначе — из токена. */
export function effectiveUserEmail(context: WebPartContext): string {
return impersonatedEmail || context.pageContext.user.email || '';
}
export interface IResolvedDebugUser {
email: string;
displayName: string;
}
interface IPeopleSearchHit {
displayName: string;
email: string;
}
/** Сырой ответ ClientPeoplePickerSearchUser — value внутри двойного JSON. */
interface IPeoplePickerEntity {
DisplayText?: string;
Description?: string;
EntityData?: { Email?: string };
}
async function searchPeopleByText(context: WebPartContext, queryText: string): Promise<IPeopleSearchHit[]> {
const url = `${context.pageContext.web.absoluteUrl}/_api/SP.UI.ApplicationPages.ClientPeoplePickerWebServiceInterface.clientPeoplePickerSearchUser`;
const body = JSON.stringify({
queryParams: {
AllowEmailAddresses: true,
AllowMultipleEntities: false,
MaximumEntitySuggestions: 5,
PrincipalSource: 15, // All
PrincipalType: 1, // User
QueryString: queryText
}
});
const response = await context.spHttpClient.post(url, SPHttpClient.configurations.v1, {
headers: {
Accept: 'application/json;odata=nometadata',
'Content-Type': 'application/json;odata=nometadata'
},
body
});
if (!response.ok) return [];
const json: { value?: string } = await response.json();
if (!json.value) return [];
let entries: IPeoplePickerEntity[];
try {
entries = JSON.parse(json.value);
} catch {
return [];
}
return entries
.map((e) => ({ displayName: e.DisplayText || '', email: e.EntityData?.Email || e.Description || '' }))
.filter((e) => !!e.email);
}
/**
* Ищет сотрудника по имени для отладочной подмены (см. property pane —
* PropertyPaneDebugUser/DebugUserField). Порядок «Фамилия Имя» или «Имя
* Фамилия» не важен: если прямой запрос ничего не находит, пробуем с
* переставленными первыми двумя словами.
*/
export async function resolveDebugUser(context: WebPartContext, nameQuery: string): Promise<IResolvedDebugUser | undefined> {
const text = nameQuery.trim();
if (!text) return undefined;
let hits = await searchPeopleByText(context, text);
if (hits.length === 0) {
const tokens = text.split(/\s+/).filter(Boolean);
if (tokens.length >= 2) {
const swapped = [tokens[1], tokens[0], ...tokens.slice(2)].join(' ');
hits = await searchPeopleByText(context, swapped);
}
}
return hits[0];
}
@@ -4,12 +4,21 @@
// достаточно для нечастых событий (сохранение формы, решение согласующего).
const EVENT_NAME = 'rp-requests-changed';
export function emitRequestsChanged(): void {
window.dispatchEvent(new Event(EVENT_NAME));
/**
* force=true — повторный клик по УЖЕ открытому пункту меню (см. goto в
* RequestPortal.tsx): пользователь явно просит обновить список, поэтому таблица
* прячет прежние строки и показывает «Загрузка…», пока не придёт свежий ответ.
* force=false/не задан — фоновые обновления (создание заявки, решение
* согласующего, десятиминутный автообновление): строки остаются на экране, чтобы
* не моргать, если данные не изменились.
*/
export function emitRequestsChanged(force?: boolean): void {
window.dispatchEvent(new CustomEvent<{ force?: boolean }>(EVENT_NAME, { detail: { force } }));
}
/** Подписка на изменения. Возвращает функцию отписки (вызвать в cleanup эффекта). */
export function onRequestsChanged(handler: () => void): () => void {
window.addEventListener(EVENT_NAME, handler);
return () => window.removeEventListener(EVENT_NAME, handler);
export function onRequestsChanged(handler: (force?: boolean) => void): () => void {
const listener = (e: Event): void => handler((e as CustomEvent<{ force?: boolean }>).detail?.force);
window.addEventListener(EVENT_NAME, listener);
return () => window.removeEventListener(EVENT_NAME, listener);
}