feat: migrated 1c form
This commit is contained in:
@@ -29,6 +29,7 @@
|
|||||||
| [`recruiting-selection`](../src/webparts/requestPortal/forms/RecruitingSelection/README.md) | Заявка на подбор | Рекрутинг | нет | нет | нет | много полей (~42), поля-люди (в т.ч. мульти) | ✅ |
|
| [`recruiting-selection`](../src/webparts/requestPortal/forms/RecruitingSelection/README.md) | Заявка на подбор | Рекрутинг | нет | нет | нет | много полей (~42), поля-люди (в т.ч. мульти) | ✅ |
|
||||||
| [`recruiting-hire`](../src/webparts/requestPortal/forms/RecruitingHire/README.md) | Заявка на прием | Рекрутинг | да | да | да | маска телефона, деньги, условное «взамен кого», источник для орг. места | ✅ |
|
| [`recruiting-hire`](../src/webparts/requestPortal/forms/RecruitingHire/README.md) | Заявка на прием | Рекрутинг | да | да | да | маска телефона, деньги, условное «взамен кого», источник для орг. места | ✅ |
|
||||||
| [`it-workplace-setup`](../src/webparts/requestPortal/forms/WorkplaceSetup/README.md) | Организация рабочего места | IT | да | да | да | префилл из «прием», поля ПО/доступы правит руководитель | ✅ |
|
| [`it-workplace-setup`](../src/webparts/requestPortal/forms/WorkplaceSetup/README.md) | Организация рабочего места | IT | да | да | да | префилл из «прием», поля ПО/доступы правит руководитель | ✅ |
|
||||||
|
| [`it-1c-ticket`](../src/webparts/requestPortal/forms/Ticket1C/README.md) | Заявка в отдел 1С | IT | да | нет | да | контакты предзаполняются из Graph (/me); вместо фазы исполнения — карточка в Microsoft Planner (согласовано/сразу без согласования — рабочий бакет, отклонено — отдельный бакет) | ✅ |
|
||||||
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
||||||
|
|
||||||
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
|
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
| `recruiting-selection` | Заявка на подбор | Рекрутинг | нет | нет | — |
|
| `recruiting-selection` | Заявка на подбор | Рекрутинг | нет | нет | — |
|
||||||
| `recruiting-hire` | Заявка на прием | Рекрутинг | да | да | — |
|
| `recruiting-hire` | Заявка на прием | Рекрутинг | да | да | — |
|
||||||
| `it-workplace-setup` | Организация рабочего места | IT | да | да | из `recruiting-hire` |
|
| `it-workplace-setup` | Организация рабочего места | IT | да | да | из `recruiting-hire` |
|
||||||
|
| `it-1c-ticket` | Заявка в отдел 1С | IT | да | да | — |
|
||||||
| `hr-transfer` | На перемещение сотрудников | Отдел кадров | (в разработке) | — | — |
|
| `hr-transfer` | На перемещение сотрудников | Отдел кадров | (в разработке) | — | — |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -66,6 +67,11 @@
|
|||||||
…/Портал-Заявок.aspx?rptype=it-workplace-setup&rpsource=42
|
…/Портал-Заявок.aspx?rptype=it-workplace-setup&rpsource=42
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Согласующему открыть заявку в отдел 1С №12:**
|
||||||
|
```
|
||||||
|
…/Портал-Заявок.aspx?rpmode=approve&rptype=it-1c-ticket&rpid=12
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Кто формирует ссылки
|
## Кто формирует ссылки
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const REQUEST_TYPES: IRequestType[] = [
|
|||||||
|
|
||||||
// IT
|
// IT
|
||||||
{ id: 'it-workplace-setup', categoryId: 'it', title: 'Заявка на организацию рабочего места', icon: 'monitor' },
|
{ id: 'it-workplace-setup', categoryId: 'it', title: 'Заявка на организацию рабочего места', icon: 'monitor' },
|
||||||
|
{ id: 'it-1c-ticket', categoryId: 'it', title: 'Заявка в отдел 1С', icon: 'clipboard' },
|
||||||
|
|
||||||
// Отдел кадров
|
// Отдел кадров
|
||||||
{ id: 'hr-transfer', categoryId: 'hr', title: 'на перемещение сотрудников', icon: 'send' }
|
{ id: 'hr-transfer', categoryId: 'hr', title: 'на перемещение сотрудников', icon: 'send' }
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Заявка в отдел 1С (it-1c-ticket)
|
||||||
|
|
||||||
|
**Назначение:** тикет в отдел 1С — доработки, доступы, ошибки, блокировки.
|
||||||
|
**Категория:** IT
|
||||||
|
**Списки SP:** «Заявка в отдел 1С — данные» / «Заявка в отдел 1С — согласование»
|
||||||
|
**RequestTypeId (внешний API маршрутов):** 10
|
||||||
|
|
||||||
|
## Маршрут
|
||||||
|
- Фазы: только согласование. Отдельной фазы исполнения нет — после того как
|
||||||
|
согласование пройдено (или если для вида работ оно не требуется), заявка сразу
|
||||||
|
уходит не исполнителю из матрицы, а в Microsoft Planner (см. «Особое поведение»).
|
||||||
|
- Особенности: маршрут условный — набор шагов зависит от поля **«Виды работ»**
|
||||||
|
(`WorkType`), которое передаётся в `resolveRoute`/`getPlannedMatrix` через параметр
|
||||||
|
`fieldValues: [{ fieldName: 'WorkType', value: data.WorkType }]` (см.
|
||||||
|
`IResolveFieldValue` в `shared/approval/models/IApprovalRouteApi.ts`). Для части
|
||||||
|
видов работ маршрут пуст — согласование не требуется вовсе.
|
||||||
|
- **Матрица нередактируема заявителем** (в отличие от WorkplaceSetup/RecruitingHire):
|
||||||
|
раз маршрут однозначно определён видом работ, у заявителя нет ни UserPicker'а, ни
|
||||||
|
кнопок добавить/удалить/переставить шаг — `useMatrixEditor` вызывается с
|
||||||
|
`editable=false` даже в форме подачи, не только в просмотре. Если шаг маршрута не
|
||||||
|
разрешился в конкретного человека — отправка блокируется с просьбой обратиться к
|
||||||
|
администратору маршрутов (сам заявитель это не чинит).
|
||||||
|
|
||||||
|
## Особое поведение
|
||||||
|
- **Контакты заявителя предзаполняются через Microsoft Graph** (`/me`): ФИО, email,
|
||||||
|
телефон, отдел, должность — единственная форма портала, которая это делает
|
||||||
|
(`services/profile.service.ts`, только для новой заявки, при ошибке Graph — просто
|
||||||
|
остаётся пусто, без ошибки формы).
|
||||||
|
- **Planner вместо фазы исполнения.** Когда маршрут согласования полностью пройден
|
||||||
|
(или изначально пуст) — `Ticket1CForm`/`Ticket1CView` создают карточку в Microsoft
|
||||||
|
Planner через `services/plannerTicket.service.ts` (`api.tnsu.kz/Planner/CreateTask`):
|
||||||
|
рабочий бакет — при согласовании, отдельный бакет «Отклонённые» — при отклонении
|
||||||
|
(с указанием, кто отклонил и по какой причине). Id созданной карточки сохраняется в
|
||||||
|
колонку `PlannerTaskId` — это защита от повторного создания карточки, если решение
|
||||||
|
применяется из нескольких вкладок/сессий одновременно.
|
||||||
|
- Ошибку резолва маршрута (например, если в админке маршрутов ещё не настроен маршрут
|
||||||
|
для этого вида работ) форма не путает с «согласование не требуется» — отправка
|
||||||
|
блокируется с понятным сообщением, а не молча уходит в Planner в обход согласования.
|
||||||
|
- Условные шаги маршрута в SharePoint-списке согласования не хранятся отдельно — это
|
||||||
|
функциональность внешнего approval-route API (настраивается в его собственной админке,
|
||||||
|
вне этого репозитория).
|
||||||
|
|
||||||
|
## Секции формы
|
||||||
|
RequestSection · ContactSection
|
||||||
|
|
||||||
|
## Точки входа
|
||||||
|
- rptype: `it-1c-ticket`
|
||||||
|
- Режим согласующего (просмотр): есть (`Ticket1CView`)
|
||||||
|
- «Создать на основе»: нет
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
margin-bottom: $sp-4;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: $navy-700;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .18s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $gold-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
|
import styles from './Ticket1CForm.module.scss';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
FormCard,
|
||||||
|
FormModeProvider,
|
||||||
|
FileDropzone,
|
||||||
|
AttachmentList,
|
||||||
|
CollapseSection,
|
||||||
|
FormField,
|
||||||
|
Icon,
|
||||||
|
RequestNumber,
|
||||||
|
SubmitActions,
|
||||||
|
useRequestSubmit
|
||||||
|
} from '../../shared/ui';
|
||||||
|
import {
|
||||||
|
ApprovalMatrix,
|
||||||
|
StatusBadge,
|
||||||
|
useMatrixEditor,
|
||||||
|
getPlannedMatrix,
|
||||||
|
notifyActiveStep,
|
||||||
|
REQUEST_STATUS,
|
||||||
|
isEditableStatus,
|
||||||
|
IApproverToWrite,
|
||||||
|
findUnassignedStep
|
||||||
|
} from '../../shared/approval';
|
||||||
|
import {
|
||||||
|
uploadAttachments,
|
||||||
|
getAttachments,
|
||||||
|
deleteAttachment,
|
||||||
|
IAttachmentInfo
|
||||||
|
} from '../../shared/attachments/attachments.service';
|
||||||
|
import { IRequestFormProps } from '../../models/types';
|
||||||
|
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
|
import {
|
||||||
|
saveTicketRequest,
|
||||||
|
setPlannerTaskId,
|
||||||
|
ticket1cApprovalService,
|
||||||
|
TICKET1C_REQUEST_TYPE_ID,
|
||||||
|
DATA_LIST_TITLE,
|
||||||
|
APPROVAL_LIST_TITLE
|
||||||
|
} from './submit';
|
||||||
|
import { loadTicketRequest } from './load';
|
||||||
|
import { PlannerTicketService } from './services/plannerTicket.service';
|
||||||
|
import { getApplicantProfile } from './services/profile.service';
|
||||||
|
import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME } from './config';
|
||||||
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { ContactSection } from './sections/ContactSection';
|
||||||
|
|
||||||
|
/** Текст карточки Planner из данных заявки (соответствует старому buildPlannerNotes). */
|
||||||
|
function buildPlannerNotes(data: IFormData, code: string): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (code) lines.push(`Код заявки: ${code}`);
|
||||||
|
lines.push(`Организация: ${data.Organization}`);
|
||||||
|
lines.push(`ФИО: ${data.FIO}`);
|
||||||
|
lines.push(`Email: ${data.Email}`);
|
||||||
|
lines.push(`Телефон: ${data.Phone}`);
|
||||||
|
lines.push(`Отдел: ${data.Department}`);
|
||||||
|
lines.push(`Должность: ${data.Position}`);
|
||||||
|
lines.push(`База 1С: ${data.DB1C}`);
|
||||||
|
lines.push(`Виды работ: ${data.WorkType}`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Вопрос:');
|
||||||
|
lines.push(data.Question);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ticket1CForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => {
|
||||||
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
|
||||||
|
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||||
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
|
const submit = useRequestSubmit();
|
||||||
|
|
||||||
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
// Открыта СВОЯ заявка на редактирование — подгружаем содержимое, статус и вложения.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
Promise.all([loadTicketRequest(editId), getAttachments(DATA_LIST_TITLE, editId)])
|
||||||
|
.then(([loaded, atts]) => {
|
||||||
|
if (alive) {
|
||||||
|
setData(loaded.data);
|
||||||
|
setRequestStatus(loaded.status);
|
||||||
|
setRequestCode(loaded.code || undefined);
|
||||||
|
setExistingAttachments(atts);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) {
|
||||||
|
submit.setError(`Не удалось загрузить заявку №${editId}: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [editId]);
|
||||||
|
|
||||||
|
// Предзаполнение контактов заявителя текущим пользователем — только для новой заявки.
|
||||||
|
// Телефон/отдел/должность в pageContext нет — берём из Graph (/me), с фолбэком.
|
||||||
|
useEffect(() => {
|
||||||
|
if (editId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
getApplicantProfile(context)
|
||||||
|
.then((profile) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
FIO: prev.FIO || profile.displayName,
|
||||||
|
Email: prev.Email || profile.email,
|
||||||
|
Phone: prev.Phone || profile.phone,
|
||||||
|
Department: prev.Department || profile.department,
|
||||||
|
Position: prev.Position || profile.jobTitle
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [editId]);
|
||||||
|
|
||||||
|
const approval = useMemo(() => ticket1cApprovalService(), []);
|
||||||
|
const userEmail = context.pageContext.user.email;
|
||||||
|
|
||||||
|
// Маршрут полностью и однозначно определяется видом работ — настроен в админке
|
||||||
|
// маршрутов (условные шаги по WorkType, см. fieldValues), а не выбирается
|
||||||
|
// заявителем. Поэтому матрица здесь ВСЕГДА нередактируема (editable=false) —
|
||||||
|
// заявитель только видит, кто согласует, без возможности добавить/убрать/
|
||||||
|
// переставить согласующего.
|
||||||
|
const loadMatrix = useCallback(
|
||||||
|
() =>
|
||||||
|
editId
|
||||||
|
? approval.getMatrixForRequest(editId)
|
||||||
|
: getPlannedMatrix(context, TICKET1C_REQUEST_TYPE_ID, userEmail, approval, 'Approval', null, [
|
||||||
|
{ fieldName: 'WorkType', value: data.WorkType }
|
||||||
|
]),
|
||||||
|
[context, userEmail, approval, editId, data.WorkType]
|
||||||
|
);
|
||||||
|
const matrix = useMatrixEditor(
|
||||||
|
loadMatrix,
|
||||||
|
`${userEmail}-${editId ?? 'new'}-${data.WorkType}`,
|
||||||
|
false,
|
||||||
|
!!editId || !!data.WorkType,
|
||||||
|
(login) => approval.getJobTitle(login)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isValid = !!(
|
||||||
|
data.TaskName.trim() &&
|
||||||
|
data.WorkType &&
|
||||||
|
data.Organization &&
|
||||||
|
data.DB1C &&
|
||||||
|
data.Question.trim()
|
||||||
|
);
|
||||||
|
const validate = (): string | undefined =>
|
||||||
|
isValid
|
||||||
|
? undefined
|
||||||
|
: 'Заполните обязательные поля: наименование задачи, вид работ, организация, база 1С, вопрос.';
|
||||||
|
|
||||||
|
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||||
|
rows.map((r) => ({
|
||||||
|
email: r.email,
|
||||||
|
position: r.position,
|
||||||
|
allowReassign: r.allowReassign,
|
||||||
|
order: r.order
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleRemoveAttachment = (fileName: string): void => {
|
||||||
|
if (requestId === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteAttachment(DATA_LIST_TITLE, requestId, fileName)
|
||||||
|
.then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName)))
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = (intendedStatus: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||||
|
// Пустой маршрут — согласование не требуется по правилам этого вида работ.
|
||||||
|
const hasApprovers = matrix.rows.length > 0;
|
||||||
|
const finalStatus =
|
||||||
|
intendedStatus === REQUEST_STATUS.draft
|
||||||
|
? REQUEST_STATUS.draft
|
||||||
|
: hasApprovers
|
||||||
|
? REQUEST_STATUS.onApproval
|
||||||
|
: REQUEST_STATUS.approved;
|
||||||
|
|
||||||
|
const { id, code } = await saveTicketRequest(requestId, data, toApprovers(matrix.rows), finalStatus);
|
||||||
|
setRequestId(id);
|
||||||
|
setRequestStatus(finalStatus);
|
||||||
|
if (code) {
|
||||||
|
setRequestCode(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
let attachments = existingAttachments;
|
||||||
|
if (files.length > 0) {
|
||||||
|
await uploadAttachments(DATA_LIST_TITLE, id, files);
|
||||||
|
setFiles([]);
|
||||||
|
attachments = await getAttachments(DATA_LIST_TITLE, id);
|
||||||
|
setExistingAttachments(attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onSaved) {
|
||||||
|
onSaved();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalStatus === REQUEST_STATUS.onApproval) {
|
||||||
|
await notifyActiveStep(context, approval, id, {
|
||||||
|
requestTypeId: TICKET1C_REQUEST_TYPE_ID,
|
||||||
|
dataListName: DATA_LIST_TITLE,
|
||||||
|
approvalListName: APPROVAL_LIST_TITLE
|
||||||
|
});
|
||||||
|
} else if (finalStatus === REQUEST_STATUS.approved) {
|
||||||
|
// Согласование не требуется для этого вида работ — сразу передаём в отдел 1С.
|
||||||
|
const taskId = await new PlannerTicketService(context).createTask({
|
||||||
|
planId: PLANNER_PLAN_ID,
|
||||||
|
bucketName: PLANNER_BUCKET_NAME,
|
||||||
|
title: data.TaskName,
|
||||||
|
notes: buildPlannerNotes(data, code || String(id)),
|
||||||
|
attachments,
|
||||||
|
webAbsoluteUrl: context.pageContext.web.absoluteUrl
|
||||||
|
});
|
||||||
|
await setPlannerTaskId(id, taskId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// На отправку маршрут должен быть либо пустым (согласование не нужно), либо
|
||||||
|
// полностью укомплектован. Ошибку резолва маршрута (напр. тип заявки ещё не
|
||||||
|
// настроен в админке маршрутов) не путаем с «согласование не требуется» —
|
||||||
|
// иначе заявка молча уйдёт в Planner в обход согласования.
|
||||||
|
const validateSubmit = (): string | undefined => {
|
||||||
|
const formError = validate();
|
||||||
|
if (formError) return formError;
|
||||||
|
if (matrix.error) {
|
||||||
|
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||||
|
}
|
||||||
|
// Матрица нередактируема (маршрут задаётся видом работ, а не заявителем) —
|
||||||
|
// незаполненный шаг заявитель исправить сам не может, только администратор
|
||||||
|
// маршрутов (роль в шаге не разрешилась в конкретного человека).
|
||||||
|
const unassigned = findUnassignedStep(matrix.rows);
|
||||||
|
if (unassigned) {
|
||||||
|
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||||
|
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
|
||||||
|
|
||||||
|
const sectionProps = { data, set, context };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button type="button" className={styles.back} onClick={onCancel}>
|
||||||
|
<Icon name="arrow-left" size="sm" />
|
||||||
|
<span>К списку заявок</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<FormCard
|
||||||
|
title={requestType.title}
|
||||||
|
subtitle="Категория: IT"
|
||||||
|
headerRight={
|
||||||
|
requestId || requestStatus ? (
|
||||||
|
<>
|
||||||
|
{requestStatus && <StatusBadge status={requestStatus} />}
|
||||||
|
{requestCode && <RequestNumber code={requestCode} />}
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{submit.savedKind && !submit.error && (
|
||||||
|
<Alert variant="ok">
|
||||||
|
{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||||
|
|
||||||
|
<FormModeProvider readOnly={readOnly}>
|
||||||
|
<RequestSection {...sectionProps} />
|
||||||
|
<ContactSection {...sectionProps} />
|
||||||
|
</FormModeProvider>
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<CollapseSection title="Вложения" icon="upload">
|
||||||
|
{existingAttachments.length > 0 && (
|
||||||
|
<FormField label="Прикреплённые файлы" full>
|
||||||
|
<AttachmentList items={existingAttachments} onRemove={handleRemoveAttachment} />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
<FormField label={existingAttachments.length > 0 ? 'Добавить файлы' : 'Файлы'} full>
|
||||||
|
<FileDropzone files={files} onChange={setFiles} />
|
||||||
|
</FormField>
|
||||||
|
</CollapseSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={matrix}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
emptyText="Согласование не требуется для этого вида работ."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SubmitActions
|
||||||
|
submit={submit}
|
||||||
|
isValid={isValid}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onSaveDraft={handleSaveDraft}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitLabel="Отправить"
|
||||||
|
/>
|
||||||
|
</FormCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Ticket1CForm;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.state {
|
||||||
|
padding: $sp-8;
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: $sp-4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import styles from './Ticket1CView.module.scss';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
FormCard,
|
||||||
|
FormModeProvider,
|
||||||
|
CollapseSection,
|
||||||
|
FormField,
|
||||||
|
AttachmentList,
|
||||||
|
PrintButton,
|
||||||
|
Icon
|
||||||
|
} from '../../shared/ui';
|
||||||
|
import { navigate } from '../../shared/routing';
|
||||||
|
import {
|
||||||
|
ApproverService,
|
||||||
|
DecisionPanel,
|
||||||
|
StatusBadge,
|
||||||
|
ApprovalMatrix,
|
||||||
|
useMatrixEditor,
|
||||||
|
notifyActiveStep,
|
||||||
|
isEditableStatus,
|
||||||
|
REQUEST_STATUS
|
||||||
|
} from '../../shared/approval';
|
||||||
|
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||||
|
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
|
import { loadTicketRequest } from './load';
|
||||||
|
import {
|
||||||
|
ticket1cApprovalService,
|
||||||
|
getPlannerTaskId,
|
||||||
|
setPlannerTaskId,
|
||||||
|
TICKET1C_REQUEST_TYPE_ID,
|
||||||
|
DATA_LIST_TITLE,
|
||||||
|
APPROVAL_LIST_TITLE
|
||||||
|
} from './submit';
|
||||||
|
import { PlannerTicketService } from './services/plannerTicket.service';
|
||||||
|
import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME, PLANNER_REJECTED_BUCKET_NAME } from './config';
|
||||||
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { ContactSection } from './sections/ContactSection';
|
||||||
|
|
||||||
|
interface ITicket1CViewProps {
|
||||||
|
context: WebPartContext;
|
||||||
|
itemId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текст карточки Planner из данных заявки; rejection — автор и причина отклонения, если есть. */
|
||||||
|
function buildPlannerNotes(
|
||||||
|
data: IFormData,
|
||||||
|
code: string,
|
||||||
|
rejection?: { who: string; comment: string }
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (code) lines.push(`Код заявки: ${code}`);
|
||||||
|
lines.push(`Организация: ${data.Organization}`);
|
||||||
|
lines.push(`ФИО: ${data.FIO}`);
|
||||||
|
lines.push(`Email: ${data.Email}`);
|
||||||
|
lines.push(`Телефон: ${data.Phone}`);
|
||||||
|
lines.push(`Отдел: ${data.Department}`);
|
||||||
|
lines.push(`Должность: ${data.Position}`);
|
||||||
|
lines.push(`База 1С: ${data.DB1C}`);
|
||||||
|
lines.push(`Виды работ: ${data.WorkType}`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Вопрос:');
|
||||||
|
lines.push(data.Question);
|
||||||
|
if (rejection) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('ОТКЛОНЕНО');
|
||||||
|
lines.push(`Кто отклонил: ${rejection.who}`);
|
||||||
|
lines.push(`Причина: ${rejection.comment}`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заявка в отдел 1С только для просмотра (режим согласующего).
|
||||||
|
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId }) => {
|
||||||
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
|
const [status, setStatus] = useState<string>('');
|
||||||
|
const [code, setCode] = useState<string>('');
|
||||||
|
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
const approverService = useMemo(
|
||||||
|
() => new ApproverService(ticket1cApprovalService(), DATA_LIST_TITLE),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const approval = useMemo(() => ticket1cApprovalService(), []);
|
||||||
|
|
||||||
|
// decisionSeq входит в reloadKey: после решения строки маршрута меняются на
|
||||||
|
// сервере, поэтому счётчик инкрементируется и useMatrixEditor перечитывает матрицу.
|
||||||
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
|
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
|
||||||
|
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
|
approval.getJobTitle(l)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([loadTicketRequest(itemId), getAttachments(DATA_LIST_TITLE, itemId)])
|
||||||
|
.then(([loaded, files]) => {
|
||||||
|
if (alive) {
|
||||||
|
setData(loaded.data);
|
||||||
|
setStatus(loaded.status);
|
||||||
|
setCode(loaded.code);
|
||||||
|
setAttachments(files);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) {
|
||||||
|
setError(`Не удалось загрузить заявку №${itemId}: ${(e as Error).message}`);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [itemId]);
|
||||||
|
|
||||||
|
// Передать согласованную/отклонённую заявку в Planner. PlannerTaskId — защита
|
||||||
|
// от повторного создания карточки, если решение применяется из нескольких
|
||||||
|
// вкладок/сессий одновременно (см. submit.ts).
|
||||||
|
const handoffToPlanner = useCallback(
|
||||||
|
async (finalStatus: string, currentData: IFormData, currentCode: string): Promise<void> => {
|
||||||
|
const existingTaskId = await getPlannerTaskId(itemId);
|
||||||
|
if (existingTaskId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rejection: { who: string; comment: string } | undefined;
|
||||||
|
if (finalStatus === REQUEST_STATUS.rejected) {
|
||||||
|
const rows = await approval.getMatrixForRequest(itemId);
|
||||||
|
const rejectedRow = rows.filter((r) => r.status === REQUEST_STATUS.rejected)[0];
|
||||||
|
if (rejectedRow) {
|
||||||
|
rejection = { who: rejectedRow.who, comment: rejectedRow.comment };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucketName =
|
||||||
|
finalStatus === REQUEST_STATUS.rejected ? PLANNER_REJECTED_BUCKET_NAME : PLANNER_BUCKET_NAME;
|
||||||
|
const atts = await getAttachments(DATA_LIST_TITLE, itemId);
|
||||||
|
const taskId = await new PlannerTicketService(context).createTask({
|
||||||
|
planId: PLANNER_PLAN_ID,
|
||||||
|
bucketName,
|
||||||
|
title: currentData.TaskName,
|
||||||
|
notes: buildPlannerNotes(currentData, currentCode, rejection),
|
||||||
|
attachments: atts,
|
||||||
|
webAbsoluteUrl: context.pageContext.web.absoluteUrl
|
||||||
|
});
|
||||||
|
await setPlannerTaskId(itemId, taskId);
|
||||||
|
},
|
||||||
|
[approval, context, itemId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={styles.state}>
|
||||||
|
<Alert variant="err">{error}</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionProps = { data, set, context };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
|
{isEditableStatus(status) && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => navigate({ mode: 'edit', nav: 'new', formKey: 'it-1c-ticket', itemId })}
|
||||||
|
>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<PrintButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rp-print-root">
|
||||||
|
<FormCard
|
||||||
|
title="Заявка в отдел 1С"
|
||||||
|
subtitle={`№${code || itemId}`}
|
||||||
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
|
>
|
||||||
|
<FormModeProvider readOnly>
|
||||||
|
<RequestSection {...sectionProps} />
|
||||||
|
<ContactSection {...sectionProps} />
|
||||||
|
</FormModeProvider>
|
||||||
|
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<CollapseSection title="Вложения" icon="upload">
|
||||||
|
<FormField label="Файлы" full>
|
||||||
|
<AttachmentList items={attachments} />
|
||||||
|
</FormField>
|
||||||
|
</CollapseSection>
|
||||||
|
</div>
|
||||||
|
</FormCard>
|
||||||
|
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={approvers}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
emptyText="Согласование не требовалось для этого вида работ."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<DecisionPanel
|
||||||
|
service={approverService}
|
||||||
|
requestId={itemId}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
onDecided={(newStatus) => {
|
||||||
|
setStatus(newStatus);
|
||||||
|
setDecisionSeq((n) => n + 1);
|
||||||
|
// Очередь сдвинулась — уведомляем следующий активный шаг.
|
||||||
|
notifyActiveStep(context, approval, itemId, {
|
||||||
|
requestTypeId: TICKET1C_REQUEST_TYPE_ID,
|
||||||
|
dataListName: DATA_LIST_TITLE,
|
||||||
|
approvalListName: APPROVAL_LIST_TITLE
|
||||||
|
}).catch(() => undefined);
|
||||||
|
|
||||||
|
if (newStatus === REQUEST_STATUS.approved || newStatus === REQUEST_STATUS.rejected) {
|
||||||
|
handoffToPlanner(newStatus, data, code).catch((e) =>
|
||||||
|
setError(`Не удалось передать заявку в Planner: ${(e as Error).message}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDelegated={() => {
|
||||||
|
setDecisionSeq((n) => n + 1);
|
||||||
|
notifyActiveStep(context, approval, itemId, {
|
||||||
|
requestTypeId: TICKET1C_REQUEST_TYPE_ID,
|
||||||
|
dataListName: DATA_LIST_TITLE,
|
||||||
|
approvalListName: APPROVAL_LIST_TITLE
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Ticket1CView;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"dataListTitle": "Заявка в отдел 1С — согласование",
|
||||||
|
"columns": [
|
||||||
|
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||||
|
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
|
||||||
|
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Заявка в отдел 1С — данные", "lookupShowField": "Title" },
|
||||||
|
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||||
|
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||||
|
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
||||||
|
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
||||||
|
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
||||||
|
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Варианты Choice-полей — см. forms/Ticket1C/provisioning.json (единый источник правды).
|
||||||
|
|
||||||
|
export const WORK_TYPE_OPTIONS: string[] = [
|
||||||
|
'Создание пользователя в 1С',
|
||||||
|
'Расширение прав доступа / предоставление доступа к документам других пользователей',
|
||||||
|
'Открытие даты запрета',
|
||||||
|
'Прочее',
|
||||||
|
'Не работает 1С',
|
||||||
|
'Блокировка пользователя',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ORGANIZATION_OPTIONS: string[] = [
|
||||||
|
'AFL Engineering',
|
||||||
|
'CL MANAGEMENT',
|
||||||
|
'KazPromService',
|
||||||
|
'QAZGEOMAX',
|
||||||
|
'TANSU Construction',
|
||||||
|
'АСТЕЛ-К',
|
||||||
|
'ГеоТрек',
|
||||||
|
'Монтажник-Т',
|
||||||
|
'ОСН Беркут',
|
||||||
|
'CT Global',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DB1C_OPTIONS: string[] = [
|
||||||
|
'1С: Зарплата и управление персоналом (ЗУП)',
|
||||||
|
'1С: Документооборот (ДО)',
|
||||||
|
'1С: Автотранспортное предприятие (АТП)',
|
||||||
|
'1С: БСО TANSU Construction',
|
||||||
|
'1С: Бухгалтерия AFL Engineering',
|
||||||
|
'1С: Бухгалтерия CL MANAGEMENT',
|
||||||
|
'1С: Бухгалтерия KazPromService',
|
||||||
|
'1С: Бухгалтерия QAZGEOMAX',
|
||||||
|
'1С: Бухгалтерия АСТЕЛ-К',
|
||||||
|
'1С: Бухгалтерия ГеоТрек',
|
||||||
|
'1С: Бухгалтерия Монтажник-Т',
|
||||||
|
'1С: Бухгалтерия ОСН Беркут',
|
||||||
|
'1С: Бухгалтерия CT Global',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const QUESTION_MAX = 1000;
|
||||||
|
|
||||||
|
// Значения из property pane старой web part (см. живую страницу SharePoint).
|
||||||
|
export const PLANNER_PLAN_ID = '168WqDV8CEKkW3cL6olCRZgACwYa';
|
||||||
|
export const PLANNER_BUCKET_NAME = 'Входящие неотсортированные';
|
||||||
|
export const PLANNER_REJECTED_BUCKET_NAME = 'Отклонённые';
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
|
import { IFormData, EMPTY_DATA } from './model';
|
||||||
|
|
||||||
|
const LIST_TITLE = 'Заявка в отдел 1С — данные';
|
||||||
|
|
||||||
|
// TaskName пишется/читается через системную колонку Title (см. provisioning.json/titleColumnName).
|
||||||
|
const SCALAR_FIELDS: (keyof IFormData)[] = [
|
||||||
|
'WorkType', 'Organization', 'DB1C', 'FIO', 'Email', 'Phone', 'Department', 'Position', 'Question',
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface ILoadedRequest {
|
||||||
|
data: IFormData;
|
||||||
|
status: string;
|
||||||
|
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Прочитать заявку по id: модель формы + статус (для режима чтения). */
|
||||||
|
export async function loadTicketRequest(itemId: number): Promise<ILoadedRequest> {
|
||||||
|
const item = (await getSP()
|
||||||
|
.web.lists.getByTitle(LIST_TITLE)
|
||||||
|
.items.getById(itemId)
|
||||||
|
.select(...SCALAR_FIELDS, 'Title', 'Status', 'RequestUniqueCode')()) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const data: IFormData = { ...EMPTY_DATA };
|
||||||
|
const writable = data as unknown as Record<string, string>;
|
||||||
|
|
||||||
|
SCALAR_FIELDS.forEach((key) => {
|
||||||
|
const value = item[key];
|
||||||
|
writable[key] = value === null || value === undefined ? '' : String(value);
|
||||||
|
});
|
||||||
|
data.TaskName = (item.Title as string) || '';
|
||||||
|
|
||||||
|
return { data, status: (item.Status as string) || '', code: (item.RequestUniqueCode as string) || '' };
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Данные формы «Заявка в отдел 1С». Ключи = internalName колонок SP-списка
|
||||||
|
* «Заявка в отдел 1С — данные».
|
||||||
|
*/
|
||||||
|
export interface IFormData {
|
||||||
|
/** Пишется в системную колонку Title (см. provisioning.json/titleColumnName). */
|
||||||
|
TaskName: string;
|
||||||
|
WorkType: string;
|
||||||
|
Organization: string;
|
||||||
|
DB1C: string;
|
||||||
|
FIO: string;
|
||||||
|
Email: string;
|
||||||
|
Phone: string;
|
||||||
|
Department: string;
|
||||||
|
Position: string;
|
||||||
|
Question: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Обновление одного поля формы с сохранением типа значения. */
|
||||||
|
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
||||||
|
|
||||||
|
/** Пропсы каждой секции формы. */
|
||||||
|
export interface ISectionProps {
|
||||||
|
data: IFormData;
|
||||||
|
set: SetField;
|
||||||
|
context: WebPartContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_DATA: IFormData = {
|
||||||
|
TaskName: '',
|
||||||
|
WorkType: '',
|
||||||
|
Organization: '',
|
||||||
|
DB1C: '',
|
||||||
|
FIO: '',
|
||||||
|
Email: '',
|
||||||
|
Phone: '',
|
||||||
|
Department: '',
|
||||||
|
Position: '',
|
||||||
|
Question: '',
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"dataListTitle": "Заявка в отдел 1С — данные",
|
||||||
|
"titleColumnName": "Наименование задачи",
|
||||||
|
"columns": [
|
||||||
|
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
|
||||||
|
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||||
|
|
||||||
|
{ "internalName": "WorkType", "title": "Виды работ", "type": "Choice", "required": true, "choices": [
|
||||||
|
"Создание пользователя в 1С",
|
||||||
|
"Расширение прав доступа / предоставление доступа к документам других пользователей",
|
||||||
|
"Открытие даты запрета",
|
||||||
|
"Прочее",
|
||||||
|
"Не работает 1С",
|
||||||
|
"Блокировка пользователя"
|
||||||
|
] },
|
||||||
|
{ "internalName": "Organization", "title": "Организация", "type": "Choice", "required": true, "choices": [
|
||||||
|
"AFL Engineering",
|
||||||
|
"CL MANAGEMENT",
|
||||||
|
"KazPromService",
|
||||||
|
"QAZGEOMAX",
|
||||||
|
"TANSU Construction",
|
||||||
|
"АСТЕЛ-К",
|
||||||
|
"ГеоТрек",
|
||||||
|
"Монтажник-Т",
|
||||||
|
"ОСН Беркут",
|
||||||
|
"CT Global"
|
||||||
|
] },
|
||||||
|
{ "internalName": "DB1C", "title": "База 1С", "type": "Choice", "required": true, "choices": [
|
||||||
|
"1С: Зарплата и управление персоналом (ЗУП)",
|
||||||
|
"1С: Документооборот (ДО)",
|
||||||
|
"1С: Автотранспортное предприятие (АТП)",
|
||||||
|
"1С: БСО TANSU Construction",
|
||||||
|
"1С: Бухгалтерия AFL Engineering",
|
||||||
|
"1С: Бухгалтерия CL MANAGEMENT",
|
||||||
|
"1С: Бухгалтерия KazPromService",
|
||||||
|
"1С: Бухгалтерия QAZGEOMAX",
|
||||||
|
"1С: Бухгалтерия АСТЕЛ-К",
|
||||||
|
"1С: Бухгалтерия ГеоТрек",
|
||||||
|
"1С: Бухгалтерия Монтажник-Т",
|
||||||
|
"1С: Бухгалтерия ОСН Беркут",
|
||||||
|
"1С: Бухгалтерия CT Global"
|
||||||
|
] },
|
||||||
|
|
||||||
|
{ "internalName": "FIO", "title": "Ф.И.О.", "type": "Text" },
|
||||||
|
{ "internalName": "Email", "title": "Email", "type": "Text" },
|
||||||
|
{ "internalName": "Phone", "title": "Телефон", "type": "Text" },
|
||||||
|
{ "internalName": "Department", "title": "Отдел", "type": "Text" },
|
||||||
|
{ "internalName": "Position", "title": "Должность", "type": "Text" },
|
||||||
|
|
||||||
|
{ "internalName": "Question", "title": "Вопрос", "type": "Note", "required": true },
|
||||||
|
|
||||||
|
{ "internalName": "PlannerTaskId", "title": "Id задачи Planner", "type": "Text" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { CollapseSection, FormField, TextInput, PhoneInput } from '../../../shared/ui';
|
||||||
|
import { ISectionProps } from '../model';
|
||||||
|
|
||||||
|
export const ContactSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||||
|
<CollapseSection title="Контакты заявителя" icon="people">
|
||||||
|
<FormField label="Ф.И.О." htmlFor="FIO">
|
||||||
|
<TextInput id="FIO" value={data.FIO} onChange={(v) => set('FIO', v)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Email" htmlFor="Email">
|
||||||
|
<TextInput id="Email" value={data.Email} onChange={(v) => set('Email', v)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Телефон" htmlFor="Phone">
|
||||||
|
<PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Отдел" htmlFor="Department">
|
||||||
|
<TextInput id="Department" value={data.Department} onChange={(v) => set('Department', v)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Должность" htmlFor="Position">
|
||||||
|
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
||||||
|
</FormField>
|
||||||
|
</CollapseSection>
|
||||||
|
);
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { CollapseSection, FormField, Select, TextArea, TextInput, ISelectOption } from '../../../shared/ui';
|
||||||
|
import { ISectionProps } from '../model';
|
||||||
|
import { WORK_TYPE_OPTIONS, ORGANIZATION_OPTIONS, DB1C_OPTIONS, QUESTION_MAX } from '../config';
|
||||||
|
|
||||||
|
const toOptions = (values: string[]): ISelectOption[] => values.map((v) => ({ value: v, label: v }));
|
||||||
|
|
||||||
|
const WORK_TYPE_SELECT_OPTIONS = toOptions(WORK_TYPE_OPTIONS);
|
||||||
|
const ORGANIZATION_SELECT_OPTIONS = toOptions(ORGANIZATION_OPTIONS);
|
||||||
|
const DB1C_SELECT_OPTIONS = toOptions(DB1C_OPTIONS);
|
||||||
|
|
||||||
|
export const RequestSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||||
|
<CollapseSection title="Заявка" icon="clipboard">
|
||||||
|
<FormField label="Наименование задачи" htmlFor="TaskName" required full>
|
||||||
|
<TextInput id="TaskName" value={data.TaskName} onChange={(v) => set('TaskName', v)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Виды работ" htmlFor="WorkType" required>
|
||||||
|
<Select
|
||||||
|
id="WorkType"
|
||||||
|
value={data.WorkType}
|
||||||
|
onChange={(v) => set('WorkType', v)}
|
||||||
|
options={WORK_TYPE_SELECT_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Организация" htmlFor="Organization" required>
|
||||||
|
<Select
|
||||||
|
id="Organization"
|
||||||
|
value={data.Organization}
|
||||||
|
onChange={(v) => set('Organization', v)}
|
||||||
|
options={ORGANIZATION_SELECT_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="База 1С" htmlFor="DB1C" required>
|
||||||
|
<Select
|
||||||
|
id="DB1C"
|
||||||
|
value={data.DB1C}
|
||||||
|
onChange={(v) => set('DB1C', v)}
|
||||||
|
options={DB1C_SELECT_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label={`Вопрос (${data.Question.length}/${QUESTION_MAX})`} htmlFor="Question" required full>
|
||||||
|
<TextArea
|
||||||
|
id="Question"
|
||||||
|
value={data.Question}
|
||||||
|
onChange={(v) => set('Question', v.slice(0, QUESTION_MAX))}
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</CollapseSection>
|
||||||
|
);
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { AadHttpClient } from '@microsoft/sp-http';
|
||||||
|
import { IAttachmentInfo } from '../../../shared/attachments/attachments.service';
|
||||||
|
|
||||||
|
// Тот же ресурс Azure AD, что и у approval-route API портала (см. shared/config.ts).
|
||||||
|
const API_CLIENT_ID = '9232d9e2-9807-4753-808f-07250e5474b0';
|
||||||
|
|
||||||
|
export interface ICreateTaskParams {
|
||||||
|
planId: string;
|
||||||
|
bucketName: string;
|
||||||
|
title: string;
|
||||||
|
notes: string;
|
||||||
|
attachments: IAttachmentInfo[];
|
||||||
|
webAbsoluteUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт карточку в Planner по данным согласованной/отклонённой заявки.
|
||||||
|
* Перенесено из 1c-ticket-form/services/PlannerService.ts без изменений в контракте
|
||||||
|
* (тот же backend-эндпоинт и таймаут — создание задачи + заметки/вложения в Graph
|
||||||
|
* может занимать больше 30с).
|
||||||
|
*/
|
||||||
|
export class PlannerTicketService {
|
||||||
|
private static readonly ENDPOINT = 'https://api.tnsu.kz/Planner/CreateTask';
|
||||||
|
private static readonly TIMEOUT_MS = 120000;
|
||||||
|
|
||||||
|
public constructor(private context: WebPartContext) {}
|
||||||
|
|
||||||
|
public async createTask(params: ICreateTaskParams): Promise<string> {
|
||||||
|
const body = JSON.stringify({
|
||||||
|
planId: params.planId,
|
||||||
|
bucketName: params.bucketName,
|
||||||
|
title: params.title,
|
||||||
|
notes: params.notes,
|
||||||
|
attachments: params.attachments.map((a) => ({
|
||||||
|
fileName: a.fileName,
|
||||||
|
serverRelativeUrl: a.url,
|
||||||
|
})),
|
||||||
|
webAbsoluteUrl: params.webAbsoluteUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const aadClient = await this.context.aadHttpClientFactory.getClient(API_CLIENT_ID);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), PlannerTicketService.TIMEOUT_MS);
|
||||||
|
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await aadClient.post(PlannerTicketService.ENDPOINT, AadHttpClient.configurations.v1, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
throw new Error(`Ошибка от backend Planner: ${resp.status} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: { taskId: string } = await resp.json();
|
||||||
|
return data.taskId;
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[PlannerTicketService] Error creating task:', errorMsg);
|
||||||
|
|
||||||
|
if (errorMsg.includes('Failed to fetch') || errorMsg.includes('NetworkError')) {
|
||||||
|
throw new Error(
|
||||||
|
`Не удалось подключиться к API Planner. Проверьте:\n` +
|
||||||
|
`1. Доступность сервера ${PlannerTicketService.ENDPOINT}\n` +
|
||||||
|
`2. Настройки CORS на сервере\n` +
|
||||||
|
`3. Подключение к интернету`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { MSGraphClientV3 } from '@microsoft/sp-http';
|
||||||
|
|
||||||
|
export interface IApplicantProfile {
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
department: string;
|
||||||
|
jobTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Профиль текущего пользователя для предзаполнения контактов заявителя.
|
||||||
|
* Graph /me даёт телефон/отдел/должность — pageContext их не содержит.
|
||||||
|
* При недоступности Graph — fallback на минимум из pageContext (без ошибки формы).
|
||||||
|
*/
|
||||||
|
export async function getApplicantProfile(context: WebPartContext): Promise<IApplicantProfile> {
|
||||||
|
const fallback: IApplicantProfile = {
|
||||||
|
displayName: context.pageContext.user.displayName || '',
|
||||||
|
email: context.pageContext.user.email || '',
|
||||||
|
phone: '',
|
||||||
|
department: '',
|
||||||
|
jobTitle: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client: MSGraphClientV3 = await context.msGraphClientFactory.getClient('3');
|
||||||
|
const me: {
|
||||||
|
displayName?: string;
|
||||||
|
mail?: string;
|
||||||
|
userPrincipalName?: string;
|
||||||
|
mobilePhone?: string;
|
||||||
|
businessPhones?: string[];
|
||||||
|
department?: string;
|
||||||
|
jobTitle?: string;
|
||||||
|
} = await client
|
||||||
|
.api('/me')
|
||||||
|
.select('displayName,mail,userPrincipalName,mobilePhone,businessPhones,department,jobTitle')
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const phone = me.mobilePhone || (me.businessPhones && me.businessPhones.length > 0 ? me.businessPhones[0] : '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayName: me.displayName || fallback.displayName,
|
||||||
|
email: me.mail || me.userPrincipalName || fallback.email,
|
||||||
|
phone: phone || '',
|
||||||
|
department: me.department || '',
|
||||||
|
jobTitle: me.jobTitle || ''
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
|
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
||||||
|
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
||||||
|
import { IFormData } from './model';
|
||||||
|
|
||||||
|
export const DATA_LIST_TITLE = 'Заявка в отдел 1С — данные';
|
||||||
|
export const APPROVAL_LIST_TITLE = 'Заявка в отдел 1С — согласование';
|
||||||
|
// Тип заявки для кода заявки, резолва маршрута (resolveRoute) и уведомлений
|
||||||
|
// (notifyActiveStep) — подтверждён через GET /api/request-types: id 10,
|
||||||
|
// name "Заявка в отдел 1С" (не путать с «Маршрут #8» — это id самого маршрута
|
||||||
|
// в админке, отдельная сущность от id типа заявки).
|
||||||
|
export const TICKET1C_REQUEST_TYPE_ID = 10;
|
||||||
|
|
||||||
|
/** Экземпляр сервиса списка согласования этой формы. */
|
||||||
|
export function ticket1cApprovalService(): ApprovalListService {
|
||||||
|
return new ApprovalListService(APPROVAL_LIST_TITLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поля элемента списка данных из модели формы. TaskName пишется в системную колонку Title. */
|
||||||
|
function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
||||||
|
const item: Record<string, unknown> = { Title: data.TaskName, Status: status };
|
||||||
|
|
||||||
|
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
||||||
|
if (key === 'TaskName') return;
|
||||||
|
const value = data[key];
|
||||||
|
item[key] = value === '' ? null : value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISaveResult {
|
||||||
|
id: number;
|
||||||
|
/** Задан только при создании новой заявки (существующий код не меняется). */
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить заявку в отдел 1С: содержимое + маршрут согласования.
|
||||||
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
|
* Фазы исполнения нет: после согласования заявка уходит в Planner, а не исполнителю
|
||||||
|
* из матрицы. Возвращает Id заявки (+ код при создании).
|
||||||
|
*/
|
||||||
|
export async function saveTicketRequest(
|
||||||
|
existingId: number | undefined,
|
||||||
|
data: IFormData,
|
||||||
|
approvers: IApproverToWrite[],
|
||||||
|
status: string
|
||||||
|
): Promise<ISaveResult> {
|
||||||
|
const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE);
|
||||||
|
const item = buildItem(data, status);
|
||||||
|
|
||||||
|
let id: number;
|
||||||
|
let code: string | undefined;
|
||||||
|
if (existingId) {
|
||||||
|
await list.items.getById(existingId).update(item);
|
||||||
|
id = existingId;
|
||||||
|
} else {
|
||||||
|
const added = await list.items.add(item);
|
||||||
|
id = (added as { Id: number }).Id;
|
||||||
|
// Код известен только после создания элемента (нужен его Id).
|
||||||
|
code = buildRequestUniqueCode(TICKET1C_REQUEST_TYPE_ID, id, new Date());
|
||||||
|
await list.items.getById(id).update({ RequestUniqueCode: code });
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeRoute(ticket1cApprovalService(), id, approvers, [], status);
|
||||||
|
return { id, code };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Id уже созданной карточки Planner для этой заявки, если есть. Пишется сразу
|
||||||
|
* после createTask — служит защитой от повторного создания карточки, если
|
||||||
|
* решение согласующего применяется из нескольких вкладок/сессий одновременно.
|
||||||
|
*/
|
||||||
|
export async function getPlannerTaskId(id: number): Promise<string | undefined> {
|
||||||
|
const item = (await getSP()
|
||||||
|
.web.lists.getByTitle(DATA_LIST_TITLE)
|
||||||
|
.items.getById(id)
|
||||||
|
.select('PlannerTaskId')()) as { PlannerTaskId?: string };
|
||||||
|
return item.PlannerTaskId || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPlannerTaskId(id: number, taskId: string): Promise<void> {
|
||||||
|
await getSP().web.lists.getByTitle(DATA_LIST_TITLE).items.getById(id).update({ PlannerTaskId: taskId });
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
|
|||||||
),
|
),
|
||||||
'it-workplace-setup': React.lazy(
|
'it-workplace-setup': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'form-workplace-setup' */ './WorkplaceSetup/WorkplaceSetupForm')
|
() => import(/* webpackChunkName: 'form-workplace-setup' */ './WorkplaceSetup/WorkplaceSetupForm')
|
||||||
|
),
|
||||||
|
'it-1c-ticket': React.lazy(
|
||||||
|
() => import(/* webpackChunkName: 'form-1c-ticket' */ './Ticket1C/Ticket1CForm')
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
|
|||||||
),
|
),
|
||||||
'it-workplace-setup': React.lazy(
|
'it-workplace-setup': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'view-workplace-setup' */ './WorkplaceSetup/WorkplaceSetupView')
|
() => import(/* webpackChunkName: 'view-workplace-setup' */ './WorkplaceSetup/WorkplaceSetupView')
|
||||||
|
),
|
||||||
|
'it-1c-ticket': React.lazy(
|
||||||
|
() => import(/* webpackChunkName: 'view-1c-ticket' */ './Ticket1C/Ticket1CView')
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { APPROVAL_API_CLIENT_ID, APPROVAL_API_BASE_URL } from '../config';
|
import { APPROVAL_API_CLIENT_ID, APPROVAL_API_BASE_URL } from '../config';
|
||||||
import { ApprovalStatus, IApiResponse, IRequestType, IResolvedRoute } from './models/IApprovalRouteApi';
|
import { ApprovalStatus, IApiResponse, IRequestType, IResolvedRoute, IResolveFieldValue } from './models/IApprovalRouteApi';
|
||||||
|
|
||||||
// Все обращения к внешнему approval-route API — только здесь.
|
// Все обращения к внешнему approval-route API — только здесь.
|
||||||
// Клиент авторизуется через AadHttpClient (SSO по текущему пользователю).
|
// Клиент авторизуется через AadHttpClient (SSO по текущему пользователю).
|
||||||
@@ -30,12 +30,18 @@ export async function getRequestTypes(context: WebPartContext): Promise<IRequest
|
|||||||
// 'Approval' — согласующие (кто одобряет),
|
// 'Approval' — согласующие (кто одобряет),
|
||||||
// 'Execution' — исполнители (кто выполняет после согласования).
|
// 'Execution' — исполнители (кто выполняет после согласования).
|
||||||
// Один и тот же маршрут, разные наборы шагов.
|
// Один и тот же маршрут, разные наборы шагов.
|
||||||
|
//
|
||||||
|
// fieldValues — условие видимости шагов (см. IResolveFieldValue): для форм с
|
||||||
|
// шагами, зависящими от значения колонки списка (напр. WorkType), передать
|
||||||
|
// текущее значение поля здесь. Формы без условных шагов его не передают —
|
||||||
|
// бэкенд получает пустой массив, как раньше.
|
||||||
export async function resolveRoute(
|
export async function resolveRoute(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
requestTypeId: number,
|
requestTypeId: number,
|
||||||
userEmail: string,
|
userEmail: string,
|
||||||
status: ApprovalStatus = 'Approval',
|
status: ApprovalStatus = 'Approval',
|
||||||
departmentId: string | null = null
|
departmentId: string | null = null,
|
||||||
|
fieldValues: IResolveFieldValue[] = []
|
||||||
): Promise<IResolvedRoute> {
|
): Promise<IResolvedRoute> {
|
||||||
const c = await client(context);
|
const c = await client(context);
|
||||||
const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`;
|
const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`;
|
||||||
@@ -51,7 +57,7 @@ export async function resolveRoute(
|
|||||||
userEmail,
|
userEmail,
|
||||||
projectId: null,
|
projectId: null,
|
||||||
status,
|
status,
|
||||||
fieldValues: [],
|
fieldValues,
|
||||||
departmentId: departmentId || null
|
departmentId: departmentId || null
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
|
|||||||
import { resolveRoute } from './approvalRoute.service';
|
import { resolveRoute } from './approvalRoute.service';
|
||||||
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
|
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
|
||||||
import { IMatrixRow } from './models/IMatrixRow';
|
import { IMatrixRow } from './models/IMatrixRow';
|
||||||
import { ApprovalStatus } from './models/IApprovalRouteApi';
|
import { ApprovalStatus, IResolveFieldValue } from './models/IApprovalRouteApi';
|
||||||
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
|
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
|
||||||
|
|
||||||
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
|
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
|
||||||
@@ -15,10 +15,11 @@ export async function getPlannedMatrix(
|
|||||||
userEmail: string,
|
userEmail: string,
|
||||||
approval: ApprovalListService,
|
approval: ApprovalListService,
|
||||||
status: ApprovalStatus = 'Approval',
|
status: ApprovalStatus = 'Approval',
|
||||||
departmentId?: string | null
|
departmentId?: string | null,
|
||||||
|
fieldValues?: IResolveFieldValue[]
|
||||||
): Promise<IMatrixRow[]> {
|
): Promise<IMatrixRow[]> {
|
||||||
if (!userEmail) return [];
|
if (!userEmail) return [];
|
||||||
const route = await resolveRoute(context, requestTypeId, userEmail, status, departmentId);
|
const route = await resolveRoute(context, requestTypeId, userEmail, status, departmentId, fieldValues);
|
||||||
const phase = status === 'Execution' ? APPROVAL_PHASE.execution : APPROVAL_PHASE.approval;
|
const phase = status === 'Execution' ? APPROVAL_PHASE.execution : APPROVAL_PHASE.approval;
|
||||||
const ordered = route.steps.slice().sort((a, b) => a.orderNo - b.orderNo);
|
const ordered = route.steps.slice().sort((a, b) => a.orderNo - b.orderNo);
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type {
|
|||||||
IRequestType,
|
IRequestType,
|
||||||
IResolvedRoute,
|
IResolvedRoute,
|
||||||
IResolvedRouteStep,
|
IResolvedRouteStep,
|
||||||
|
IResolveFieldValue,
|
||||||
} from './models/IApprovalRouteApi';
|
} from './models/IApprovalRouteApi';
|
||||||
|
|
||||||
export { getRequestTypes, resolveRoute } from './approvalRoute.service';
|
export { getRequestTypes, resolveRoute } from './approvalRoute.service';
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ export interface IApiResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Условие видимости шага маршрута (настраивается в админке маршрутов —
|
||||||
|
// «Показывать шаг только при выполнении условия», сравнение «Поле списка»/
|
||||||
|
// «Оператор»/«Значение»). fieldName — внутреннее имя колонки списка (напр.
|
||||||
|
// "WorkType"), value — фактическое значение из формы (напр. "Создание
|
||||||
|
// пользователя в 1С"), с которым бэкенд сравнивает эту колонку.
|
||||||
|
export interface IResolveFieldValue {
|
||||||
|
fieldName: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/template-routes/resolve → data
|
// GET /api/template-routes/resolve → data
|
||||||
export interface IResolvedRoute {
|
export interface IResolvedRoute {
|
||||||
routeId: number;
|
routeId: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user