Заявка на услуги (#18)
Tests / frontend-test (push) Successful in 1m15s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/18
This commit was merged in pull request #18.
This commit is contained in:
2026-09-01 12:48:41 +00:00
parent ddce6b4f25
commit cc2efd8771
56 changed files with 2557 additions and 97 deletions
@@ -0,0 +1,28 @@
# Заявка на услуги (`wh-service-request`)
**Назначение:** электронный аналог бумажной Service Requisition Form ПТО — заявка на договор/услуги/работы (субподряд, поставка, аренда) по проекту.
**Категория:** Склад / Снабжение
**Списки SP:** «Заявка на услуги — данные», «Заявка на услуги — согласование»
**RequestTypeId (внешний API маршрутов):** резолвится в рантайме по `pageUrl` (см. `docs/forms.md`)
## Маршрут
- Фазы: согласование (без фазы исполнения — договор дальше ведётся вне портала)
- Особенности: маршрут зависит от выбранного проекта (`ProjectId`, oid из `ProjectPicker`) — пока проект не выбран, `resolveRoute` отвечает 404, и матрица не резолвится (см. `matrixReady` в `ServiceRequestForm.tsx`, тот же приём, что в `TmcTransferForm`)
## Особое поведение
- Подача за другого: заявитель — `PeopleField`, по умолчанию текущий пользователь, можно выбрать любого — маршрут резолвится на ВЫБРАННОГО (см. `handleRequesterChange`).
- Табличная часть «Приложения» (`Annexes`) — описание документа + номер приложения + сам файл, прикреплённый прямо к строке (`fileName`, хранится как встроенное вложение элемента списка — см. shared/attachments). Отдельного общего блока «Вложения» у формы нет: каждый файл привязан к своей строке.
- Табличная часть «Смета» (`Costs`) — список групп (разделов работ), внутри каждой свои позиции; название группы необязательно. Сумма по позиции, по группе и общий итог считаются на лету (`qty × rate`, см. `cost.ts`), не хранятся в структуре — правка количества/цены не может их рассинхронизировать.
- Под «Объём работ» (текстовое поле) — детализация по позициям в ТОМ ЖЕ формате, что и смета: группы/позиции/цена/сумма (`WorkVolumes`, см. `CostGroupsEditor.tsx` — общий редактор, переиспользуется и в CostsSection, и здесь). Дополняет текст итоговыми цифрами, не заменяет его.
- `Costs`/`WorkVolumes` сериализуются в JSON-колонки (`EstimateJson`/`WorkVolumesJson`) ОБЪЕКТОМ `{ groups, total }` — готовый итог кладётся прямо в JSON рядом с группами (см. `serializeGroups`/`parseGroupedJson` в `cost.ts`), а не отдельной числовой колонкой: он и так пересчитывается из groups при каждом сохранении, отдельная колонка была бы лишней схемой ради того же значения. Сама форма читает только `groups` — total из хранилища не доверяет, всегда считает заново (`costTotal`); `total` в JSON нужен тем, кто читает колонку напрямую (отчёты, sp-query), не поднимая форму.
- `Annexes` сериализуется в `AnnexesJson` просто массивом (без обёртки total — там считать нечего) — тот же приём, что в `TmcTransfer` (`ItemsJson`).
- Продолжительность работ под датами «начало/окончание» — производная величина (считается на лету), в SharePoint не пишется.
- Фазы исполнения нет: `saveServiceRequest` пишет маршрут с пустым списком исполнителей.
## Секции формы
HeaderSection, ScopeSection, AnnexesSection, CostsSection, RemarksSection
## Точки входа
- rptype: `wh-service-request`
- Режим согласующего (просмотр): есть (`ServiceRequestView`)
- «Создать на основе»: —
@@ -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,384 @@
import * as React from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import styles from './ServiceRequestForm.module.scss';
import {
Alert,
FormCard,
InitiatorLine,
FormModeProvider,
Icon,
RequestNumber,
SubmitActions,
useRequestSubmit
} from '../../shared/ui';
import {
ApprovalMatrix,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
notifyActiveStep,
useRequestTypeId,
REQUEST_STATUS,
isEditableStatus,
IApproverToWrite,
unassignedStepMessage,
resolveWriteListId
} from '../../shared/approval';
import {
uploadAttachments,
getAttachments,
deleteAttachment,
IAttachmentInfo
} from '../../shared/attachments/attachments.service';
import { effectiveUserEmail } from '../../shared/impersonation';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { IRequestFormProps } from '../../models/types';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { hasAnyCostItem } from './cost';
import { saveServiceRequest, serviceRequestApprovalService } from './submit';
import { loadServiceRequest } from './load';
import { HeaderSection } from './sections/HeaderSection';
import { ScopeSection } from './sections/ScopeSection';
import { AnnexesSection } from './sections/AnnexesSection';
import { CostsSection } from './sections/CostsSection';
import { RemarksSection } from './sections/RemarksSection';
const ServiceRequestForm: React.FC<IRequestFormProps> = ({
requestType,
context,
onCancel,
onSaved,
editId,
editListId,
onCodeLoaded
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
// Файлы, выбранные для строк «Приложения», ещё не загруженные на сервер (нужен
// id элемента — см. save()). Ключ — fileName (= выбранное имя строки), не индекс
// строки: индекс «плывёт» при удалении строк, а имя однозначно и переживает это.
const [annexFiles, setAnnexFiles] = useState<Record<string, 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);
const requesterEmail = effectiveUserEmail(context);
setData((prev) => ({
...prev,
// email — через effectiveUserEmail (учитывает отладочную подмену личности);
// displayName подменить нечем, справочник ФИО тут не читается — при
// подмене имя останется настоящим, для отладки это неважно.
RequesterEmail: requesterEmail,
RequesterName: user.displayName || '',
RequestDate: prev.RequestDate || today
}));
// Организации/должность заявителя (Employee/ByEmail) — как в TmcTransfer.
let alive = true;
getEmployeeOrganizations(context, requesterEmail)
.then((records) => {
if (alive) {
setData((prev) => ({
...prev,
Organization: formatEmployeeOrganizations(records),
RequesterJobTitle: prev.RequesterJobTitle || records[0]?.position || ''
}));
}
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [editId, context]);
// Выбор другого заявителя подтягивает его ФИО, должность и организации.
const handleRequesterChange = (login: string): void => {
setData((prev) => ({ ...prev, RequesterEmail: login }));
if (!login) {
setData((prev) => ({ ...prev, Organization: '' }));
return;
}
getEmployeeOrganizations(context, login)
.then((records) =>
setData((prev) => ({
...prev,
RequesterName: records[0]?.fio || prev.RequesterName,
RequesterJobTitle: records[0]?.position || prev.RequesterJobTitle,
Organization: formatEmployeeOrganizations(records)
}))
)
.catch(() => undefined);
};
// Открыта СВОЯ заявка на редактирование — подгружаем содержимое, статус и вложения.
useEffect(() => {
if (!editId) {
return;
}
let alive = true;
Promise.all([loadServiceRequest(editId, editListId), getAttachments(editListId || dataListTitle(FORM_ID), editId)])
.then(([loaded, atts]) => {
if (alive) {
setData(loaded.data);
setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined);
if (loaded.code) onCodeLoaded?.(loaded.code);
setExistingAttachments(atts);
}
})
.catch((e) => {
if (alive) {
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
}
});
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId, editListId]);
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
// serviceRequestApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => serviceRequestApprovalService(editListId), [editListId]);
// Маршрут резолвится по e-mail ВЫБРАННОГО заявителя (не обязательно текущего
// пользователя) — подача за другого, как в TmcTransfer.
const userEmail = data.RequesterEmail;
const requestTypeIdState = useRequestTypeId(context, requestType.id);
const requestTypeId = requestTypeIdState.id;
// Маршрут зависит от проекта (см. approval-route: согласующие — руководитель
// проекта, ПТО объекта) — пока проект не выбран, resolve вернёт 404.
const projectId = data.ProjectId || null;
const matrixReady = !!editId || (!!userEmail && !!projectId && !!requestTypeId);
const loadMatrix = useCallback(
() =>
editId
? approval.getMatrixForRequest(editId)
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, undefined, projectId),
[context, userEmail, approval, editId, projectId, requestTypeId]
);
const matrix = useMatrixEditor(loadMatrix, `${userEmail}-${editId ?? 'new'}-${projectId}`, !readOnly, matrixReady, (login) =>
approval.getJobTitle(login)
);
const hasCosts = hasAnyCostItem(data.Costs);
const isValid =
!!data.RequesterEmail &&
data.ProjectName.trim().length > 0 &&
data.ContractKind.trim().length > 0 &&
data.ContractAction.trim().length > 0 &&
data.ActivityName.trim().length > 0 &&
data.ScopeOfWork.trim().length > 0 &&
hasCosts;
const validate = (): string | undefined => {
if (!isValid) {
return 'Заполните обязательные поля: заявитель, проект, предмет заявки, действие с договором, наименование и объём работ, хотя бы одну строку сметы.';
}
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
};
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
rows.map((r) => ({
email: r.email,
position: r.position,
allowReassign: r.allowReassign,
order: r.order
}));
// Ссылки на скачивание для уже загруженных файлов строк «Приложения».
const attachmentUrls = useMemo(() => {
const map: Record<string, string> = {};
existingAttachments.forEach((a) => {
map[a.fileName] = a.url;
});
return map;
}, [existingAttachments]);
// Выбрали файл для строки приложения — сохраняем в памяти до save() (нужен id
// элемента, см. saveServiceRequest). Если у строки уже был загруженный файл —
// сразу открепляем старый, иначе на элементе повиснет лишнее вложение.
const handlePickAnnexFile = (rowIndex: number, file: File): void => {
const prevFileName = data.Annexes[rowIndex]?.fileName;
if (prevFileName && requestId !== undefined && existingAttachments.some((a) => a.fileName === prevFileName)) {
deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, prevFileName)
.then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== prevFileName)))
.catch(() => undefined);
}
setData((prev) => ({
...prev,
Annexes: prev.Annexes.map((row, i) => (i === rowIndex ? { ...row, fileName: file.name } : row))
}));
setAnnexFiles((prev) => ({ ...prev, [file.name]: file }));
};
// Открепить файл строки: если он уже на сервере — удаляем вложение сразу,
// иначе просто забываем ещё не загруженный выбор.
const handleRemoveAnnexFile = (rowIndex: number): void => {
const fileName = data.Annexes[rowIndex]?.fileName;
setData((prev) => ({
...prev,
Annexes: prev.Annexes.map((row, i) => (i === rowIndex ? { ...row, fileName: '' } : row))
}));
if (!fileName) {
return;
}
setAnnexFiles((prev) => {
const next = { ...prev };
delete next[fileName];
return next;
});
if (requestId !== undefined && existingAttachments.some((a) => a.fileName === fileName)) {
deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, fileName)
.then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName)))
.catch(() => undefined);
}
};
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
// validate()/validateSubmit() уже гарантировали requestTypeId к моменту вызова
// (см. useRequestSubmit.run — валидирует ДО save), но TS этого через границу
// замыкания не видит — локальный const и явная проверка снимают both проблемы.
const rtId = requestTypeId;
if (!rtId) {
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
}
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await saveServiceRequest(requestId, rtId, data, toApprovers(matrix.rows), status, writeListId);
setRequestId(id);
setRequestStatus(status);
if (code) {
setRequestCode(code);
}
// Файлы строк «Приложения» — только те, чья строка ещё существует (строку
// могли удалить, не открепляя файл явно — см. AnnexesSection.removeRow) и
// чьё имя всё ещё совпадает (замену уже отправляет handlePickAnnexFile).
const annexFileNames = new Set(data.Annexes.map((r) => r.fileName).filter(Boolean));
const pendingAnnexFiles = Object.entries(annexFiles)
.filter(([fileName]) => annexFileNames.has(fileName))
.map(([, file]) => file);
if (pendingAnnexFiles.length > 0) {
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, pendingAnnexFiles);
setAnnexFiles({});
setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id));
}
if (onSaved) {
onSaved();
}
if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, {
requestTypeId: rtId,
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
});
}
};
const validateSubmit = (): string | undefined => {
const formError = validate();
if (formError) return formError;
if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
}
const unassignedError = unassignedStepMessage(
matrix.rows,
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
);
if (unassignedError) return unassignedError;
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,
onRequesterChange: handleRequesterChange,
attachmentUrls,
onPickAnnexFile: handlePickAnnexFile,
onRemoveAnnexFile: handleRemoveAnnexFile
};
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={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
fallback={{ name: data.RequesterName, email: data.RequesterEmail }}
/>
}
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} />
<ScopeSection {...sectionProps} />
<AnnexesSection {...sectionProps} />
<CostsSection {...sectionProps} />
<RemarksSection {...sectionProps} />
</FormModeProvider>
<ApprovalMatrix
editor={matrix}
search={(q) => approval.searchUsers(q)}
waiting={!matrixReady}
waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования."
/>
<SubmitActions
submit={submit}
isValid={isValid}
readOnly={readOnly}
onCancel={onCancel}
onSaveDraft={handleSaveDraft}
onSubmit={handleSubmit}
/>
</FormCard>
</div>
);
};
export default ServiceRequestForm;
@@ -0,0 +1,17 @@
@import '../../../../styles/design-tokens';
.state {
padding: $sp-8;
color: $gray-500;
font-size: $fs-sm;
text-align: center;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4;
}
@@ -0,0 +1,204 @@
import * as React from 'react';
import { useState, useEffect, useMemo } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './ServiceRequestView.module.scss';
import {
Alert,
Button,
FormCard,
InitiatorLine,
FormModeProvider,
PrintSplitButton,
OrgLetterhead,
Icon
} from '../../shared/ui';
import { navigate } from '../../shared/routing';
import {
ApproverService,
DecisionPanel,
StatusBadge,
ApprovalMatrix,
useMatrixEditor,
notifyActiveStep,
useRequestTypeId,
isEditableStatus
} from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
approvalListTitleForListId,
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadServiceRequest } from './load';
import { serviceRequestApprovalService } from './submit';
import { HeaderSection } from './sections/HeaderSection';
import { ScopeSection } from './sections/ScopeSection';
import { AnnexesSection } from './sections/AnnexesSection';
import { CostsSection } from './sections/CostsSection';
import { RemarksSection } from './sections/RemarksSection';
interface IServiceRequestViewProps {
context: WebPartContext;
itemId: number;
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
formKey: string;
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
listId?: string;
/** Уведомление о загруженном уникальном коде заявки — см. IApprovalViewProps.onCodeLoaded. */
onCodeLoaded?: (code: string) => void;
}
// Заявка «Заявка на услуги» только для просмотра (режим согласующего).
const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
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);
// Какая из организаций заявителя (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintSplitButton, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
const { id: requestTypeId } = useRequestTypeId(context, formKey);
// В просмотре форма read-only, но секции ждут set — даём no-op.
const set: SetField = () => undefined;
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// serviceRequestApprovalService можно звать не опасаясь исключения.
const approval = useMemo(() => serviceRequestApprovalService(listId), [listId]);
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const loadApprovers = React.useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]);
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
approval.getJobTitle(l)
);
useEffect(() => {
let alive = true;
setLoading(true);
Promise.all([loadServiceRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)])
.then(([loaded, files]) => {
if (alive) {
setData(loaded.data);
setStatus(loaded.status);
setCode(loaded.code);
onCodeLoaded?.(loaded.code);
setAttachments(files);
setError(null);
setLoading(false);
}
})
.catch((e) => {
if (alive) {
setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`);
setLoading(false);
}
});
return () => {
alive = false;
};
}, [itemId, listId]);
// Ссылки на скачивание для файлов строк «Приложения» (см. AnnexesSection).
// Считаем ДО ранних return'ов ниже: хук, вызванный только на части рендеров,
// ломает порядок хуков (React #310 — «rendered more hooks than during the
// previous render»), потому что на первом рендере (loading) его бы не было.
const attachmentUrls = useMemo(() => {
const map: Record<string, string> = {};
attachments.forEach((a) => {
map[a.fileName] = a.url;
});
return map;
}, [attachments]);
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, printOrgIndex, attachmentUrls };
const notify = (): void => {
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
{isEditableStatus(status) && (
<Button
variant="secondary"
onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId, listId })}
>
<Icon name="edit" size="sm" />
Редактировать
</Button>
)}
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
</div>
<div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard
title="Заявка на услуги"
subtitle={`№${code || itemId}`}
belowSubtitle={
<InitiatorLine
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
itemId={itemId}
/>
}
headerRight={status ? <StatusBadge status={status} /> : undefined}
>
<FormModeProvider readOnly>
<HeaderSection {...sectionProps} />
<ScopeSection {...sectionProps} />
<AnnexesSection {...sectionProps} />
<CostsSection {...sectionProps} />
<RemarksSection {...sectionProps} />
</FormModeProvider>
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
</div>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
requestId={itemId}
search={(q) => approval.searchUsers(q)}
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
notify();
}}
/>
</div>
</div>
);
};
export default ServiceRequestView;
@@ -0,0 +1,16 @@
{
"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": "SLAhours", "title": "SLA, часов", "type": "Number" },
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
]
}
@@ -0,0 +1,77 @@
import { ICostGroup, ICostRow } from './model';
/**
* Число из поля ввода: пустое/нечисловое — 0. Пробелы убираем (их вставляет
* копипаста из Excel), запятую приводим к точке — в русской раскладке
* десятичный разделитель именно запятая.
*/
function toNumber(raw: string): number {
const normalized = raw.replace(/\s/g, '').replace(',', '.');
const value = Number(normalized);
return Number.isFinite(value) ? value : 0;
}
/** Сумма по строке сметы без НДС: количество × цена. */
export function rowAmount(row: ICostRow): number {
return toNumber(row.qty) * toNumber(row.rate);
}
/** Сумма по группе сметы без НДС. */
export function groupAmount(group: ICostGroup): number {
return group.items.reduce((sum, row) => sum + rowAmount(row), 0);
}
/** Итог по всей смете без НДС (сумма по всем группам). */
export function costTotal(groups: ICostGroup[]): number {
return groups.reduce((sum, group) => sum + groupAmount(group), 0);
}
/** Есть хотя бы одна заполненная позиция хоть в одной группе — минимум для отправки. */
export function hasAnyCostItem(groups: ICostGroup[]): boolean {
return groups.some((group) => group.items.some((row) => row.description.trim().length > 0));
}
/**
* Форма JSON-колонки для групп (EstimateJson / WorkVolumesJson): группы +
* готовый итог. Итог кладём РЯДОМ с группами в тот же JSON, а не отдельной
* числовой колонкой SharePoint — он и так пересчитывается из groups при
* каждом сохранении (см. serializeGroups), отдельная колонка была бы просто
* ещё одним местом, которое нужно провижинить и держать в курсе схемы.
*/
export interface IGroupedJson {
groups: ICostGroup[];
total: number;
}
/** Собрать JSON-колонку: группы как есть + пересчитанный итог. */
export function serializeGroups(groups: ICostGroup[]): IGroupedJson {
return { groups, total: costTotal(groups) };
}
/**
* Разобрать JSON-колонку групп. Итог из хранилища НЕ используется формой —
* она всегда считает его заново из groups (см. costTotal); в разобранном
* объекте total пригождается только тем, кто читает эту колонку напрямую
* (отчёты, sp-query), не поднимая форму.
*/
export function parseGroupedJson(raw: unknown): ICostGroup[] {
if (typeof raw !== 'string' || raw.trim() === '') return [];
try {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed as ICostGroup[]; // старый формат (просто массив), до появления total
if (parsed && typeof parsed === 'object' && Array.isArray((parsed as IGroupedJson).groups)) {
return (parsed as IGroupedJson).groups;
}
return [];
} catch {
return [];
}
}
/**
* Сумма для показа: «1 234 567,89». Без символа валюты — валюта подписана в
* заголовке колонки (тенге), как в бумажной форме.
*/
export function formatAmount(value: number): string {
return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value);
}
@@ -0,0 +1,110 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles';
import { parseGroupedJson } from './cost';
import { FORM_ID, IFormData, EMPTY_DATA, EMPTY_ANNEX_ROW, IAnnexRow } from './model';
export interface ILoadedRequest {
data: IFormData;
status: string;
/** Уникальный код заявки; '' — если запись создана до появления поля. */
code: string;
}
/** Колонки списка данных, из которых собирается модель формы. */
const SELECT_FIELDS = [
'Id',
'RequesterEmail',
'RequesterName',
'RequesterJobTitle',
'Organization',
'Department',
'DepartmentId',
'RequestDate',
'Revision',
'ProjectName',
'ProjectId',
'ProjectManager',
'Customer',
'ActivityId',
'ActivityName',
'ContractKind',
'ContractAction',
'ScopeOfWork',
'SuggestedSuppliers',
'EstimatedStartDate',
'EstimatedFinishDate',
'GeneralRemarks',
'AnnexesJson',
'WorkVolumesJson',
'EstimateJson',
'RequestStatus',
'RequestUniqueCode'
];
/**
* Безопасный разбор JSON-колонки табличной части: пустой массив при пустом
* значении или битом JSON — заявка должна открываться даже с испорченной
* таблицей, иначе её нельзя было бы починить через форму.
*/
function parseRows<T>(raw: unknown): T[] {
if (typeof raw !== 'string' || raw.trim() === '') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as T[]) : [];
} catch {
return [];
}
}
/** ISO-дата из SharePoint → YYYY-MM-DD для input type=date. */
function toDateInput(raw: unknown): string {
return typeof raw === 'string' ? raw.slice(0, 10) : '';
}
/**
* Прочитать заявку «Заявка на услуги» по id: модель формы + статус + код.
*
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции (старые ссылки
* без параметра).
*/
export async function loadServiceRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list.items.getById(itemId).select(...SELECT_FIELDS)()) as Record<string, unknown>;
const data: IFormData = {
...EMPTY_DATA,
RequesterEmail: (item.RequesterEmail as string) || '',
RequesterName: (item.RequesterName as string) || '',
RequesterJobTitle: (item.RequesterJobTitle as string) || '',
Organization: (item.Organization as string) || '',
Department: (item.Department as string) || '',
DepartmentId: (item.DepartmentId as string) || '',
RequestDate: toDateInput(item.RequestDate),
Revision: (item.Revision as string) || '',
ProjectName: (item.ProjectName as string) || '',
ProjectId: (item.ProjectId as string) || '',
ProjectManager: (item.ProjectManager as string) || '',
Customer: (item.Customer as string) || '',
ActivityId: (item.ActivityId as string) || '',
ActivityName: (item.ActivityName as string) || '',
ContractKind: (item.ContractKind as string) || '',
ContractAction: (item.ContractAction as string) || '',
ScopeOfWork: (item.ScopeOfWork as string) || '',
WorkVolumes: parseGroupedJson(item.WorkVolumesJson),
SuggestedSuppliers: (item.SuggestedSuppliers as string) || '',
EstimatedStartDate: toDateInput(item.EstimatedStartDate),
EstimatedFinishDate: toDateInput(item.EstimatedFinishDate),
GeneralRemarks: (item.GeneralRemarks as string) || '',
// fileName — через spread с дефолтом: заявки, сохранённые до появления
// прикреплённых файлов у строки приложения, не имеют его в JSON вовсе.
Annexes: parseRows<IAnnexRow>(item.AnnexesJson).map((row) => ({ ...EMPTY_ANNEX_ROW, ...row })),
Costs: parseGroupedJson(item.EstimateJson)
};
return {
data,
status: (item.RequestStatus as string) || '',
code: (item.RequestUniqueCode as string) || ''
};
}
@@ -0,0 +1,217 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { ISelectOption } from '../../shared/ui';
import { RequestTypeId } from '../../models/types';
/**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
* SharePoint выводятся из него, см. shared/listTitles.ts.
*/
export const FORM_ID: RequestTypeId = 'wh-service-request';
/**
* Строка таблицы приложений: документ, который заявитель прикладывает к
* заявке (чертежи, ТЗ, ведомости объёмов, перечень материалов…).
*/
export interface IAnnexRow {
/** Описание документа. */
description: string;
/** Номер приложения / шифр проекта (напр. «ASL-1812-2025-5-КЖ»). */
annexNo: string;
/**
* Имя реально прикреплённого файла — привязывает строку к одному из вложений
* элемента списка (см. shared/attachments). '' — файл ещё не выбран.
* Отдельного списка/библиотеки под это не заводим: вложения — встроенная
* возможность любого списка SharePoint, форма использует её напрямую, без
* отдельного общего блока «Вложения» — каждый файл привязан к своей строке.
*/
fileName: string;
}
/**
* Строка сметы. Сумму по строке НЕ храним — она считается из qty × rate
* (см. cost.ts): иначе при правке количества сумма молча расходилась бы.
*/
export interface ICostRow {
/** Наименование позиции. */
description: string;
/** Единица измерения. */
unit: string;
/** Количество. */
qty: string;
/** Цена за единицу без НДС, тенге. */
rate: string;
}
/**
* Группа сметы — раздел работ (напр. «Бетонная подготовка», «Монолитные
* фундаменты»), внутри которого свои позиции. У бумажной формы такого деления
* нет (там одна плоская таблица), но реальные сметы почти всегда идут по
* разделам — группировка нужна, чтобы длинная смета читалась. Название группы
* необязательно (можно оставить одну безымянную «Группа 1» на всю смету).
*/
export interface ICostGroup {
/** Название раздела сметы; '' — группа без названия. */
name: string;
items: ICostRow[];
}
export const EMPTY_ANNEX_ROW: IAnnexRow = { description: '', annexNo: '', fileName: '' };
/** Пустая строка сметы — количество/цена вводятся руками, без значений по умолчанию. */
export const EMPTY_COST_ROW: ICostRow = { description: '', unit: '', qty: '', rate: '' };
/** Новая группа сметы — сразу с одной пустой позицией, чтобы не жать «Добавить позицию» отдельно. */
export const EMPTY_COST_GROUP: ICostGroup = { name: '', items: [{ ...EMPTY_COST_ROW }] };
/** Предмет заявки (чем закрывается потребность). */
export const CONTRACT_KIND_OPTIONS: ISelectOption[] = [
{ value: 'Субподряд', label: 'Субподряд' },
{ value: 'Услуги', label: 'Услуги' },
{ value: 'Поставка', label: 'Поставка' },
{ value: 'Аренда', label: 'Аренда' },
{ value: 'Прочее', label: 'Прочее' }
];
/** Что делаем с договором — в бумажной форме это ряд взаимоисключающих галочек. */
export const CONTRACT_ACTION_OPTIONS: ISelectOption[] = [
{ value: 'Новый договор', label: 'Новый договор' },
{ value: 'Доп. соглашение', label: 'Доп. соглашение' },
{ value: 'Возобновление договора', label: 'Возобновление договора' },
{ value: 'Пролонгация договора', label: 'Пролонгация договора' },
{ value: 'Расторжение договора', label: 'Расторжение договора' }
];
/**
* Данные формы «Заявка на услуги» (бумажный аналог — Service Requisition Form
* ПТО). Ключи = internalName колонок SP-списка «Заявка на услуги — данные».
* Табличные части (Annexes / Costs) в колонки не разворачиваются, а
* сериализуются в JSON-колонки AnnexesJson / EstimateJson (см. submit.ts / load.ts).
*/
export interface IFormData {
// --- Заявитель ---
/** Email заявителя; по нему резолвится маршрут согласования (можно подать за другого). */
RequesterEmail: string;
/** Ф.И.О. заявителя — для печати; подтягивается из Employee/ByEmail. */
RequesterName: string;
/** Должность заявителя (из справочника, можно поправить — справочник не всегда точен). */
RequesterJobTitle: string;
/** Организации заявителя (`\n`-joined «Компания — должность»), read-only. */
Organization: string;
/** Отдел (справочник Dictionary/Departments) — на маршрут не влияет. */
Department: string;
/** Id отдела из справочника; '' — если введён вручную. */
DepartmentId: string;
/** Дата заявки (YYYY-MM-DD). */
RequestDate: string;
/** Ревизия заявки: бумажная форма живёт ревизиями (00, U01, U02…). */
Revision: string;
// --- Проект и работы ---
ProjectName: string;
/** oid проекта ('' — если введён вручную); по нему бэкенд резолвит руководителя проекта. */
ProjectId: string;
/** Снимок email руководителя проекта — для разбора маршрута постфактум. */
ProjectManager: string;
/** Заказчик проекта (подставляется из справочника проектов, можно поправить). */
Customer: string;
/** Идентификация работ / Activity ID (напр. «КОД СМР 2.3»). */
ActivityId: string;
/** Наименование работ. */
ActivityName: string;
/** Предмет заявки — см. CONTRACT_KIND_OPTIONS. */
ContractKind: string;
/** Действие с договором — см. CONTRACT_ACTION_OPTIONS. */
ContractAction: string;
// --- Описание требований ---
/** Объём работ — основное содержание заявки, длинный текст. */
ScopeOfWork: string;
/**
* Детализация объёмов работ по разделам (группам) — тот же формат
* группа/позиция/цена/сумма, что и у сметы (см. CostGroupsEditor,
* сериализуется в WorkVolumesJson). Дополняет текстовое описание
* ScopeOfWork итоговыми цифрами по каждой позиции, а не заменяет его.
*/
WorkVolumes: ICostGroup[];
/** Предлагаемые поставщики / требования к субподрядчику. */
SuggestedSuppliers: string;
EstimatedStartDate: string;
EstimatedFinishDate: string;
/** Общие замечания. */
GeneralRemarks: string;
// --- Табличные части ---
/** Приложения к заявке (сериализуются в AnnexesJson). */
Annexes: IAnnexRow[];
/** Сметная стоимость по группам (сериализуется в EstimateJson). */
Costs: ICostGroup[];
}
/** Обновление одного поля формы с сохранением типа значения. */
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
/** Пропсы каждой секции формы. */
export interface ISectionProps {
data: IFormData;
set: SetField;
context: WebPartContext;
/** Индекс организации для печати (см. shared/ui/PrintSplitButton и OrganizationField.printIndex). */
printOrgIndex?: number;
/**
* Выбор заявителя подтягивает его ФИО, должность и организации — поэтому у
* поля отдельный обработчик. Не передан (режим чтения) — правка через set.
*/
onRequesterChange?: (login: string) => void;
/**
* Только для AnnexesSection: ссылки на скачивание уже прикреплённых файлов
* (fileName → ServerRelativeUrl). Не передан — ссылки не показываются
* (напр. новая заявка до первого сохранения, там вложений ещё нет).
*/
attachmentUrls?: Record<string, string>;
/**
* Только для AnnexesSection: выбрать файл для строки приложения — форма
* держит его в памяти (см. ServiceRequestForm.annexFiles) и реально
* загружает при сохранении (нужен id элемента). Не передан — прикрепление
* недоступно (режим просмотра).
*/
onPickAnnexFile?: (rowIndex: number, file: File) => void;
/**
* Только для AnnexesSection: открепить файл строки. Если он уже загружен на
* сервер — форма сразу удаляет вложение элемента, иначе просто забывает
* выбор (ещё не сохранённый). Не передан — открепление недоступно.
*/
onRemoveAnnexFile?: (rowIndex: number) => void;
}
/** Ревизия по умолчанию — как в бумажной форме («Рев:00»). */
export const DEFAULT_REVISION = '00';
export const REQUESTER_PLACEHOLDER = 'Начните вводить Ф.И.О. заявителя…';
export const EMPTY_DATA: IFormData = {
RequesterEmail: '',
RequesterName: '',
RequesterJobTitle: '',
Organization: '',
Department: '',
DepartmentId: '',
RequestDate: '',
Revision: DEFAULT_REVISION,
ProjectName: '',
ProjectId: '',
ProjectManager: '',
Customer: '',
ActivityId: '',
ActivityName: '',
ContractKind: '',
ContractAction: '',
ScopeOfWork: '',
// Новая заявка сразу показывает одну пустую группу (не «пустое место с
// кнопкой») — так понятнее, что заполнять; загруженная заявка эти значения
// всё равно перезапишет (см. load.ts). Тот же приём — у Costs ниже.
WorkVolumes: [{ name: '', items: [{ ...EMPTY_COST_ROW }] }],
SuggestedSuppliers: '',
EstimatedStartDate: '',
EstimatedFinishDate: '',
GeneralRemarks: '',
Annexes: [],
Costs: [{ name: '', items: [{ ...EMPTY_COST_ROW }] }]
};
@@ -0,0 +1,35 @@
{
"dataListTitle": "Заявка на услуги — данные",
"columns": [
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "RequesterEmail", "title": "Заявитель (email)", "type": "Text" },
{ "internalName": "RequesterName", "title": "Заявитель (Ф.И.О.)", "type": "Text" },
{ "internalName": "RequesterJobTitle", "title": "Должность заявителя", "type": "Text" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "Department", "title": "Отдел", "type": "Text" },
{ "internalName": "DepartmentId", "title": "Id отдела (справочник)", "type": "Text" },
{ "internalName": "RequestDate", "title": "Дата заявки", "type": "DateTime", "dateOnly": true },
{ "internalName": "Revision", "title": "Ревизия", "type": "Text" },
{ "internalName": "ProjectName", "title": "Проект", "type": "Text" },
{ "internalName": "ProjectId", "title": "Id проекта", "type": "Text" },
{ "internalName": "ProjectManager", "title": "Руководитель проекта (email)", "type": "Text" },
{ "internalName": "Customer", "title": "Заказчик проекта", "type": "Text" },
{ "internalName": "ActivityId", "title": "Идентификация работ (Activity ID)", "type": "Text" },
{ "internalName": "ActivityName", "title": "Наименование работ", "type": "Note" },
{ "internalName": "ContractKind", "title": "Предмет заявки", "type": "Choice", "choices": ["Субподряд", "Услуги", "Поставка", "Аренда", "Прочее"] },
{ "internalName": "ContractAction", "title": "Действие с договором", "type": "Choice", "choices": ["Новый договор", "Доп. соглашение", "Возобновление договора", "Пролонгация договора", "Расторжение договора"] },
{ "internalName": "ScopeOfWork", "title": "Объём работ", "type": "Note" },
{ "internalName": "WorkVolumesJson", "title": "Объёмы работ по позициям (JSON, с итогом)", "type": "Note" },
{ "internalName": "SuggestedSuppliers", "title": "Предлагаемые поставщики", "type": "Note" },
{ "internalName": "EstimatedStartDate", "title": "Предполагаемая дата начала", "type": "DateTime", "dateOnly": true },
{ "internalName": "EstimatedFinishDate", "title": "Предполагаемая дата окончания", "type": "DateTime", "dateOnly": true },
{ "internalName": "GeneralRemarks", "title": "Общие замечания", "type": "Note" },
{ "internalName": "AnnexesJson", "title": "Приложения (JSON)", "type": "Note" },
{ "internalName": "EstimateJson", "title": "Сметная стоимость (JSON, с итогом)", "type": "Note" }
]
}
@@ -0,0 +1,174 @@
@import '../../../../../styles/design-tokens';
// Тело CollapseSection — грид 1fr 1fr; таблица должна занимать обе колонки.
.full {
grid-column: 1 / -1;
}
.tableWrap {
width: 100%;
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
th,
td {
border: 1px solid $gray-200;
padding: 0;
text-align: left;
vertical-align: middle;
}
th {
padding: 7px 9px;
background: $navy-50;
font-weight: 600;
font-size: $fs-caption;
color: $gray-700;
white-space: nowrap;
}
td {
padding: 4px 6px;
}
}
.num {
width: 36px;
text-align: center;
}
.annexNo {
width: 180px;
}
.file {
width: 220px;
}
.act {
width: 40px;
text-align: center;
}
// Скрытый нативный <input type=file> — открывается кликом по кнопке .attach.
.fileInput {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.attach {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 9px;
border: 1px dashed $gray-300;
border-radius: $r-sm;
background: transparent;
color: $gray-700;
font-family: $font-sans;
font-size: $fs-caption;
cursor: pointer;
transition: .18s;
&:hover {
border-color: $gold-400;
color: $gold-600;
}
}
.fileName {
display: inline-flex;
align-items: center;
gap: $sp-2;
max-width: 100%;
a,
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: $font-sans;
font-size: $fs-sm;
}
a {
color: $navy-700;
&:hover {
color: $gold-600;
}
}
}
.removeFile {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 4px;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-500;
cursor: pointer;
transition: .18s;
&:hover {
background: $red-50;
color: $red-500;
}
}
.noFile {
color: $gray-400;
}
.remove {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 5px;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-500;
cursor: pointer;
transition: .18s;
&:hover {
background: $red-50;
color: $red-500;
}
}
.empty {
padding: 14px !important;
text-align: center;
color: $gray-500;
}
.toolbar {
display: flex;
align-items: center;
gap: $sp-2;
margin-top: $sp-3;
}
// См. CostGroupsEditor: на бумаге прокрутки нет, уехавшее вбок теряется.
@media print {
.tableWrap {
overflow: visible;
}
}
@@ -0,0 +1,177 @@
import * as React from 'react';
import styles from './AnnexesSection.module.scss';
import { Button, CollapseSection, Icon, TextInput, useFormReadOnly } from '../../../shared/ui';
import { EMPTY_ANNEX_ROW, IAnnexRow, ISectionProps } from '../model';
/** Файл строки: ссылка на скачивание, если уже загружен, иначе — прикрепить/открепить. */
const AnnexFileCell: React.FC<{
row: IAnnexRow;
readOnly: boolean;
url?: string;
onPick?: (file: File) => void;
onRemove?: () => void;
}> = ({ row, readOnly, url, onPick, onRemove }) => {
const inputRef = React.useRef<HTMLInputElement>(null);
if (!row.fileName) {
if (readOnly || !onPick) {
return <span className={styles.noFile}>—</span>;
}
return (
<>
<button type="button" className={styles.attach} onClick={() => inputRef.current?.click()}>
<Icon name="upload" size="sm" />
Прикрепить
</button>
<input
ref={inputRef}
type="file"
className={styles.fileInput}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onPick(file);
e.target.value = '';
}}
/>
</>
);
}
return (
<span className={styles.fileName}>
{url ? (
<a href={url} target="_blank" rel="noopener noreferrer" title={row.fileName}>
{row.fileName}
</a>
) : (
<span title={row.fileName}>{row.fileName}</span>
)}
{!readOnly && onRemove && (
<button type="button" className={styles.removeFile} title="Открепить файл" onClick={onRemove}>
<Icon name="close" size="sm" />
</button>
)}
</span>
);
};
/**
* Приложения к заявке (проектные чертежи, ТЗ, ведомости объёмов, перечень
* материалов…) — описание документа, номер приложения и сам файл. Файл
* физически хранится как вложение элемента списка (та же встроенная
* возможность SharePoint, что раньше использовал общий блок «Вложения» —
* его убрали, каждый файл теперь привязан к своей строке напрямую, отдельная
* библиотека под это не нужна).
*/
export const AnnexesSection: React.FC<ISectionProps> = ({
data,
set,
attachmentUrls,
onPickAnnexFile,
onRemoveAnnexFile
}) => {
const readOnly = useFormReadOnly();
const setRow = (index: number, patch: Partial<IAnnexRow>): void => {
set(
'Annexes',
data.Annexes.map((row, i) => (i === index ? { ...row, ...patch } : row))
);
};
const addRow = (): void => set('Annexes', [...data.Annexes, { ...EMPTY_ANNEX_ROW }]);
const removeRow = (index: number): void => {
// Строка с файлом (уже загруженным или ещё нет) — сначала открепляем через
// родителя (см. ServiceRequestForm.handleRemoveAnnexFile), иначе загруженное
// вложение останется висеть на элементе без ссылающейся на него строки.
if (data.Annexes[index]?.fileName) {
onRemoveAnnexFile?.(index);
}
set('Annexes', data.Annexes.filter((_, i) => i !== index));
};
return (
<CollapseSection title="Приложения" icon="document">
<div className={styles.full}>
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.num}>#</th>
<th>Описание документа</th>
<th className={styles.annexNo}>Приложение №</th>
<th className={styles.file}>Файл</th>
{!readOnly && <th className={styles.act} aria-label="Действия" />}
</tr>
</thead>
<tbody>
{data.Annexes.length === 0 ? (
<tr>
<td colSpan={readOnly ? 4 : 5} className={styles.empty}>
Приложения не добавлены
</td>
</tr>
) : (
data.Annexes.map((row, i) =>
readOnly ? (
<tr key={i}>
<td className={styles.num}>{i + 1}</td>
<td>{row.description}</td>
<td className={styles.annexNo}>{row.annexNo}</td>
<td className={styles.file}>
<AnnexFileCell row={row} readOnly url={attachmentUrls?.[row.fileName]} />
</td>
</tr>
) : (
<tr key={i}>
<td className={styles.num}>{i + 1}</td>
<td>
<TextInput
value={row.description}
onChange={(v) => setRow(i, { description: v })}
ariaLabel="Описание документа"
borderless
/>
</td>
<td className={styles.annexNo}>
<TextInput
value={row.annexNo}
onChange={(v) => setRow(i, { annexNo: v })}
ariaLabel="Приложение №"
borderless
/>
</td>
<td className={styles.file}>
<AnnexFileCell
row={row}
readOnly={false}
url={attachmentUrls?.[row.fileName]}
onPick={onPickAnnexFile ? (file) => onPickAnnexFile(i, file) : undefined}
onRemove={onRemoveAnnexFile ? () => onRemoveAnnexFile(i) : undefined}
/>
</td>
<td className={styles.act}>
<button type="button" className={styles.remove} title="Удалить строку" onClick={() => removeRow(i)}>
<Icon name="trash" size="sm" />
</button>
</td>
</tr>
)
)
)}
</tbody>
</table>
</div>
{!readOnly && (
<div className={styles.toolbar}>
<Button variant="secondary" size="sm" onClick={addRow}>
<Icon name="plus" size="sm" />
Добавить документ
</Button>
</div>
)}
</div>
</CollapseSection>
);
};
@@ -0,0 +1,175 @@
@import '../../../../../styles/design-tokens';
// Тело CollapseSection — грид 1fr 1fr; смета должна занимать обе колонки.
.full {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: $sp-4;
}
.group {
border: 1px solid $gray-200;
border-radius: $r-md;
background: $gray-50;
padding: $sp-4;
}
.groupHead {
display: flex;
align-items: center;
gap: $sp-3;
margin-bottom: $sp-3;
}
.groupLabel {
flex-shrink: 0;
font-family: $font-sans;
font-size: $fs-sm;
font-weight: 600;
color: $gray-700;
}
.groupHead input {
flex: 1;
min-width: 0;
}
.removeGroup {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 6px;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-500;
cursor: pointer;
transition: .18s;
&:hover {
background: $red-50;
color: $red-500;
}
}
// Строка целиком в фикс-колонках (ед.изм/кол-во/цена/сумма) физически не
// влезает в узкую панель — форма открывается в Drawer сбоку, а не на всю
// страницу, и его ширина не связана с шириной окна браузера (в отличие от
// брейкпоинтов CollapseSection/FormGrid, которые следят за viewport, а не за
// шириной своего контейнера). Вместо того чтобы сжимать поля до
// нечитаемости, даём строке её естественную минимальную ширину и скроллим
// горизонтально контейнером — тот же приём, что раньше был у плоской таблицы
// (см. AnnexesSection .tableWrap), а не «резиновая» вёрстка, которая сама себя
// сжать до бесконечности не умеет (grid-колонки по умолчанию не сжимаются
// уже своего содержимого).
.rowsWrap {
width: 100%;
overflow-x: auto;
}
.row {
display: grid;
grid-template-columns: minmax(160px, 1fr) 84px 96px 128px 128px 30px;
gap: $sp-2;
align-items: center;
min-width: 640px;
& + & {
margin-top: $sp-2;
}
}
.colHead {
font-family: $font-sans;
font-size: 11px;
font-weight: 600;
letter-spacing: .03em;
text-transform: uppercase;
color: $gray-500;
}
.cellStatic {
padding: 9px 12px;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
}
.cellAmount {
padding: 9px 12px;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-500;
text-align: right;
white-space: nowrap;
}
.removeItem {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-500;
cursor: pointer;
transition: .18s;
&:hover {
background: $red-50;
color: $red-500;
}
}
.empty {
padding: $sp-3 0;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-500;
text-align: center;
}
.groupFoot {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-3;
margin-top: $sp-3;
flex-wrap: wrap;
}
.groupTotal {
font-family: $font-sans;
font-size: $fs-sm;
font-weight: 600;
color: $gray-700;
}
.total {
align-self: flex-end;
font-family: $font-sans;
font-size: $fs-body;
font-weight: 700;
color: $navy-700;
}
// ---- Печать ----
// На бумаге прокрутки нет: «уехавшее вбок» просто потерялось бы, а сам
// контейнер печатается вместе с полосой прокрутки (видно на печати заявки).
// Отпускаем ширину — колонки строки суммарно уже A4, так что она укладывается
// в лист целиком.
//
// Блок ДОЛЖЕН стоять в конце файла: @media print не повышает специфичность, и
// базовое `.row { min-width }` выше перебило бы это правило, стой оно раньше.
@media print {
.rowsWrap {
overflow: visible;
}
.row {
min-width: 0;
}
}
@@ -0,0 +1,195 @@
import * as React from 'react';
import styles from './CostGroupsEditor.module.scss';
import { Button, Icon, TextInput, useFormReadOnly } from '../../../shared/ui';
import { costTotal, formatAmount, groupAmount, rowAmount } from '../cost';
import { EMPTY_COST_GROUP, EMPTY_COST_ROW, ICostGroup, ICostRow } from '../model';
interface IGroupProps {
group: ICostGroup;
index: number;
readOnly: boolean;
onChangeName: (name: string) => void;
onChangeItem: (itemIndex: number, patch: Partial<ICostRow>) => void;
onAddItem: () => void;
onRemoveItem: (itemIndex: number) => void;
onRemoveGroup: () => void;
canRemoveGroup: boolean;
}
/** Один раздел (группа): название + таблица позиций внутри неё. */
const CostGroup: React.FC<IGroupProps> = ({
group,
index,
readOnly,
onChangeName,
onChangeItem,
onAddItem,
onRemoveItem,
onRemoveGroup,
canRemoveGroup
}) => (
<div className={styles.group}>
<div className={styles.groupHead}>
<span className={styles.groupLabel}>Группа {index + 1}</span>
<TextInput
value={group.name}
onChange={onChangeName}
placeholder="Наименование группы"
readOnly={readOnly}
/>
{!readOnly && canRemoveGroup && (
<button type="button" className={styles.removeGroup} title="Удалить группу" onClick={onRemoveGroup}>
<Icon name="trash" size="sm" />
</button>
)}
</div>
<div className={styles.rowsWrap}>
<div className={styles.row} role="row">
<span className={styles.colHead}>Наименование позиции</span>
<span className={styles.colHead}>Ед. изм.</span>
<span className={styles.colHead}>Количество</span>
<span className={styles.colHead}>Цена</span>
<span className={styles.colHead}>Сумма</span>
{!readOnly && <span className={styles.colHead} aria-hidden />}
</div>
{group.items.length === 0 ? (
<div className={styles.empty}>Позиции не добавлены</div>
) : (
group.items.map((item, i) =>
readOnly ? (
<div className={styles.row} key={i} role="row">
<span className={styles.cellStatic}>{item.description || '—'}</span>
<span className={styles.cellStatic}>{item.unit || '—'}</span>
<span className={styles.cellStatic}>{item.qty || '—'}</span>
<span className={styles.cellStatic}>{item.rate || '—'}</span>
<span className={styles.cellAmount}>{formatAmount(rowAmount(item))}</span>
</div>
) : (
<div className={styles.row} key={i} role="row">
<TextInput
value={item.description}
onChange={(v) => onChangeItem(i, { description: v })}
placeholder="Наименование"
ariaLabel="Наименование позиции"
/>
<TextInput
value={item.unit}
onChange={(v) => onChangeItem(i, { unit: v })}
placeholder="шт."
ariaLabel="Единица измерения"
/>
<TextInput
value={item.qty}
onChange={(v) => onChangeItem(i, { qty: v })}
placeholder="0"
ariaLabel="Количество"
/>
<TextInput
value={item.rate}
onChange={(v) => onChangeItem(i, { rate: v })}
placeholder="0"
ariaLabel="Цена за единицу"
/>
<span className={styles.cellAmount}>{item.qty || item.rate ? formatAmount(rowAmount(item)) : 'авто'}</span>
<button type="button" className={styles.removeItem} title="Удалить позицию" onClick={() => onRemoveItem(i)}>
<Icon name="trash" size="sm" />
</button>
</div>
)
)
)}
</div>
<div className={styles.groupFoot}>
{!readOnly && (
<Button variant="secondary" size="sm" onClick={onAddItem}>
<Icon name="plus" size="sm" />
Добавить позицию
</Button>
)}
<span className={styles.groupTotal}>Сумма по группе: {formatAmount(groupAmount(group))}</span>
</div>
</div>
);
export interface ICostGroupsEditorProps {
groups: ICostGroup[];
onChange: (groups: ICostGroup[]) => void;
/** Подпись итоговой строки. По умолчанию — «Итого без НДС». */
totalLabel?: string;
/** Текст при пустом списке групп в режиме просмотра. */
emptyReadOnlyText?: string;
}
/**
* Редактор таблицы «группы → позиции с ценой и количеством»: используется и
* для сметной стоимости (CostsSection), и для детализации объёмов работ
* (ScopeSection) — один и тот же формат, разные поля модели формы. Сумма по
* позиции, по группе и общий итог — производные (qty × цена), считаются на
* лету и НЕ хранятся построчно: правка количества/цены не может их
* рассинхронизировать (см. cost.ts).
*/
export const CostGroupsEditor: React.FC<ICostGroupsEditorProps> = ({
groups,
onChange,
totalLabel = 'Итого без НДС',
emptyReadOnlyText = 'Не заполнено'
}) => {
const readOnly = useFormReadOnly();
const setGroup = (index: number, patch: Partial<ICostGroup>): void => {
onChange(groups.map((group, i) => (i === index ? { ...group, ...patch } : group)));
};
const setItem = (groupIndex: number, itemIndex: number, patch: Partial<ICostRow>): void => {
setGroup(groupIndex, {
items: groups[groupIndex].items.map((row, i) => (i === itemIndex ? { ...row, ...patch } : row))
});
};
const addItem = (groupIndex: number): void =>
setGroup(groupIndex, { items: [...groups[groupIndex].items, { ...EMPTY_COST_ROW }] });
const removeItem = (groupIndex: number, itemIndex: number): void =>
setGroup(groupIndex, { items: groups[groupIndex].items.filter((_, i) => i !== itemIndex) });
const addGroup = (): void => onChange([...groups, { ...EMPTY_COST_GROUP, items: [{ ...EMPTY_COST_ROW }] }]);
const removeGroup = (groupIndex: number): void => onChange(groups.filter((_, i) => i !== groupIndex));
return (
<div className={styles.full}>
{groups.map((group, i) => (
<CostGroup
key={i}
group={group}
index={i}
readOnly={readOnly}
onChangeName={(name) => setGroup(i, { name })}
onChangeItem={(itemIndex, patch) => setItem(i, itemIndex, patch)}
onAddItem={() => addItem(i)}
onRemoveItem={(itemIndex) => removeItem(i, itemIndex)}
onRemoveGroup={() => removeGroup(i)}
canRemoveGroup={groups.length > 1}
/>
))}
{groups.length === 0 && readOnly && <div className={styles.empty}>{emptyReadOnlyText}</div>}
{!readOnly && (
<Button variant="secondary" size="sm" onClick={addGroup}>
<Icon name="plus" size="sm" />
Добавить группу
</Button>
)}
{groups.length > 0 && (
<div className={styles.total}>
{totalLabel}: {formatAmount(costTotal(groups))}
</div>
)}
</div>
);
};
@@ -0,0 +1,15 @@
import * as React from 'react';
import { CollapseSection } from '../../../shared/ui';
import { CostGroupsEditor } from './CostGroupsEditor';
import { ISectionProps } from '../model';
/** Сметная стоимость услуг-работ по разделам (группам) — см. CostGroupsEditor. */
export const CostsSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Сметная стоимость услуг-работ" icon="calculator">
<CostGroupsEditor
groups={data.Costs}
onChange={(groups) => set('Costs', groups)}
emptyReadOnlyText="Смета не заполнена"
/>
</CollapseSection>
);
@@ -0,0 +1,137 @@
import * as React from 'react';
import {
FormGrid,
FormField,
TextInput,
TextArea,
Select,
PeopleField,
OrganizationField
} from '../../../shared/ui';
import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary';
import {
ISectionProps,
REQUESTER_PLACEHOLDER,
CONTRACT_KIND_OPTIONS,
CONTRACT_ACTION_OPTIONS
} from '../model';
/**
* Шапка заявки: кто подаёт, по какому проекту и какие работы заказываем.
* Заказчик подставляется из справочника проектов, но остаётся редактируемым —
* в справочнике он не всегда совпадает со стороной договора.
*/
export const HeaderSection: React.FC<ISectionProps> = ({
data,
set,
context,
printOrgIndex,
onRequesterChange
}) => (
<FormGrid>
<FormField label="Заявитель" htmlFor="RequesterEmail" required full>
<PeopleField
context={context}
value={data.RequesterEmail}
onChange={onRequesterChange ?? ((v) => set('RequesterEmail', v))}
placeholder={REQUESTER_PLACEHOLDER}
/>
</FormField>
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
<FormField label="Должность заявителя" htmlFor="RequesterJobTitle">
<TextInput
id="RequesterJobTitle"
value={data.RequesterJobTitle}
onChange={(v) => set('RequesterJobTitle', v)}
placeholder="Напр. вед. инженер ПТО"
/>
</FormField>
<FormField label="Отдел" htmlFor="Department">
<DepartmentPicker
id="Department"
context={context}
value={data.Department}
onChange={(name, id) => {
set('Department', name);
set('DepartmentId', id);
}}
/>
</FormField>
<FormField label="Дата заявки" htmlFor="RequestDate">
<TextInput
id="RequestDate"
type="date"
value={data.RequestDate}
onChange={(v) => set('RequestDate', v)}
/>
</FormField>
<FormField label="Ревизия" htmlFor="Revision">
<TextInput
id="Revision"
value={data.Revision}
onChange={(v) => set('Revision', v)}
placeholder="00"
/>
</FormField>
<FormField label="Проект" htmlFor="ProjectName" required>
<ProjectPicker
id="ProjectName"
context={context}
value={data.ProjectName}
placeholder="Начните вводить название проекта…"
onChange={(name, id, project) => {
set('ProjectName', name);
set('ProjectId', id);
set('ProjectManager', project ? project.projectManager || '' : '');
if (project && project.customerName) {
set('Customer', project.customerName);
}
}}
/>
</FormField>
<FormField label="Заказчик проекта" htmlFor="Customer">
<TextInput id="Customer" value={data.Customer} onChange={(v) => set('Customer', v)} />
</FormField>
<FormField label="Идентификация работ (Activity ID)" htmlFor="ActivityId">
<TextInput
id="ActivityId"
value={data.ActivityId}
onChange={(v) => set('ActivityId', v)}
placeholder="Напр. КОД СМР 2.3"
/>
</FormField>
<FormField label="Предмет заявки" htmlFor="ContractKind" required>
<Select
id="ContractKind"
value={data.ContractKind}
onChange={(v) => set('ContractKind', v)}
options={CONTRACT_KIND_OPTIONS}
placeholder="Выберите…"
/>
</FormField>
<FormField label="Действие с договором" htmlFor="ContractAction" required>
<Select
id="ContractAction"
value={data.ContractAction}
onChange={(v) => set('ContractAction', v)}
options={CONTRACT_ACTION_OPTIONS}
placeholder="Выберите…"
/>
</FormField>
<FormField label="Наименование работ" htmlFor="ActivityName" required full>
<TextArea
id="ActivityName"
value={data.ActivityName}
onChange={(v) => set('ActivityName', v)}
rows={2}
placeholder="Напр. строительно-монтажные работы по устройству монолитных конструкций (раздел КЖ)"
/>
</FormField>
</FormGrid>
);
@@ -0,0 +1,18 @@
import * as React from 'react';
import { CollapseSection, FormField, TextArea } from '../../../shared/ui';
import { ISectionProps } from '../model';
/** Общие замечания — отдельный блок бумажной формы, не относится к объёму работ. */
export const RemarksSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Общие замечания" icon="info">
<FormField label="Общие замечания" htmlFor="GeneralRemarks" full>
<TextArea
id="GeneralRemarks"
value={data.GeneralRemarks}
onChange={(v) => set('GeneralRemarks', v)}
rows={4}
placeholder="Особые условия, ссылки на нормативы, порядок приёмки скрытых работ…"
/>
</FormField>
</CollapseSection>
);
@@ -0,0 +1,26 @@
@import '../../../../../styles/design-tokens';
// Продолжительность — производная от дат, а не поле ввода: показываем строкой
// под датами, на всю ширину сетки формы.
.duration {
grid-column: 1 / -1;
font-family: $font-sans;
font-size: $fs-caption;
color: $gray-500;
}
// Блок детализации объёмов работ (CostGroupsEditor) — своя подпись сверху
// (не через FormField: там подпись сцеплена с htmlFor одного контрола, а
// здесь под ней целый редактор групп, не один инпут).
.volumesBlock {
grid-column: 1 / -1;
}
.volumesLabel {
display: block;
margin-bottom: $sp-2;
font-family: $font-sans;
font-size: $fs-label;
font-weight: 500;
color: $gray-700;
}
@@ -0,0 +1,78 @@
import * as React from 'react';
import styles from './ScopeSection.module.scss';
import { CollapseSection, FormField, TextInput, TextArea } from '../../../shared/ui';
import { CostGroupsEditor } from './CostGroupsEditor';
import { ISectionProps } from '../model';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* Продолжительность в календарных днях по датам «с» и «по» (включительно, как
* считают сроки в графике работ). Не поле модели, а производная величина —
* поэтому считается на лету и в SharePoint не пишется.
*/
function durationDays(start: string, finish: string): number | undefined {
if (!start || !finish) return undefined;
const days = (Date.parse(finish) - Date.parse(start)) / MS_PER_DAY;
if (!Number.isFinite(days) || days < 0) return undefined;
return Math.round(days) + 1;
}
/** Описание требований: объём работ, поставщики, сроки. */
export const ScopeSection: React.FC<ISectionProps> = ({ data, set }) => {
const days = durationDays(data.EstimatedStartDate, data.EstimatedFinishDate);
return (
<CollapseSection title="Описание требований" icon="helmet">
<FormField label="Объём работ" htmlFor="ScopeOfWork" required full>
<TextArea
id="ScopeOfWork"
value={data.ScopeOfWork}
onChange={(v) => set('ScopeOfWork', v)}
rows={12}
placeholder="Состав и объёмы работ, ссылки на рабочую документацию, требования к исполнительной документации…"
/>
</FormField>
<div className={styles.volumesBlock}>
<span className={styles.volumesLabel}>Итого объёмы работ</span>
<CostGroupsEditor
groups={data.WorkVolumes}
onChange={(groups) => set('WorkVolumes', groups)}
totalLabel="Итого"
emptyReadOnlyText="Объёмы работ не заполнены"
/>
</div>
<FormField label="Предлагаемые поставщики" htmlFor="SuggestedSuppliers" full>
<TextArea
id="SuggestedSuppliers"
value={data.SuggestedSuppliers}
onChange={(v) => set('SuggestedSuppliers', v)}
rows={3}
placeholder="Требования к подрядчику и/или конкретные организации из утверждённого перечня"
/>
</FormField>
<FormField label="Предполагаемая дата начала" htmlFor="EstimatedStartDate">
<TextInput
id="EstimatedStartDate"
type="date"
value={data.EstimatedStartDate}
onChange={(v) => set('EstimatedStartDate', v)}
/>
</FormField>
<FormField label="Предполагаемая дата окончания" htmlFor="EstimatedFinishDate">
<TextInput
id="EstimatedFinishDate"
type="date"
value={data.EstimatedFinishDate}
onChange={(v) => set('EstimatedFinishDate', v)}
min={data.EstimatedStartDate || undefined}
/>
</FormField>
{days !== undefined && <div className={styles.duration}>Продолжительность: {days} кал. дн.</div>}
</CollapseSection>
);
};
@@ -0,0 +1,69 @@
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { serializeGroups } from './cost';
import { FORM_ID, IFormData } from './model';
/**
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts).
*/
export function serviceRequestApprovalService(listId?: string): ApprovalListService {
return approvalServiceFor(FORM_ID, listId);
}
/** Осмысленный заголовок элемента (системная колонка Title). */
function buildTitle(data: IFormData): string {
const subject = data.ActivityName.trim() || data.ContractKind || 'услуги';
return ['Заявка на услуги', [subject, data.ProjectName].filter(Boolean).join(' — ')]
.filter(Boolean)
.join(': ');
}
/**
* Поля элемента списка данных из модели формы. Табличные части уходят в
* JSON-колонки; у групп (Costs/WorkVolumes) в тот же JSON кладётся и готовый
* итог — см. serializeGroups в cost.ts.
*/
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 === 'Annexes' || key === 'Costs' || key === 'WorkVolumes') return; // ниже, отдельными колонками
const value = data[key];
item[key] = value === '' ? null : value;
});
item.AnnexesJson = data.Annexes.length > 0 ? JSON.stringify(data.Annexes) : null;
item.WorkVolumesJson = data.WorkVolumes.length > 0 ? JSON.stringify(serializeGroups(data.WorkVolumes)) : null;
item.EstimateJson = data.Costs.length > 0 ? JSON.stringify(serializeGroups(data.Costs)) : null;
return item;
}
export type ISaveResult = ISaveItemResult;
/**
* Сохранить «Заявку на услуги»: контент + маршрут согласования.
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём
* новую. Возвращает Id заявки (+ уникальный код при создании).
*
* listId — партиция, в которую пишем: для обновления — партиция ЭТОЙ заявки,
* для новой — ТЕКУЩАЯ партиция, резолвленная вызывающей стороной
* (см. resolveWriteListId).
*/
export async function saveServiceRequest(
existingId: number | undefined,
requestTypeId: number,
data: IFormData,
approvers: IApproverToWrite[],
status: string,
listId?: string
): Promise<ISaveResult> {
const item = buildItem(data, status);
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
// Фазы исполнения у заявки на услуги нет — договор дальше ведут вне портала,
// поэтому исполнителей не пишем (пустой массив).
await writeRoute(serviceRequestApprovalService(listId), result.id, approvers, [], status);
return result;
}