feat: added notifications workflow
This commit is contained in:
@@ -29,6 +29,10 @@
|
|||||||
{
|
{
|
||||||
"resource": "9232d9e2-9807-4753-808f-07250e5474b0",
|
"resource": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||||
"scope": "Reports.Read"
|
"scope": "Reports.Read"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resource": "Microsoft Flow Service",
|
||||||
|
"scope": "Flows.Read.All"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"features": [
|
"features": [
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
// Переход «создать на основе» (в т.ч. из открытой заявки в панели): переключаем
|
// Переход «создать на основе» (в т.ч. из открытой заявки в панели): переключаем
|
||||||
// вкладку категорий на категорию целевой формы, чтобы она совпала с открытой формой.
|
// вкладку категорий на категорию целевой формы, чтобы она совпала с открытой формой.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (route.mode === 'create' && route.formKey) {
|
if ((route.mode === 'create' || route.mode === 'edit') && route.formKey) {
|
||||||
const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0];
|
const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0];
|
||||||
if (found) setActiveCategoryId(found.categoryId);
|
if (found) setActiveCategoryId(found.categoryId);
|
||||||
}
|
}
|
||||||
@@ -117,6 +117,11 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
? { formKey: route.formKey, sourceId: route.sourceId }
|
? { formKey: route.formKey, sourceId: route.sourceId }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
editForm={
|
||||||
|
route.mode === 'edit' && route.formKey && route.itemId
|
||||||
|
? { formKey: route.formKey, itemId: route.itemId }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : route.nav === 'search' ? (
|
) : route.nav === 'search' ? (
|
||||||
|
|||||||
@@ -16,15 +16,23 @@ export interface ISubmitFeatureProps {
|
|||||||
activeCategoryId: string;
|
activeCategoryId: string;
|
||||||
/** Открыть сразу форму с предзаполнением (производная заявка), минуя плитки. */
|
/** Открыть сразу форму с предзаполнением (производная заявка), минуя плитки. */
|
||||||
initialForm?: { formKey: string; sourceId: number };
|
initialForm?: { formKey: string; sourceId: number };
|
||||||
|
/** Открыть сразу форму на РЕДАКТИРОВАНИЕ существующей заявки (черновик/возвращена). */
|
||||||
|
editForm?: { formKey: string; itemId: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Фича «Подать заявку»: плитки → беспоук-форма (категории/вкладки — в RequestPortal).
|
// Фича «Подать заявку»: плитки → беспоук-форма (категории/вкладки — в RequestPortal).
|
||||||
// Владелец: автор портала. Живёт целиком в features/submit/**.
|
// Владелец: автор портала. Живёт целиком в features/submit/**.
|
||||||
const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context, activeCategoryId, initialForm }) => {
|
const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
|
||||||
// Если пришли по ссылке «создать на основе» — сразу открываем нужную форму.
|
context,
|
||||||
|
activeCategoryId,
|
||||||
|
initialForm,
|
||||||
|
editForm,
|
||||||
|
}) => {
|
||||||
|
// «Создать на основе» и «редактировать» оба сразу открывают нужную форму, минуя плитки.
|
||||||
|
const preOpen = initialForm ?? editForm;
|
||||||
const initialSelected = useMemo(
|
const initialSelected = useMemo(
|
||||||
() => (initialForm ? REQUEST_TYPES.filter((r) => r.id === initialForm.formKey)[0] ?? null : null),
|
() => (preOpen ? REQUEST_TYPES.filter((r) => r.id === preOpen.formKey)[0] ?? null : null),
|
||||||
[initialForm]
|
[preOpen]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [selectedRequest, setSelectedRequest] = useState<IRequestType | null>(initialSelected);
|
const [selectedRequest, setSelectedRequest] = useState<IRequestType | null>(initialSelected);
|
||||||
@@ -61,9 +69,10 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context, activeCategoryI
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceId — только для формы, открытой «на основе» исходной заявки.
|
// sourceId — «на основе» исходной заявки; editId — правка существующей.
|
||||||
const sourceId =
|
const sourceId =
|
||||||
initialForm && request.id === initialForm.formKey ? initialForm.sourceId : undefined;
|
initialForm && request.id === initialForm.formKey ? initialForm.sourceId : undefined;
|
||||||
|
const editId = editForm && request.id === editForm.formKey ? editForm.itemId : undefined;
|
||||||
|
|
||||||
// Ленивый рендер формы: её код подтягивается только сейчас, при открытии.
|
// Ленивый рендер формы: её код подтягивается только сейчас, при открытии.
|
||||||
return (
|
return (
|
||||||
@@ -74,6 +83,7 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context, activeCategoryI
|
|||||||
onCancel={handleBack}
|
onCancel={handleBack}
|
||||||
onSaved={emitRequestsChanged}
|
onSaved={emitRequestsChanged}
|
||||||
sourceId={sourceId}
|
sourceId={sourceId}
|
||||||
|
editId={editId}
|
||||||
/>
|
/>
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
import styles from './RecruitingHireForm.module.scss';
|
import styles from './RecruitingHireForm.module.scss';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
FormCard,
|
FormCard,
|
||||||
FormModeProvider,
|
FormModeProvider,
|
||||||
FileDropzone,
|
FileDropzone,
|
||||||
|
AttachmentList,
|
||||||
CollapseSection,
|
CollapseSection,
|
||||||
FormField,
|
FormField,
|
||||||
Icon,
|
Icon,
|
||||||
@@ -18,24 +19,66 @@ import {
|
|||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
|
notifyActiveStep,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite
|
IApproverToWrite
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { uploadAttachments } from '../../shared/attachments/attachments.service';
|
import {
|
||||||
|
uploadAttachments,
|
||||||
|
getAttachments,
|
||||||
|
deleteAttachment,
|
||||||
|
IAttachmentInfo
|
||||||
|
} from '../../shared/attachments/attachments.service';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { saveHireRequest, hireApprovalService, HIRE_REQUEST_TYPE_ID, DATA_LIST_TITLE } from './submit';
|
import { loadHireRequest } from './load';
|
||||||
|
import {
|
||||||
|
saveHireRequest,
|
||||||
|
hireApprovalService,
|
||||||
|
HIRE_REQUEST_TYPE_ID,
|
||||||
|
DATA_LIST_TITLE,
|
||||||
|
APPROVAL_LIST_TITLE
|
||||||
|
} from './submit';
|
||||||
import { ApplicationSection } from './sections/ApplicationSection';
|
import { ApplicationSection } from './sections/ApplicationSection';
|
||||||
import { CandidateSection } from './sections/CandidateSection';
|
import { CandidateSection } from './sections/CandidateSection';
|
||||||
import { PpeSection } from './sections/PpeSection';
|
import { PpeSection } from './sections/PpeSection';
|
||||||
import { ConditionsSection } from './sections/ConditionsSection';
|
import { ConditionsSection } from './sections/ConditionsSection';
|
||||||
|
|
||||||
const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved }) => {
|
const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||||
|
requestType,
|
||||||
|
context,
|
||||||
|
onCancel,
|
||||||
|
onSaved,
|
||||||
|
editId
|
||||||
|
}) => {
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
|
||||||
const [requestId, setRequestId] = useState<number | undefined>(undefined);
|
const [requestId, setRequestId] = useState<number | undefined>(undefined);
|
||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
// Режим правки: грузим существующую заявку (id/статус → сохранение как update) и
|
||||||
|
// её уже прикреплённые вложения.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
Promise.all([loadHireRequest(editId), getAttachments(DATA_LIST_TITLE, editId)])
|
||||||
|
.then(([loaded, atts]) => {
|
||||||
|
if (alive) {
|
||||||
|
setData(loaded.data);
|
||||||
|
setRequestId(editId);
|
||||||
|
setRequestStatus(loaded.status);
|
||||||
|
setExistingAttachments(atts);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [editId]);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
@@ -70,6 +113,16 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||||
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
|
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
|
||||||
|
|
||||||
|
// Удаление уже прикреплённого файла (в правке) — сразу из заявки.
|
||||||
|
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 = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||||
const id = await saveHireRequest(
|
const id = await saveHireRequest(
|
||||||
requestId,
|
requestId,
|
||||||
@@ -83,10 +136,20 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента
|
await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента
|
||||||
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
||||||
|
setExistingAttachments(await getAttachments(DATA_LIST_TITLE, id)); // показать добавленные
|
||||||
}
|
}
|
||||||
if (onSaved) {
|
if (onSaved) {
|
||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
|
// Отправка на согласование — уведомляем первого активного согласующего
|
||||||
|
// (поток оповещения). Черновик не уведомляет. Ошибки внутри проглатываются.
|
||||||
|
if (status === REQUEST_STATUS.onApproval) {
|
||||||
|
await notifyActiveStep(context, approval, id, {
|
||||||
|
requestTypeId: HIRE_REQUEST_TYPE_ID,
|
||||||
|
dataListName: DATA_LIST_TITLE,
|
||||||
|
approvalListName: APPROVAL_LIST_TITLE,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveDraft = (): Promise<void> =>
|
const handleSaveDraft = (): Promise<void> =>
|
||||||
@@ -131,7 +194,12 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<CollapseSection title="Вложения" icon="upload">
|
<CollapseSection title="Вложения" icon="upload">
|
||||||
<FormField label="Файлы" full>
|
{existingAttachments.length > 0 && (
|
||||||
|
<FormField label="Прикреплённые файлы" full>
|
||||||
|
<AttachmentList items={existingAttachments} onRemove={handleRemoveAttachment} />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
<FormField label={existingAttachments.length > 0 ? 'Добавить файлы' : 'Файлы'} full>
|
||||||
<FileDropzone files={files} onChange={setFiles} />
|
<FileDropzone files={files} onChange={setFiles} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ import {
|
|||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
|
notifyActiveStep,
|
||||||
|
isEditableStatus,
|
||||||
APPROVAL_PHASE
|
APPROVAL_PHASE
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { navigate } from '../../shared/routing';
|
import { navigate } from '../../shared/routing';
|
||||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { loadHireRequest } from './load';
|
import { loadHireRequest } from './load';
|
||||||
import { hireApprovalService, DATA_LIST_TITLE } from './submit';
|
import { hireApprovalService, HIRE_REQUEST_TYPE_ID, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit';
|
||||||
import { ApplicationSection } from './sections/ApplicationSection';
|
import { ApplicationSection } from './sections/ApplicationSection';
|
||||||
import { CandidateSection } from './sections/CandidateSection';
|
import { CandidateSection } from './sections/CandidateSection';
|
||||||
import { PpeSection } from './sections/PpeSection';
|
import { PpeSection } from './sections/PpeSection';
|
||||||
@@ -119,6 +121,17 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
|
{isEditableStatus(status) && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() =>
|
||||||
|
navigate({ mode: 'edit', nav: 'new', formKey: 'recruiting-hire', itemId })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -174,6 +187,12 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
|
// Очередь сдвинулась — уведомляем следующий активный шаг.
|
||||||
|
notifyActiveStep(context, approval, itemId, {
|
||||||
|
requestTypeId: HIRE_REQUEST_TYPE_ID,
|
||||||
|
dataListName: DATA_LIST_TITLE,
|
||||||
|
approvalListName: APPROVAL_LIST_TITLE,
|
||||||
|
}).catch(() => undefined);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"dataListTitle": "Прием на работу — данные",
|
"dataListTitle": "Прием на работу — данные",
|
||||||
"columns": [
|
"columns": [
|
||||||
|
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
|
||||||
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||||
|
|
||||||
{ "internalName": "Phone", "title": "Телефон", "type": "Text" },
|
{ "internalName": "Phone", "title": "Телефон", "type": "Text" },
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export async function saveHireRequest(
|
|||||||
} else {
|
} else {
|
||||||
const added = await list.items.add(item);
|
const added = await list.items.add(item);
|
||||||
id = (added as { Id: number }).Id;
|
id = (added as { Id: number }).Id;
|
||||||
|
// Уникальный код заявки: пока = id (id известен только после создания).
|
||||||
|
await list.items.getById(id).update({ RequestUniqueCode: String(id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeRoute(hireApprovalService(), id, approvers, executors, status);
|
await writeRoute(hireApprovalService(), id, approvers, executors, status);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"dataListTitle": "Заявка на подбор — данные",
|
"dataListTitle": "Заявка на подбор — данные",
|
||||||
"columns": [
|
"columns": [
|
||||||
|
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
|
||||||
{ "internalName": "Responsible", "title": "Ответственный за подачу заявки", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
|
{ "internalName": "Responsible", "title": "Ответственный за подачу заявки", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
|
||||||
{ "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true },
|
||||||
{ "internalName": "IsRegistered", "title": "Зарегистрирована", "type": "Boolean" },
|
{ "internalName": "IsRegistered", "title": "Зарегистрирована", "type": "Boolean" },
|
||||||
|
|||||||
@@ -54,5 +54,8 @@ export async function submitRecruitingSelection(data: IFormData): Promise<void>
|
|||||||
item[`${MULTI_PERSON_FIELDS[i]}Id`] = ids;
|
item[`${MULTI_PERSON_FIELDS[i]}Id`] = ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sp.web.lists.getByTitle(LIST_TITLE).items.add(item);
|
const added = await sp.web.lists.getByTitle(LIST_TITLE).items.add(item);
|
||||||
|
const id = (added as { Id: number }).Id;
|
||||||
|
// Уникальный код заявки: пока = id (id известен только после создания).
|
||||||
|
await sp.web.lists.getByTitle(LIST_TITLE).items.getById(id).update({ RequestUniqueCode: String(id) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
FormCard,
|
FormCard,
|
||||||
FormModeProvider,
|
FormModeProvider,
|
||||||
FileDropzone,
|
FileDropzone,
|
||||||
|
AttachmentList,
|
||||||
CollapseSection,
|
CollapseSection,
|
||||||
FormField,
|
FormField,
|
||||||
Icon,
|
Icon,
|
||||||
@@ -18,26 +19,42 @@ import {
|
|||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
getPlannedMatrix,
|
getPlannedMatrix,
|
||||||
|
notifyActiveStep,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite
|
IApproverToWrite
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { uploadAttachments } from '../../shared/attachments/attachments.service';
|
import {
|
||||||
|
uploadAttachments,
|
||||||
|
getAttachments,
|
||||||
|
deleteAttachment,
|
||||||
|
IAttachmentInfo
|
||||||
|
} from '../../shared/attachments/attachments.service';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import {
|
import {
|
||||||
saveWorkplaceRequest,
|
saveWorkplaceRequest,
|
||||||
workplaceApprovalService,
|
workplaceApprovalService,
|
||||||
WORKPLACE_REQUEST_TYPE_ID,
|
WORKPLACE_REQUEST_TYPE_ID,
|
||||||
DATA_LIST_TITLE
|
DATA_LIST_TITLE,
|
||||||
|
APPROVAL_LIST_TITLE
|
||||||
} from './submit';
|
} from './submit';
|
||||||
import { loadPrefillFromHire } from './prefill';
|
import { loadPrefillFromHire } from './prefill';
|
||||||
|
import { loadWorkplaceRequest } from './load';
|
||||||
import { EmployeeSection } from './sections/EmployeeSection';
|
import { EmployeeSection } from './sections/EmployeeSection';
|
||||||
import { EquipmentSection } from './sections/EquipmentSection';
|
import { EquipmentSection } from './sections/EquipmentSection';
|
||||||
|
|
||||||
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, sourceId }) => {
|
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
||||||
|
requestType,
|
||||||
|
context,
|
||||||
|
onCancel,
|
||||||
|
onSaved,
|
||||||
|
sourceId,
|
||||||
|
editId
|
||||||
|
}) => {
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
|
||||||
// Id и статус текущей заявки: заполняются после первого сохранения.
|
// Id и статус текущей заявки: заполняются после первого сохранения.
|
||||||
const [requestId, setRequestId] = useState<number | undefined>(undefined);
|
const [requestId, setRequestId] = useState<number | undefined>(undefined);
|
||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
@@ -48,6 +65,27 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
|
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
// Режим правки: грузим существующую заявку и её id/статус (сохранение → update).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
Promise.all([loadWorkplaceRequest(editId), getAttachments(DATA_LIST_TITLE, editId)])
|
||||||
|
.then(([loaded, atts]) => {
|
||||||
|
if (alive) {
|
||||||
|
setData(loaded.data);
|
||||||
|
setRequestId(editId);
|
||||||
|
setRequestStatus(loaded.status);
|
||||||
|
setExistingAttachments(atts);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [editId]);
|
||||||
|
|
||||||
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
|
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sourceId) {
|
if (!sourceId) {
|
||||||
@@ -94,6 +132,16 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||||
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
|
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
|
||||||
|
|
||||||
|
// Удаление уже прикреплённого файла (в правке) — сразу из заявки.
|
||||||
|
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 = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||||
const id = await saveWorkplaceRequest(
|
const id = await saveWorkplaceRequest(
|
||||||
requestId,
|
requestId,
|
||||||
@@ -108,10 +156,19 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
await uploadAttachments(DATA_LIST_TITLE, id, files);
|
await uploadAttachments(DATA_LIST_TITLE, id, files);
|
||||||
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
||||||
|
setExistingAttachments(await getAttachments(DATA_LIST_TITLE, id)); // показать добавленные
|
||||||
}
|
}
|
||||||
if (onSaved) {
|
if (onSaved) {
|
||||||
onSaved();
|
onSaved();
|
||||||
}
|
}
|
||||||
|
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
|
||||||
|
if (status === REQUEST_STATUS.onApproval) {
|
||||||
|
await notifyActiveStep(context, approval, id, {
|
||||||
|
requestTypeId: WORKPLACE_REQUEST_TYPE_ID,
|
||||||
|
dataListName: DATA_LIST_TITLE,
|
||||||
|
approvalListName: APPROVAL_LIST_TITLE,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveDraft = (): Promise<void> =>
|
const handleSaveDraft = (): Promise<void> =>
|
||||||
@@ -154,7 +211,12 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
|||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<CollapseSection title="Вложения" icon="upload">
|
<CollapseSection title="Вложения" icon="upload">
|
||||||
<FormField label="Файлы" full>
|
{existingAttachments.length > 0 && (
|
||||||
|
<FormField label="Прикреплённые файлы" full>
|
||||||
|
<AttachmentList items={existingAttachments} onRemove={handleRemoveAttachment} />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
<FormField label={existingAttachments.length > 0 ? 'Добавить файлы' : 'Файлы'} full>
|
||||||
<FileDropzone files={files} onChange={setFiles} />
|
<FileDropzone files={files} onChange={setFiles} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
|
|||||||
@@ -10,20 +10,30 @@ import {
|
|||||||
CollapseSection,
|
CollapseSection,
|
||||||
FormField,
|
FormField,
|
||||||
AttachmentList,
|
AttachmentList,
|
||||||
PrintButton
|
PrintButton,
|
||||||
|
Icon
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
|
import { navigate } from '../../shared/routing';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
|
notifyActiveStep,
|
||||||
|
isEditableStatus,
|
||||||
APPROVAL_PHASE
|
APPROVAL_PHASE
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { loadWorkplaceRequest } from './load';
|
import { loadWorkplaceRequest } from './load';
|
||||||
import { workplaceApprovalService, updateReviewFields, DATA_LIST_TITLE } from './submit';
|
import {
|
||||||
|
workplaceApprovalService,
|
||||||
|
updateReviewFields,
|
||||||
|
WORKPLACE_REQUEST_TYPE_ID,
|
||||||
|
DATA_LIST_TITLE,
|
||||||
|
APPROVAL_LIST_TITLE
|
||||||
|
} from './submit';
|
||||||
import { EmployeeSection } from './sections/EmployeeSection';
|
import { EmployeeSection } from './sections/EmployeeSection';
|
||||||
import { EquipmentSection } from './sections/EquipmentSection';
|
import { EquipmentSection } from './sections/EquipmentSection';
|
||||||
|
|
||||||
@@ -162,6 +172,17 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
|
{isEditableStatus(status) && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() =>
|
||||||
|
navigate({ mode: 'edit', nav: 'new', formKey: 'it-workplace-setup', itemId })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<PrintButton />
|
<PrintButton />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -219,6 +240,12 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
|
// Очередь сдвинулась — уведомляем следующий активный шаг.
|
||||||
|
notifyActiveStep(context, approval, itemId, {
|
||||||
|
requestTypeId: WORKPLACE_REQUEST_TYPE_ID,
|
||||||
|
dataListName: DATA_LIST_TITLE,
|
||||||
|
approvalListName: APPROVAL_LIST_TITLE,
|
||||||
|
}).catch(() => undefined);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"dataListTitle": "Организация рабочего места — данные",
|
"dataListTitle": "Организация рабочего места — данные",
|
||||||
"columns": [
|
"columns": [
|
||||||
|
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
|
||||||
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||||
|
|
||||||
{ "internalName": "FullName", "title": "Ф.И.О.", "type": "Text", "required": true },
|
{ "internalName": "FullName", "title": "Ф.И.О.", "type": "Text", "required": true },
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export async function saveWorkplaceRequest(
|
|||||||
} else {
|
} else {
|
||||||
const added = await list.items.add(item);
|
const added = await list.items.add(item);
|
||||||
id = (added as { Id: number }).Id;
|
id = (added as { Id: number }).Id;
|
||||||
|
// Уникальный код заявки: пока = id (id известен только после создания).
|
||||||
|
await list.items.getById(id).update({ RequestUniqueCode: String(id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeRoute(workplaceApprovalService(), id, approvers, executors, status);
|
await writeRoute(workplaceApprovalService(), id, approvers, executors, status);
|
||||||
|
|||||||
@@ -37,4 +37,9 @@ export interface IRequestFormProps {
|
|||||||
* Формы, не поддерживающие производные заявки, игнорируют.
|
* Формы, не поддерживающие производные заявки, игнорируют.
|
||||||
*/
|
*/
|
||||||
sourceId?: number;
|
sourceId?: number;
|
||||||
|
/**
|
||||||
|
* Id существующей заявки для РЕДАКТИРОВАНИЯ (черновик/возвращена/отклонена):
|
||||||
|
* форма грузит её данные и при сохранении обновляет ту же запись.
|
||||||
|
*/
|
||||||
|
editId?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export type { IApproverToWrite } from './approvalList.service';
|
|||||||
export { ApproverService } from './approverDecision.service';
|
export { ApproverService } from './approverDecision.service';
|
||||||
export type { ApproverDecision } from './approverDecision.service';
|
export type { ApproverDecision } from './approverDecision.service';
|
||||||
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
||||||
|
export { notifyActiveStep } from './notify.service';
|
||||||
|
export type { INotifyTarget } from './notify.service';
|
||||||
export { useMatrixEditor } from './useMatrixEditor';
|
export { useMatrixEditor } from './useMatrixEditor';
|
||||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { AadHttpClient } from '@microsoft/sp-http';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { FLOW_RESOURCE, FLOW_NOTIFY_URL } from '../config';
|
||||||
|
import { ApprovalListService } from './approvalList.service';
|
||||||
|
import { REQUEST_STATUS } from './models/statuses';
|
||||||
|
|
||||||
|
// Данные формы для тела запроса к потоку (постоянны для формы).
|
||||||
|
export interface INotifyTarget {
|
||||||
|
requestTypeId: number; // RequestTypeId (id типа заявки во внешнем API маршрутов)
|
||||||
|
dataListName: string; // ListName (список данных заявки)
|
||||||
|
approvalListName: string; // ApproveListName (список согласования)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST в поток оповещения (Entra-триггер) через AadHttpClient — обычный fetch → 401,
|
||||||
|
// AadHttpClient даёт токен Flow Service (и снимает вопрос CORS).
|
||||||
|
async function postNotify(context: WebPartContext, body: Record<string, unknown>): Promise<void> {
|
||||||
|
if (!FLOW_NOTIFY_URL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const client = await context.aadHttpClientFactory.getClient(FLOW_RESOURCE);
|
||||||
|
const res = await client.post(FLOW_NOTIFY_URL, AadHttpClient.configurations.v1, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Поток оповещения: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находит АКТИВНЫЙ шаг маршрута (На согласовании / На исполнении) и уведомляет его.
|
||||||
|
// Вызывать после отправки заявки и после каждого положительного решения. Поток по
|
||||||
|
// переданным полям сам находит согласующего/исполнителя и шлёт уведомление.
|
||||||
|
// Уведомление некритично — ошибки глотаем, чтобы не ронять основной сценарий.
|
||||||
|
export async function notifyActiveStep(
|
||||||
|
context: WebPartContext,
|
||||||
|
approval: ApprovalListService,
|
||||||
|
requestId: number,
|
||||||
|
target: INotifyTarget
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const rows = await approval.getForRequest(requestId);
|
||||||
|
const active = rows.filter(
|
||||||
|
(r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution
|
||||||
|
)[0];
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await postNotify(context, {
|
||||||
|
RequestTypeId: target.requestTypeId,
|
||||||
|
RowID: active.id,
|
||||||
|
RequestID: requestId,
|
||||||
|
ListName: target.dataListName,
|
||||||
|
ApproveListName: target.approvalListName,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// не критично — просто не отправили уведомление
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
<div className={styles.matrixNote}>{emptyText}</div>
|
<div className={styles.matrixNote}>{emptyText}</div>
|
||||||
)}
|
)}
|
||||||
{!loading && !error && rows.length > 0 && (
|
{!loading && !error && rows.length > 0 && (
|
||||||
|
<div className={styles.matrixScroll}>
|
||||||
<table className={styles.matrix}>
|
<table className={styles.matrix}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -51,45 +52,57 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((r, i) => (
|
{rows.map((r, i) => (
|
||||||
<tr key={i}>
|
<React.Fragment key={i}>
|
||||||
<td>{r.who}</td>
|
<tr className={r.comment ? styles.rowNoBorder : undefined}>
|
||||||
<td>{r.position}</td>
|
<td data-label={roleLabel}>{r.who}</td>
|
||||||
<td>{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}</td>
|
<td data-label="Должность">{r.position}</td>
|
||||||
<td>{r.date}</td>
|
<td data-label="Статус">
|
||||||
{editable && (
|
{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}
|
||||||
<td className={styles.matrixActions}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.iconBtn}
|
|
||||||
disabled={i === 0}
|
|
||||||
onClick={() => editor.moveUp(i)}
|
|
||||||
title="Выше"
|
|
||||||
>
|
|
||||||
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.iconBtn}
|
|
||||||
disabled={i === rows.length - 1}
|
|
||||||
onClick={() => editor.moveDown(i)}
|
|
||||||
title="Ниже"
|
|
||||||
>
|
|
||||||
<Icon name="chevron-down" size="sm" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
|
||||||
onClick={() => editor.remove(i)}
|
|
||||||
title="Удалить"
|
|
||||||
>
|
|
||||||
<Icon name="trash" size="sm" />
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
|
<td data-label="Дата">{r.date}</td>
|
||||||
|
{editable && (
|
||||||
|
<td className={styles.matrixActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.iconBtn}
|
||||||
|
disabled={i === 0}
|
||||||
|
onClick={() => editor.moveUp(i)}
|
||||||
|
title="Выше"
|
||||||
|
>
|
||||||
|
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.iconBtn}
|
||||||
|
disabled={i === rows.length - 1}
|
||||||
|
onClick={() => editor.moveDown(i)}
|
||||||
|
title="Ниже"
|
||||||
|
>
|
||||||
|
<Icon name="chevron-down" size="sm" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
||||||
|
onClick={() => editor.remove(i)}
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<Icon name="trash" size="sm" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
{r.comment && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={editable ? 5 : 4} className={styles.commentCell}>
|
||||||
|
<span className={styles.commentLabel}>Комментарий:</span> {r.comment}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{editable && !loading && (
|
{editable && !loading && (
|
||||||
<div className={styles.matrixPicker}>
|
<div className={styles.matrixPicker}>
|
||||||
|
|||||||
@@ -91,8 +91,16 @@
|
|||||||
color: $gray-500;
|
color: $gray-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обёртка таблицы: на промежуточных ширинах — горизонтальный скролл вместо
|
||||||
|
// сплющивания колонок. На телефоне таблица становится карточками (медиазапрос ниже).
|
||||||
|
.matrixScroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
.matrix {
|
.matrix {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 480px; // колонки не сжимаются; при нехватке места .matrixScroll прокручивает
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: $fs-sm;
|
font-size: $fs-sm;
|
||||||
|
|
||||||
@@ -113,6 +121,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Строка, у которой ниже идёт комментарий: убираем нижнюю границу, чтобы строка
|
||||||
|
// и её комментарий читались как единый блок (граница переезжает на .commentCell).
|
||||||
|
.rowNoBorder td {
|
||||||
|
border-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentCell {
|
||||||
|
padding: 0 8px 8px !important;
|
||||||
|
border-bottom: 1px solid $gray-200;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $gray-500;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap; // сохранить переносы строк из комментария
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentLabel {
|
||||||
|
color: $gray-400;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.matrixActions {
|
.matrixActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $sp-1;
|
gap: $sp-1;
|
||||||
@@ -158,3 +186,54 @@
|
|||||||
.matrixPicker {
|
.matrixPicker {
|
||||||
margin-top: $sp-3;
|
margin-top: $sp-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Мобильные экраны: таблица маршрута → карточки ----
|
||||||
|
// Скрываем шапку, каждая строка — блок, ячейки в столбик с подписью поля
|
||||||
|
// (из data-label). Так маршрут читается на телефоне без горизонтального скролла.
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.matrix {
|
||||||
|
min-width: 0; // на телефоне не растягиваем — раскладываем в столбик
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix tr {
|
||||||
|
display: block;
|
||||||
|
border-bottom: 1px solid $gray-200;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строка с комментарием: линия — на самом комментарии, не на строке.
|
||||||
|
.rowNoBorder {
|
||||||
|
border-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix td {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
border: 0 !important;
|
||||||
|
padding: 3px 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подпись поля перед значением (только у ячеек с data-label).
|
||||||
|
.matrix td[data-label]::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
flex: 0 0 44%;
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrixActions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-top: $sp-2 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentCell {
|
||||||
|
display: block;
|
||||||
|
padding: 2px 0 6px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,3 +45,16 @@ export async function uploadAttachments(
|
|||||||
await item.attachmentFiles.add(file.name, content);
|
await item.attachmentFiles.add(file.name, content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Удалить вложение элемента по имени файла (для правки заявки). */
|
||||||
|
export async function deleteAttachment(
|
||||||
|
listTitle: string,
|
||||||
|
itemId: number,
|
||||||
|
fileName: string
|
||||||
|
): Promise<void> {
|
||||||
|
await getSP()
|
||||||
|
.web.lists.getByTitle(listTitle)
|
||||||
|
.items.getById(itemId)
|
||||||
|
.attachmentFiles.getByName(fileName)
|
||||||
|
.delete();
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,3 +17,17 @@ export const DATA_SITE_URL = 'https://tnsukz.sharepoint.com/sites/requests';
|
|||||||
*/
|
*/
|
||||||
export const APPROVAL_API_CLIENT_ID = '9232d9e2-9807-4753-808f-07250e5474b0';
|
export const APPROVAL_API_CLIENT_ID = '9232d9e2-9807-4753-808f-07250e5474b0';
|
||||||
export const APPROVAL_API_BASE_URL = 'https://api.tnsu.kz/approval-route';
|
export const APPROVAL_API_BASE_URL = 'https://api.tnsu.kz/approval-route';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Power Automate: поток оповещения согласующего/исполнителя.
|
||||||
|
* Триггер «When an HTTP request is received» защищён Entra («любой пользователь
|
||||||
|
* тенанта»), поэтому URL без подписи (`sig`), а вызываем через AadHttpClient с
|
||||||
|
* ресурсом Flow Service (обычный fetch → 401; так же решается CORS). Разрешение
|
||||||
|
* «Microsoft Flow Service / Flows.Read.All» объявлено в package-solution.json и
|
||||||
|
* одобряется в Центре администрирования SharePoint (API access) при развёртывании.
|
||||||
|
* Шлём тело `{ RowID: <id строки списка согласования> }` — поток сам находит по
|
||||||
|
* строке согласующего, статус и заявку и уведомляет. (Паттерн из kps-ct-form.)
|
||||||
|
*/
|
||||||
|
export const FLOW_RESOURCE = 'https://service.flow.microsoft.com/';
|
||||||
|
export const FLOW_NOTIFY_URL =
|
||||||
|
'https://eab3d2815f2beb42bf2e7ec1731708.92.environment.api.powerplatform.com:443/powerautomate/automations/direct/cu/07/workflows/b21516edf8a14287a65a39e4116dfe4f/triggers/manual/paths/invoke?api-version=1';
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import * as React from 'react';
|
|||||||
// перезагрузки страницы. useRoute() подписан и на popstate (кнопка «назад»),
|
// перезагрузки страницы. useRoute() подписан и на popstate (кнопка «назад»),
|
||||||
// и на её собственное событие, поэтому реагирует на оба способа смены URL.
|
// и на её собственное событие, поэтому реагирует на оба способа смены URL.
|
||||||
|
|
||||||
export type PortalMode = 'submit' | 'approve' | 'create';
|
export type PortalMode = 'submit' | 'approve' | 'create' | 'edit';
|
||||||
|
|
||||||
/** Раздел бокового меню. */
|
/** Раздел бокового меню. */
|
||||||
export type NavSection = 'new' | 'approvals' | 'own' | 'search';
|
export type NavSection = 'new' | 'approvals' | 'own' | 'search';
|
||||||
@@ -59,6 +59,11 @@ export function parseRoute(search: string = window.location.search): IRoute {
|
|||||||
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
|
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
|
||||||
return { mode: 'approve', nav, formKey, itemId };
|
return { mode: 'approve', nav, formKey, itemId };
|
||||||
}
|
}
|
||||||
|
// Редактирование существующей заявки (черновик/возвращена/отклонена) — открывает
|
||||||
|
// форму на правку. nav='new', т.к. правка живёт в области подачи.
|
||||||
|
if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) {
|
||||||
|
return { mode: 'edit', nav: 'new', formKey, itemId };
|
||||||
|
}
|
||||||
// Создание производной заявки с предзаполнением из исходной.
|
// Создание производной заявки с предзаполнением из исходной.
|
||||||
if (formKey && sourceId && !isNaN(sourceId)) {
|
if (formKey && sourceId && !isNaN(sourceId)) {
|
||||||
return { mode: 'create', nav: 'new', formKey, sourceId };
|
return { mode: 'create', nav: 'new', formKey, sourceId };
|
||||||
@@ -82,6 +87,10 @@ function buildQuery(route: IRoute): string {
|
|||||||
params.set('rpmode', 'approve');
|
params.set('rpmode', 'approve');
|
||||||
params.set('rptype', route.formKey);
|
params.set('rptype', route.formKey);
|
||||||
params.set('rpid', String(route.itemId));
|
params.set('rpid', String(route.itemId));
|
||||||
|
} else if (route.mode === 'edit' && route.formKey && route.itemId) {
|
||||||
|
params.set('rpmode', 'edit');
|
||||||
|
params.set('rptype', route.formKey);
|
||||||
|
params.set('rpid', String(route.itemId));
|
||||||
} else if (route.mode === 'create' && route.formKey && route.sourceId) {
|
} else if (route.mode === 'create' && route.formKey && route.sourceId) {
|
||||||
params.set('rptype', route.formKey);
|
params.set('rptype', route.formKey);
|
||||||
params.set('rpsource', String(route.sourceId));
|
params.set('rpsource', String(route.sourceId));
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
color: $navy-700;
|
color: $navy-700;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
@@ -30,6 +32,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $white;
|
||||||
|
color: $gray-500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .16s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $red-500;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
color: $gray-500;
|
color: $gray-500;
|
||||||
font-size: $fs-sm;
|
font-size: $fs-sm;
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { IAttachmentInfo } from '../../attachments/attachments.service';
|
|||||||
|
|
||||||
interface IAttachmentListProps {
|
interface IAttachmentListProps {
|
||||||
items: IAttachmentInfo[];
|
items: IAttachmentInfo[];
|
||||||
|
/** Если задан — рядом с файлом показывается кнопка удаления (режим правки). */
|
||||||
|
onRemove?: (fileName: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Список вложений заявки как ссылки на скачивание (режим чтения/согласующего). */
|
/** Список вложений заявки как ссылки на скачивание; с onRemove — ещё и удаление. */
|
||||||
export const AttachmentList: React.FC<IAttachmentListProps> = ({ items }) => {
|
export const AttachmentList: React.FC<IAttachmentListProps> = ({ items, onRemove }) => {
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return <p className={styles.empty}>Вложений нет.</p>;
|
return <p className={styles.empty}>Вложений нет.</p>;
|
||||||
}
|
}
|
||||||
@@ -21,6 +23,17 @@ export const AttachmentList: React.FC<IAttachmentListProps> = ({ items }) => {
|
|||||||
<a href={attachment.url} target="_blank" rel="noreferrer" className={styles.link}>
|
<a href={attachment.url} target="_blank" rel="noreferrer" className={styles.link}>
|
||||||
{attachment.fileName}
|
{attachment.fileName}
|
||||||
</a>
|
</a>
|
||||||
|
{onRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeBtn}
|
||||||
|
title="Удалить вложение"
|
||||||
|
aria-label={`Удалить ${attachment.fileName}`}
|
||||||
|
onClick={() => onRemove(attachment.fileName)}
|
||||||
|
>
|
||||||
|
<Icon name="trash" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
Reference in New Issue
Block a user