feat: выбор нескольких проектов в заявке на приём
Tests / frontend-test (push) Successful in 2m2s

Проект больше не влияет на маршрут (условие по Project убрано в админке),
поэтому поле стало мультивыбором (ProjectPickerMulti). Список хранится в
новой колонке ProjectsJson; старые Project/ProjectId/ProjectCode заполняются
производными значениями для представлений и отчётов, старые заявки читаются
из них. Title и текстовые колонки обрезаются до 255 символов, чтобы длинные
названия не роняли сохранение. Требует провижининга колонки ProjectsJson.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-08 12:39:14 +05:00
parent cfe97dc67e
commit 57efa7620e
17 changed files with 471 additions and 51 deletions
@@ -6,6 +6,9 @@ export { getProjects } from './projects.service';
export type { IProject } from './models/IProject';
export { ProjectPicker } from './ui/ProjectPicker';
export type { IProjectPickerProps } from './ui/ProjectPicker';
export type { IProjectRef } from './models/IProjectRef';
export { ProjectPickerMulti } from './ui/ProjectPickerMulti';
export type { IProjectPickerMultiProps } from './ui/ProjectPickerMulti';
export { getCompanies } from './companies.service';
export type { ICompany } from './models/ICompany';
export { CompanyPickerMulti } from './ui/CompanyPickerMulti';
@@ -0,0 +1,14 @@
/**
* Выбранный проект в мультивыборе (ProjectPickerMulti) — то, что хранится в
* заявке (Note-колонка *Json), а не вся запись справочника IProject.
* Ключи в PascalCase, как у ICompany: так одинаково читаются оба мультивыбора
* и журнал правок показывает их по Name (см. shared/fieldChanges).
*/
export interface IProjectRef {
/** GUID проекта (IProject.oid). */
Id: string;
/** Наименование проекта. */
Name: string;
/** Код проекта (IProject.code), '' — если в справочнике не задан. */
Code: string;
}
@@ -0,0 +1,114 @@
@import '../../../../../styles/design-tokens';
// Чипы и поле — как у CompanyPickerMulti, строка подсказки — как у ProjectPicker
// (название + мелкая строка «код · заказчик»).
.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: flex;
flex-direction: column;
gap: 2px;
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;
}
}
.meta {
font-size: $fs-caption;
color: $gray-500;
}
@@ -0,0 +1,140 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './ProjectPickerMulti.module.scss';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { getProjects } from '../projects.service';
import { IProject } from '../models/IProject';
import { IProjectRef } from '../models/IProjectRef';
export interface IProjectPickerMultiProps {
context: WebPartContext;
id?: string;
/** Выбранные проекты (Id + Name + Code). */
value: IProjectRef[];
onChange: (projects: IProjectRef[]) => void;
placeholder?: string;
/** Только чтение (режим согласующего). */
readOnly?: boolean;
}
const MAX_SUGGESTIONS = 30;
function toRef(project: IProject): IProjectRef {
return { Id: project.oid, Name: project.name, Code: project.code || '' };
}
/**
* Мультивыбор проектов из справочника (GET /Project): ввод текста фильтрует
* список по названию и коду, выбранные показываются чипами. Ручной ввод без
* выбора из списка не сохраняется — в отличие от одиночного ProjectPicker.
*/
export const ProjectPickerMulti: React.FC<IProjectPickerMultiProps> = ({
context,
id,
value,
onChange,
placeholder,
readOnly
}) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [projects, setProjects] = React.useState<IProject[]>([]);
const [query, setQuery] = React.useState<string>('');
const [open, setOpen] = React.useState<boolean>(false);
React.useEffect(() => {
let alive = true;
getProjects(context)
.then((list) => {
if (alive) setProjects(list);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [context]);
const selectedIds = React.useMemo(() => new Set(value.map((p) => p.Id)), [value]);
const suggestions = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return projects
.filter(
(p) =>
!selectedIds.has(p.oid) &&
(p.name.toLowerCase().indexOf(q) !== -1 || (p.code && p.code.toLowerCase().indexOf(q) !== -1))
)
.slice(0, MAX_SUGGESTIONS);
}, [projects, query, selectedIds]);
const add = (project: IProject): void => {
onChange([...value, toRef(project)]);
setQuery('');
setOpen(false);
};
const remove = (projectId: string): void => {
onChange(value.filter((p) => p.Id !== projectId));
};
return (
<div className={styles.wrap}>
{value.length > 0 && (
<div className={styles.chips}>
{value.map((p) => (
<span key={p.Id} className={styles.chip}>
{p.Name}
{!effectiveReadOnly && (
<button
type="button"
className={styles.chipRemove}
aria-label={`Убрать ${p.Name}`}
title="Убрать"
onClick={() => remove(p.Id)}
>
×
</button>
)}
</span>
))}
</div>
)}
{!effectiveReadOnly && (
<div className={styles.field}>
<input
id={id}
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 && (
<div className={styles.list}>
{suggestions.map((p) => (
<button
key={p.oid}
type="button"
className={styles.item}
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick.
onMouseDown={(e) => e.preventDefault()}
onClick={() => add(p)}
>
{p.name}
<span className={styles.meta}>{[p.code, p.customerName].filter(Boolean).join(' · ')}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
);
};