diff --git a/.eslintrc.js b/.eslintrc.js index a790d60..cb42e16 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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. diff --git a/src/webparts/requestPortal/RequestPortalWebPart.manifest.json b/src/webparts/requestPortal/RequestPortalWebPart.manifest.json index 594ae8a..33195e2 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.manifest.json +++ b/src/webparts/requestPortal/RequestPortalWebPart.manifest.json @@ -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": {} }] } diff --git a/src/webparts/requestPortal/RequestPortalWebPart.ts b/src/webparts/requestPortal/RequestPortalWebPart.ts index df494cb..d4524ff 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.ts +++ b/src/webparts/requestPortal/RequestPortalWebPart.ts @@ -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(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 { - 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 { + // Сохраняем ТОЛЬКО 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(); + } + } }) ] }, diff --git a/src/webparts/requestPortal/components/IRequestPortalProps.ts b/src/webparts/requestPortal/components/IRequestPortalProps.ts index b18b06d..0d71bb6 100644 --- a/src/webparts/requestPortal/components/IRequestPortalProps.ts +++ b/src/webparts/requestPortal/components/IRequestPortalProps.ts @@ -1,7 +1,6 @@ import { WebPartContext } from '@microsoft/sp-webpart-base'; export interface IRequestPortalProps { - description: string; isDarkTheme: boolean; environmentMessage: string; hasTeamsContext: boolean; diff --git a/src/webparts/requestPortal/components/RequestPortal.module.scss b/src/webparts/requestPortal/components/RequestPortal.module.scss index b34cbb5..9aa1ad4 100644 --- a/src/webparts/requestPortal/components/RequestPortal.module.scss +++ b/src/webparts/requestPortal/components/RequestPortal.module.scss @@ -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 { diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 0eba3a4..2a70890 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -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 = ({ hasTeamsContext, context // (setImpersonatedEmail в render) до создания этого элемента, и меняется оно // только вместе с новым render всей веб-части. const impersonatedEmail = getImpersonatedEmail(); + const impersonatedDisplayName = getImpersonatedDisplayName(); const route = useRoute(); const [navCollapsed, setNavCollapsed] = React.useState(false); @@ -133,7 +134,7 @@ const RequestPortal: React.FC = ({ 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 = ({ hasTeamsContext, context закрыть: она исчезает только когда свойство веб-части очищено. */} {impersonatedEmail && (
- Включена отладочная подмена: портал работает от имени {impersonatedEmail}. - Очистите поле в свойствах веб-части, чтобы вернуться к своей учётной записи. + Включена отладочная подмена: портал работает от имени{' '} + + {impersonatedDisplayName ? `${impersonatedDisplayName} (${impersonatedEmail})` : impersonatedEmail} + + . Очистите поле в свойствах веб-части, чтобы вернуться к своей учётной записи.
)}
diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 2a96cf0..9ec124b 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -416,7 +416,14 @@ export const RequestsTableView: React.FC = ({ }; load().catch(() => undefined); // ошибки уже обработаны внутри load через setError - const unsubscribe = onRequestsChanged(() => { + const unsubscribe = onRequestsChanged((force) => { + // Явный повторный клик по уже открытому пункту меню — пользователь ждёт + // видимого обновления, а не молчаливой подмены данных за спиной. Прежние + // строки прячем сразу; свежие подставит load(). + if (force) { + setRows(undefined); + setLoadedKey(''); + } load().catch(() => undefined); }); // Пока вкладка браузера скрыта, обновляться незачем — при возврате к ней diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.tsx b/src/webparts/requestPortal/features/myRequests/SearchView.tsx index 7038d4a..75bdca8 100644 --- a/src/webparts/requestPortal/features/myRequests/SearchView.tsx +++ b/src/webparts/requestPortal/features/myRequests/SearchView.tsx @@ -200,7 +200,8 @@ export const SearchView: React.FC = ({ }; load().catch(() => undefined); - const unsubscribe = onRequestsChanged(() => { + const unsubscribe = onRequestsChanged((force) => { + if (force) setRows(undefined); load().catch(() => undefined); }); const timer = window.setInterval(() => { diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx index 31b0bce..1bd3345 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx @@ -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 = ({ 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 = ({ rtId, data, toApprovers(matrix.rows), + toApprovers(executors.rows), status, editListId ); @@ -203,6 +230,10 @@ const ClearanceForm: React.FC = ({ 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 = ({ approval.searchUsers(q)} /> + {/* Блок исполнителя показываем, только когда по маршруту реально есть + исполнители (условие «IsManager» и т.п. могло не назначить ни одного). */} + {matrixReady && !executors.loading && executors.rows.length > 0 && ( + approval.searchUsers(q)} + title="Исполнение (после согласования)" + roleLabel="Исполнитель" + emptyText="Исполнители не заданы по маршруту." + /> + )} + = ({ 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(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 = ({ context, itemId, formKey approval.searchUsers(q)} /> + + {!executors.loading && executors.rows.length > 0 && ( + approval.searchUsers(q)} + title="Исполнение" + roleLabel="Исполнитель" + emptyText="Исполнители не назначены." + /> + )}
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} diff --git a/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx b/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx index 28ae2d1..9e45ea6 100644 --- a/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx +++ b/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx @@ -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 = ({ placeholder={EMPLOYEE_PLACEHOLDER} /> + {/* Подтягивается из профиля при выборе сотрудника, но поле остаётся + редактируемым: если профиль не синхронизирован (SPS-JobTitle пуст), + HR должен суметь ввести должность вручную — иначе маршрут + согласования (условие «IsManager» резолвится именно по ней) не + соберётся, и список исполнителей ниже останется пустым. */} set('Position', v)} placeholder="Подставится автоматически по выбранному сотруднику" /> diff --git a/src/webparts/requestPortal/forms/Clearance/submit.ts b/src/webparts/requestPortal/forms/Clearance/submit.ts index 0745a12..aa3da4f 100644 --- a/src/webparts/requestPortal/forms/Clearance/submit.ts +++ b/src/webparts/requestPortal/forms/Clearance/submit.ts @@ -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 { @@ -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 }; } diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index 125622e..3f1ae9d 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -123,9 +123,9 @@ const RecruitingSelectionForm: React.FC = ({ (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; diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/load.ts b/src/webparts/requestPortal/forms/RecruitingSelection/load.ts index 088be1d..5a363df 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/load.ts @@ -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(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; const data: IFormData = { ...EMPTY_DATA }; const writable = data as unknown as Record; + writable.Companies = parseJsonArray(item.CompaniesJson); + writable.Languages = parseJsonArray(item.LanguagesJson); + writable.Programs = parseJsonArray(item.ProgramsJson); + writable.WorkplaceProvision = parseJsonArray(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) { diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/model.ts b/src/webparts/requestPortal/forms/RecruitingSelection/model.ts index 35a1b92..146a94b 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/model.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/model.ts @@ -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 = 'Начните вводить имя сотрудника…'; diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json b/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json index 30af6ea..d8dd602 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json @@ -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 } ] } diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx index 5de503b..4f7e054 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx @@ -1,39 +1,85 @@ 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 = ({ data, set, context }) => ( - - - set('Responsible', v)} - placeholder={PERSON_PLACEHOLDER} - /> - - - set('SubmitDate', v)} /> - - - set('ClosingDeadline', v)} - /> - - -