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
@@ -177,6 +177,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<DecisionPanel
service={approverService}
requestId={itemId}
search={(q) => approval.searchUsers(q)}
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
@@ -187,6 +188,16 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
approvalListName: APPROVAL_LIST_TITLE,
}).catch(() => undefined);
}}
onDelegated={() => {
// Шаг переназначен: активной стала строка делегата — обновляем
// матрицу и уведомляем нового активного участника.
setDecisionSeq((n) => n + 1);
notifyActiveStep(context, approval, itemId, {
requestTypeId: HIRE_REQUEST_TYPE_ID,
dataListName: DATA_LIST_TITLE,
approvalListName: APPROVAL_LIST_TITLE,
}).catch(() => undefined);
}}
/>
</div>
</div>
@@ -8,6 +8,7 @@
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] }
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
]
}
@@ -120,12 +120,13 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
? approval
.getMatrixForRequest(editId)
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution))
: getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
[context, userEmail, approval, editId]
: getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Approval', data.DepartmentId),
[context, userEmail, approval, editId, data.DepartmentId]
);
const matrix = useMatrixEditor(
loadMatrix,
`${userEmail}-${editId ?? 'new'}`,
// Отдел влияет на резолв маршрута — смена отдела у НОВОЙ заявки должна перепланировать матрицу.
`${userEmail}-${editId ?? 'new'}-${data.DepartmentId}`,
!readOnly,
true,
(login) => approval.getJobTitle(login)
@@ -138,12 +139,12 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
? approval
.getMatrixForRequest(editId)
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution))
: getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Execution'),
[context, userEmail, approval, editId]
: getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Execution', data.DepartmentId),
[context, userEmail, approval, editId, data.DepartmentId]
);
const executors = useMatrixEditor(
loadExecutors,
`${userEmail}-${editId ?? 'new'}`,
`${userEmail}-${editId ?? 'new'}-${data.DepartmentId}`,
false,
true,
(login) => approval.getJobTitle(login)
@@ -236,6 +236,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
<DecisionPanel
service={approverService}
requestId={itemId}
search={(q) => approval.searchUsers(q)}
onBeforeDecide={canEditFields ? saveReviewFields : undefined}
onDecided={(newStatus) => {
setStatus(newStatus);
@@ -247,6 +248,16 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
approvalListName: APPROVAL_LIST_TITLE,
}).catch(() => undefined);
}}
onDelegated={() => {
// Шаг переназначен: активной стала строка делегата — обновляем
// матрицу и уведомляем нового активного участника.
setDecisionSeq((n) => n + 1);
notifyActiveStep(context, approval, itemId, {
requestTypeId: WORKPLACE_REQUEST_TYPE_ID,
dataListName: DATA_LIST_TITLE,
approvalListName: APPROVAL_LIST_TITLE,
}).catch(() => undefined);
}}
/>
</div>
</div>
@@ -8,6 +8,7 @@
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] }
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
]
}
@@ -4,7 +4,7 @@ import { IFormData, EMPTY_DATA } from './model';
const LIST_TITLE = 'Организация рабочего места — данные';
const SCALAR_FIELDS: (keyof IFormData)[] = [
'FullName', 'NameLatin', 'Phone', 'Position', 'Department', 'WorkKind', 'Location', 'StartDate',
'FullName', 'NameLatin', 'Phone', 'Position', 'Department', 'DepartmentId', 'WorkKind', 'Location', 'StartDate',
'Software', 'AccessRights'
];
@@ -12,6 +12,8 @@ export interface IFormData {
Phone: string;
Position: string;
Department: string;
/** Id отдела из справочника (Dictionary/Departments); '' — если введён вручную без выбора из списка. */
DepartmentId: string;
WorkKind: string;
Location: string;
StartDate: string;
@@ -37,6 +39,7 @@ export const EMPTY_DATA: IFormData = {
Phone: '',
Position: '',
Department: '',
DepartmentId: '',
WorkKind: '',
Location: '',
StartDate: '',
@@ -9,6 +9,7 @@
{ "internalName": "Phone", "title": "Телефон", "type": "Text" },
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
{ "internalName": "Department", "title": "Отдел / участок", "type": "Text" },
{ "internalName": "DepartmentId", "title": "Id отдела (справочник)", "type": "Text" },
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
{ "internalName": "Location", "title": "Местоположение", "type": "Text" },
{ "internalName": "StartDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
@@ -1,8 +1,9 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, PhoneInput } from '../../../shared/ui';
import { DepartmentPicker } from '../../../shared/dictionary';
import { ISectionProps } from '../model';
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set }) => (
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context }) => (
<CollapseSection title="Сотрудник" icon="people">
<FormField label="Ф.И.О." htmlFor="FullName" required full>
<TextInput id="FullName" value={data.FullName} onChange={(v) => set('FullName', v)} />
@@ -17,7 +18,15 @@ export const EmployeeSection: React.FC<ISectionProps> = ({ data, set }) => (
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormField>
<FormField label="Отдел / участок" htmlFor="Department">
<TextInput id="Department" value={data.Department} onChange={(v) => set('Department', v)} />
<DepartmentPicker
id="Department"
context={context}
value={data.Department}
onChange={(name, id) => {
set('Department', name);
set('DepartmentId', id);
}}
/>
</FormField>
<FormField label="Вид работ" htmlFor="WorkKind">
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
@@ -25,6 +25,7 @@ const FIELDS = {
// скрытое системное поле SharePoint (не видно в UI списка).
allowReassign: 'AllowReassignToOtherApprovers', // «Можно перепоручать» (Да/Нет)
phase: 'Phase', // «Фаза» (Choice): Согласование | Исполнение
delegatedBy: 'DelegatedBy', // «Кем переназначено» (User) → DelegatedById
};
// Согласующий для записи в список.
@@ -140,12 +141,16 @@ export class ApprovalListService {
}
// Добавляет одну строку согласования с заданным Order (для делегата).
// delegatedById — Id пользователя, который выполнил переназначение (не строки
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту
// строку ещё раз, если делегат недоступен (см. ApproverService.delegate).
public async addApproverRow(
requestId: number,
approver: IApproverToWrite,
order: number,
status: string,
phase: string = APPROVAL_PHASE.approval
phase: string = APPROVAL_PHASE.approval,
delegatedById?: number
): Promise<void> {
const approverId = await this.resolveUserId(approver.email);
await this.list().items.add({
@@ -156,6 +161,7 @@ export class ApprovalListService {
[FIELDS.order]: order,
[FIELDS.allowReassign]: approver.allowReassign,
[FIELDS.phase]: phase,
...(delegatedById ? { [`${FIELDS.delegatedBy}Id`]: delegatedById } : {}),
});
}
@@ -163,12 +169,21 @@ export class ApprovalListService {
public async getForRequest(requestId: number): Promise<IApprovalRow[]> {
const rows = (await this.list()
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
.select('Id', FIELDS.status, FIELDS.order, FIELDS.allowReassign, FIELDS.phase, `${FIELDS.approver}/Id`)
.expand(FIELDS.approver)
.select(
'Id',
FIELDS.status,
FIELDS.order,
FIELDS.allowReassign,
FIELDS.phase,
`${FIELDS.approver}/Id`,
`${FIELDS.delegatedBy}/Id`
)
.expand(FIELDS.approver, FIELDS.delegatedBy)
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
return rows.map((r) => {
const approver = r[FIELDS.approver] as { Id?: number } | undefined;
const delegatedBy = r[FIELDS.delegatedBy] as { Id?: number } | undefined;
return {
id: r.Id as number,
status: (r[FIELDS.status] as string) || '',
@@ -177,6 +192,7 @@ export class ApprovalListService {
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
// Пустая фаза (старые строки без колонки) трактуется как «Согласование».
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
delegatedById: delegatedBy?.Id ?? 0,
};
});
}
@@ -34,7 +34,8 @@ export async function resolveRoute(
context: WebPartContext,
requestTypeId: number,
userEmail: string,
status: ApprovalStatus = 'Approval'
status: ApprovalStatus = 'Approval',
departmentId: string | null = null
): Promise<IResolvedRoute> {
const c = await client(context);
const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`;
@@ -50,7 +51,8 @@ export async function resolveRoute(
userEmail,
projectId: null,
status,
fieldValues: []
fieldValues: [],
departmentId: departmentId || null
})
});
if (!response.ok) {
@@ -8,15 +8,17 @@ import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
// status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution'
// (исполнители). Имена подтягиваются по executorEmail (бэкенд отдаёт email + должность).
// departmentId — Id отдела из справочника (влияет на маршрут, если он различается по отделу).
export async function getPlannedMatrix(
context: WebPartContext,
requestTypeId: number,
userEmail: string,
approval: ApprovalListService,
status: ApprovalStatus = 'Approval'
status: ApprovalStatus = 'Approval',
departmentId?: string | null
): Promise<IMatrixRow[]> {
if (!userEmail) return [];
const route = await resolveRoute(context, requestTypeId, userEmail, status);
const route = await resolveRoute(context, requestTypeId, userEmail, status, departmentId);
const phase = status === 'Execution' ? APPROVAL_PHASE.execution : APPROVAL_PHASE.approval;
const ordered = route.steps.slice().sort((a, b) => a.orderNo - b.orderNo);
return Promise.all(
@@ -11,6 +11,13 @@ import { emitRequestsChanged } from '../requestEvents';
// approve/return/reject — согласующий; execute — исполнитель отметил «Выполнено».
export type ApproverDecision = 'approve' | 'return' | 'reject' | 'execute';
// Активная строка текущего пользователя: либо он сам назначен на неё
// (isDelegator=false), либо это его делегат недоступен (в отпуске и т.п.) и он,
// как делегировавший, вправе переназначить шаг ещё раз (isDelegator=true).
export interface IPendingRow extends IApprovalRow {
isDelegator: boolean;
}
// Решение → статус его строки в списке согласования.
const ROW_STATUS: Record<ApproverDecision, string> = {
approve: REQUEST_STATUS.approved,
@@ -35,7 +42,12 @@ export class ApproverService {
// Очередь уже проставлена на запись (см. createSteps/advanceQueue) — активна
// ровно одна строка со статусом «На согласовании» на фазу, искать «первую по
// порядку» не нужно.
public async getPendingRowForCurrentUser(requestId: number): Promise<IApprovalRow | undefined> {
//
// Если пользователь сам не назначен на активную строку, но именно он когда-то
// делегировал её текущему держателю (delegatedById) — строка тоже считается
// «его», но с isDelegator=true: DecisionPanel в этом случае даёт только
// переназначить (не решать за делегата), см. её использование там.
public async getPendingRowForCurrentUser(requestId: number): Promise<IPendingRow | undefined> {
const rows = await this.approval.getForRequest(requestId);
const userId = await this.approval.getCurrentUserId();
// Активные строки текущего шага: согласующий («На согласовании») или, когда
@@ -45,11 +57,17 @@ export class ApproverService {
const active = rows.filter(
(r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution
);
return active.filter((r) => r.approverId === userId)[0];
const own = active.filter((r) => r.approverId === userId)[0];
if (own) return { ...own, isDelegator: false };
const delegated = active.filter((r) => r.delegatedById === userId)[0];
return delegated ? { ...delegated, isDelegator: true } : undefined;
}
// Делегирование: согласующий добавляет делеганта ПЕРЕД собой (Order — между
// предыдущей строкой и своей). Делегат согласует раньше и сам не делегирует.
// предыдущей строкой и своей). Делегат согласует раньше и сам не делегирует
// (allowReassign всегда false у новой строки) — зато делегировавший (текущий
// пользователь) сохраняет право переназначить эту строку ещё раз, если делегат
// тоже окажется недоступен (см. getPendingRowForCurrentUser/isDelegator).
public async delegate(
requestId: number,
currentRowId: number,
@@ -62,6 +80,7 @@ export class ApproverService {
const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1;
const newOrder = (prevOrder + current.order) / 2;
const position = await this.approval.getJobTitle(user.loginName);
const delegatedById = await this.approval.getCurrentUserId();
// Делегат наследует фазу той строки, вместо которой согласует (согласование/исполнение).
// Обе строки — делегат и прежний согласующий — уходят в «Ждёт очереди»,
// activateNextQueued затем активирует делегата (у него меньший order).
@@ -70,7 +89,8 @@ export class ApproverService {
{ email: user.email, position, allowReassign: false },
newOrder,
REQUEST_STATUS.queued,
current.phase
current.phase,
delegatedById
);
await this.approval.setStatus(current.id, REQUEST_STATUS.queued);
await this.approval.activateNextQueued(requestId);
@@ -22,7 +22,7 @@ export { getRequestTypes, resolveRoute } from './approvalRoute.service';
export { ApprovalListService } from './approvalList.service';
export type { IApproverToWrite } from './approvalList.service';
export { ApproverService } from './approverDecision.service';
export type { ApproverDecision } from './approverDecision.service';
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
export { notifyActiveStep } from './notify.service';
export type { INotifyTarget } from './notify.service';
@@ -6,4 +6,8 @@ export interface IApprovalRow {
order: number; // колонка Order (очередь согласования)
allowReassign: boolean; // AllowReassignToOtherApprovers
phase: string; // Phase: «Согласование» | «Исполнение» (см. APPROVAL_PHASE)
/** Id пользователя сайта, который делегировал ЭТУ строку (DelegatedBy). 0 — строка не делегирована
* (пришла из маршрута напрямую). Позволяет делегировавшему переназначить шаг ещё раз,
* если делегат недоступен (например, в отпуске) — см. ApproverService.getPendingRowForCurrentUser. */
delegatedById: number;
}
@@ -24,6 +24,13 @@
margin-top: 16px;
}
// Блок переназначения (делегирования) — отделён от кнопок решения тонкой линией.
.delegate {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid $gray-200;
}
.state {
margin-top: 16px;
padding: 24px;
@@ -2,8 +2,10 @@ import * as React from 'react';
import { useState, useEffect } from 'react';
import styles from './DecisionPanel.module.scss';
import { Button, Alert, FormField, TextArea, Icon } from '../../ui';
import { ApproverService, ApproverDecision } from '../approverDecision.service';
import { ApproverService, ApproverDecision, IPendingRow } from '../approverDecision.service';
import { APPROVAL_PHASE } from '../models/statuses';
import { ISiteUser } from '../models/ISiteUser';
import { UserPicker } from './UserPicker';
interface IDecisionPanelProps {
service: ApproverService;
@@ -14,22 +16,31 @@ interface IDecisionPanelProps {
/** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные
* согласующим). Если бросит — решение не применяется, показывается ошибка. */
onBeforeDecide?: () => Promise<void>;
/** Поиск пользователей — нужен для переназначения (делегирования). Без него
* кнопка «Переназначить» не показывается. */
search?: (query: string) => Promise<ISiteUser[]>;
/** Вызывается после успешного переназначения (обновить экран/матрицу). */
onDelegated?: () => void;
}
// Панель решения согласующего: проверяет, его ли сейчас очередь, и если да —
// показывает комментарий и кнопки Согласовать / Вернуть / Отклонить.
// Панель решения текущего шага: согласующий (Согласовать/Вернуть/Отклонить) или
// исполнитель («Выполнено»). Если у шага разрешено переназначение (allowReassign)
// — можно передать шаг другому сотруднику (делегат встаёт перед текущим).
export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
service,
requestId,
onDecided,
onBeforeDecide,
search,
onDelegated,
}) => {
const [checking, setChecking] = useState<boolean>(true);
const [rowId, setRowId] = useState<number | undefined>(undefined);
const [phase, setPhase] = useState<string>(APPROVAL_PHASE.approval);
const [pendingRow, setPendingRow] = useState<IPendingRow | undefined>(undefined);
const [comment, setComment] = useState<string>('');
const [submitting, setSubmitting] = useState<boolean>(false);
const [resultStatus, setResultStatus] = useState<string | undefined>(undefined);
const [delegated, setDelegated] = useState<boolean>(false);
const [showDelegate, setShowDelegate] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@@ -39,16 +50,13 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
.getPendingRowForCurrentUser(requestId)
.then((row) => {
if (alive) {
setRowId(row?.id);
if (row) {
setPhase(row.phase);
}
setPendingRow(row);
setChecking(false);
}
})
.catch((e) => {
if (alive) {
setError(`Не удалось проверить очередь согласования: ${(e as Error).message}`);
setError(`Не удалось проверить очередь: ${(e as Error).message}`);
setChecking(false);
}
});
@@ -58,7 +66,7 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
}, [requestId, service]);
async function decide(decision: ApproverDecision): Promise<void> {
if (rowId === undefined) {
if (!pendingRow) {
return;
}
if ((decision === 'return' || decision === 'reject') && comment.trim().length === 0) {
@@ -68,11 +76,10 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
setError(null);
setSubmitting(true);
try {
// Сохраняем правки согласующего (если есть) ДО применения решения.
if (onBeforeDecide) {
await onBeforeDecide();
}
const status = await service.decide(requestId, rowId, decision, comment.trim());
const status = await service.decide(requestId, pendingRow.id, decision, comment.trim());
setResultStatus(status);
setSubmitting(false);
if (onDecided) {
@@ -84,14 +91,46 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
}
}
async function handleDelegate(user: ISiteUser): Promise<void> {
if (!pendingRow) {
return;
}
setError(null);
setSubmitting(true);
try {
await service.delegate(requestId, pendingRow.id, user);
setDelegated(true);
setSubmitting(false);
if (onDelegated) {
onDelegated();
}
} catch (e) {
setError(`Не удалось переназначить: ${(e as Error).message}`);
setSubmitting(false);
}
}
const phase = pendingRow?.phase ?? APPROVAL_PHASE.approval;
const isExecution = phase === APPROVAL_PHASE.execution;
// Делегировавший переназначает вне зависимости от allowReassign строки — это
// его собственное право (создал шаг — может его поправить), а не право делегата.
const canDelegate = !!search && !!pendingRow && (pendingRow.isDelegator || pendingRow.allowReassign);
const isDelegator = !!pendingRow?.isDelegator;
// Пока проверяем очередь или если действий нет (не твоя очередь / заявка
// обработана / тебя нет в маршруте) — панель не показываем вовсе.
if (checking || rowId === undefined) {
if (checking || !pendingRow) {
return null;
}
if (delegated) {
return (
<div className={styles.panel}>
<Alert variant="ok">Шаг переназначен — заявка передана выбранному сотруднику.</Alert>
</div>
);
}
if (resultStatus) {
return (
<div className={styles.panel}>
@@ -103,9 +142,42 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
);
}
// Фаза исполнения: одно действие «Выполнено» (без обязательного комментария),
// строка помечается как «Согласовано» → computeRequestStatus переведёт заявку в
// «Выполнение»/«Закрыта».
// Блок переназначения — общий для согласующего и исполнителя (когда разрешено).
const delegateBlock = canDelegate ? (
<div className={styles.delegate}>
{showDelegate ? (
<FormField label="Кому переназначить">
<UserPicker
search={search}
onPick={handleDelegate}
placeholder="Начните вводить ФИО сотрудника…"
/>
</FormField>
) : (
<Button variant="secondary" disabled={submitting} onClick={() => setShowDelegate(true)}>
<Icon name="people" size="sm" />
Переназначить
</Button>
)}
</div>
) : null;
// Строка сейчас у делегата, а не у самого пользователя — решать за делегата
// нельзя, только переназначить (если он, например, недоступен — в отпуске).
if (isDelegator) {
return (
<div className={styles.panel}>
<div className={styles.title}>{isExecution ? 'Исполнение' : 'Ваше решение'}</div>
<Alert variant="warn">
Этот шаг сейчас у сотрудника, которому вы его переназначили. Если он недоступен —
переназначьте шаг ещё раз.
</Alert>
{error && <Alert variant="err">{error}</Alert>}
{delegateBlock}
</div>
);
}
if (isExecution) {
return (
<div className={styles.panel}>
@@ -125,6 +197,7 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
Выполнено
</Button>
</div>
{delegateBlock}
</div>
);
}
@@ -155,6 +228,7 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
Отклонить
</Button>
</div>
{delegateBlock}
</div>
);
};
@@ -18,6 +18,13 @@ export const DATA_SITE_URL = 'https://tnsukz.sharepoint.com/sites/requests';
export const APPROVAL_API_CLIENT_ID = '9232d9e2-9807-4753-808f-07250e5474b0';
export const APPROVAL_API_BASE_URL = 'https://api.tnsu.kz/approval-route';
/**
* Справочники (Dictionary API) — тот же gateway api.tnsu.kz и тот же AAD-app
* (APPROVAL_API_CLIENT_ID), но другой путь/сервис. Используется, например, для
* GET /Dictionary/Departments.
*/
export const DICTIONARY_API_BASE_URL = 'https://api.tnsu.kz';
/**
* Power Automate: поток оповещения согласующего/исполнителя.
* Триггер «When an HTTP request is received» защищён Entra («любой пользователь
@@ -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>
);
};