feat: подразделения в подборе и приёме фильтруются по организации
Tests / frontend-test (push) Successful in 1m14s

This commit is contained in:
Тимур Городецкий
2026-09-25 15:24:33 +05:00
parent 06044bbce9
commit ebc74dc46d
4 changed files with 94 additions and 10 deletions
@@ -1,9 +1,16 @@
import * as React from "react"; import * as React from "react";
import { CollapseSection, FormField, TextInput } from "../../../shared/ui"; import { CollapseSection, FormField, TextInput } from "../../../shared/ui";
import { PositionPicker } from "../../../shared/dictionary"; import { DepartmentPicker, PositionPicker } from "../../../shared/dictionary";
import { ISectionProps } from "../model"; import { IFormData, ISectionProps } from "../model";
import { HireDateField } from "./HireDateField"; 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 { interface ICandidateSectionProps extends ISectionProps {
/** Начальник расчётной группы правит отдел/участок даже в режиме просмотра (см. approverRights.ts). */ /** Начальник расчётной группы правит отдел/участок даже в режиме просмотра (см. approverRights.ts). */
payrollEditable?: boolean; payrollEditable?: boolean;
@@ -42,11 +49,15 @@ export const CandidateSection: React.FC<ICandidateSectionProps> = ({
/> />
</FormField> </FormField>
<FormField label="2.4 Отдел/Участок" htmlFor="Department"> <FormField label="2.4 Отдел/Участок" htmlFor="Department">
<TextInput {/* Выбор из справочника или свой текст — отдела может не быть в справочнике. */}
<DepartmentPicker
context={context}
id="Department" id="Department"
value={data.Department} value={data.Department}
onChange={(v) => set("Department", v)} onChange={(name) => set("Department", name)}
companyIds={departmentCompanyIds(data)}
readOnly={payrollEditable ? false : undefined} readOnly={payrollEditable ? false : undefined}
placeholder="Выберите из списка или введите вручную"
/> />
</FormField> </FormField>
<FormField label="2.5 Местоположение" htmlFor="SiteLocation"> <FormField label="2.5 Местоположение" htmlFor="SiteLocation">
@@ -15,6 +15,9 @@ export const DepartmentSection: React.FC<ISectionProps> = ({ data, set, context
id="Department" id="Department"
context={context} context={context}
value={data.Department} value={data.Department}
valueId={data.DepartmentId}
// Подразделения всех организаций из «1.5 Организация».
companyIds={data.Companies.map((c) => c.Id)}
onChange={(name, id) => { onChange={(name, id) => {
set('Department', name); set('Department', name);
set('DepartmentId', id); set('DepartmentId', id);
@@ -4,32 +4,45 @@ import styles from './DepartmentPicker.module.scss';
import { useFormReadOnly } from '@tnsu/ui-kit'; import { useFormReadOnly } from '@tnsu/ui-kit';
import { getDepartments } from '../departments.service'; import { getDepartments } from '../departments.service';
import { IDepartment } from '../models/IDepartment'; import { IDepartment } from '../models/IDepartment';
import { filterDepartmentsByCompanies, isDepartmentOutsideCompanies } from './departmentCompanyFilter';
export interface IDepartmentPickerProps { export interface IDepartmentPickerProps {
context: WebPartContext; context: WebPartContext;
id?: string; id?: string;
/** Текущее название отдела (то, что видно в поле). */ /** Текущее название отдела (то, что видно в поле). */
value: string; value: string;
/** Id выбранного отдела, если форма его хранит, — точнее, чем сверка по названию. */
valueId?: string;
/** name — введённый/выбранный текст; id — Id из справочника ('' если текст введён вручную и не выбран из списка). */ /** name — введённый/выбранный текст; id — Id из справочника ('' если текст введён вручную и не выбран из списка). */
onChange: (name: string, id: string) => void; onChange: (name: string, id: string) => void;
placeholder?: string; placeholder?: string;
/** Только чтение (режим согласующего). */ /** Только чтение (режим согласующего). */
readOnly?: boolean; readOnly?: boolean;
/**
* Показывать подразделения только этих компаний (Id из Dictionary/Companies).
* undefined — все компании; [] — организация не выбрана, поле заблокировано.
* При смене набора выбранное подразделение чужой компании очищается.
*/
companyIds?: string[];
} }
const MAX_SUGGESTIONS = 30; const MAX_SUGGESTIONS = 30;
const NO_COMPANY_PLACEHOLDER = 'Сначала выберите организацию';
/** Поле «Отдел / участок» со справочником (Dictionary/Departments): ввод текста фильтрует список, выбор проставляет Id. */ /** Поле «Отдел / участок» со справочником (Dictionary/Departments): ввод текста фильтрует список, выбор проставляет Id. */
export const DepartmentPicker: React.FC<IDepartmentPickerProps> = ({ export const DepartmentPicker: React.FC<IDepartmentPickerProps> = ({
context, context,
id, id,
value, value,
valueId,
onChange, onChange,
placeholder, placeholder,
readOnly readOnly,
companyIds
}) => { }) => {
const contextReadOnly = useFormReadOnly(); const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly; const isCompanyMissing = !!companyIds && companyIds.length === 0;
const effectiveReadOnly = (readOnly ?? contextReadOnly) || isCompanyMissing;
const [departments, setDepartments] = React.useState<IDepartment[]>([]); const [departments, setDepartments] = React.useState<IDepartment[]>([]);
const [open, setOpen] = React.useState<boolean>(false); const [open, setOpen] = React.useState<boolean>(false);
@@ -46,11 +59,33 @@ export const DepartmentPicker: React.FC<IDepartmentPickerProps> = ({
}; };
}, [context]); }, [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 suggestions = React.useMemo(() => {
const available = filterDepartmentsByCompanies(departments, companyIds);
const query = value.trim().toLowerCase(); 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 => { const handlePick = (dept: IDepartment): void => {
onChange(dept.Name, dept.Id); onChange(dept.Name, dept.Id);
@@ -63,7 +98,7 @@ export const DepartmentPicker: React.FC<IDepartmentPickerProps> = ({
id={id} id={id}
className={styles.input} className={styles.input}
value={value} value={value}
placeholder={placeholder} placeholder={isCompanyMissing ? NO_COMPANY_PLACEHOLDER : placeholder}
readOnly={effectiveReadOnly} readOnly={effectiveReadOnly}
// setOpen(true) и при вводе, а не только по фокусу: после выбора список // setOpen(true) и при вводе, а не только по фокусу: после выбора список
// закрывается (handlePick), но фокус остаётся на поле — без этого // закрывается (handlePick), но фокус остаётся на поле — без этого
@@ -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);
}