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

This commit is contained in:
Тимур Городецкий
2026-08-24 15:12:42 +05:00
parent fd19ddc41a
commit 0b2961a741
23 changed files with 1016 additions and 79 deletions
@@ -178,6 +178,8 @@ export const SOFTWARE_OPTIONS: ISelectOption[] = [
{ value: 'MS Outlook', label: 'MS Outlook' },
{ value: 'MS Project', label: 'MS Project' },
{ value: 'AutoCAD', label: 'AutoCAD' },
{ value: 'Autodesk Forma', label: 'Autodesk Forma' },
{ value: 'BIM', label: 'BIM' },
{ value: 'Revit', label: 'Revit' },
{ value: 'ArchiCAD', label: 'ArchiCAD' },
{ value: 'Компас-3D', label: 'Компас-3D' },
@@ -0,0 +1,104 @@
@import '../../../../../styles/design-tokens';
.wrap {
position: relative;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: $sp-1;
margin-bottom: $sp-1;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 5px 6px 5px 10px;
border: 1px solid $gray-200;
border-radius: 999px;
background: $navy-50;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
word-break: break-word;
}
.chipRemove {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: $gray-500;
font-size: 15px;
line-height: 1;
cursor: pointer;
transition: .15s;
&:hover {
background: $gray-200;
color: $gray-900;
}
}
.field {
position: relative;
}
.input {
box-sizing: border-box;
width: 100%;
padding: 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;
&::placeholder {
color: $gray-400;
}
}
.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: block;
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;
}
}
@@ -1,22 +1,25 @@
import * as React from 'react';
import { useState } from 'react';
import styles from './LanguagesField.module.scss';
import { Select, TextInput, Button, useFormReadOnly, ISelectOption } from '../../../shared/ui';
import { OTHER_VALUE } from '../model';
import { useMemo, useState } from 'react';
import styles from './MultiChoiceField.module.scss';
import { useFormReadOnly, ISelectOption } from '../../../shared/ui';
export interface IMultiChoiceFieldProps {
value: string[];
onChange: (values: string[]) => void;
options: ISelectOption[];
/** Разрешить «Другое» с ручным вводом произвольного значения. */
/** Разрешить ввод произвольного значения, которого нет в списке. */
allowOther?: boolean;
placeholder?: string;
}
const MAX_SUGGESTIONS = 30;
/**
* Мультивыбор из фиксированного списка: выбор значения → «Добавить», выбранные —
* чипами с удалением. Уже выбранные исключаются из списка. allowOther добавляет
* пункт «Другое» с полем ручного ввода. Переиспользует стили LanguagesField.
* Мультивыбор из фиксированного списка в стиле пикера-справочника
* (ср. CompanyPickerMulti): при фокусе выпадают доступные опции, ввод текста их
* фильтрует, выбор пункта СРАЗУ добавляет чип (без кнопки «Добавить»). Уже
* выбранные из списка исключаются. allowOther позволяет добавить чипом
* произвольное значение, которого нет в списке. Хранит массив строк (string[]).
*/
export const MultiChoiceField: React.FC<IMultiChoiceFieldProps> = ({
value,
@@ -26,69 +29,106 @@ export const MultiChoiceField: React.FC<IMultiChoiceFieldProps> = ({
placeholder
}) => {
const readOnly = useFormReadOnly();
const [draft, setDraft] = useState<string>('');
const [draftOther, setDraftOther] = useState<string>('');
const [query, setQuery] = useState<string>('');
const [open, setOpen] = useState<boolean>(false);
const selected = new Set(value);
const available = options.filter((o) => !selected.has(o.value));
const addOptions: ISelectOption[] = allowOther ? [...available, { value: OTHER_VALUE, label: OTHER_VALUE }] : available;
const selected = useMemo(() => new Set(value), [value]);
// Ярлык для чипа: известное значение — по label из опций, произвольное — как есть.
const labelFor = (v: string): string => options.filter((o) => o.value === v)[0]?.label ?? v;
const isOther = draft === OTHER_VALUE;
const pending = isOther ? draftOther.trim() : draft;
const suggestions = useMemo(() => {
const q = query.trim().toLowerCase();
// Пустой запрос — показываем все ещё не выбранные опции (клик-дропдаун);
// с запросом — фильтруем по label (типа-хед).
const available = options.filter(
(o) => !selected.has(o.value) && (!q || o.label.toLowerCase().indexOf(q) !== -1)
);
return available.slice(0, MAX_SUGGESTIONS);
}, [options, query, selected]);
const add = (): void => {
if (!pending || selected.has(pending)) return;
onChange([...value, pending]);
setDraft('');
setDraftOther('');
// «Другое»: если разрешено и введён текст, которого нет ни среди опций, ни среди
// уже выбранных — предлагаем добавить его как произвольное значение.
const trimmed = query.trim();
const canAddOther =
!!allowOther &&
trimmed.length > 0 &&
!selected.has(trimmed) &&
!options.some((o) => o.value.toLowerCase() === trimmed.toLowerCase());
const add = (v: string): void => {
if (!v || selected.has(v)) return;
onChange([...value, v]);
setQuery('');
// Список НЕ закрываем: поле сохраняет фокус (mousedown с preventDefault),
// поэтому оставляем открытым — сразу видно оставшиеся варианты и можно
// добавлять подряд, не теряя и не возвращая фокус.
setOpen(true);
};
const remove = (v: string): void => onChange(value.filter((x) => x !== v));
return (
<div>
<div className={styles.wrap}>
{value.length > 0 && (
<div className={styles.list}>
<div className={styles.chips}>
{value.map((v) => (
<div key={v} className={styles.row}>
<span className={styles.lang}>{v}</span>
<span key={v} className={styles.chip}>
{labelFor(v)}
{!readOnly && (
<button
type="button"
className={styles.remove}
aria-label={`Убрать ${v}`}
className={styles.chipRemove}
aria-label={`Убрать ${labelFor(v)}`}
title="Убрать"
onClick={() => remove(v)}
>
×
</button>
)}
</div>
</span>
))}
</div>
)}
{!readOnly && addOptions.length > 0 && (
<div className={styles.add}>
<div className={styles.addField}>
<Select
value={draft}
onChange={(v) => {
setDraft(v);
if (v !== OTHER_VALUE) setDraftOther('');
}}
options={addOptions}
placeholder={placeholder ?? '— выберите —'}
/>
</div>
{isOther && (
<div className={styles.addField}>
<TextInput value={draftOther} onChange={setDraftOther} placeholder="Введите значение" />
{!readOnly && (
<div className={styles.field}>
<input
className={styles.input}
value={query}
placeholder={placeholder}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
{open && (suggestions.length > 0 || canAddOther) && (
<div className={styles.list}>
{suggestions.map((o) => (
<button
key={o.value}
type="button"
className={styles.item}
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick.
onMouseDown={(e) => e.preventDefault()}
onClick={() => add(o.value)}
>
{o.label}
</button>
))}
{canAddOther && (
<button
type="button"
className={styles.item}
onMouseDown={(e) => e.preventDefault()}
onClick={() => add(trimmed)}
>
Добавить «{trimmed}»
</button>
)}
</div>
)}
<Button type="button" variant="secondary" onClick={add} disabled={!pending}>
Добавить
</Button>
</div>
)}
</div>
@@ -76,6 +76,9 @@ async function applyPeople(item: Record<string, unknown>, data: IFormData): Prom
for (let j = 0; j < logins.length; j++) {
ids.push(await resolveUserId(logins[j]));
}
// Multi-value поле (UserMulti) в режиме odata=minimalmetadata пишется ПЛОСКИМ
// массивом Id ([1,2,3]). Обёртка { results: [...] } — старый verbose-формат,
// здесь сервер её не принимает («Ожидался StartArray, обнаружен StartObject»).
item[`${MULTI_PERSON_FIELDS[i]}Id`] = ids;
}
}