feat: migrated 1c form

This commit is contained in:
Тимур Городецкий
2026-08-11 10:39:04 +05:00
parent dcf3f06121
commit a8e54f2b35
24 changed files with 1185 additions and 6 deletions
@@ -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')
)
};