fix: заявки рекрутинга не давали отправить без выьбранного проекта, но пропускали пустую матрицу согласования
Tests / frontend-test (push) Successful in 1m23s

This commit is contained in:
Тимур Городецкий
2026-09-21 09:29:34 +05:00
parent 070c3ad752
commit 25c1188c32
11 changed files with 342 additions and 10 deletions
@@ -14,6 +14,10 @@ export { getEmployeeOrganizations, formatEmployeeOrganizations } from './employe
export type { IEmployeeOrganization } from './models/IEmployeeOrganization';
export { getNomenclature } from './nomenclature.service';
export type { INomenclatureRow } from './models/INomenclatureRow';
export { searchPositions } from './positions.service';
export type { IPosition } from './models/IPosition';
export { PositionPicker } from './ui/PositionPicker';
export type { IPositionPickerProps } from './ui/PositionPicker';
export {
directorPositionFor,
getEmployeeEmailByPosition,
@@ -0,0 +1,14 @@
// GET https://api.tnsu.kz/Dictionary/Positions?search=&page=&pageSize= →
// { total, page, pageSize, items: IPosition[] }. Справочник большой (878+
// записей), поэтому ищем на сервере через search, а не тянем всё разом.
export interface IPosition {
id: string;
position: string;
}
export interface IPositionListResponse {
total: number;
page: number;
pageSize: number;
items: IPosition[];
}
@@ -0,0 +1,27 @@
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 { IPosition, IPositionListResponse } from './models/IPosition';
/**
* GET /Dictionary/Positions?search=...&page=1&pageSize=... — справочник
* должностей (878+ записей). В отличие от /Project (см. projects.service),
* весь список разом не тянем и не кэшируем — ищем на сервере по мере ввода
* (см. PositionPicker), иначе каждое открытие формы качало бы сотни записей.
*/
export async function searchPositions(
context: WebPartContext,
search: string,
pageSize: number
): Promise<IPosition[]> {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const response: HttpClientResponse = await client.get(
`${DICTIONARY_API_BASE_URL}/Dictionary/Positions?search=${encodeURIComponent(search)}&page=1&pageSize=${pageSize}`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`Dictionary/Positions: ${response.status} ${response.statusText}`);
}
const body: IPositionListResponse = await response.json();
return body.items ?? [];
}
@@ -0,0 +1,90 @@
@import '../../../../../styles/design-tokens';
.wrap {
position: relative;
}
.input {
box-sizing: border-box;
width: 100%;
// справа место под крестик-очистку
padding: 11px 36px 11px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-900;
transition: .18s;
@include input-focus-gold;
@include readonly-field(':read-only');
&::placeholder {
color: $gray-400;
}
}
.clear {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-400;
font-size: 18px;
line-height: 1;
cursor: pointer;
transition: .15s;
&:hover {
background: $gray-100;
color: $gray-700;
}
}
.list {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin-top: $sp-1;
max-height: 260px;
overflow-y: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-sm;
box-shadow: $sh-2;
}
.item {
display: flex;
width: 100%;
padding: 9px 12px;
border: 0;
background: transparent;
text-align: left;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
cursor: pointer;
&:hover {
background: $navy-50;
}
}
.status {
padding: 9px 12px;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-500;
}
@@ -0,0 +1,136 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './PositionPicker.module.scss';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { searchPositions } from '../positions.service';
import { IPosition } from '../models/IPosition';
export interface IPositionPickerProps {
context: WebPartContext;
id?: string;
/** Текущее название должности (то, что видно в поле). Ручной ввод — тоже валидное значение. */
value: string;
onChange: (value: string) => void;
placeholder?: string;
/** Только чтение (режим согласующего). */
readOnly?: boolean;
}
const DEBOUNCE_MS = 300;
const PAGE_SIZE = 30;
/**
* Поле «Должность» со справочником (GET /Dictionary/Positions): ручной ввод
* не блокируется, а по мере набора (с задержкой) подтягиваются подсказки из
* справочника — выбор варианта просто подставляет его название в поле.
* Поиск — на сервере (?search=), т.к. справочник большой (878+ записей).
*/
export const PositionPicker: React.FC<IPositionPickerProps> = ({
context,
id,
value,
onChange,
placeholder,
readOnly
}) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [suggestions, setSuggestions] = React.useState<IPosition[]>([]);
const [open, setOpen] = React.useState<boolean>(false);
const [loading, setLoading] = React.useState<boolean>(false);
// Счётчик запросов — чтобы ответ на устаревший запрос (после ввода новых
// символов) не перезаписал подсказки более свежего.
const requestIdRef = React.useRef(0);
React.useEffect(() => {
const query = value.trim();
if (!query) {
setSuggestions([]);
setLoading(false);
return;
}
const requestId = ++requestIdRef.current;
setLoading(true);
const timer = setTimeout(() => {
searchPositions(context, query, PAGE_SIZE)
.then((list) => {
if (requestIdRef.current === requestId) {
setSuggestions(list);
setLoading(false);
}
})
.catch(() => {
if (requestIdRef.current === requestId) {
setSuggestions([]);
setLoading(false);
}
});
}, DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [context, value]);
const handlePick = (position: IPosition): void => {
onChange(position.position);
setOpen(false);
};
return (
<div className={styles.wrap}>
<input
id={id}
className={styles.input}
value={value}
placeholder={placeholder}
readOnly={effectiveReadOnly}
// setOpen(true) и при вводе, а не только по фокусу: после выбора список
// закрывается (handlePick), но фокус остаётся на поле — без этого
// повторный ввод в том же фокусе не открывал бы выпадашку заново.
onChange={(e) => {
onChange(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
{/* Быстрая очистка. preventDefault на mousedown — чтобы клик не «съел»
фокус до onClick (как в списке ниже). */}
{!effectiveReadOnly && value.length > 0 && (
<button
type="button"
className={styles.clear}
aria-label="Очистить"
title="Очистить"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange('');
setOpen(false);
}}
>
×
</button>
)}
{!effectiveReadOnly && open && loading && (
<div className={styles.list}>
<div className={styles.status}>Поиск…</div>
</div>
)}
{!effectiveReadOnly && open && !loading && suggestions.length > 0 && (
<div className={styles.list}>
{suggestions.map((p) => (
<button
key={p.id}
type="button"
className={styles.item}
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick, и список закрывается до выбора.
onMouseDown={(e) => e.preventDefault()}
onClick={() => handlePick(p)}
>
{p.position}
</button>
))}
</div>
)}
</div>
);
};