From ebc74dc46d8e9989a0e5c25960b7534f4efb4c5a 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: Fri, 25 Sep 2026 15:24:33 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D0=B4=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=D0=B5=20=D0=B8=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D1=91=D0=BC=D0=B5=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80?= =?UTF-8?q?=D1=83=D1=8E=D1=82=D1=81=D1=8F=20=D0=BF=D0=BE=20=D0=BE=D1=80?= =?UTF-8?q?=D0=B3=D0=B0=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sections/CandidateSection.tsx | 19 ++++++-- .../sections/DepartmentSection.tsx | 3 ++ .../shared/dictionary/ui/DepartmentPicker.tsx | 47 ++++++++++++++++--- .../dictionary/ui/departmentCompanyFilter.ts | 35 ++++++++++++++ 4 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 src/webparts/requestPortal/shared/dictionary/ui/departmentCompanyFilter.ts diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/CandidateSection.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/CandidateSection.tsx index 0d374f6..8d90d3d 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/CandidateSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/sections/CandidateSection.tsx @@ -1,9 +1,16 @@ import * as React from "react"; import { CollapseSection, FormField, TextInput } from "../../../shared/ui"; -import { PositionPicker } from "../../../shared/dictionary"; -import { ISectionProps } from "../model"; +import { DepartmentPicker, PositionPicker } from "../../../shared/dictionary"; +import { IFormData, ISectionProps } from "../model"; import { HireDateField } from "./HireDateField"; +// Отдел ищем в «Основном месте работы», если оно выбрано, иначе — во всех +// организациях заявки. +function departmentCompanyIds(data: IFormData): string[] { + if (data.MainCompanyId) return [data.MainCompanyId]; + return data.Companies.map((c) => c.Id); +} + interface ICandidateSectionProps extends ISectionProps { /** Начальник расчётной группы правит отдел/участок даже в режиме просмотра (см. approverRights.ts). */ payrollEditable?: boolean; @@ -42,11 +49,15 @@ export const CandidateSection: React.FC = ({ /> - set("Department", v)} + onChange={(name) => set("Department", name)} + companyIds={departmentCompanyIds(data)} readOnly={payrollEditable ? false : undefined} + placeholder="Выберите из списка или введите вручную" /> diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/sections/DepartmentSection.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/sections/DepartmentSection.tsx index 2f50a4a..4d7257c 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/sections/DepartmentSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/sections/DepartmentSection.tsx @@ -15,6 +15,9 @@ export const DepartmentSection: React.FC = ({ data, set, context id="Department" context={context} value={data.Department} + valueId={data.DepartmentId} + // Подразделения всех организаций из «1.5 Организация». + companyIds={data.Companies.map((c) => c.Id)} onChange={(name, id) => { set('Department', name); set('DepartmentId', id); diff --git a/src/webparts/requestPortal/shared/dictionary/ui/DepartmentPicker.tsx b/src/webparts/requestPortal/shared/dictionary/ui/DepartmentPicker.tsx index ed8c2af..e4c3224 100644 --- a/src/webparts/requestPortal/shared/dictionary/ui/DepartmentPicker.tsx +++ b/src/webparts/requestPortal/shared/dictionary/ui/DepartmentPicker.tsx @@ -4,32 +4,45 @@ import styles from './DepartmentPicker.module.scss'; import { useFormReadOnly } from '@tnsu/ui-kit'; import { getDepartments } from '../departments.service'; import { IDepartment } from '../models/IDepartment'; +import { filterDepartmentsByCompanies, isDepartmentOutsideCompanies } from './departmentCompanyFilter'; export interface IDepartmentPickerProps { context: WebPartContext; id?: string; /** Текущее название отдела (то, что видно в поле). */ value: string; + /** Id выбранного отдела, если форма его хранит, — точнее, чем сверка по названию. */ + valueId?: string; /** name — введённый/выбранный текст; id — Id из справочника ('' если текст введён вручную и не выбран из списка). */ onChange: (name: string, id: string) => void; placeholder?: string; /** Только чтение (режим согласующего). */ readOnly?: boolean; + /** + * Показывать подразделения только этих компаний (Id из Dictionary/Companies). + * undefined — все компании; [] — организация не выбрана, поле заблокировано. + * При смене набора выбранное подразделение чужой компании очищается. + */ + companyIds?: string[]; } const MAX_SUGGESTIONS = 30; +const NO_COMPANY_PLACEHOLDER = 'Сначала выберите организацию'; /** Поле «Отдел / участок» со справочником (Dictionary/Departments): ввод текста фильтрует список, выбор проставляет Id. */ export const DepartmentPicker: React.FC = ({ context, id, value, + valueId, onChange, placeholder, - readOnly + readOnly, + companyIds }) => { const contextReadOnly = useFormReadOnly(); - const effectiveReadOnly = readOnly ?? contextReadOnly; + const isCompanyMissing = !!companyIds && companyIds.length === 0; + const effectiveReadOnly = (readOnly ?? contextReadOnly) || isCompanyMissing; const [departments, setDepartments] = React.useState([]); const [open, setOpen] = React.useState(false); @@ -46,11 +59,33 @@ export const DepartmentPicker: React.FC = ({ }; }, [context]); + // Сверку делаем только при смене компаний (или приходе справочника), а не на + // каждое нажатие клавиши — иначе набираемое название, совпавшее с отделом + // чужой компании, стиралось бы прямо во время ввода. Поэтому текущее значение + // читаем из ref, а не держим в зависимостях. + const latest = React.useRef({ value, valueId, onChange }); + latest.current = { value, valueId, onChange }; + const companyKey = companyIds ? companyIds.join('|') : undefined; + + React.useEffect(() => { + if ((readOnly ?? contextReadOnly) || departments.length === 0) return; + const current = latest.current; + if (isDepartmentOutsideCompanies(departments, current.value, current.valueId, companyIds)) { + current.onChange('', ''); + } + // companyIds сравниваем по companyKey: массив пересоздаётся на каждом рендере. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [departments, companyKey, readOnly, contextReadOnly]); + const suggestions = React.useMemo(() => { + const available = filterDepartmentsByCompanies(departments, companyIds); const query = value.trim().toLowerCase(); - if (!query) return []; - return departments.filter((d) => d.Name.toLowerCase().indexOf(query) !== -1).slice(0, MAX_SUGGESTIONS); - }, [departments, value]); + // Без фильтра по компании справочник — сотни записей, показываем их только + // по вводу. С фильтром список короткий — открываем его сразу, по фокусу. + if (!query) return companyIds ? available.slice(0, MAX_SUGGESTIONS) : []; + return available.filter((d) => d.Name.toLowerCase().indexOf(query) !== -1).slice(0, MAX_SUGGESTIONS); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [departments, value, companyKey]); const handlePick = (dept: IDepartment): void => { onChange(dept.Name, dept.Id); @@ -63,7 +98,7 @@ export const DepartmentPicker: React.FC = ({ id={id} className={styles.input} value={value} - placeholder={placeholder} + placeholder={isCompanyMissing ? NO_COMPANY_PLACEHOLDER : placeholder} readOnly={effectiveReadOnly} // setOpen(true) и при вводе, а не только по фокусу: после выбора список // закрывается (handlePick), но фокус остаётся на поле — без этого diff --git a/src/webparts/requestPortal/shared/dictionary/ui/departmentCompanyFilter.ts b/src/webparts/requestPortal/shared/dictionary/ui/departmentCompanyFilter.ts new file mode 100644 index 0000000..4ce954c --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/ui/departmentCompanyFilter.ts @@ -0,0 +1,35 @@ +import { IDepartment } from '../models/IDepartment'; + +/** + * Подразделения только указанных компаний. companyIds === undefined — без + * ограничения (формы, где поле не зависит от организации). + */ +export function filterDepartmentsByCompanies(departments: IDepartment[], companyIds?: string[]): IDepartment[] { + if (!companyIds) return departments; + return departments.filter((d) => companyIds.indexOf(d.CompanyId) !== -1); +} + +/** + * Выбранное подразделение относится к ДРУГОЙ компании, чем разрешённые, — его + * надо очистить. + * + * Сначала по Id (точно: названия вроде «Бухгалтерия» повторяются в разных + * компаниях), без Id — по названию. Текст, которого в справочнике нет вовсе + * (введён вручную), не трогаем: компанию у него определить нельзя. + */ +export function isDepartmentOutsideCompanies( + departments: IDepartment[], + name: string, + id: string | undefined, + companyIds?: string[] +): boolean { + if (!companyIds || !name.trim()) return false; + + const byId = id ? departments.filter((d) => d.Id === id)[0] : undefined; + if (byId) return companyIds.indexOf(byId.CompanyId) === -1; + + const normalizedName = name.trim().toLowerCase(); + const byName = departments.filter((d) => d.Name.trim().toLowerCase() === normalizedName); + if (byName.length === 0) return false; + return !byName.some((d) => companyIds.indexOf(d.CompanyId) !== -1); +}