From 3b4ee93b485ff0e068bd3ba263c4f77fc363eeff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Tue, 25 Aug 2026 11:43:57 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BF=D0=BE=D0=BB=D0=B5=20=D0=BE=D1=80=D0=B3=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=D0=BE=20?= =?UTF-8?q?=D0=B2=D1=81=D0=B5=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B8,=20?= =?UTF-8?q?=D0=B0=20=D1=82=D0=B0=D0=BA=D0=B6=D0=B5=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D1=88=D0=B8=D1=80=D0=B8=D0=BB=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D0=BD=D0=B5=D1=82=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=B0=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20ui-kit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 8 +- package.json | 2 +- src/webparts/requestPortal/data/requests.ts | 6 +- .../forms/Clearance/ClearanceForm.tsx | 25 +++- .../requestPortal/forms/Clearance/load.ts | 4 +- .../requestPortal/forms/Clearance/model.ts | 9 +- .../forms/Clearance/provisioning.json | 3 +- .../Clearance/sections/RequestSection.tsx | 3 +- .../requestPortal/forms/Clearance/submit.ts | 3 +- .../sections/ApplicationSection.tsx | 4 +- .../sections/ApplicationSection.tsx | 4 +- .../forms/Referral/ReferralForm.tsx | 37 ++++- .../requestPortal/forms/Referral/load.ts | 2 + .../requestPortal/forms/Referral/model.ts | 7 + .../forms/Referral/provisioning.json | 1 + .../Referral/sections/ReferrerSection.tsx | 3 +- .../requestPortal/forms/Referral/submit.ts | 1 + .../forms/TmcTransfer/TmcTransferForm.tsx | 16 +- .../requestPortal/forms/TmcTransfer/load.ts | 2 + .../requestPortal/forms/TmcTransfer/model.ts | 7 + .../forms/TmcTransfer/provisioning.json | 1 + .../TmcTransfer/sections/HeaderSection.tsx | 3 +- .../forms/UnpaidLeave/UnpaidLeaveForm.tsx | 46 ++++-- .../forms/UnpaidLeave/UnpaidLeaveView.tsx | 2 +- .../requestPortal/forms/UnpaidLeave/load.ts | 4 +- .../requestPortal/forms/UnpaidLeave/model.ts | 11 +- .../forms/UnpaidLeave/provisioning.json | 3 +- .../UnpaidLeave/sections/RequestSection.tsx | 16 +- .../requestPortal/forms/UnpaidLeave/submit.ts | 3 +- .../forms/Vacation/VacationForm.tsx | 47 ++++-- .../forms/Vacation/VacationView.tsx | 2 +- .../requestPortal/forms/Vacation/load.ts | 11 +- .../requestPortal/forms/Vacation/model.ts | 18 ++- .../forms/Vacation/provisioning.json | 4 +- .../Vacation/sections/RequestSection.tsx | 24 ++- .../requestPortal/forms/Vacation/submit.ts | 18 ++- .../WorkplaceSetup/WorkplaceSetupForm.tsx | 30 +++- .../forms/WorkplaceSetup/load.ts | 2 +- .../forms/WorkplaceSetup/model.ts | 8 + .../forms/WorkplaceSetup/provisioning.json | 1 + .../sections/EmployeeSection.tsx | 3 +- .../shared/approval/approvalRoute.service.ts | 141 +++++++++++------- .../shared/approval/ui/ApprovalMatrix.tsx | 11 +- .../shared/approval/ui/approval.module.scss | 17 +++ .../shared/approval/useMatrixEditor.ts | 13 +- .../requestPortal/shared/dateUtils.ts | 16 ++ .../employeeOrganizations.service.ts | 59 ++++++++ .../requestPortal/shared/dictionary/index.ts | 2 + .../models/IEmployeeOrganization.ts | 17 +++ .../OrganizationField.module.scss | 21 +++ .../OrganizationField/OrganizationField.tsx | 38 +++++ .../shared/ui/PeopleField/PeopleField.tsx | 2 + .../ui/PeopleField/PeopleFieldMulti.tsx | 2 + .../ui/PeopleField/peoplePickerStyles.ts | 40 +++++ src/webparts/requestPortal/shared/ui/index.ts | 1 + 55 files changed, 641 insertions(+), 143 deletions(-) create mode 100644 src/webparts/requestPortal/shared/dateUtils.ts create mode 100644 src/webparts/requestPortal/shared/dictionary/employeeOrganizations.service.ts create mode 100644 src/webparts/requestPortal/shared/dictionary/models/IEmployeeOrganization.ts create mode 100644 src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.module.scss create mode 100644 src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.tsx create mode 100644 src/webparts/requestPortal/shared/ui/PeopleField/peoplePickerStyles.ts diff --git a/package-lock.json b/package-lock.json index b8a94e3..1543f7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@microsoft/sp-webpart-base": "1.22.1", "@pnp/sp": "^4.20.0", "@pnp/spfx-controls-react": "^3.25.0", - "@tnsu/ui-kit": "^0.4.0", + "@tnsu/ui-kit": "^0.9.0", "react": "17.0.1", "react-dom": "17.0.1", "tslib": "2.3.1" @@ -9870,9 +9870,9 @@ "license": "0BSD" }, "node_modules/@tnsu/ui-kit": { - "version": "0.4.0", - "resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.4.0/ui-kit-0.4.0.tgz", - "integrity": "sha512-J3cBvoR2vV0SC5GihOagLAqvC0txQE342qIh6viabMt+Um6Jg4mhTff+ZkVahG0g+bF6sqSCUl3x813aO82KuQ==", + "version": "0.9.0", + "resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.9.0/ui-kit-0.9.0.tgz", + "integrity": "sha512-q4eLdsAd0FW7Jem1sVazztuFrUQxdP72d6KwBa7L/6YKqJzB2PzdaZqUWaFkp7+qgtxhK9Zotx18PHjG2i5Zmw==", "license": "ISC", "peerDependencies": { "@fluentui/react": "^8.106.4", diff --git a/package.json b/package.json index 4613a1b..7f3dd08 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@microsoft/sp-webpart-base": "1.22.1", "@pnp/sp": "^4.20.0", "@pnp/spfx-controls-react": "^3.25.0", - "@tnsu/ui-kit": "^0.4.0", + "@tnsu/ui-kit": "^0.9.0", "react": "17.0.1", "react-dom": "17.0.1", "tslib": "2.3.1" diff --git a/src/webparts/requestPortal/data/requests.ts b/src/webparts/requestPortal/data/requests.ts index 4e3a902..1735966 100644 --- a/src/webparts/requestPortal/data/requests.ts +++ b/src/webparts/requestPortal/data/requests.ts @@ -15,10 +15,10 @@ export const REQUEST_TYPES: IRequestType[] = [ { id: 'it-1c-ticket', categoryId: 'it', title: 'Заявка в отдел 1С', icon: 'clipboard' }, // Отдел кадров - { id: 'hr-vacation', categoryId: 'hr', title: 'Заявка на отпуск', icon: 'calendar' }, - { id: 'hr-unpaid-leave', categoryId: 'hr', title: 'Отпуск без содержания', icon: 'calendar' }, + { id: 'hr-vacation', categoryId: 'hr', title: 'Заявка на трудовой отпуск', icon: 'calendar' }, + { id: 'hr-unpaid-leave', categoryId: 'hr', title: 'Заявка на отпуск без содержания', icon: 'document' }, { id: 'clearance-form', categoryId: 'hr', title: 'Обходной лист', icon: 'clipboard' }, - { id: 'hr-transfer', categoryId: 'hr', title: 'на перемещение сотрудников', icon: 'send' }, + { id: 'hr-transfer', categoryId: 'hr', title: 'Заявка на перемещение сотрудников', icon: 'people' }, // Склад / Снабжение { id: 'wh-tmc-transfer', categoryId: 'warehouse', title: 'СЗ на перемещение ТМЦ', icon: 'send' } diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx index 355b7b8..ecd5371 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx @@ -26,6 +26,7 @@ import { unassignedStepMessage } from '../../shared/approval'; import { IRequestFormProps } from '../../models/types'; +import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { approvalListTitle, @@ -100,19 +101,29 @@ const ClearanceForm: React.FC = ({ setData((prev) => ({ ...prev, TerminationDate: prev.TerminationDate || today })); }, [editId]); - // Должность/подразделение больше не вводятся вручную — подтягиваются из - // SharePoint User Profile (SPS-JobTitle/SPS-Department) при выборе сотрудника. - // Если профиль не синхронизирован и поле там пусто — пока просто остаётся - // пустым (TODO: фолбэк на справочник api.tnsu.kz, если это окажется частой - // проблемой на практике). + // Подразделение подтягивается из SharePoint User Profile (SPS-Department) при + // выборе сотрудника — если профиль не синхронизирован, поле остаётся пустым. + // Должность и организации — из Employee/ByEmail (см. shared/dictionary/ + // employeeOrganizations.service.ts), а не из профиля: сотрудник может состоять + // сразу в нескольких организациях, должность берём из ПЕРВОЙ записи, чтобы + // Должность и Организация оставались парой из одной записи. const handleEmployeeChange = (login: string): void => { - setData((prev) => ({ ...prev, Employee: login, Position: '', Department: '' })); + setData((prev) => ({ ...prev, Employee: login, Position: '', Department: '', Organization: '' })); if (!login) { return; } approval .getProfileFields(login) - .then((profile) => setData((prev) => ({ ...prev, Position: profile.jobTitle, Department: profile.department }))) + .then((profile) => setData((prev) => ({ ...prev, Department: profile.department }))) + .catch(() => undefined); + getEmployeeOrganizations(context, login) + .then((records) => + setData((prev) => ({ + ...prev, + Position: records[0]?.position || prev.Position, + Organization: formatEmployeeOrganizations(records) + })) + ) .catch(() => undefined); }; diff --git a/src/webparts/requestPortal/forms/Clearance/load.ts b/src/webparts/requestPortal/forms/Clearance/load.ts index 9496198..01e3276 100644 --- a/src/webparts/requestPortal/forms/Clearance/load.ts +++ b/src/webparts/requestPortal/forms/Clearance/load.ts @@ -31,6 +31,7 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro 'ParkingCard', 'AccessCard', 'TrainingAmount', + 'Organization', 'RequestStatus', 'RequestUniqueCode', 'Employee/Title', @@ -50,7 +51,8 @@ export async function loadClearanceRequest(itemId: number, listId?: string): Pro TerminationDate: termination.slice(0, 10), // ISO → YYYY-MM-DD для input type=date ParkingCard: !!item.ParkingCard, AccessCard: !!item.AccessCard, - TrainingAmount: amount === null || amount === undefined ? '' : String(amount) + TrainingAmount: amount === null || amount === undefined ? '' : String(amount), + Organization: (item.Organization as string) || '' }; return { diff --git a/src/webparts/requestPortal/forms/Clearance/model.ts b/src/webparts/requestPortal/forms/Clearance/model.ts index b7400d3..217e533 100644 --- a/src/webparts/requestPortal/forms/Clearance/model.ts +++ b/src/webparts/requestPortal/forms/Clearance/model.ts @@ -25,6 +25,12 @@ export interface IFormData { AccessCard: boolean; /** Сумма обучения — «сырые» цифры без пробелов (см. MoneyInput). Пусто = не указана. */ TrainingAmount: string; + /** + * Организации сотрудника (`\n`-joined «Компания — должность» на строку) — + * подтягивается автоматически из Employee/ByEmail при выборе Employee, + * read-only (см. shared/ui/OrganizationField). + */ + Organization: string; } /** Обновление одного поля формы с сохранением типа значения. */ @@ -49,7 +55,8 @@ export const EMPTY_DATA: IFormData = { TerminationDate: '', ParkingCard: false, AccessCard: false, - TrainingAmount: '' + TrainingAmount: '', + Organization: '' }; /** Плейсхолдер для поля выбора сотрудника. */ diff --git a/src/webparts/requestPortal/forms/Clearance/provisioning.json b/src/webparts/requestPortal/forms/Clearance/provisioning.json index 227ec46..bf10705 100644 --- a/src/webparts/requestPortal/forms/Clearance/provisioning.json +++ b/src/webparts/requestPortal/forms/Clearance/provisioning.json @@ -11,6 +11,7 @@ { "internalName": "ParkingCard", "title": "Парковочная карта", "type": "Boolean" }, { "internalName": "AccessCard", "title": "Карта СКУД", "type": "Boolean" }, - { "internalName": "TrainingAmount", "title": "Обучение: сумма", "type": "Number", "decimals": 2 } + { "internalName": "TrainingAmount", "title": "Обучение: сумма", "type": "Number", "decimals": 2 }, + { "internalName": "Organization", "title": "Организация", "type": "Note" } ] } diff --git a/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx b/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx index 9e45ea6..79fd408 100644 --- a/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx +++ b/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField } from '../../../shared/ui'; +import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField, OrganizationField } from '../../../shared/ui'; import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model'; // Подразделение подтягивается автоматически при выборе сотрудника (см. @@ -54,6 +54,7 @@ export const RequestSection: React.FC = ({ placeholder="Подставится автоматически по выбранному сотруднику" /> + = ({ data, set, context return ( - + set('Companies', companies)} - placeholder="Начните вводить название компании…" + placeholder="Начните вводить название организации…" /> diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx index b36e38f..3853ea7 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx @@ -58,13 +58,13 @@ export const ApplicationSection: React.FC = ({ data, set, context /> )} - + set('Companies', v)} - placeholder="Начните вводить название компании…" + placeholder="Начните вводить название организации…" /> diff --git a/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx index dc724a5..b281a19 100644 --- a/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx +++ b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx @@ -27,6 +27,7 @@ import { initialSubmitStatus } from '../../shared/approval'; import { IRequestFormProps } from '../../models/types'; +import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { approvalListTitle, @@ -94,9 +95,10 @@ const ReferralForm: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [editId, editListId]); - // Новая заявка: рекомендатель — сам подающий (подставляем его, должность и - // подразделение из профиля SharePoint — SPS-JobTitle/SPS-Department, как в - // «Обходном листе»), дата подачи — сегодня. + // Новая заявка: рекомендатель — сам подающий (подставляем его; подразделение — + // из профиля SharePoint SPS-Department, как в «Обходном листе»; должность и + // организации — из Employee/ByEmail, см. shared/dictionary/ + // employeeOrganizations.service.ts), дата подачи — сегодня. useEffect(() => { if (editId) { return; @@ -114,24 +116,35 @@ const ReferralForm: React.FC = ({ if (alive) { setData((prev) => ({ ...prev, - ReferrerPosition: prev.ReferrerPosition || profile.jobTitle, ReferrerDepartment: prev.ReferrerDepartment || profile.department })); } }) .catch(() => undefined); + getEmployeeOrganizations(context, user.email) + .then((records) => { + if (!alive) return; + setData((prev) => ({ + ...prev, + ReferrerPosition: prev.ReferrerPosition || records[0]?.position || prev.ReferrerPosition, + Organization: formatEmployeeOrganizations(records) + })); + }) + .catch(() => undefined); return () => { alive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [editId]); - // Выбор другого рекомендателя подтягивает его должность и подразделение из - // профиля — оба поля остаются редактируемыми (фолбэк, если профиль не - // синхронизирован), поэтому не затираем то, что уже введено, пустым значением. + // Выбор другого рекомендателя подтягивает его подразделение из профиля, + // должность и организации — из Employee/ByEmail. Оба поля (Position/ + // Department) остаются редактируемыми (фолбэк, если источник недоступен), + // поэтому не затираем то, что уже введено, пустым значением. const handleReferrerChange = (login: string): void => { setData((prev) => ({ ...prev, Referrer: login })); if (!login) { + setData((prev) => ({ ...prev, Organization: '' })); return; } approval @@ -139,11 +152,19 @@ const ReferralForm: React.FC = ({ .then((profile) => setData((prev) => ({ ...prev, - ReferrerPosition: profile.jobTitle || prev.ReferrerPosition, ReferrerDepartment: profile.department || prev.ReferrerDepartment })) ) .catch(() => undefined); + getEmployeeOrganizations(context, login) + .then((records) => + setData((prev) => ({ + ...prev, + ReferrerPosition: records[0]?.position || prev.ReferrerPosition, + Organization: formatEmployeeOrganizations(records) + })) + ) + .catch(() => undefined); }; // Планируемый маршрут резолвится по e-mail РЕКОМЕНДАТЕЛЯ (кандидата в тенанте diff --git a/src/webparts/requestPortal/forms/Referral/load.ts b/src/webparts/requestPortal/forms/Referral/load.ts index cbc574c..a3587a0 100644 --- a/src/webparts/requestPortal/forms/Referral/load.ts +++ b/src/webparts/requestPortal/forms/Referral/load.ts @@ -29,6 +29,7 @@ export async function loadReferralRequest(itemId: number, listId?: string): Prom 'ReferrerPosition', 'ReferrerDepartment', 'ReferrerPhone', + 'Organization', 'CandidateName', 'CandidatePhone', 'CandidateEmail', @@ -62,6 +63,7 @@ export async function loadReferralRequest(itemId: number, listId?: string): Prom ReferrerPosition: (item.ReferrerPosition as string) || '', ReferrerDepartment: (item.ReferrerDepartment as string) || '', ReferrerPhone: (item.ReferrerPhone as string) || '', + Organization: (item.Organization as string) || '', CandidateName: (item.CandidateName as string) || '', CandidatePhone: (item.CandidatePhone as string) || '', CandidateEmail: (item.CandidateEmail as string) || '', diff --git a/src/webparts/requestPortal/forms/Referral/model.ts b/src/webparts/requestPortal/forms/Referral/model.ts index 616493c..ec6c79b 100644 --- a/src/webparts/requestPortal/forms/Referral/model.ts +++ b/src/webparts/requestPortal/forms/Referral/model.ts @@ -42,6 +42,12 @@ export interface IFormData { ReferrerPosition: string; ReferrerDepartment: string; ReferrerPhone: string; + /** + * Организации рекомендателя (`\n`-joined «Компания — должность» на строку) — + * подтягивается автоматически из Employee/ByEmail при выборе Referrer, + * read-only (см. shared/ui/OrganizationField). + */ + Organization: string; CandidateName: string; CandidatePhone: string; @@ -87,6 +93,7 @@ export const EMPTY_DATA: IFormData = { ReferrerPosition: '', ReferrerDepartment: '', ReferrerPhone: '', + Organization: '', CandidateName: '', CandidatePhone: '', CandidateEmail: '', diff --git a/src/webparts/requestPortal/forms/Referral/provisioning.json b/src/webparts/requestPortal/forms/Referral/provisioning.json index f7652a5..1d93fc5 100644 --- a/src/webparts/requestPortal/forms/Referral/provisioning.json +++ b/src/webparts/requestPortal/forms/Referral/provisioning.json @@ -9,6 +9,7 @@ { "internalName": "ReferrerPosition", "title": "Должность рекомендателя", "type": "Text" }, { "internalName": "ReferrerDepartment", "title": "Подразделение / Объект рекомендателя", "type": "Text" }, { "internalName": "ReferrerPhone", "title": "Контактный телефон рекомендателя", "type": "Text" }, + { "internalName": "Organization", "title": "Организация", "type": "Note" }, { "internalName": "CandidateName", "title": "ФИО кандидата", "type": "Text", "required": true }, { "internalName": "CandidatePhone", "title": "Контактный телефон кандидата", "type": "Text" }, diff --git a/src/webparts/requestPortal/forms/Referral/sections/ReferrerSection.tsx b/src/webparts/requestPortal/forms/Referral/sections/ReferrerSection.tsx index b4b91fc..1d2a312 100644 --- a/src/webparts/requestPortal/forms/Referral/sections/ReferrerSection.tsx +++ b/src/webparts/requestPortal/forms/Referral/sections/ReferrerSection.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { CollapseSection, FormField, TextInput, PhoneInput, PeopleField } from '../../../shared/ui'; +import { CollapseSection, FormField, TextInput, PhoneInput, PeopleField, OrganizationField } from '../../../shared/ui'; import { ISectionProps, REFERRER_PLACEHOLDER } from '../model'; /** Секция 1: кто рекомендует — дата подачи, сам рекомендатель и его контакты. */ @@ -31,6 +31,7 @@ export const ReferrerSection: React.FC = ({ data, set, context, o placeholder="Подставится автоматически по выбранному сотруднику" /> + set('ReferrerPhone', v)} /> diff --git a/src/webparts/requestPortal/forms/Referral/submit.ts b/src/webparts/requestPortal/forms/Referral/submit.ts index 1e14c08..ff1a537 100644 --- a/src/webparts/requestPortal/forms/Referral/submit.ts +++ b/src/webparts/requestPortal/forms/Referral/submit.ts @@ -33,6 +33,7 @@ function buildItem(data: IFormData, status: string, referrerId: number): Record< ReferrerPosition: data.ReferrerPosition || null, ReferrerDepartment: data.ReferrerDepartment || null, ReferrerPhone: data.ReferrerPhone || null, + Organization: data.Organization || null, CandidateName: data.CandidateName || null, CandidatePhone: data.CandidatePhone || null, CandidateEmail: data.CandidateEmail || null, diff --git a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx index 2f82ad1..3349fca 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx +++ b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx @@ -36,6 +36,7 @@ import { IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { effectiveUserEmail } from '../../shared/impersonation'; +import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { IRequestFormProps } from '../../models/types'; import { @@ -77,15 +78,28 @@ const TmcTransferForm: React.FC = ({ } const user = context.pageContext.user; const today = new Date().toISOString().slice(0, 10); + const requesterEmail = effectiveUserEmail(context); setData((prev) => ({ ...prev, // email — через effectiveUserEmail (учитывает отладочную подмену личности); // displayName подменить нечем, справочник ФИО тут не читается — при // подмене имя останется настоящим, для отладки это неважно. - RequesterEmail: effectiveUserEmail(context), + RequesterEmail: requesterEmail, AuthorName: user.displayName || '', RequestDate: prev.RequestDate || today })); + // Организации заявителя (Employee/ByEmail) — read-only, см. shared/ui/OrganizationField. + let alive = true; + getEmployeeOrganizations(context, requesterEmail) + .then((records) => { + if (alive) { + setData((prev) => ({ ...prev, Organization: formatEmployeeOrganizations(records) })); + } + }) + .catch(() => undefined); + return () => { + alive = false; + }; }, [editId, context]); // Открыта СВОЯ заявка на редактирование — подгружаем содержимое, статус и вложения. diff --git a/src/webparts/requestPortal/forms/TmcTransfer/load.ts b/src/webparts/requestPortal/forms/TmcTransfer/load.ts index 836b2a8..587c167 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/load.ts +++ b/src/webparts/requestPortal/forms/TmcTransfer/load.ts @@ -38,6 +38,7 @@ export async function loadTmcTransferRequest(itemId: number, listId?: string): P 'Recipient', 'RequesterEmail', 'AuthorName', + 'Organization', 'Subject', 'RequestDate', 'SourceProject', @@ -60,6 +61,7 @@ export async function loadTmcTransferRequest(itemId: number, listId?: string): P Recipient: (item.Recipient as string) || '', RequesterEmail: (item.RequesterEmail as string) || '', AuthorName: (item.AuthorName as string) || '', + Organization: (item.Organization as string) || '', Subject: (item.Subject as string) || '', RequestDate: requestDate.slice(0, 10), // ISO → YYYY-MM-DD для input type=date SourceProject: (item.SourceProject as string) || '', diff --git a/src/webparts/requestPortal/forms/TmcTransfer/model.ts b/src/webparts/requestPortal/forms/TmcTransfer/model.ts index 5e7e40c..8f2d43f 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/model.ts +++ b/src/webparts/requestPortal/forms/TmcTransfer/model.ts @@ -20,6 +20,12 @@ export interface IFormData { // NB: не «Author» — это зарезервированное системное имя колонки SP (Created By). RequesterEmail: string; // От — email заявителя (текущий пользователь) AuthorName: string; // От — Ф.И.О. заявителя (для печати) + /** + * Организации заявителя (`\n`-joined «Компания — должность» на строку) — + * подтягивается автоматически из Employee/ByEmail, read-only (см. + * shared/ui/OrganizationField). + */ + Organization: string; Subject: string; // Тема (по умолч. «Перемещение ТМЦ») RequestDate: string; // Дата (YYYY-MM-DD) @@ -58,6 +64,7 @@ export const EMPTY_DATA: IFormData = { Recipient: DEFAULT_RECIPIENT, RequesterEmail: '', AuthorName: '', + Organization: '', Subject: DEFAULT_SUBJECT, RequestDate: '', SourceProject: '', diff --git a/src/webparts/requestPortal/forms/TmcTransfer/provisioning.json b/src/webparts/requestPortal/forms/TmcTransfer/provisioning.json index f6ea1ce..6fa11f4 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/provisioning.json +++ b/src/webparts/requestPortal/forms/TmcTransfer/provisioning.json @@ -7,6 +7,7 @@ { "internalName": "Recipient", "title": "Кому", "type": "Text" }, { "internalName": "RequesterEmail", "title": "От (email заявителя)", "type": "Text" }, { "internalName": "AuthorName", "title": "От (Ф.И.О.)", "type": "Text" }, + { "internalName": "Organization", "title": "Организация", "type": "Note" }, { "internalName": "Subject", "title": "Тема", "type": "Text" }, { "internalName": "RequestDate", "title": "Дата", "type": "DateTime", "dateOnly": true }, diff --git a/src/webparts/requestPortal/forms/TmcTransfer/sections/HeaderSection.tsx b/src/webparts/requestPortal/forms/TmcTransfer/sections/HeaderSection.tsx index 049ccd7..ac98526 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/sections/HeaderSection.tsx +++ b/src/webparts/requestPortal/forms/TmcTransfer/sections/HeaderSection.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { FormGrid, FormField, TextInput, TextArea } from '../../../shared/ui'; +import { FormGrid, FormField, TextInput, TextArea, OrganizationField } from '../../../shared/ui'; import { ProjectPicker } from '../../../shared/dictionary'; import { ISectionProps } from '../model'; @@ -9,6 +9,7 @@ import { ISectionProps } from '../model'; */ export const HeaderSection: React.FC = ({ data, set, context }) => ( + = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [editId, editListId]); - // Новая заявка — по умолчанию на себя: подставляем текущего пользователя и его должность. + // Новая заявка — по умолчанию на себя: подставляем текущего пользователя, + // его должность и организации (Employee/ByEmail — см. shared/dictionary/ + // employeeOrganizations.service.ts; сотрудник может состоять сразу в + // нескольких организациях, должность берём из ПЕРВОЙ записи). useEffect(() => { if (editId) { return; @@ -96,12 +101,14 @@ const UnpaidLeaveForm: React.FC = ({ let alive = true; const user = context.pageContext.user; setData((prev) => ({ ...prev, Employee: prev.Employee || user.email })); - approval - .getJobTitle(user.loginName) - .then((title) => { - if (alive && title) { - setData((prev) => ({ ...prev, Position: prev.Position || title })); - } + getEmployeeOrganizations(context, user.email) + .then((records) => { + if (!alive) return; + setData((prev) => ({ + ...prev, + Position: prev.Position || records[0]?.position || prev.Position, + Organization: formatEmployeeOrganizations(records) + })); }) .catch(() => undefined); return () => { @@ -110,15 +117,21 @@ const UnpaidLeaveForm: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [editId]); - // Выбор другого сотрудника подтягивает его должность. + // Выбор другого сотрудника подтягивает его должность и организации. const handleEmployeeChange = (login: string): void => { setData((prev) => ({ ...prev, Employee: login })); if (!login) { + setData((prev) => ({ ...prev, Organization: '' })); return; } - approval - .getJobTitle(login) - .then((title) => setData((prev) => ({ ...prev, Position: title || prev.Position }))) + getEmployeeOrganizations(context, login) + .then((records) => + setData((prev) => ({ + ...prev, + Position: records[0]?.position || prev.Position, + Organization: formatEmployeeOrganizations(records) + })) + ) .catch(() => undefined); }; @@ -142,13 +155,22 @@ const UnpaidLeaveForm: React.FC = ({ // Даты корректны, если конец не раньше начала (обе обязательны). const datesValid = !!(data.StartDate && data.EndDate && data.EndDate >= data.StartDate); + // Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала + // отпуска — календарь (min у input type=date, см. RequestSection) не даёт + // выбрать более раннюю дату, но проверяем и здесь на случай обхода UI. + const minStartDate = isoDatePlusDays(MIN_NOTICE_DAYS); + const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate; const isValid = !!( data.Employee && data.Position.trim() && datesValid && + isStartDateValid && data.Reason.trim() ); const validate = (): string | undefined => { + if (data.StartDate && !isStartDateValid) { + return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`; + } if (!data.Employee || !data.Position.trim() || !data.StartDate || !data.EndDate || !data.Reason.trim()) { return 'Заполните обязательные поля: сотрудник, должность, дата начала, дата окончания, основание.'; } diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx index a2abf3b..96d73a1 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx +++ b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx @@ -120,7 +120,7 @@ const UnpaidLeaveView: React.FC = ({ context, itemId, for
= ({ data, set, context, onEmployeeChange }) => ( - + = ({ data, set, context, on set('Position', v)} /> + - set('StartDate', v)} /> + set('StartDate', v)} + min={isoDatePlusDays(MIN_NOTICE_DAYS)} + /> set('EndDate', v)} /> diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/submit.ts b/src/webparts/requestPortal/forms/UnpaidLeave/submit.ts index 1a9f704..302b9c7 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/submit.ts +++ b/src/webparts/requestPortal/forms/UnpaidLeave/submit.ts @@ -48,7 +48,8 @@ function buildItem( EndDate: data.EndDate || null, DaysCount: days ?? null, Reason: data.Reason || null, - SubstituteId: substituteId ?? null + SubstituteId: substituteId ?? null, + Organization: data.Organization || null }; } diff --git a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx index a03926d..65fb69e 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx @@ -25,14 +25,16 @@ import { unassignedStepMessage } from '../../shared/approval'; import { IRequestFormProps } from '../../models/types'; +import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; +import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils'; import { approvalListTitle, approvalListTitleForListId, dataListTitle, dataListTitleForListId } from '../../shared/listTitles'; -import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; +import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_DAYS } from './model'; import { saveVacationRequest, vacationApprovalService } from './submit'; import { loadVacationRequest } from './load'; import { RequestSection } from './sections/RequestSection'; @@ -88,7 +90,10 @@ const VacationForm: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [editId, editListId]); - // Новая заявка — по умолчанию на себя: подставляем текущего пользователя и его должность. + // Новая заявка — по умолчанию на себя: подставляем текущего пользователя, + // его должность и организации (Employee/ByEmail — см. shared/dictionary/ + // employeeOrganizations.service.ts; сотрудник может состоять сразу в + // нескольких организациях, должность берём из ПЕРВОЙ записи). useEffect(() => { if (editId) { return; @@ -96,12 +101,14 @@ const VacationForm: React.FC = ({ let alive = true; const user = context.pageContext.user; setData((prev) => ({ ...prev, Employee: prev.Employee || user.email })); - approval - .getJobTitle(user.loginName) - .then((title) => { - if (alive && title) { - setData((prev) => ({ ...prev, Position: prev.Position || title })); - } + getEmployeeOrganizations(context, user.email) + .then((records) => { + if (!alive) return; + setData((prev) => ({ + ...prev, + Position: prev.Position || records[0]?.position || prev.Position, + Organization: formatEmployeeOrganizations(records) + })); }) .catch(() => undefined); return () => { @@ -110,15 +117,22 @@ const VacationForm: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [editId]); - // Выбор другого сотрудника подтягивает его должность (как в исходной pto-tracker). + // Выбор другого сотрудника подтягивает его должность и организации (как в + // исходной pto-tracker, но источник — Employee/ByEmail вместо SPS-JobTitle). const handleEmployeeChange = (login: string): void => { setData((prev) => ({ ...prev, Employee: login })); if (!login) { + setData((prev) => ({ ...prev, Organization: '' })); return; } - approval - .getJobTitle(login) - .then((title) => setData((prev) => ({ ...prev, Position: title || prev.Position }))) + getEmployeeOrganizations(context, login) + .then((records) => + setData((prev) => ({ + ...prev, + Position: records[0]?.position || prev.Position, + Organization: formatEmployeeOrganizations(records) + })) + ) .catch(() => undefined); }; @@ -141,14 +155,23 @@ const VacationForm: React.FC = ({ ); const daysNum = Number(data.DaysCount); + // Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала + // отпуска — календарь (min у input type=date, см. RequestSection) не даёт + // выбрать более раннюю дату, но проверяем и здесь на случай обхода UI. + const minStartDate = isoDatePlusDays(MIN_NOTICE_DAYS); + const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate; const isValid = !!( data.Employee && data.Position.trim() && data.StartDate && + isStartDateValid && Number.isFinite(daysNum) && daysNum > 0 ); const validate = (): string | undefined => { + if (data.StartDate && !isStartDateValid) { + return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`; + } if (!isValid) return 'Заполните обязательные поля: сотрудник, должность, дата начала, количество дней (> 0).'; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; diff --git a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx index 114eb8d..5cf6977 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx @@ -120,7 +120,7 @@ const VacationView: React.FC = ({ context, itemId, formKey,
; + .expand('Employee', 'Substitute')()) as Record; const employee = item.Employee as { Title?: string; EMail?: string } | undefined; + const substitute = item.Substitute as { EMail?: string } | undefined; const start = (item.StartDate as string) || ''; const days = item.DaysCount; @@ -44,7 +47,9 @@ export async function loadVacationRequest(itemId: number, listId?: string): Prom Employee: employee?.EMail || '', Position: (item.Position as string) || '', StartDate: start.slice(0, 10), // ISO → YYYY-MM-DD для input type=date - DaysCount: days === null || days === undefined ? '' : String(days) + DaysCount: days === null || days === undefined ? '' : String(days), + Substitute: substitute?.EMail || '', + Organization: (item.Organization as string) || '' }; return { diff --git a/src/webparts/requestPortal/forms/Vacation/model.ts b/src/webparts/requestPortal/forms/Vacation/model.ts index ec1f7ea..1d91995 100644 --- a/src/webparts/requestPortal/forms/Vacation/model.ts +++ b/src/webparts/requestPortal/forms/Vacation/model.ts @@ -22,6 +22,16 @@ export interface IFormData { StartDate: string; /** Строка из input type=number; при записи → number. */ DaysCount: string; + /** E-mail замещающего на время отсутствия (People). Пусто = не выбран. */ + Substitute: string; + /** + * Организации сотрудника (`\n`-joined «Компания — должность» на строку) — + * подтягивается автоматически из Employee/ByEmail (см. + * shared/dictionary/employeeOrganizations.service.ts) при выборе Employee, + * read-only (см. shared/ui/OrganizationField). Сотрудник может состоять + * сразу в нескольких организациях — тогда строк несколько. + */ + Organization: string; } /** Обновление одного поля формы с сохранением типа значения. */ @@ -43,8 +53,14 @@ export const EMPTY_DATA: IFormData = { Employee: '', Position: '', StartDate: '', - DaysCount: '' + DaysCount: '', + Substitute: '', + Organization: '' }; /** Плейсхолдер для поля выбора сотрудника. */ export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…'; +/** Заявку нужно подавать не позднее чем за столько дней до даты начала отпуска. */ +export const MIN_NOTICE_DAYS = 14; +/** Плейсхолдер для поля выбора замещающего. */ +export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О. замещающего…'; diff --git a/src/webparts/requestPortal/forms/Vacation/provisioning.json b/src/webparts/requestPortal/forms/Vacation/provisioning.json index 9da57f5..67b165a 100644 --- a/src/webparts/requestPortal/forms/Vacation/provisioning.json +++ b/src/webparts/requestPortal/forms/Vacation/provisioning.json @@ -8,6 +8,8 @@ { "internalName": "Position", "title": "Должность", "type": "Text" }, { "internalName": "StartDate", "title": "Дата начала", "type": "DateTime", "dateOnly": true }, { "internalName": "EndDate", "title": "Дата окончания", "type": "DateTime", "dateOnly": true }, - { "internalName": "DaysCount", "title": "Количество дней", "type": "Number" } + { "internalName": "DaysCount", "title": "Количество дней", "type": "Number" }, + { "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" }, + { "internalName": "Organization", "title": "Организация", "type": "Note" } ] } diff --git a/src/webparts/requestPortal/forms/Vacation/sections/RequestSection.tsx b/src/webparts/requestPortal/forms/Vacation/sections/RequestSection.tsx index 7e5fe4d..ddd4536 100644 --- a/src/webparts/requestPortal/forms/Vacation/sections/RequestSection.tsx +++ b/src/webparts/requestPortal/forms/Vacation/sections/RequestSection.tsx @@ -1,10 +1,11 @@ import * as React from 'react'; -import { CollapseSection, FormField, TextInput, PeopleField } from '../../../shared/ui'; -import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model'; +import { CollapseSection, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui'; +import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model'; +import { isoDatePlusDays } from '../../../shared/dateUtils'; /** Единственная секция формы «Заявка на отпуск»: сотрудник, должность, даты. */ export const RequestSection: React.FC = ({ data, set, context, onEmployeeChange }) => ( - + = ({ data, set, context, on set('Position', v)} /> + - set('StartDate', v)} /> + set('StartDate', v)} + min={isoDatePlusDays(MIN_NOTICE_DAYS)} + /> set('DaysCount', v)} /> + + set('Substitute', v)} + placeholder={SUBSTITUTE_PLACEHOLDER} + /> + ); diff --git a/src/webparts/requestPortal/forms/Vacation/submit.ts b/src/webparts/requestPortal/forms/Vacation/submit.ts index 6df8fa2..0b6defa 100644 --- a/src/webparts/requestPortal/forms/Vacation/submit.ts +++ b/src/webparts/requestPortal/forms/Vacation/submit.ts @@ -29,8 +29,14 @@ function buildTitle(employeeName: string): string { return `Отпуск — ${employeeName} — ${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`; } -/** Поля элемента списка данных из модели формы (+ Employee по Id, + вычисленная EndDate). */ -function buildItem(data: IFormData, status: string, employeeId: number, employeeName: string): Record { +/** Поля элемента списка данных из модели формы (+ Employee/Substitute по Id, + вычисленная EndDate). */ +function buildItem( + data: IFormData, + status: string, + employeeId: number, + employeeName: string, + substituteId: number | undefined +): Record { const days = Number(data.DaysCount); return { Title: buildTitle(employeeName), @@ -39,7 +45,9 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee Position: data.Position || null, StartDate: data.StartDate || null, DaysCount: Number.isFinite(days) && days > 0 ? days : null, - EndDate: computeEndDate(data.StartDate, days) || null + EndDate: computeEndDate(data.StartDate, days) || null, + SubstituteId: substituteId ?? null, + Organization: data.Organization || null }; } @@ -68,7 +76,9 @@ export async function saveVacationRequest( listId?: string ): Promise { const employee = await ensureEmployee(data.Employee); - const item = buildItem(data, status, employee.Id, employee.Title || data.Employee); + // Замещающий необязателен: резолвим только если выбран. + const substituteId = data.Substitute ? (await ensureEmployee(data.Substitute)).Id : undefined; + const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId); const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); await writeRoute(vacationApprovalService(listId), result.id, approvers, [], status); diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx index 49f43ff..03f00f1 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx @@ -36,6 +36,7 @@ import { IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { effectiveUserEmail } from '../../shared/impersonation'; +import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { IRequestFormProps } from '../../models/types'; import { @@ -111,7 +112,10 @@ const WorkplaceSetupForm: React.FC = ({ loadPrefillFromHire(sourceId) .then((prefilled) => { if (alive) { - setData(prefilled); + // Merge, а не replace: организация заявителя (ниже) резолвится отдельным + // эффектом и могла успеть записаться раньше — не затираем её пустышкой + // из prefilled (в loadPrefillFromHire её нет). + setData((prev) => ({ ...prefilled, Organization: prev.Organization || prefilled.Organization })); } }) .catch((e) => { @@ -129,6 +133,30 @@ const WorkplaceSetupForm: React.FC = ({ // workplaceApprovalService можно звать не опасаясь исключения. const approval = useMemo(() => workplaceApprovalService(editListId), [editListId]); const userEmail = effectiveUserEmail(context); + + // Организации заявителя (Employee/ByEmail) — форма не о самом заявителе, а о + // новом сотруднике (FullName — обычный текст, предзаполняется из «Заявки на + // прием», и его чаще всего ещё нет в системе кадров), поэтому организация + // тянется по ТЕКУЩЕМУ пользователю. На редактировании уже сохранённое + // значение не перезатираем. + useEffect(() => { + if (editId || !userEmail) { + return; + } + let alive = true; + getEmployeeOrganizations(context, userEmail) + .then((records) => { + if (alive) { + setData((prev) => ({ ...prev, Organization: prev.Organization || formatEmployeeOrganizations(records) })); + } + }) + .catch(() => undefined); + return () => { + alive = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editId, userEmail]); + // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. const requestTypeIdState = useRequestTypeId(context, requestType.id); diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts index f0896d0..36b28b5 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts @@ -4,7 +4,7 @@ import { FORM_ID, IFormData, EMPTY_DATA } from './model'; const SCALAR_FIELDS: (keyof IFormData)[] = [ 'FullName', 'NameLatin', 'Phone', 'Position', 'Department', 'DepartmentId', 'WorkKind', 'Location', 'StartDate', - 'Software', 'AccessRights' + 'Software', 'AccessRights', 'Organization' ]; export interface ILoadedRequest { diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/model.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/model.ts index fd295b9..6741b6c 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/model.ts +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/model.ts @@ -24,6 +24,13 @@ export interface IFormData { WorkKind: string; Location: string; StartDate: string; + /** + * Организации заявителя (`\n`-joined «Компания — должность» на строку) — + * подтягивается автоматически из Employee/ByEmail по e-mail заявителя (не + * нового сотрудника — его ещё нет в системе кадров), read-only (см. + * shared/ui/OrganizationField). + */ + Organization: string; // Оборудование и доступы (заполняются вручную) NeedsEquipment: boolean; Software: string; @@ -50,6 +57,7 @@ export const EMPTY_DATA: IFormData = { WorkKind: '', Location: '', StartDate: '', + Organization: '', NeedsEquipment: false, Software: '', AccessRights: '' diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/provisioning.json b/src/webparts/requestPortal/forms/WorkplaceSetup/provisioning.json index d4a16eb..b1c2dd4 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/provisioning.json +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/provisioning.json @@ -13,6 +13,7 @@ { "internalName": "WorkKind", "title": "Вид работ", "type": "Text" }, { "internalName": "Location", "title": "Местоположение", "type": "Text" }, { "internalName": "StartDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true }, + { "internalName": "Organization", "title": "Организация заявителя", "type": "Note" }, { "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" }, { "internalName": "Software", "title": "Необходимое ПО", "type": "Note" }, diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/sections/EmployeeSection.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/sections/EmployeeSection.tsx index 21947f2..01d25fd 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/sections/EmployeeSection.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/sections/EmployeeSection.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { CollapseSection, FormField, TextInput, PhoneInput } from '../../../shared/ui'; +import { CollapseSection, FormField, TextInput, PhoneInput, OrganizationField } from '../../../shared/ui'; import { DepartmentPicker } from '../../../shared/dictionary'; import { ISectionProps } from '../model'; @@ -37,5 +37,6 @@ export const EmployeeSection: React.FC = ({ data, set, context }) set('StartDate', v)} /> + ); diff --git a/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts b/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts index 6321ca7..4d8990f 100644 --- a/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts @@ -17,19 +17,46 @@ async function client(context: WebPartContext): Promise { return context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID); } +// Ретрай-политика на сетевые сбои — тот же принцип и те же по порядку величины +// значения, что и у RETRY в shared/pnp/pnpjs.ts (там для PnPjs/SharePoint). +const NETWORK_RETRY_ATTEMPTS = 2; +const NETWORK_RETRY_INTERVAL_MS = 1000; + +/** + * Мягкий ретрай на СЕТЕВЫЕ сбои (fetch не долетел вовсе — TypeError «Failed to + * fetch»/«NetworkError», не HTTP-статус). Тот же принцип, что и + * BrowserFetchWithRetry у PnPjs-клиента (shared/pnp/pnpjs.ts) — там ретрай уже + * есть, а этот внешний API его был лишён, из-за чего случайная заминка сети или + * холодный старт бэкенда сразу превращались в необратимое «Failed to fetch» в + * UI матрицы. HTTP-ошибки (404/401/500 — throw Error ниже по коду) НЕ ретраим: + * повтор того же запроса их не лечит, только тянет время до сообщения об ошибке. + */ +async function withNetworkRetry(fn: () => Promise): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + return await fn(); + } catch (e) { + if (attempt >= NETWORK_RETRY_ATTEMPTS || !(e instanceof TypeError)) throw e; + await new Promise((resolve) => setTimeout(resolve, NETWORK_RETRY_INTERVAL_MS * (attempt + 1))); + } + } +} + // GET /api/request-types — список типов заявок (id + имя). Пригодится, чтобы // сопоставить форму с RequestTypeId, если он неизвестен заранее. export async function getRequestTypes(context: WebPartContext): Promise { - const c = await client(context); - const response: HttpClientResponse = await c.get( - `${getApprovalApiBaseUrl()}/api/request-types`, - AadHttpClient.configurations.v1 - ); - if (!response.ok) { - throw new Error(`request-types: ${response.status} ${response.statusText}`); - } - const body: IApiResponse = await response.json(); - return body.data ?? []; + return withNetworkRetry(async () => { + const c = await client(context); + const response: HttpClientResponse = await c.get( + `${getApprovalApiBaseUrl()}/api/request-types`, + AadHttpClient.configurations.v1 + ); + if (!response.ok) { + throw new Error(`request-types: ${response.status} ${response.statusText}`); + } + const body: IApiResponse = await response.json(); + return body.data ?? []; + }); } /** @@ -43,16 +70,18 @@ export async function getRequestTypes(context: WebPartContext): Promise { - const c = await client(context); - const response: HttpClientResponse = await c.get( - `${getApprovalApiBaseUrl()}/api/request-types/lists`, - AadHttpClient.configurations.v1 - ); - if (!response.ok) { - throw new Error(`request-types/lists: ${response.status} ${response.statusText}`); - } - const body: IApiResponse = await response.json(); - return body.data ?? []; + return withNetworkRetry(async () => { + const c = await client(context); + const response: HttpClientResponse = await c.get( + `${getApprovalApiBaseUrl()}/api/request-types/lists`, + AadHttpClient.configurations.v1 + ); + if (!response.ok) { + throw new Error(`request-types/lists: ${response.status} ${response.statusText}`); + } + const body: IApiResponse = await response.json(); + return body.data ?? []; + }); } /** @@ -67,16 +96,18 @@ export async function getCurrentListPartition( context: WebPartContext, requestTypeId: number ): Promise { - const c = await client(context); - const response: HttpClientResponse = await c.get( - `${getApprovalApiBaseUrl()}/api/request-types/${requestTypeId}/lists/current`, - AadHttpClient.configurations.v1 - ); - if (!response.ok) { - throw new Error(`request-types/${requestTypeId}/lists/current: ${response.status} ${response.statusText}`); - } - const body: IApiResponse = await response.json(); - return body.data; + return withNetworkRetry(async () => { + const c = await client(context); + const response: HttpClientResponse = await c.get( + `${getApprovalApiBaseUrl()}/api/request-types/${requestTypeId}/lists/current`, + AadHttpClient.configurations.v1 + ); + if (!response.ok) { + throw new Error(`request-types/${requestTypeId}/lists/current: ${response.status} ${response.statusText}`); + } + const body: IApiResponse = await response.json(); + return body.data; + }); } // Кеш на уровне МОДУЛЯ (не useRef/useState) — список типов заявок админ меняет @@ -138,30 +169,32 @@ export async function resolveRoute( // eslint-disable-next-line @rushstack/no-new-null -- см. departmentId projectId: string | null = null ): Promise { - const c = await client(context); - const url = `${getApprovalApiBaseUrl()}/api/template-routes/resolve`; + return withNetworkRetry(async () => { + const c = await client(context); + const url = `${getApprovalApiBaseUrl()}/api/template-routes/resolve`; - // Эндпоинт сменился с GET (query) на POST (JSON-тело). - // projectId (uuid проекта) — для форм, где согласующий зависит от проекта - // (напр. «руководитель проекта-источника» в СЗ на перемещение ТМЦ): бэкенд по - // нему подставляет исполнителя роли. Формы без привязки к проекту шлют null. - const response: HttpClientResponse = await c.post(url, AadHttpClient.configurations.v1, { - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json' - }, - body: JSON.stringify({ - requestTypeId, - userEmail, - projectId: projectId || null, - status, - fieldValues, - departmentId: departmentId || null - }) + // Эндпоинт сменился с GET (query) на POST (JSON-тело). + // projectId (uuid проекта) — для форм, где согласующий зависит от проекта + // (напр. «руководитель проекта-источника» в СЗ на перемещение ТМЦ): бэкенд по + // нему подставляет исполнителя роли. Формы без привязки к проекту шлют null. + const response: HttpClientResponse = await c.post(url, AadHttpClient.configurations.v1, { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify({ + requestTypeId, + userEmail, + projectId: projectId || null, + status, + fieldValues, + departmentId: departmentId || null + }) + }); + if (!response.ok) { + throw new Error(`resolve: ${response.status} ${response.statusText}`); + } + const body: IApiResponse = await response.json(); + return body.data; }); - if (!response.ok) { - throw new Error(`resolve: ${response.status} ${response.statusText}`); - } - const body: IApiResponse = await response.json(); - return body.data; } diff --git a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx index 4779bd8..e6daf7e 100644 --- a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx @@ -40,7 +40,7 @@ export const ApprovalMatrix: React.FC = ({ waiting = false, waitingText = '', }) => { - const { rows, loading, error, editable, blocks } = editor; + const { rows, loading, error, editable, blocks, retry } = editor; // Подпись «доп. согласующий»/«доп. исполнитель» — из roleLabel, а не // хардкод: иначе блок «Исполнение» (roleLabel="Исполнитель") показывал бы @@ -113,7 +113,14 @@ export const ApprovalMatrix: React.FC = ({
{title}
{waiting &&
{waitingText}
} {!waiting && loading &&
Загрузка маршрута…
} - {!waiting && !loading && error &&
Маршрут недоступен: {error}
} + {!waiting && !loading && error && ( +
+ Маршрут недоступен: {error}{' '} + +
+ )} {!waiting && !loading && !error && rows.length === 0 && (
{emptyText}
)} diff --git a/src/webparts/requestPortal/shared/approval/ui/approval.module.scss b/src/webparts/requestPortal/shared/approval/ui/approval.module.scss index 1fb415c..58f4404 100644 --- a/src/webparts/requestPortal/shared/approval/ui/approval.module.scss +++ b/src/webparts/requestPortal/shared/approval/ui/approval.module.scss @@ -91,6 +91,23 @@ color: $gray-500; } +// Инлайновая ссылка-кнопка повтора запроса рядом с текстом ошибки — тот же +// приём, что у .email в InitiatorLine: не отдельная кнопка с рамкой, а +// подчёркиваемая ссылка внутри строки заметки. +.retryBtn { + padding: 0; + border: none; + background: none; + font: inherit; + color: $navy-700; + text-decoration: underline; + cursor: pointer; + + &:hover { + color: $gold-600; + } +} + // Обёртка таблицы: на промежуточных ширинах — горизонтальный скролл вместо // сплющивания колонок. На телефоне таблица становится карточками (медиазапрос ниже). .matrixScroll { diff --git a/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts b/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts index a125909..46c5b87 100644 --- a/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts +++ b/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts @@ -7,6 +7,8 @@ export interface IUseMatrixEditor { loading: boolean; error: string | undefined; editable: boolean; + /** Перезапросить маршрут ещё раз (после сетевой ошибки) без смены reloadKey. */ + retry: () => void; addApprover: (user: ISiteUser) => void; /** Переместить ШАГ целиком (вместе со всеми параллельными строками) выше/ниже. */ moveBlockUp: (index: number) => void; @@ -155,6 +157,10 @@ export function useMatrixEditor( const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(undefined); + // Ручной повтор после ошибки (см. кнопку «Повторить» в ApprovalMatrix) — свой + // счётчик, а не reloadKey: тот приходит от вызывающего и не должен знать о + // повторных попытках, это внутренняя механика самого хука. + const [retrySeq, setRetrySeq] = useState(0); useEffect(() => { if (!ready) return; @@ -175,9 +181,11 @@ export function useMatrixEditor( return () => { active = false; }; - // load — стабильная ссылка от вызывающего (useCallback); перезагрузка по reloadKey/ready. + // load — стабильная ссылка от вызывающего (useCallback); перезагрузка по reloadKey/ready/retrySeq. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reloadKey, ready]); + }, [reloadKey, ready, retrySeq]); + + const retry = (): void => setRetrySeq((n) => n + 1); const appendUser = (user: ISiteUser, position: string): void => { setRows((prev) => { @@ -235,6 +243,7 @@ export function useMatrixEditor( loading, error, editable, + retry, addApprover, moveBlockUp, moveBlockDown, diff --git a/src/webparts/requestPortal/shared/dateUtils.ts b/src/webparts/requestPortal/shared/dateUtils.ts new file mode 100644 index 0000000..b7fe35a --- /dev/null +++ b/src/webparts/requestPortal/shared/dateUtils.ts @@ -0,0 +1,16 @@ +function pad2(n: number): string { + return n < 10 ? `0${n}` : `${n}`; +} + +/** Сегодня + n дней в формате YYYY-MM-DD (для min/max у input type=date). */ +export function isoDatePlusDays(days: number): string { + const d = new Date(); + d.setDate(d.getDate() + days); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + +/** YYYY-MM-DD → дд.мм.гггг, для текста ошибок валидации. Невалидную строку возвращает как есть. */ +export function formatIsoDate(iso: string): string { + const [y, m, d] = iso.split('-'); + return y && m && d ? `${d}.${m}.${y}` : iso; +} diff --git a/src/webparts/requestPortal/shared/dictionary/employeeOrganizations.service.ts b/src/webparts/requestPortal/shared/dictionary/employeeOrganizations.service.ts new file mode 100644 index 0000000..9e6e760 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/employeeOrganizations.service.ts @@ -0,0 +1,59 @@ +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 { IEmployeeOrganization } from './models/IEmployeeOrganization'; + +// GET /Employee/ByEmail?Email=... — организации сотрудника (см. IEmployeeOrganization: +// один email может вернуть несколько записей). 404 (email не найден в системе +// кадров) — не ошибка формы, просто пусто: не у каждого приглашённого/внешнего +// пользователя портала есть карточка сотрудника. +async function fetchEmployeeOrganizations(context: WebPartContext, email: string): Promise { + const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID); + const response: HttpClientResponse = await client.get( + `${DICTIONARY_API_BASE_URL}/Employee/ByEmail?Email=${encodeURIComponent(email)}`, + AadHttpClient.configurations.v1 + ); + if (response.status === 404) return []; + if (!response.ok) { + throw new Error(`Employee/ByEmail: ${response.status} ${response.statusText}`); + } + const body = (await response.json()) as Array>; + return body.map((r) => ({ + id: (r.Id as string) || '', + fio: (r.Fio as string) || '', + email: (r.Email as string) || '', + company: (r.Company as string) || '', + companyId: (r.CompanyId as string) || '', + section: (r.Section as string) || '', + departmentId: (r.DepartmentId as string) || '', + position: (r.Position as string) || '', + mobile: (r.Mobile as string) || '' + })); +} + +// Кеш по e-mail (не один общий промис, как у companies/departments — там список +// один на всех, здесь результат зависит от аргумента). Ошибку не кешируем — см. +// companies.service.ts, тот же принцип. +const cache = new Map>(); + +/** Организации сотрудника по e-mail. Пустой email → [] без обращения к API. */ +export function getEmployeeOrganizations(context: WebPartContext, email: string): Promise { + if (!email) return Promise.resolve([]); + const cached = cache.get(email); + if (cached) return cached; + const promise = fetchEmployeeOrganizations(context, email).catch((e) => { + cache.delete(email); + throw e; + }); + cache.set(email, promise); + return promise; +} + +/** + * «Компания — должность» по каждой записи, по одной на строку (`\n`) — общий + * формат и для отображения (построчно), и для хранения в списке SharePoint + * (колонка Organization, Note — поддерживает переносы строк). + */ +export function formatEmployeeOrganizations(records: IEmployeeOrganization[]): string { + return records.map((r) => (r.position ? `${r.company} — ${r.position}` : r.company)).join('\n'); +} diff --git a/src/webparts/requestPortal/shared/dictionary/index.ts b/src/webparts/requestPortal/shared/dictionary/index.ts index 0fb3b8f..20e302a 100644 --- a/src/webparts/requestPortal/shared/dictionary/index.ts +++ b/src/webparts/requestPortal/shared/dictionary/index.ts @@ -10,3 +10,5 @@ export { getCompanies } from './companies.service'; export type { ICompany } from './models/ICompany'; export { CompanyPickerMulti } from './ui/CompanyPickerMulti'; export type { ICompanyPickerMultiProps } from './ui/CompanyPickerMulti'; +export { getEmployeeOrganizations, formatEmployeeOrganizations } from './employeeOrganizations.service'; +export type { IEmployeeOrganization } from './models/IEmployeeOrganization'; diff --git a/src/webparts/requestPortal/shared/dictionary/models/IEmployeeOrganization.ts b/src/webparts/requestPortal/shared/dictionary/models/IEmployeeOrganization.ts new file mode 100644 index 0000000..d31d5ff --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/models/IEmployeeOrganization.ts @@ -0,0 +1,17 @@ +/** + * Одна запись из GET /Employee/ByEmail — сотрудник МОЖЕТ состоять сразу в + * нескольких организациях (совместительство/аффилированные ТОО), тогда на один + * и тот же e-mail эндпоинт возвращает НЕСКОЛЬКО записей с разными Company/ + * CompanyId/Section/DepartmentId/Position. + */ +export interface IEmployeeOrganization { + id: string; + fio: string; + email: string; + company: string; + companyId: string; + section: string; + departmentId: string; + position: string; + mobile: string; +} diff --git a/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.module.scss b/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.module.scss new file mode 100644 index 0000000..1d7d945 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.module.scss @@ -0,0 +1,21 @@ +@import '../../../../../styles/design-tokens'; + +.list { + display: flex; + flex-direction: column; + gap: $sp-1; +} + +// Read-only «карточка» на организацию — тот же радиус/паддинг, что у TextInput +// ($r-sm, 11px/13px из @tnsu/ui-kit), но приглушённый фон/рамка (как в +// readonly-field) — сигнал «подставлено автоматически, не редактируется». +.line { + box-sizing: border-box; + padding: 11px 13px; + border: 1px solid $gray-200; + border-radius: $r-sm; + background: $gray-50; + font-family: $font-sans; + font-size: 13.5px; + color: $gray-700; +} diff --git a/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.tsx b/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.tsx new file mode 100644 index 0000000..e7a778d --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import styles from './OrganizationField.module.scss'; +import { FormField } from '@tnsu/ui-kit'; + +interface IOrganizationFieldProps { + /** `\n`-joined «Компания — должность» на организацию (см. formatEmployeeOrganizations). */ + value: string; + /** + * Подпись поля. По умолчанию «Организация» — переопределяют формы, где + * секция описывает ДРУГОГО человека (напр. нового сотрудника), чтобы не + * читалось как «организация того, о ком заявка». + */ + label?: string; +} + +/** + * Организации сотрудника — read-only, подтягивается автоматически по e-mail + * (см. shared/dictionary/employeeOrganizations.service.ts), руками не + * редактируется. Сотрудник может состоять сразу в НЕСКОЛЬКИХ организациях — + * тогда строк несколько. Пока не резолвилось (сотрудник не выбран/не найден) — + * ничего не рендерим, а не пустой блок. + */ +export const OrganizationField: React.FC = ({ value, label = 'Организация' }) => { + const lines = value ? value.split('\n') : []; + if (lines.length === 0) return null; + + return ( + +
+ {lines.map((line) => ( +
+ {line} +
+ ))} +
+
+ ); +}; diff --git a/src/webparts/requestPortal/shared/ui/PeopleField/PeopleField.tsx b/src/webparts/requestPortal/shared/ui/PeopleField/PeopleField.tsx index 256411b..70f4720 100644 --- a/src/webparts/requestPortal/shared/ui/PeopleField/PeopleField.tsx +++ b/src/webparts/requestPortal/shared/ui/PeopleField/PeopleField.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext'; +import { peoplePickerStyles } from './peoplePickerStyles'; import { useFormReadOnly } from '@tnsu/ui-kit'; interface IPeopleFieldProps { @@ -34,6 +35,7 @@ export const PeopleField: React.FC = ({ context, value, onCha disabled={effectiveReadOnly} placeholder={placeholder} onChange={handleChange} + styles={peoplePickerStyles} /> ); }; diff --git a/src/webparts/requestPortal/shared/ui/PeopleField/PeopleFieldMulti.tsx b/src/webparts/requestPortal/shared/ui/PeopleField/PeopleFieldMulti.tsx index c51f1c0..24df05c 100644 --- a/src/webparts/requestPortal/shared/ui/PeopleField/PeopleFieldMulti.tsx +++ b/src/webparts/requestPortal/shared/ui/PeopleField/PeopleFieldMulti.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext'; +import { peoplePickerStyles } from './peoplePickerStyles'; import { useFormReadOnly } from '@tnsu/ui-kit'; interface IPeopleFieldMultiProps { @@ -43,6 +44,7 @@ export const PeopleFieldMulti: React.FC = ({ disabled={effectiveReadOnly} placeholder={placeholder} onChange={handleChange} + styles={peoplePickerStyles} /> ); }; diff --git a/src/webparts/requestPortal/shared/ui/PeopleField/peoplePickerStyles.ts b/src/webparts/requestPortal/shared/ui/PeopleField/peoplePickerStyles.ts new file mode 100644 index 0000000..1ad7459 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/PeopleField/peoplePickerStyles.ts @@ -0,0 +1,40 @@ +import type { IBasePickerStyles } from '@fluentui/react/lib/Pickers'; + +/** + * PeoplePicker — сторонний Fluent-контрол (@pnp/spfx-controls-react), а не наш + * @tnsu/ui-kit: у него нет своего CSS-модуля и без этих стилей он выглядит + * дефолтным Fluent-полем — другой border-radius, другие паддинги, другой цвет + * рамки, не в тон соседним полям формы (TextInput и т.п.). Стилизуется через + * fluent-проп `styles` (IBasePickerStyles), а не через className/CSS-модуль, + * поэтому значения ниже — те же токены, что в styles/_design-tokens.scss + * ($gray-300, $gold-500, $r-sm, TextInput.module.scss), но продублированы как + * значения: Fluent styles-проп не умеет импортировать SCSS-переменные. + */ +export const peoplePickerStyles: Partial = { + text: { + boxSizing: 'border-box', + padding: '9px 12px', + border: '1px solid #D1D5DB', // $gray-300 + borderRadius: 6, // $r-sm + background: '#fff', + fontFamily: "'Inter', -apple-system, 'Segoe UI', sans-serif", + fontSize: 13.5, + transition: '.18s', + selectors: { + // Фокус — на самом input внутри .text, подсветку показываем на всей рамке. + ':focus-within': { + borderColor: '#D4A01E', // $gold-500 + boxShadow: '0 0 0 3px rgba(212,160,30,.12)' + } + } + }, + input: { + padding: 0, + fontFamily: "'Inter', -apple-system, 'Segoe UI', sans-serif", + fontSize: 13.5, + color: '#111827', // $gray-900 + selectors: { + '::placeholder': { color: '#9CA3AF' } // $gray-400 + } + } +}; diff --git a/src/webparts/requestPortal/shared/ui/index.ts b/src/webparts/requestPortal/shared/ui/index.ts index f7109ed..8c0e1e9 100644 --- a/src/webparts/requestPortal/shared/ui/index.ts +++ b/src/webparts/requestPortal/shared/ui/index.ts @@ -38,6 +38,7 @@ export { EMPTY_LINE_ITEM } from './ItemsTable/ILineItem'; export { parseExcelRows } from './ItemsTable/parseExcelRows'; export { PeopleField } from './PeopleField/PeopleField'; export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti'; +export { OrganizationField } from './OrganizationField/OrganizationField'; export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay'; export { SubmitActions } from './SubmitActions/SubmitActions'; export { InitiatorLine } from './InitiatorLine/InitiatorLine';