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

This commit is contained in:
Тимур Городецкий
2026-08-17 18:29:31 +05:00
parent d8fe538f36
commit 5b2c062e81
22 changed files with 1273 additions and 192 deletions
@@ -0,0 +1,31 @@
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 { ICompany } from './models/ICompany';
// GET /Dictionary/Companies — справочник компаний, ответ — плоский массив
// { Id, Name } (без обёртки, как /Dictionary/Departments).
async function fetchCompanies(context: WebPartContext): Promise<ICompany[]> {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const response: HttpClientResponse = await client.get(
`${DICTIONARY_API_BASE_URL}/Dictionary/Companies`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`Dictionary/Companies: ${response.status} ${response.statusText}`);
}
return response.json();
}
// Справочник в рамках сессии не меняется — грузим один раз и отдаём тот же
// промис повторным вызовам (как в departments.service).
let cache: Promise<ICompany[]> | undefined;
export function getCompanies(context: WebPartContext): Promise<ICompany[]> {
if (!cache) {
cache = fetchCompanies(context).catch((e) => {
cache = undefined;
throw e;
});
}
return cache;
}
@@ -6,3 +6,11 @@ export { getProjects } from './projects.service';
export type { IProject } from './models/IProject';
export { ProjectPicker } from './ui/ProjectPicker';
export type { IProjectPickerProps } from './ui/ProjectPicker';
export { getCompanies } from './companies.service';
export type { ICompany } from './models/ICompany';
export { CompanyPickerMulti } from './ui/CompanyPickerMulti';
export type { ICompanyPickerMultiProps } from './ui/CompanyPickerMulti';
export { getProjects1C } from './projects1c.service';
export type { IProject1C } from './models/IProject1C';
export { Project1CPicker } from './ui/Project1CPicker';
export type { IProject1CPickerProps } from './ui/Project1CPicker';
@@ -0,0 +1,8 @@
// GET https://api.tnsu.kz/Dictionary/Companies → плоский массив { Id, Name }
// (PascalCase, без обёртки — как /Dictionary/Departments).
export interface ICompany {
/** GUID компании — стабильный идентификатор. */
Id: string;
/** Наименование (напр. «ТОО AFL Engineering») — то, что видит пользователь. */
Name: string;
}
@@ -0,0 +1,11 @@
// GET https://api.tnsu.kz/Dictionary/Projects1C → плоский массив
// { Id, Code, ProjectName } (PascalCase, без обёртки — как /Dictionary/Companies).
// Это отдельный справочник 1С-проектов; не путать с /Project (approval-route).
export interface IProject1C {
/** GUID проекта — стабильный идентификатор. */
Id: string;
/** Код проекта (напр. «БК0000069»). */
Code: string;
/** Наименование проекта — то, что видит пользователь. */
ProjectName: string;
}
@@ -0,0 +1,31 @@
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 { IProject1C } from './models/IProject1C';
// GET /Dictionary/Projects1C — справочник проектов 1С, ответ — плоский массив
// { Id, Code, ProjectName } (без обёртки, как /Dictionary/Companies).
async function fetchProjects1C(context: WebPartContext): Promise<IProject1C[]> {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const response: HttpClientResponse = await client.get(
`${DICTIONARY_API_BASE_URL}/Dictionary/Projects1C`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`Dictionary/Projects1C: ${response.status} ${response.statusText}`);
}
return response.json();
}
// Справочник в рамках сессии не меняется — грузим один раз и отдаём тот же
// промис повторным вызовам (как в companies/departments.service).
let cache: Promise<IProject1C[]> | undefined;
export function getProjects1C(context: WebPartContext): Promise<IProject1C[]> {
if (!cache) {
cache = fetchProjects1C(context).catch((e) => {
cache = undefined;
throw e;
});
}
return cache;
}
@@ -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;
}
}
@@ -0,0 +1,129 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './CompanyPickerMulti.module.scss';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { getCompanies } from '../companies.service';
import { ICompany } from '../models/ICompany';
export interface ICompanyPickerMultiProps {
context: WebPartContext;
id?: string;
/** Выбранные компании (Id + Name). */
value: ICompany[];
onChange: (companies: ICompany[]) => void;
placeholder?: string;
/** Только чтение (режим согласующего). */
readOnly?: boolean;
}
const MAX_SUGGESTIONS = 30;
/**
* Мультивыбор компаний из справочника (GET /Dictionary/Companies): ввод текста
* фильтрует список, выбранные показываются чипами. Хранит массив { Id, Name }.
*/
export const CompanyPickerMulti: React.FC<ICompanyPickerMultiProps> = ({
context,
id,
value,
onChange,
placeholder,
readOnly
}) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [companies, setCompanies] = React.useState<ICompany[]>([]);
const [query, setQuery] = React.useState<string>('');
const [open, setOpen] = React.useState<boolean>(false);
React.useEffect(() => {
let alive = true;
getCompanies(context)
.then((list) => {
if (alive) setCompanies(list);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [context]);
const selectedIds = React.useMemo(() => new Set(value.map((c) => c.Id)), [value]);
const suggestions = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return companies
.filter((c) => !selectedIds.has(c.Id) && c.Name.toLowerCase().indexOf(q) !== -1)
.slice(0, MAX_SUGGESTIONS);
}, [companies, query, selectedIds]);
const add = (company: ICompany): void => {
onChange([...value, { Id: company.Id, Name: company.Name }]);
setQuery('');
setOpen(false);
};
const remove = (companyId: string): void => {
onChange(value.filter((c) => c.Id !== companyId));
};
return (
<div className={styles.wrap}>
{value.length > 0 && (
<div className={styles.chips}>
{value.map((c) => (
<span key={c.Id} className={styles.chip}>
{c.Name}
{!effectiveReadOnly && (
<button
type="button"
className={styles.chipRemove}
aria-label={`Убрать ${c.Name}`}
title="Убрать"
onClick={() => remove(c.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((c) => (
<button
key={c.Id}
type="button"
className={styles.item}
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick.
onMouseDown={(e) => e.preventDefault()}
onClick={() => add(c)}
>
{c.Name}
</button>
))}
</div>
)}
</div>
)}
</div>
);
};
@@ -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;
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,118 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './Project1CPicker.module.scss';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { getProjects1C } from '../projects1c.service';
import { IProject1C } from '../models/IProject1C';
export interface IProject1CPickerProps {
context: WebPartContext;
id?: string;
/** Текущее наименование проекта (то, что видно в поле). */
value: string;
/**
* name — введённый/выбранный текст; id — GUID проекта ('' при ручном вводе);
* code — код проекта ('' при ручном вводе). При выборе из списка приходят все три.
*/
onChange: (name: string, id: string, code: string) => void;
placeholder?: string;
/** Только чтение (режим согласующего). */
readOnly?: boolean;
}
const MAX_SUGGESTIONS = 30;
/** Поле выбора проекта из справочника 1С (GET /Dictionary/Projects1C): ввод текста фильтрует список по названию/коду. */
export const Project1CPicker: React.FC<IProject1CPickerProps> = ({
context,
id,
value,
onChange,
placeholder,
readOnly
}) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [projects, setProjects] = React.useState<IProject1C[]>([]);
const [open, setOpen] = React.useState<boolean>(false);
React.useEffect(() => {
let alive = true;
getProjects1C(context)
.then((list) => {
if (alive) setProjects(list);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [context]);
const suggestions = React.useMemo(() => {
const query = value.trim().toLowerCase();
if (!query) return [];
return projects
.filter(
(p) =>
p.ProjectName.toLowerCase().indexOf(query) !== -1 ||
(p.Code && p.Code.toLowerCase().indexOf(query) !== -1)
)
.slice(0, MAX_SUGGESTIONS);
}, [projects, value]);
const handlePick = (project: IProject1C): void => {
onChange(project.ProjectName, project.Id, project.Code);
setOpen(false);
};
return (
<div className={styles.wrap}>
<input
id={id}
className={styles.input}
value={value}
placeholder={placeholder}
readOnly={effectiveReadOnly}
onChange={(e) => {
onChange(e.target.value, '', '');
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
{!effectiveReadOnly && value.length > 0 && (
<button
type="button"
className={styles.clear}
aria-label="Очистить"
title="Очистить"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange('', '', '');
setOpen(false);
}}
>
×
</button>
)}
{!effectiveReadOnly && open && 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.ProjectName}
<span className={styles.meta}>{p.Code}</span>
</button>
))}
</div>
)}
</div>
);
};