From a8e54f2b354462420e9bc99a63bdc39d1fee3db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Tue, 11 Aug 2026 10:39:04 +0500 Subject: [PATCH] feat: migrated 1c form --- docs/forms.md | 1 + docs/routing.md | 6 + src/webparts/requestPortal/data/requests.ts | 1 + .../requestPortal/forms/Ticket1C/README.md | 49 +++ .../forms/Ticket1C/Ticket1CForm.module.scss | 20 ++ .../forms/Ticket1C/Ticket1CForm.tsx | 325 ++++++++++++++++++ .../forms/Ticket1C/Ticket1CView.module.scss | 14 + .../forms/Ticket1C/Ticket1CView.tsx | 251 ++++++++++++++ .../forms/Ticket1C/approval/provisioning.json | 14 + .../requestPortal/forms/Ticket1C/config.ts | 46 +++ .../requestPortal/forms/Ticket1C/load.ts | 35 ++ .../requestPortal/forms/Ticket1C/model.ts | 42 +++ .../forms/Ticket1C/provisioning.json | 54 +++ .../Ticket1C/sections/ContactSection.tsx | 23 ++ .../Ticket1C/sections/RequestSection.tsx | 53 +++ .../services/plannerTicket.service.ts | 83 +++++ .../Ticket1C/services/profile.service.ts | 53 +++ .../requestPortal/forms/Ticket1C/submit.ts | 85 +++++ src/webparts/requestPortal/forms/registry.ts | 3 + .../requestPortal/forms/viewRegistry.ts | 3 + .../shared/approval/approvalRoute.service.ts | 12 +- .../approval/approvalSubmission.service.ts | 7 +- .../requestPortal/shared/approval/index.ts | 1 + .../approval/models/IApprovalRouteApi.ts | 10 + 24 files changed, 1185 insertions(+), 6 deletions(-) create mode 100644 src/webparts/requestPortal/forms/Ticket1C/README.md create mode 100644 src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.module.scss create mode 100644 src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx create mode 100644 src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.module.scss create mode 100644 src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx create mode 100644 src/webparts/requestPortal/forms/Ticket1C/approval/provisioning.json create mode 100644 src/webparts/requestPortal/forms/Ticket1C/config.ts create mode 100644 src/webparts/requestPortal/forms/Ticket1C/load.ts create mode 100644 src/webparts/requestPortal/forms/Ticket1C/model.ts create mode 100644 src/webparts/requestPortal/forms/Ticket1C/provisioning.json create mode 100644 src/webparts/requestPortal/forms/Ticket1C/sections/ContactSection.tsx create mode 100644 src/webparts/requestPortal/forms/Ticket1C/sections/RequestSection.tsx create mode 100644 src/webparts/requestPortal/forms/Ticket1C/services/plannerTicket.service.ts create mode 100644 src/webparts/requestPortal/forms/Ticket1C/services/profile.service.ts create mode 100644 src/webparts/requestPortal/forms/Ticket1C/submit.ts diff --git a/docs/forms.md b/docs/forms.md index ee795bc..638e9f0 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -29,6 +29,7 @@ | [`recruiting-selection`](../src/webparts/requestPortal/forms/RecruitingSelection/README.md) | Заявка на подбор | Рекрутинг | нет | нет | нет | много полей (~42), поля-люди (в т.ч. мульти) | ✅ | | [`recruiting-hire`](../src/webparts/requestPortal/forms/RecruitingHire/README.md) | Заявка на прием | Рекрутинг | да | да | да | маска телефона, деньги, условное «взамен кого», источник для орг. места | ✅ | | [`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` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) | Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения; diff --git a/docs/routing.md b/docs/routing.md index 99cd6cd..3e85312 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -43,6 +43,7 @@ | `recruiting-selection` | Заявка на подбор | Рекрутинг | нет | нет | — | | `recruiting-hire` | Заявка на прием | Рекрутинг | да | да | — | | `it-workplace-setup` | Организация рабочего места | IT | да | да | из `recruiting-hire` | +| `it-1c-ticket` | Заявка в отдел 1С | IT | да | да | — | | `hr-transfer` | На перемещение сотрудников | Отдел кадров | (в разработке) | — | — | --- @@ -66,6 +67,11 @@ …/Портал-Заявок.aspx?rptype=it-workplace-setup&rpsource=42 ``` +**Согласующему открыть заявку в отдел 1С №12:** +``` +…/Портал-Заявок.aspx?rpmode=approve&rptype=it-1c-ticket&rpid=12 +``` + --- ## Кто формирует ссылки diff --git a/src/webparts/requestPortal/data/requests.ts b/src/webparts/requestPortal/data/requests.ts index 94d9acf..c5b65df 100644 --- a/src/webparts/requestPortal/data/requests.ts +++ b/src/webparts/requestPortal/data/requests.ts @@ -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' } diff --git a/src/webparts/requestPortal/forms/Ticket1C/README.md b/src/webparts/requestPortal/forms/Ticket1C/README.md new file mode 100644 index 0000000..eb28793 --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/README.md @@ -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`) +- «Создать на основе»: нет diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.module.scss b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.module.scss new file mode 100644 index 0000000..e1ef36d --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.module.scss @@ -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; + } +} diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx new file mode 100644 index 0000000..31484ad --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx @@ -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 = ({ requestType, context, onCancel, onSaved, editId }) => { + const [data, setData] = useState(EMPTY_DATA); + const [files, setFiles] = useState([]); + const [existingAttachments, setExistingAttachments] = useState([]); + const [requestId, setRequestId] = useState(editId); + const [requestStatus, setRequestStatus] = useState(undefined); + const [requestCode, setRequestCode] = useState(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) => async (): Promise => { + // Пустой маршрут — согласование не требуется по правилам этого вида работ. + 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 => submit.run('draft', save(REQUEST_STATUS.draft), validate); + const handleSubmit = (): Promise => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); + + const sectionProps = { data, set, context }; + + return ( +
+ + + + {requestStatus && } + {requestCode && } + + ) : undefined + } + > + {submit.savedKind && !submit.error && ( + + {submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена.'} + + )} + {submit.error && {submit.error}} + + + + + + + {!readOnly && ( + + {existingAttachments.length > 0 && ( + + + + )} + 0 ? 'Добавить файлы' : 'Файлы'} full> + + + + )} + + approval.searchUsers(q)} + emptyText="Согласование не требуется для этого вида работ." + /> + + + +
+ ); +}; + +export default Ticket1CForm; diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.module.scss b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.module.scss new file mode 100644 index 0000000..6a27763 --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.module.scss @@ -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; +} diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx new file mode 100644 index 0000000..0b4a573 --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx @@ -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 = ({ context, itemId }) => { + const [data, setData] = useState(EMPTY_DATA); + const [status, setStatus] = useState(''); + const [code, setCode] = useState(''); + const [attachments, setAttachments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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(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 => { + 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
Загрузка заявки…
; + } + if (error) { + return ( +
+ {error} +
+ ); + } + + const sectionProps = { data, set, context }; + + return ( +
+
+ {isEditableStatus(status) && ( + + )} + +
+ +
+ : undefined} + > + + + + + +
+ + + + + +
+
+ + approval.searchUsers(q)} + emptyText="Согласование не требовалось для этого вида работ." + /> +
+ +
+ 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); + }} + /> +
+
+ ); +}; + +export default Ticket1CView; diff --git a/src/webparts/requestPortal/forms/Ticket1C/approval/provisioning.json b/src/webparts/requestPortal/forms/Ticket1C/approval/provisioning.json new file mode 100644 index 0000000..23d387d --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/approval/provisioning.json @@ -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" } + ] +} diff --git a/src/webparts/requestPortal/forms/Ticket1C/config.ts b/src/webparts/requestPortal/forms/Ticket1C/config.ts new file mode 100644 index 0000000..986d05b --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/config.ts @@ -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 = 'Отклонённые'; diff --git a/src/webparts/requestPortal/forms/Ticket1C/load.ts b/src/webparts/requestPortal/forms/Ticket1C/load.ts new file mode 100644 index 0000000..a8a043c --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/load.ts @@ -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 { + const item = (await getSP() + .web.lists.getByTitle(LIST_TITLE) + .items.getById(itemId) + .select(...SCALAR_FIELDS, 'Title', 'Status', 'RequestUniqueCode')()) as Record; + + const data: IFormData = { ...EMPTY_DATA }; + const writable = data as unknown as Record; + + 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) || '' }; +} diff --git a/src/webparts/requestPortal/forms/Ticket1C/model.ts b/src/webparts/requestPortal/forms/Ticket1C/model.ts new file mode 100644 index 0000000..fbeb6a6 --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/model.ts @@ -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 = (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: '', +}; diff --git a/src/webparts/requestPortal/forms/Ticket1C/provisioning.json b/src/webparts/requestPortal/forms/Ticket1C/provisioning.json new file mode 100644 index 0000000..3e6e155 --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/provisioning.json @@ -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" } + ] +} diff --git a/src/webparts/requestPortal/forms/Ticket1C/sections/ContactSection.tsx b/src/webparts/requestPortal/forms/Ticket1C/sections/ContactSection.tsx new file mode 100644 index 0000000..106b826 --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/sections/ContactSection.tsx @@ -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 = ({ data, set }) => ( + + + set('FIO', v)} /> + + + set('Email', v)} /> + + + set('Phone', v)} /> + + + set('Department', v)} /> + + + set('Position', v)} /> + + +); diff --git a/src/webparts/requestPortal/forms/Ticket1C/sections/RequestSection.tsx b/src/webparts/requestPortal/forms/Ticket1C/sections/RequestSection.tsx new file mode 100644 index 0000000..b275f80 --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/sections/RequestSection.tsx @@ -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 = ({ data, set }) => ( + + + set('TaskName', v)} /> + + + set('Organization', v)} + options={ORGANIZATION_SELECT_OPTIONS} + placeholder="— выберите —" + /> + + +