feat: added optin to add delegant for executor and change delegant

This commit is contained in:
Тимур Городецкий
2026-08-10 13:55:05 +05:00
parent 3a15d83ded
commit 99f0946968
23 changed files with 407 additions and 40 deletions
@@ -0,0 +1,32 @@
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 { IDepartment } from './models/IDepartment';
// GET /Dictionary/Departments — справочник отделов по всем компаниям, ответ —
// плоский массив (без обёртки {data: ...}, в отличие от approval-route API).
async function fetchDepartments(context: WebPartContext): Promise<IDepartment[]> {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const response: HttpClientResponse = await client.get(
`${DICTIONARY_API_BASE_URL}/Dictionary/Departments`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`Dictionary/Departments: ${response.status} ${response.statusText}`);
}
return response.json();
}
// Справочник большой (сотни записей) и в рамках сессии не меняется — грузим
// один раз и отдаём тот же промис повторным вызовам (иначе каждое открытие
// формы/пикера заново тянуло бы весь список).
let cache: Promise<IDepartment[]> | undefined;
export function getDepartments(context: WebPartContext): Promise<IDepartment[]> {
if (!cache) {
cache = fetchDepartments(context).catch((e) => {
cache = undefined;
throw e;
});
}
return cache;
}
@@ -0,0 +1,4 @@
export { getDepartments } from './departments.service';
export type { IDepartment } from './models/IDepartment';
export { DepartmentPicker } from './ui/DepartmentPicker';
export type { IDepartmentPickerProps } from './ui/DepartmentPicker';
@@ -0,0 +1,7 @@
// GET /Dictionary/Departments (api.tnsu.kz) → IDepartment[]
export interface IDepartment {
Id: string;
Name: string;
CompanyId: string;
Company: string;
}
@@ -0,0 +1,63 @@
@import '../../../../../styles/design-tokens';
.wrap {
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;
@include readonly-field(':read-only');
&::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;
}
}
.company {
font-size: $fs-caption;
color: $gray-500;
}
@@ -0,0 +1,91 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './DepartmentPicker.module.scss';
import { useFormReadOnly } from '../../ui/FormMode';
import { getDepartments } from '../departments.service';
import { IDepartment } from '../models/IDepartment';
export interface IDepartmentPickerProps {
context: WebPartContext;
id?: string;
/** Текущее название отдела (то, что видно в поле). */
value: string;
/** name — введённый/выбранный текст; id — Id из справочника ('' если текст введён вручную и не выбран из списка). */
onChange: (name: string, id: string) => void;
placeholder?: string;
/** Только чтение (режим согласующего). */
readOnly?: boolean;
}
const MAX_SUGGESTIONS = 30;
/** Поле «Отдел / участок» со справочником (Dictionary/Departments): ввод текста фильтрует список, выбор проставляет Id. */
export const DepartmentPicker: React.FC<IDepartmentPickerProps> = ({
context,
id,
value,
onChange,
placeholder,
readOnly
}) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [departments, setDepartments] = React.useState<IDepartment[]>([]);
const [open, setOpen] = React.useState<boolean>(false);
React.useEffect(() => {
let alive = true;
getDepartments(context)
.then((list) => {
if (alive) setDepartments(list);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [context]);
const suggestions = React.useMemo(() => {
const query = value.trim().toLowerCase();
if (!query) return [];
return departments.filter((d) => d.Name.toLowerCase().indexOf(query) !== -1).slice(0, MAX_SUGGESTIONS);
}, [departments, value]);
const handlePick = (dept: IDepartment): void => {
onChange(dept.Name, dept.Id);
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, '')}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
{!effectiveReadOnly && open && suggestions.length > 0 && (
<div className={styles.list}>
{suggestions.map((d) => (
<button
key={d.Id}
type="button"
className={styles.item}
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick, и список закрывается до выбора.
onMouseDown={(e) => e.preventDefault()}
onClick={() => handlePick(d)}
>
{d.Name}
<span className={styles.company}>{d.Company}</span>
</button>
))}
</div>
)}
</div>
);
};