feat: changed get time logic to approvers

This commit is contained in:
Тимур Городецкий
2026-08-13 10:01:08 +05:00
parent 2e6e138e8d
commit 8578653c16
55 changed files with 2249 additions and 46 deletions
@@ -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 };
}