fix: нейтральный текст при пустом маршруте в заявке 1С
Tests / frontend-test (push) Successful in 1m19s
Tests / frontend-test (push) Successful in 1m19s
This commit is contained in:
@@ -8,8 +8,8 @@ import {
|
||||
Select,
|
||||
PeopleField,
|
||||
PeopleFieldMulti,
|
||||
MultiChoiceField,
|
||||
} from "../../../shared/ui";
|
||||
import { MultiChoiceField } from "./MultiChoiceField";
|
||||
import {
|
||||
ISectionProps,
|
||||
PERSON_PLACEHOLDER,
|
||||
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
@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,136 +0,0 @@
|
||||
import * as React from 'react';
|
||||
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;
|
||||
|
||||
/**
|
||||
* Мультивыбор из фиксированного списка в стиле пикера-справочника
|
||||
* (ср. CompanyPickerMulti): при фокусе выпадают доступные опции, ввод текста их
|
||||
* фильтрует, выбор пункта СРАЗУ добавляет чип (без кнопки «Добавить»). Уже
|
||||
* выбранные из списка исключаются. allowOther позволяет добавить чипом
|
||||
* произвольное значение, которого нет в списке. Хранит массив строк (string[]).
|
||||
*/
|
||||
export const MultiChoiceField: React.FC<IMultiChoiceFieldProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
allowOther,
|
||||
placeholder
|
||||
}) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
const [query, setQuery] = useState<string>('');
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
// Ярлык для чипа: известное значение — по label из опций, произвольное — как есть.
|
||||
const labelFor = (v: string): string => options.filter((o) => o.value === v)[0]?.label ?? v;
|
||||
|
||||
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 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 className={styles.wrap}>
|
||||
{value.length > 0 && (
|
||||
<div className={styles.chips}>
|
||||
{value.map((v) => (
|
||||
<span key={v} className={styles.chip}>
|
||||
{labelFor(v)}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.chipRemove}
|
||||
aria-label={`Убрать ${labelFor(v)}`}
|
||||
title="Убрать"
|
||||
onClick={() => remove(v)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../shared/ui';
|
||||
import { CollapseSection, FormField, TextInput, TextArea, Select, MultiChoiceField } from '../../../shared/ui';
|
||||
import { PositionPicker } from '../../../shared/dictionary';
|
||||
import { LanguagesField } from './LanguagesField';
|
||||
import { MultiChoiceField } from './MultiChoiceField';
|
||||
import {
|
||||
ISectionProps,
|
||||
EMPLOYMENT_TYPE_OPTIONS,
|
||||
|
||||
@@ -226,7 +226,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
data.TaskName.trim() &&
|
||||
data.WorkType &&
|
||||
data.Organization &&
|
||||
data.DB1C &&
|
||||
data.DB1C.length > 0 &&
|
||||
data.Question.trim()
|
||||
);
|
||||
const validate = (): string | undefined => {
|
||||
@@ -412,7 +412,10 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
<ApprovalMatrix
|
||||
editor={matrixEditor}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
emptyText="Маршрут для этого вида работ пока не настроен — обратитесь к администратору маршрутов согласования."
|
||||
// Пустой маршрут — штатная ситуация: для части видов работ согласование
|
||||
// не нужно, заявка сразу уходит в Planner. Сбой загрузки маршрута
|
||||
// показывается отдельно (matrix.error) и блокирует отправку.
|
||||
emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С."
|
||||
/>
|
||||
|
||||
<SubmitActions
|
||||
|
||||
@@ -68,7 +68,7 @@ function buildPlannerNotes(
|
||||
lines.push(`Телефон: ${data.Phone}`);
|
||||
lines.push(`Отдел: ${data.Department}`);
|
||||
lines.push(`Должность: ${data.Position}`);
|
||||
lines.push(`База 1С: ${data.DB1C}`);
|
||||
lines.push(`База 1С: ${data.DB1C.join(', ')}`);
|
||||
lines.push(`Виды работ: ${data.WorkType}`);
|
||||
lines.push('');
|
||||
lines.push('Вопрос:');
|
||||
|
||||
@@ -3,10 +3,24 @@ import { dataListTitle } from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
|
||||
|
||||
// TaskName пишется/читается через системную колонку Title (см. provisioning.json/titleColumnName).
|
||||
// DB1C — отдельно, это множественный выбор (см. toStringArray).
|
||||
const SCALAR_FIELDS: (keyof IFormData)[] = [
|
||||
'WorkType', 'Organization', 'DB1C', 'FIO', 'Email', 'Phone', 'Department', 'Position', 'Manager', 'Question',
|
||||
'WorkType', 'Organization', 'FIO', 'Email', 'Phone', 'Department', 'Position', 'Manager', 'Question',
|
||||
];
|
||||
|
||||
/**
|
||||
* Значение колонки множественного выбора → string[]. Строка — заявки, сохранённые,
|
||||
* пока «База 1С» была одиночным выбором (или колонку ещё не переключили);
|
||||
* { results } — ответ в verbose-формате OData.
|
||||
*/
|
||||
function toStringArray(raw: unknown): string[] {
|
||||
if (Array.isArray(raw)) return raw.filter((v): v is string => typeof v === 'string' && v !== '');
|
||||
if (raw && typeof raw === 'object' && Array.isArray((raw as { results?: unknown }).results)) {
|
||||
return toStringArray((raw as { results: unknown[] }).results);
|
||||
}
|
||||
return typeof raw === 'string' && raw !== '' ? [raw] : [];
|
||||
}
|
||||
|
||||
export interface ILoadedRequest {
|
||||
data: IFormData;
|
||||
status: string;
|
||||
@@ -26,7 +40,7 @@ export async function loadTicketRequest(itemId: number, listId?: string): Promis
|
||||
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||
const item = (await list
|
||||
.items.getById(itemId)
|
||||
.select(...SCALAR_FIELDS, 'Title', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
|
||||
.select(...SCALAR_FIELDS, 'DB1C', 'Title', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
|
||||
|
||||
const data: IFormData = { ...EMPTY_DATA };
|
||||
const writable = data as unknown as Record<string, string>;
|
||||
@@ -36,6 +50,7 @@ export async function loadTicketRequest(itemId: number, listId?: string): Promis
|
||||
writable[key] = value === null || value === undefined ? '' : String(value);
|
||||
});
|
||||
data.TaskName = (item.Title as string) || '';
|
||||
data.DB1C = toStringArray(item.DB1C);
|
||||
|
||||
return {
|
||||
data,
|
||||
|
||||
@@ -16,7 +16,8 @@ export interface IFormData {
|
||||
TaskName: string;
|
||||
WorkType: string;
|
||||
Organization: string;
|
||||
DB1C: string;
|
||||
/** Базы 1С — множественный выбор (колонка MultiChoice), минимум одна. */
|
||||
DB1C: string[];
|
||||
FIO: string;
|
||||
Email: string;
|
||||
Phone: string;
|
||||
@@ -45,7 +46,7 @@ export const EMPTY_DATA: IFormData = {
|
||||
TaskName: '',
|
||||
WorkType: '',
|
||||
Organization: '',
|
||||
DB1C: '',
|
||||
DB1C: [],
|
||||
FIO: '',
|
||||
Email: '',
|
||||
Phone: '',
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"ОСН Беркут",
|
||||
"CT Global"
|
||||
] },
|
||||
{ "internalName": "DB1C", "title": "База 1С", "type": "Choice", "required": true, "choices": [
|
||||
{ "internalName": "DB1C", "title": "База 1С", "type": "Choice", "allowMultiple": true, "required": true, "choices": [
|
||||
"1С: Зарплата и управление персоналом (ЗУП)",
|
||||
"1С: Документооборот (ДО)",
|
||||
"1С: Автотранспортное предприятие (АТП)",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, Select, TextArea, TextInput, ISelectOption } from '../../../shared/ui';
|
||||
import { CollapseSection, FormField, MultiChoiceField, Select, TextArea, TextInput, ISelectOption } from '../../../shared/ui';
|
||||
import { ISectionProps } from '../model';
|
||||
import { WORK_TYPE_OPTIONS, ORGANIZATION_OPTIONS, DB1C_OPTIONS, QUESTION_MAX } from '../config';
|
||||
|
||||
@@ -32,13 +32,12 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||
placeholder="— выберите —"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="База 1С" htmlFor="DB1C" required>
|
||||
<Select
|
||||
id="DB1C"
|
||||
<FormField label="База 1С" fieldKey="DB1C" required>
|
||||
<MultiChoiceField
|
||||
value={data.DB1C}
|
||||
onChange={(v) => set('DB1C', v)}
|
||||
options={DB1C_SELECT_OPTIONS}
|
||||
placeholder="— выберите —"
|
||||
placeholder="Выберите одну или несколько баз"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={`Вопрос (${data.Question.length}/${QUESTION_MAX})`} htmlFor="Question" required full>
|
||||
|
||||
Reference in New Issue
Block a user