feat: migrated 1c form
This commit is contained in:
@@ -11,6 +11,7 @@ export const REQUEST_TYPES: IRequestType[] = [
|
||||
|
||||
// IT
|
||||
{ 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' }
|
||||
|
||||
@@ -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(
|
||||
() => 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(
|
||||
() => 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 { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
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 — только здесь.
|
||||
// Клиент авторизуется через AadHttpClient (SSO по текущему пользователю).
|
||||
@@ -30,12 +30,18 @@ export async function getRequestTypes(context: WebPartContext): Promise<IRequest
|
||||
// 'Approval' — согласующие (кто одобряет),
|
||||
// 'Execution' — исполнители (кто выполняет после согласования).
|
||||
// Один и тот же маршрут, разные наборы шагов.
|
||||
//
|
||||
// fieldValues — условие видимости шагов (см. IResolveFieldValue): для форм с
|
||||
// шагами, зависящими от значения колонки списка (напр. WorkType), передать
|
||||
// текущее значение поля здесь. Формы без условных шагов его не передают —
|
||||
// бэкенд получает пустой массив, как раньше.
|
||||
export async function resolveRoute(
|
||||
context: WebPartContext,
|
||||
requestTypeId: number,
|
||||
userEmail: string,
|
||||
status: ApprovalStatus = 'Approval',
|
||||
departmentId: string | null = null
|
||||
departmentId: string | null = null,
|
||||
fieldValues: IResolveFieldValue[] = []
|
||||
): Promise<IResolvedRoute> {
|
||||
const c = await client(context);
|
||||
const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`;
|
||||
@@ -51,7 +57,7 @@ export async function resolveRoute(
|
||||
userEmail,
|
||||
projectId: null,
|
||||
status,
|
||||
fieldValues: [],
|
||||
fieldValues,
|
||||
departmentId: departmentId || null
|
||||
})
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { resolveRoute } from './approvalRoute.service';
|
||||
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
|
||||
import { IMatrixRow } from './models/IMatrixRow';
|
||||
import { ApprovalStatus } from './models/IApprovalRouteApi';
|
||||
import { ApprovalStatus, IResolveFieldValue } from './models/IApprovalRouteApi';
|
||||
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
|
||||
|
||||
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
|
||||
@@ -15,10 +15,11 @@ export async function getPlannedMatrix(
|
||||
userEmail: string,
|
||||
approval: ApprovalListService,
|
||||
status: ApprovalStatus = 'Approval',
|
||||
departmentId?: string | null
|
||||
departmentId?: string | null,
|
||||
fieldValues?: IResolveFieldValue[]
|
||||
): Promise<IMatrixRow[]> {
|
||||
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 ordered = route.steps.slice().sort((a, b) => a.orderNo - b.orderNo);
|
||||
return Promise.all(
|
||||
|
||||
@@ -16,6 +16,7 @@ export type {
|
||||
IRequestType,
|
||||
IResolvedRoute,
|
||||
IResolvedRouteStep,
|
||||
IResolveFieldValue,
|
||||
} from './models/IApprovalRouteApi';
|
||||
|
||||
export { getRequestTypes, resolveRoute } from './approvalRoute.service';
|
||||
|
||||
@@ -20,6 +20,16 @@ export interface IApiResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
// Условие видимости шага маршрута (настраивается в админке маршрутов —
|
||||
// «Показывать шаг только при выполнении условия», сравнение «Поле списка»/
|
||||
// «Оператор»/«Значение»). fieldName — внутреннее имя колонки списка (напр.
|
||||
// "WorkType"), value — фактическое значение из формы (напр. "Создание
|
||||
// пользователя в 1С"), с которым бэкенд сравнивает эту колонку.
|
||||
export interface IResolveFieldValue {
|
||||
fieldName: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// GET /api/template-routes/resolve → data
|
||||
export interface IResolvedRoute {
|
||||
routeId: number;
|
||||
|
||||
Reference in New Issue
Block a user