feat: changed get time logic to approvers
This commit is contained in:
@@ -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,302 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import styles from './TmcTransferForm.module.scss';
|
||||
import {
|
||||
Alert,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
FileDropzone,
|
||||
AttachmentList,
|
||||
CollapseSection,
|
||||
FormField,
|
||||
Icon,
|
||||
RequestNumber,
|
||||
SubmitActions,
|
||||
useRequestSubmit
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
notifyActiveStep,
|
||||
REQUEST_STATUS,
|
||||
isEditableStatus,
|
||||
IApproverToWrite,
|
||||
APPROVAL_PHASE,
|
||||
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 {
|
||||
saveTmcTransferRequest,
|
||||
tmcTransferApprovalService,
|
||||
TMC_TRANSFER_REQUEST_TYPE_ID,
|
||||
DATA_LIST_TITLE,
|
||||
APPROVAL_LIST_TITLE
|
||||
} from './submit';
|
||||
import { loadTmcTransferRequest } from './load';
|
||||
import { HeaderSection } from './sections/HeaderSection';
|
||||
import { ItemsSection } from './sections/ItemsSection';
|
||||
|
||||
const TmcTransferForm: 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;
|
||||
}
|
||||
const user = context.pageContext.user;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
RequesterEmail: user.email || '',
|
||||
AuthorName: user.displayName || '',
|
||||
RequestDate: prev.RequestDate || today
|
||||
}));
|
||||
}, [editId, context]);
|
||||
|
||||
// Открыта СВОЯ заявка на редактирование — подгружаем содержимое, статус и вложения.
|
||||
useEffect(() => {
|
||||
if (!editId) {
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
Promise.all([loadTmcTransferRequest(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;
|
||||
};
|
||||
}, [editId]);
|
||||
|
||||
const approval = useMemo(() => tmcTransferApprovalService(), []);
|
||||
const userEmail = context.pageContext.user.email;
|
||||
// У новой заявки маршрут ПЛАНИРУЕТСЯ (resolve по внешнему API), у открытой на
|
||||
// редактирование — читается из уже записанных строк.
|
||||
// projectId = oid проекта-источника: по нему бэкенд подставляет согласующего
|
||||
// «руководитель проекта-источника». Меняется проект → перепланируем маршрут.
|
||||
const projectId = data.SourceProjectId || null;
|
||||
const targetProjectId = data.TargetProjectId || null;
|
||||
// Маршрут согласования зависит от проекта-источника («Откуда»): пока он не
|
||||
// выбран, resolve вернёт 404 — не грузим, показываем заглушку. У открытой на
|
||||
// редактирование заявки маршрут уже записан, источник не нужен.
|
||||
const matrixReady = !!editId || !!projectId;
|
||||
// Исполнитель определяется проектом-получателем («Куда»): показываем блок с
|
||||
// заглушкой, пока «Куда» не выбрано. Источник тоже нужен, иначе resolve 404.
|
||||
const executorsReady = !!editId || (!!projectId && !!targetProjectId);
|
||||
const loadMatrix = useCallback(
|
||||
() =>
|
||||
editId
|
||||
? approval
|
||||
.getMatrixForRequest(editId)
|
||||
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution))
|
||||
: getPlannedMatrix(context, TMC_TRANSFER_REQUEST_TYPE_ID, userEmail, approval, 'Approval', null, undefined, projectId),
|
||||
[context, userEmail, approval, editId, projectId]
|
||||
);
|
||||
const matrix = useMatrixEditor(loadMatrix, `${userEmail}-${editId ?? 'new'}-${projectId}`, !readOnly, matrixReady, (login) =>
|
||||
approval.getJobTitle(login)
|
||||
);
|
||||
|
||||
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
|
||||
const loadExecutors = useCallback(
|
||||
() =>
|
||||
editId
|
||||
? approval
|
||||
.getMatrixForRequest(editId)
|
||||
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution))
|
||||
: getPlannedMatrix(context, TMC_TRANSFER_REQUEST_TYPE_ID, userEmail, approval, 'Execution', null, undefined, projectId),
|
||||
[context, userEmail, approval, editId, projectId]
|
||||
);
|
||||
const executors = useMatrixEditor(
|
||||
loadExecutors,
|
||||
`${userEmail}-${editId ?? 'new'}-${projectId}-${targetProjectId}`,
|
||||
false,
|
||||
executorsReady,
|
||||
(login) => approval.getJobTitle(login)
|
||||
);
|
||||
|
||||
const hasItems = data.Items.some((i) => i.name.trim().length > 0);
|
||||
const isValid =
|
||||
data.SourceProject.trim().length > 0 && data.TargetProject.trim().length > 0 && hasItems;
|
||||
const validate = (): string | undefined =>
|
||||
isValid
|
||||
? undefined
|
||||
: 'Заполните обязательные поля: проект-источник, проект-получатель и хотя бы одну позицию ТМЦ.';
|
||||
|
||||
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 = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||
const { id, code } = await saveTmcTransferRequest(
|
||||
requestId,
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
toApprovers(executors.rows),
|
||||
status
|
||||
);
|
||||
setRequestId(id);
|
||||
setRequestStatus(status);
|
||||
if (code) {
|
||||
setRequestCode(code);
|
||||
}
|
||||
if (files.length > 0) {
|
||||
await uploadAttachments(DATA_LIST_TITLE, id, files);
|
||||
setFiles([]);
|
||||
setExistingAttachments(await getAttachments(DATA_LIST_TITLE, id));
|
||||
}
|
||||
if (onSaved) {
|
||||
onSaved();
|
||||
}
|
||||
if (status === REQUEST_STATUS.onApproval) {
|
||||
await notifyActiveStep(context, approval, id, {
|
||||
requestTypeId: TMC_TRANSFER_REQUEST_TYPE_ID,
|
||||
dataListName: DATA_LIST_TITLE,
|
||||
approvalListName: APPROVAL_LIST_TITLE
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateSubmit = (): string | undefined => {
|
||||
const formError = validate();
|
||||
if (formError) return formError;
|
||||
const unassigned = findUnassignedStep(matrix.rows);
|
||||
if (unassigned) {
|
||||
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`;
|
||||
}
|
||||
const unassignedExecutor = findUnassignedStep(executors.rows);
|
||||
if (unassignedExecutor) {
|
||||
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.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="Категория: Склад / Снабжение"
|
||||
belowSubtitle={<InitiatorLine listTitle={DATA_LIST_TITLE} itemId={requestId} />}
|
||||
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}>
|
||||
<HeaderSection {...sectionProps} />
|
||||
<ItemsSection {...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)}
|
||||
waiting={!matrixReady}
|
||||
waitingText="Выберите проект-источник («Откуда»), чтобы построить маршрут согласования."
|
||||
/>
|
||||
|
||||
{/* Блок исполнителя показываем, только когда по маршруту реально есть
|
||||
исполнители (грузится по проектам, см. executorsReady). */}
|
||||
{executorsReady && !executors.loading && executors.rows.length > 0 && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title="Исполнение (после согласования)"
|
||||
roleLabel="Исполнитель"
|
||||
emptyText="Исполнители не заданы по маршруту."
|
||||
/>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
submit={submit}
|
||||
isValid={isValid}
|
||||
readOnly={readOnly}
|
||||
onCancel={onCancel}
|
||||
onSaveDraft={handleSaveDraft}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</FormCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TmcTransferForm;
|
||||
@@ -0,0 +1,15 @@
|
||||
@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;
|
||||
gap: $sp-3;
|
||||
margin-bottom: $sp-4;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './TmcTransferView.module.scss';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
CollapseSection,
|
||||
FormField,
|
||||
AttachmentList,
|
||||
PrintButton,
|
||||
Icon
|
||||
} from '../../shared/ui';
|
||||
import { navigate } from '../../shared/routing';
|
||||
import {
|
||||
ApproverService,
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
isEditableStatus,
|
||||
APPROVAL_PHASE
|
||||
} from '../../shared/approval';
|
||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { loadTmcTransferRequest } from './load';
|
||||
import {
|
||||
tmcTransferApprovalService,
|
||||
TMC_TRANSFER_REQUEST_TYPE_ID,
|
||||
DATA_LIST_TITLE,
|
||||
APPROVAL_LIST_TITLE
|
||||
} from './submit';
|
||||
import { HeaderSection } from './sections/HeaderSection';
|
||||
import { ItemsSection } from './sections/ItemsSection';
|
||||
|
||||
interface ITmcTransferViewProps {
|
||||
context: WebPartContext;
|
||||
itemId: number;
|
||||
}
|
||||
|
||||
// Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего).
|
||||
const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ 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);
|
||||
|
||||
// В просмотре форма read-only, но секции ждут set — даём no-op.
|
||||
const set: SetField = () => undefined;
|
||||
|
||||
const approverService = useMemo(
|
||||
() => new ApproverService(tmcTransferApprovalService(), DATA_LIST_TITLE),
|
||||
[]
|
||||
);
|
||||
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
const approval = useMemo(() => tmcTransferApprovalService(), []);
|
||||
const loadApprovers = useCallback(
|
||||
() =>
|
||||
approval
|
||||
.getMatrixForRequest(itemId)
|
||||
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)),
|
||||
[approval, itemId]
|
||||
);
|
||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||
approval.getJobTitle(l)
|
||||
);
|
||||
const loadExecutors = useCallback(
|
||||
() =>
|
||||
approval
|
||||
.getMatrixForRequest(itemId)
|
||||
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)),
|
||||
[approval, itemId]
|
||||
);
|
||||
const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||
approval.getJobTitle(l)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
Promise.all([loadTmcTransferRequest(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]);
|
||||
|
||||
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: 'wh-tmc-transfer', itemId })}
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
Редактировать
|
||||
</Button>
|
||||
)}
|
||||
<PrintButton />
|
||||
</div>
|
||||
|
||||
<div className="rp-print-root">
|
||||
<FormCard
|
||||
title="Служебная записка на перемещение ТМЦ"
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={<InitiatorLine listTitle={DATA_LIST_TITLE} itemId={itemId} />}
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
<FormModeProvider readOnly>
|
||||
<HeaderSection {...sectionProps} />
|
||||
<ItemsSection {...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)} />
|
||||
|
||||
{!executors.loading && executors.rows.length > 0 && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title="Исполнение"
|
||||
roleLabel="Исполнитель"
|
||||
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: TMC_TRANSFER_REQUEST_TYPE_ID,
|
||||
dataListName: DATA_LIST_TITLE,
|
||||
approvalListName: APPROVAL_LIST_TITLE
|
||||
}).catch(() => undefined);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
requestTypeId: TMC_TRANSFER_REQUEST_TYPE_ID,
|
||||
dataListName: DATA_LIST_TITLE,
|
||||
approvalListName: APPROVAL_LIST_TITLE
|
||||
}).catch(() => undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TmcTransferView;
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"dataListTitle": "СЗ на перемещение ТМЦ — согласование",
|
||||
"columns": [
|
||||
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
|
||||
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "СЗ на перемещение ТМЦ — данные", "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,74 @@
|
||||
import { getSP } from '../../shared/pnp/pnpjs';
|
||||
import { ILineItem } from '../../shared/ui';
|
||||
import { IFormData, EMPTY_DATA } from './model';
|
||||
import { DATA_LIST_TITLE } from './submit';
|
||||
|
||||
export interface ILoadedRequest {
|
||||
data: IFormData;
|
||||
status: string;
|
||||
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** Безопасный разбор ItemsJson в массив позиций (пустой массив при ошибке/пустом значении). */
|
||||
function parseItems(raw: unknown): ILineItem[] {
|
||||
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as ILineItem[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Прочитать заявку «СЗ на перемещение ТМЦ» по id: модель формы + статус + код. */
|
||||
export async function loadTmcTransferRequest(itemId: number): Promise<ILoadedRequest> {
|
||||
const item = (await getSP()
|
||||
.web.lists.getByTitle(DATA_LIST_TITLE)
|
||||
.items.getById(itemId)
|
||||
.select(
|
||||
'Id',
|
||||
'Recipient',
|
||||
'RequesterEmail',
|
||||
'AuthorName',
|
||||
'Subject',
|
||||
'RequestDate',
|
||||
'SourceProject',
|
||||
'SourceProjectId',
|
||||
'SourceProjectManager',
|
||||
'TargetProject',
|
||||
'TargetProjectId',
|
||||
'Reason',
|
||||
'ContactPerson',
|
||||
'ContactPhone',
|
||||
'ItemsJson',
|
||||
'RequestStatus',
|
||||
'RequestUniqueCode'
|
||||
)()) as Record<string, unknown>;
|
||||
|
||||
const requestDate = (item.RequestDate as string) || '';
|
||||
|
||||
const data: IFormData = {
|
||||
...EMPTY_DATA,
|
||||
Recipient: (item.Recipient as string) || '',
|
||||
RequesterEmail: (item.RequesterEmail as string) || '',
|
||||
AuthorName: (item.AuthorName as string) || '',
|
||||
Subject: (item.Subject as string) || '',
|
||||
RequestDate: requestDate.slice(0, 10), // ISO → YYYY-MM-DD для input type=date
|
||||
SourceProject: (item.SourceProject as string) || '',
|
||||
SourceProjectId: (item.SourceProjectId as string) || '',
|
||||
SourceProjectManager: (item.SourceProjectManager as string) || '',
|
||||
TargetProject: (item.TargetProject as string) || '',
|
||||
TargetProjectId: (item.TargetProjectId as string) || '',
|
||||
Reason: (item.Reason as string) || '',
|
||||
ContactPerson: (item.ContactPerson as string) || '',
|
||||
ContactPhone: (item.ContactPhone as string) || '',
|
||||
Items: parseItems(item.ItemsJson)
|
||||
};
|
||||
|
||||
return {
|
||||
data,
|
||||
status: (item.RequestStatus as string) || '',
|
||||
code: (item.RequestUniqueCode as string) || ''
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { ILineItem } from '../../shared/ui';
|
||||
|
||||
/**
|
||||
* Данные формы «Служебная записка на перемещение ТМЦ».
|
||||
* Ключи шапки = internalName колонок SP-списка «СЗ на перемещение ТМЦ — данные».
|
||||
* Табличная часть (Items) в SP не разворачивается в колонки, а сериализуется
|
||||
* в JSON-колонку ItemsJson (см. submit.ts / load.ts).
|
||||
*/
|
||||
export interface IFormData {
|
||||
// Шапка служебной записки
|
||||
Recipient: string; // Кому (по умолч. «Директору Самаевой А.А.»)
|
||||
// NB: не «Author» — это зарезервированное системное имя колонки SP (Created By).
|
||||
RequesterEmail: string; // От — email заявителя (текущий пользователь)
|
||||
AuthorName: string; // От — Ф.И.О. заявителя (для печати)
|
||||
Subject: string; // Тема (по умолч. «Перемещение ТМЦ»)
|
||||
RequestDate: string; // Дата (YYYY-MM-DD)
|
||||
|
||||
// Проект-источник (откуда забираем) — выбор из справочника /Project
|
||||
SourceProject: string;
|
||||
SourceProjectId: string; // oid проекта ('' — если введён вручную)
|
||||
/** Снимок email руководителя проекта-источника — для будущего маршрута согласования. */
|
||||
SourceProjectManager: string;
|
||||
|
||||
// Проект-получатель (куда перемещаем)
|
||||
TargetProject: string;
|
||||
TargetProjectId: string;
|
||||
|
||||
Reason: string; // Обоснование / вступительный текст
|
||||
ContactPerson: string; // Исполнитель («Исп. …»), Ф.И.О.
|
||||
ContactPhone: string; // Телефон исполнителя
|
||||
|
||||
// Табличная часть — позиции ТМЦ (сериализуется в ItemsJson)
|
||||
Items: ILineItem[];
|
||||
}
|
||||
|
||||
/** Обновление одного поля формы с сохранением типа значения. */
|
||||
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
||||
|
||||
/** Пропсы каждой секции формы. */
|
||||
export interface ISectionProps {
|
||||
data: IFormData;
|
||||
set: SetField;
|
||||
context: WebPartContext;
|
||||
}
|
||||
|
||||
export const DEFAULT_RECIPIENT = 'Директору Самаевой А.А.';
|
||||
export const DEFAULT_SUBJECT = 'Перемещение ТМЦ';
|
||||
|
||||
export const EMPTY_DATA: IFormData = {
|
||||
Recipient: DEFAULT_RECIPIENT,
|
||||
RequesterEmail: '',
|
||||
AuthorName: '',
|
||||
Subject: DEFAULT_SUBJECT,
|
||||
RequestDate: '',
|
||||
SourceProject: '',
|
||||
SourceProjectId: '',
|
||||
SourceProjectManager: '',
|
||||
TargetProject: '',
|
||||
TargetProjectId: '',
|
||||
Reason: '',
|
||||
ContactPerson: '',
|
||||
ContactPhone: '',
|
||||
Items: []
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"dataListTitle": "СЗ на перемещение ТМЦ — данные",
|
||||
"columns": [
|
||||
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
|
||||
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||
|
||||
{ "internalName": "Recipient", "title": "Кому", "type": "Text" },
|
||||
{ "internalName": "RequesterEmail", "title": "От (email заявителя)", "type": "Text" },
|
||||
{ "internalName": "AuthorName", "title": "От (Ф.И.О.)", "type": "Text" },
|
||||
{ "internalName": "Subject", "title": "Тема", "type": "Text" },
|
||||
{ "internalName": "RequestDate", "title": "Дата", "type": "DateTime", "dateOnly": true },
|
||||
|
||||
{ "internalName": "SourceProject", "title": "Проект-источник (откуда)", "type": "Text", "required": true },
|
||||
{ "internalName": "SourceProjectId", "title": "Id проекта-источника", "type": "Text" },
|
||||
{ "internalName": "SourceProjectManager", "title": "Руководитель проекта-источника (email)", "type": "Text" },
|
||||
{ "internalName": "TargetProject", "title": "Проект-получатель (куда)", "type": "Text", "required": true },
|
||||
{ "internalName": "TargetProjectId", "title": "Id проекта-получателя", "type": "Text" },
|
||||
|
||||
{ "internalName": "Reason", "title": "Обоснование / текст", "type": "Note" },
|
||||
{ "internalName": "ContactPerson", "title": "Исполнитель (Ф.И.О.)", "type": "Text" },
|
||||
{ "internalName": "ContactPhone", "title": "Телефон исполнителя", "type": "Text" },
|
||||
|
||||
{ "internalName": "ItemsJson", "title": "Позиции ТМЦ (JSON)", "type": "Note" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react';
|
||||
import { FormGrid, FormField, TextInput, TextArea } from '../../../shared/ui';
|
||||
import { ProjectPicker } from '../../../shared/dictionary';
|
||||
import { ISectionProps } from '../model';
|
||||
|
||||
/**
|
||||
* Шапка служебной записки: заявитель, проекты «откуда → куда», дата, обоснование.
|
||||
* «Кому» и «Тема» — константы служебки (пишутся из модели по умолчанию), в форме не показываются.
|
||||
*/
|
||||
export const HeaderSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
||||
<FormGrid>
|
||||
<FormField label="Откуда (проект-источник)" htmlFor="SourceProject" required>
|
||||
<ProjectPicker
|
||||
id="SourceProject"
|
||||
context={context}
|
||||
value={data.SourceProject}
|
||||
placeholder="Начните вводить название проекта…"
|
||||
onChange={(name, id, project) => {
|
||||
set('SourceProject', name);
|
||||
set('SourceProjectId', id);
|
||||
// Снимок руководителя проекта-источника — пригодится для маршрута согласования.
|
||||
set('SourceProjectManager', project ? project.projectManager || '' : '');
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Куда (проект-получатель)" htmlFor="TargetProject" required>
|
||||
<ProjectPicker
|
||||
id="TargetProject"
|
||||
context={context}
|
||||
value={data.TargetProject}
|
||||
placeholder="Начните вводить название проекта…"
|
||||
onChange={(name, id) => {
|
||||
set('TargetProject', name);
|
||||
set('TargetProjectId', id);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Дата" htmlFor="RequestDate">
|
||||
<TextInput id="RequestDate" type="date" value={data.RequestDate} onChange={(v) => set('RequestDate', v)} />
|
||||
</FormField>
|
||||
|
||||
<FormField label="Обоснование / текст" htmlFor="Reason" full>
|
||||
<TextArea
|
||||
id="Reason"
|
||||
value={data.Reason}
|
||||
onChange={(v) => set('Reason', v)}
|
||||
placeholder="В связи с производственной необходимостью прошу согласовать перемещение ТМЦ…"
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
// Тело CollapseSection — грид 1fr 1fr; таблица должна занимать обе колонки.
|
||||
.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import styles from './ItemsSection.module.scss';
|
||||
import { CollapseSection, ItemsTable } from '../../../shared/ui';
|
||||
import { ISectionProps } from '../model';
|
||||
|
||||
/** Табличная часть СЗ: позиции ТМЦ (ручной ввод + вставка из Excel). */
|
||||
export const ItemsSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||
<CollapseSection title="Перечень ТМЦ" icon="clipboard">
|
||||
<div className={styles.full}>
|
||||
<ItemsTable value={data.Items} onChange={(rows) => set('Items', rows)} />
|
||||
</div>
|
||||
</CollapseSection>
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
import { getSP } from '../../shared/pnp/pnpjs';
|
||||
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
|
||||
import { buildRequestUniqueCode } from '../../shared/requestCode';
|
||||
import { ILineItem } from '../../shared/ui';
|
||||
import { IFormData } from './model';
|
||||
|
||||
export const DATA_LIST_TITLE = 'СЗ на перемещение ТМЦ — данные';
|
||||
export const APPROVAL_LIST_TITLE = 'СЗ на перемещение ТМЦ — согласование';
|
||||
// Тип заявки во внешнем approval-route API — подтверждён через GET /api/request-types:
|
||||
// id 12, name «Заявка на перемещение», pageUrl «wh-tmc-transfer», section «Снабжение».
|
||||
export const TMC_TRANSFER_REQUEST_TYPE_ID = 12;
|
||||
|
||||
/** Экземпляр сервиса списка согласования этой формы. */
|
||||
export function tmcTransferApprovalService(): ApprovalListService {
|
||||
return new ApprovalListService(APPROVAL_LIST_TITLE);
|
||||
}
|
||||
|
||||
/** Осмысленный заголовок элемента (системная колонка Title). */
|
||||
function buildTitle(data: IFormData): string {
|
||||
const route = [data.SourceProject, data.TargetProject].filter(Boolean).join(' → ');
|
||||
return ['Перемещение ТМЦ', route].filter(Boolean).join(': ');
|
||||
}
|
||||
|
||||
/** Поля элемента списка данных из модели формы. Items → JSON в ItemsJson. */
|
||||
function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
||||
const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status };
|
||||
|
||||
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
||||
if (key === 'Items') {
|
||||
// Табличная часть не разворачивается в колонки — сериализуем в Note-колонку.
|
||||
const items = data.Items as ILineItem[];
|
||||
item.ItemsJson = items.length > 0 ? JSON.stringify(items) : null;
|
||||
return;
|
||||
}
|
||||
const value = data[key];
|
||||
item[key] = value === '' ? null : value;
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export interface ISaveResult {
|
||||
id: number;
|
||||
/** Задан только при создании новой заявки (существующий код не меняется). */
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить заявку «СЗ на перемещение ТМЦ»: контент + маршрут (согласующие + исполнители).
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* Возвращает Id заявки (+ уникальный код при создании).
|
||||
*/
|
||||
export async function saveTmcTransferRequest(
|
||||
existingId: number | undefined,
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
executors: 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(TMC_TRANSFER_REQUEST_TYPE_ID, id, new Date());
|
||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
||||
}
|
||||
|
||||
await writeRoute(tmcTransferApprovalService(), id, approvers, executors, status);
|
||||
return { id, code };
|
||||
}
|
||||
Reference in New Issue
Block a user