feat(ServiceRequest): новая форма «Заявка на услуги» (wh-service-request)
Электронный аналог бумажной Service Requisition Form ПТО: заявитель, проект (маршрут резолвится по нему), описание требований, табличные части «Приложения» и «Смета» (JSON-колонки, сумма считается на лету), только фаза согласования — без исполнения. Форма подачи + режим согласующего, провижининг-манифесты данных и согласования, регистрация в реестрах форм. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,7 @@
|
||||
| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ |
|
||||
| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ |
|
||||
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
||||
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
||||
|
||||
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
|
||||
**Просмотр** — есть режим согласующего (`<Форма>View`).
|
||||
|
||||
@@ -22,7 +22,8 @@ export const REQUEST_TYPES: IRequestType[] = [
|
||||
{ id: 'hr-transfer', categoryId: 'hr', title: 'Заявка на перемещение сотрудников', icon: 'people' },
|
||||
|
||||
// Склад / Снабжение
|
||||
{ id: 'wh-tmc-transfer', categoryId: 'warehouse', title: 'СЗ на перемещение ТМЦ', icon: 'send' }
|
||||
{ id: 'wh-tmc-transfer', categoryId: 'warehouse', title: 'СЗ на перемещение ТМЦ', icon: 'send' },
|
||||
{ id: 'wh-service-request', categoryId: 'warehouse', title: 'Заявка на услуги', icon: 'document' }
|
||||
];
|
||||
|
||||
/** Заявки, относящиеся к указанной категории. */
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Заявка на услуги (`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`) — документы заявки (чертежи, ТЗ, ведомости объёмов) с чекбоксом «подтверждено»; не путать с блоком «Вложения» (реальные файлы) — это ОТДЕЛЬНЫЙ учёт, как в бумажной форме.
|
||||
- Табличная часть «Смета» (`Costs`) — сумма по строке и итог считаются на лету (`qty × rate`, см. `cost.ts`), не хранятся в самой строке. `CostTotal` дублируется числовой колонкой в SharePoint при сохранении — только для отчётов, форма её не читает обратно.
|
||||
- Обе табличные части сериализуются в JSON-колонки (`AnnexesJson`, `CostsJson`) — тот же приём, что в `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,342 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import styles from './ServiceRequestForm.module.scss';
|
||||
import {
|
||||
Alert,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
FileDropzone,
|
||||
AttachmentList,
|
||||
CollapseSection,
|
||||
FormField,
|
||||
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 { 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);
|
||||
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);
|
||||
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 = data.Costs.some((c) => c.description.trim().length > 0);
|
||||
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 handleRemoveAttachment = (fileName: string): void => {
|
||||
if (requestId === undefined) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
if (files.length > 0) {
|
||||
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files);
|
||||
setFiles([]);
|
||||
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 };
|
||||
|
||||
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>
|
||||
|
||||
{!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="Укажите заявителя и проект, чтобы построить маршрут согласования."
|
||||
/>
|
||||
|
||||
<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,203 @@
|
||||
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,
|
||||
CollapseSection,
|
||||
FormField,
|
||||
AttachmentList,
|
||||
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]);
|
||||
|
||||
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 };
|
||||
|
||||
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>
|
||||
|
||||
<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)} />
|
||||
</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,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,30 @@
|
||||
import { 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 costTotal(rows: ICostRow[]): number {
|
||||
return rows.reduce((sum, row) => sum + rowAmount(row), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сумма для показа: «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,105 @@
|
||||
import { getSP } from '../../shared/pnp/pnpjs';
|
||||
import { dataListTitle } from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, IAnnexRow, ICostRow } 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',
|
||||
'CostsJson',
|
||||
'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) || '',
|
||||
SuggestedSuppliers: (item.SuggestedSuppliers as string) || '',
|
||||
EstimatedStartDate: toDateInput(item.EstimatedStartDate),
|
||||
EstimatedFinishDate: toDateInput(item.EstimatedFinishDate),
|
||||
GeneralRemarks: (item.GeneralRemarks as string) || '',
|
||||
Annexes: parseRows<IAnnexRow>(item.AnnexesJson),
|
||||
Costs: parseRows<ICostRow>(item.CostsJson)
|
||||
};
|
||||
|
||||
return {
|
||||
data,
|
||||
status: (item.RequestStatus as string) || '',
|
||||
code: (item.RequestUniqueCode as string) || ''
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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;
|
||||
/** Количество страниц. */
|
||||
pages: string;
|
||||
/** Документ реально приложен (галочка «Подтверждение» бумажной формы). */
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строка сметы. Сумму по строке НЕ храним — она считается из qty × rate
|
||||
* (см. cost.ts): иначе при правке количества сумма молча расходилась бы.
|
||||
*/
|
||||
export interface ICostRow {
|
||||
/** Наименование работ / услуг. */
|
||||
description: string;
|
||||
/** Единица измерения. */
|
||||
unit: string;
|
||||
/** Количество. */
|
||||
qty: string;
|
||||
/** Цена за единицу без НДС, тенге. */
|
||||
rate: string;
|
||||
}
|
||||
|
||||
export const EMPTY_ANNEX_ROW: IAnnexRow = { description: '', annexNo: '', pages: '', confirmed: false };
|
||||
/** Пустая строка сметы: «компл. × 1» — самый частый случай бумажной формы. */
|
||||
export const EMPTY_COST_ROW: ICostRow = { description: '', unit: 'компл.', qty: '1', rate: '' };
|
||||
|
||||
/** Предмет заявки (чем закрывается потребность). */
|
||||
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 / CostsJson (см. 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;
|
||||
/** Предлагаемые поставщики / требования к субподрядчику. */
|
||||
SuggestedSuppliers: string;
|
||||
EstimatedStartDate: string;
|
||||
EstimatedFinishDate: string;
|
||||
|
||||
/** Общие замечания. */
|
||||
GeneralRemarks: string;
|
||||
|
||||
// --- Табличные части ---
|
||||
/** Приложения к заявке (сериализуются в AnnexesJson). */
|
||||
Annexes: IAnnexRow[];
|
||||
/** Сметная стоимость (сериализуется в CostsJson). */
|
||||
Costs: ICostRow[];
|
||||
}
|
||||
|
||||
/** Обновление одного поля формы с сохранением типа значения. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** Ревизия по умолчанию — как в бумажной форме («Рев: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: '',
|
||||
SuggestedSuppliers: '',
|
||||
EstimatedStartDate: '',
|
||||
EstimatedFinishDate: '',
|
||||
GeneralRemarks: '',
|
||||
Annexes: [],
|
||||
Costs: []
|
||||
};
|
||||
@@ -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": "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": "CostsJson", "title": "Сметная стоимость (JSON)", "type": "Note" },
|
||||
{ "internalName": "CostTotal", "title": "Итого без НДС", "type": "Number", "decimals": 2 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
@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: 160px;
|
||||
}
|
||||
|
||||
.pages {
|
||||
width: 96px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirmed {
|
||||
width: 110px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.act {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import * as React from 'react';
|
||||
import styles from './AnnexesSection.module.scss';
|
||||
import { Button, Checkbox, CollapseSection, Icon, TextInput, useFormReadOnly } from '../../../shared/ui';
|
||||
import { EMPTY_ANNEX_ROW, IAnnexRow, ISectionProps } from '../model';
|
||||
|
||||
/**
|
||||
* Приложения к заявке (проектные чертежи, ТЗ, ведомости объёмов, перечень
|
||||
* материалов…) — соответствует таблице «Описание документа / Приложение №
|
||||
* / Кол-во стр. / Подтверждение» бумажной формы. Подтверждение — не «файл
|
||||
* реально прикреплён» (для этого есть общий блок «Вложения»), а «документ
|
||||
* существует и учтён в заявке» — так бумажная форма используется и без
|
||||
* приложенных файлов (напр. документ уже согласован отдельно).
|
||||
*/
|
||||
export const AnnexesSection: React.FC<ISectionProps> = ({ data, set }) => {
|
||||
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 => 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.pages}>Кол-во стр.</th>
|
||||
<th className={styles.confirmed}>Подтверждение</th>
|
||||
{!readOnly && <th className={styles.act} aria-label="Действия" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.Annexes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={readOnly ? 5 : 6} 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.pages}>{row.pages}</td>
|
||||
<td className={styles.confirmed}>
|
||||
<Checkbox checked={row.confirmed} onChange={() => undefined} readOnly ariaLabel="Подтверждено" />
|
||||
</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.pages}>
|
||||
<TextInput
|
||||
value={row.pages}
|
||||
onChange={(v) => setRow(i, { pages: v })}
|
||||
ariaLabel="Кол-во страниц"
|
||||
borderless
|
||||
/>
|
||||
</td>
|
||||
<td className={styles.confirmed}>
|
||||
<Checkbox
|
||||
checked={row.confirmed}
|
||||
onChange={(v) => setRow(i, { confirmed: v })}
|
||||
ariaLabel="Подтверждено"
|
||||
/>
|
||||
</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,109 @@
|
||||
@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: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.unit {
|
||||
width: 90px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qty {
|
||||
width: 84px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rate {
|
||||
width: 150px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.amount {
|
||||
width: 160px;
|
||||
text-align: right;
|
||||
padding: 6px 9px !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.act {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.totalLabel {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
background: $navy-50;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
margin-top: $sp-3;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import * as React from 'react';
|
||||
import styles from './CostsSection.module.scss';
|
||||
import { Button, CollapseSection, Icon, TextInput, useFormReadOnly } from '../../../shared/ui';
|
||||
import { costTotal, formatAmount, rowAmount } from '../cost';
|
||||
import { EMPTY_COST_ROW, ICostRow, ISectionProps } from '../model';
|
||||
|
||||
/**
|
||||
* Сметная стоимость услуг/работ. Сумма по строке и итог — производные
|
||||
* (qty × rate), считаются на лету и НЕ хранятся в самой строке: правка
|
||||
* количества/цены не может рассинхронизировать сумму (см. cost.ts).
|
||||
* CostTotal в SharePoint дублируется отдельной колонкой при сохранении
|
||||
* (см. submit.ts) — только для отчётов, форма её сама не читает.
|
||||
*/
|
||||
export const CostsSection: React.FC<ISectionProps> = ({ data, set }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
|
||||
const setRow = (index: number, patch: Partial<ICostRow>): void => {
|
||||
set(
|
||||
'Costs',
|
||||
data.Costs.map((row, i) => (i === index ? { ...row, ...patch } : row))
|
||||
);
|
||||
};
|
||||
|
||||
const addRow = (): void => set('Costs', [...data.Costs, { ...EMPTY_COST_ROW }]);
|
||||
const removeRow = (index: number): void => set('Costs', data.Costs.filter((_, i) => i !== index));
|
||||
|
||||
return (
|
||||
<CollapseSection title="Сметная стоимость услуг-работ" icon="calculator">
|
||||
<div className={styles.full}>
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.num}>№</th>
|
||||
<th>Наименование работ / услуг</th>
|
||||
<th className={styles.unit}>Ед.изм.</th>
|
||||
<th className={styles.qty}>Кол-во</th>
|
||||
<th className={styles.rate}>Цена за ед. (KZT)</th>
|
||||
<th className={styles.amount}>Сумма без НДС (KZT)</th>
|
||||
{!readOnly && <th className={styles.act} aria-label="Действия" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.Costs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={readOnly ? 6 : 7} className={styles.empty}>
|
||||
Позиции не добавлены
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.Costs.map((row, i) =>
|
||||
readOnly ? (
|
||||
<tr key={i}>
|
||||
<td className={styles.num}>{i + 1}</td>
|
||||
<td>{row.description}</td>
|
||||
<td className={styles.unit}>{row.unit}</td>
|
||||
<td className={styles.qty}>{row.qty}</td>
|
||||
<td className={styles.rate}>{formatAmount(Number(row.rate.replace(',', '.')) || 0)}</td>
|
||||
<td className={styles.amount}>{formatAmount(rowAmount(row))}</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.unit}>
|
||||
<TextInput
|
||||
value={row.unit}
|
||||
onChange={(v) => setRow(i, { unit: v })}
|
||||
ariaLabel="Единица измерения"
|
||||
borderless
|
||||
/>
|
||||
</td>
|
||||
<td className={styles.qty}>
|
||||
<TextInput
|
||||
value={row.qty}
|
||||
onChange={(v) => setRow(i, { qty: v })}
|
||||
ariaLabel="Количество"
|
||||
borderless
|
||||
/>
|
||||
</td>
|
||||
<td className={styles.rate}>
|
||||
<TextInput
|
||||
value={row.rate}
|
||||
onChange={(v) => setRow(i, { rate: v })}
|
||||
ariaLabel="Цена за единицу"
|
||||
borderless
|
||||
/>
|
||||
</td>
|
||||
<td className={styles.amount}>{formatAmount(rowAmount(row))}</td>
|
||||
<td className={styles.act}>
|
||||
<button type="button" className={styles.remove} title="Удалить строку" onClick={() => removeRow(i)}>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
{data.Costs.length > 0 && (
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={5} className={styles.totalLabel}>
|
||||
Итого без НДС
|
||||
</td>
|
||||
<td className={styles.amount}>{formatAmount(costTotal(data.Costs))}</td>
|
||||
{!readOnly && <td className={styles.act} />}
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</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,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,20 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormGrid, FormField, TextArea } from '../../../shared/ui';
|
||||
import { ISectionProps } from '../model';
|
||||
|
||||
/** Общие замечания — отдельный блок бумажной формы, не относится к объёму работ. */
|
||||
export const RemarksSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||
<CollapseSection title="Общие замечания" icon="info">
|
||||
<FormGrid>
|
||||
<FormField label="Общие замечания" htmlFor="GeneralRemarks" full>
|
||||
<TextArea
|
||||
id="GeneralRemarks"
|
||||
value={data.GeneralRemarks}
|
||||
onChange={(v) => set('GeneralRemarks', v)}
|
||||
rows={4}
|
||||
placeholder="Особые условия, ссылки на нормативы, порядок приёмки скрытых работ…"
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</CollapseSection>
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// Продолжительность — производная от дат, а не поле ввода: показываем строкой
|
||||
// под датами, на всю ширину сетки формы.
|
||||
.duration {
|
||||
grid-column: 1 / -1;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import * as React from 'react';
|
||||
import styles from './ScopeSection.module.scss';
|
||||
import { CollapseSection, FormGrid, FormField, TextInput, TextArea } from '../../../shared/ui';
|
||||
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">
|
||||
<FormGrid>
|
||||
<FormField label="Объём работ" htmlFor="ScopeOfWork" required full>
|
||||
<TextArea
|
||||
id="ScopeOfWork"
|
||||
value={data.ScopeOfWork}
|
||||
onChange={(v) => set('ScopeOfWork', v)}
|
||||
rows={12}
|
||||
placeholder="Состав и объёмы работ, ссылки на рабочую документацию, требования к исполнительной документации…"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<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>}
|
||||
</FormGrid>
|
||||
</CollapseSection>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
||||
import { costTotal } 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-колонки, итог сметы — в отдельную числовую колонку.
|
||||
*/
|
||||
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') return; // ниже, отдельными колонками
|
||||
const value = data[key];
|
||||
item[key] = value === '' ? null : value;
|
||||
});
|
||||
|
||||
item.AnnexesJson = data.Annexes.length > 0 ? JSON.stringify(data.Annexes) : null;
|
||||
item.CostsJson = data.Costs.length > 0 ? JSON.stringify(data.Costs) : null;
|
||||
// Итог сметы дублируем числовой колонкой намеренно: в самой форме он считается
|
||||
// на лету (см. cost.ts), но представлениям SharePoint и отчётам нужна сумма
|
||||
// без разбора JSON — иначе любой отчёт по заявкам пришлось бы писать кодом.
|
||||
const total = costTotal(data.Costs);
|
||||
item.CostTotal = total > 0 ? total : 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;
|
||||
}
|
||||
@@ -37,6 +37,9 @@ const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
|
||||
'wh-tmc-transfer': React.lazy(
|
||||
() => import(/* webpackChunkName: 'form-tmc-transfer' */ './TmcTransfer/TmcTransferForm')
|
||||
),
|
||||
'wh-service-request': React.lazy(
|
||||
() => import(/* webpackChunkName: 'form-service-request' */ './ServiceRequest/ServiceRequestForm')
|
||||
),
|
||||
'clearance-form': React.lazy(
|
||||
() => import(/* webpackChunkName: 'form-clearance' */ './Clearance/ClearanceForm')
|
||||
),
|
||||
|
||||
@@ -56,6 +56,9 @@ const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
|
||||
'wh-tmc-transfer': React.lazy(
|
||||
() => import(/* webpackChunkName: 'view-tmc-transfer' */ './TmcTransfer/TmcTransferView')
|
||||
),
|
||||
'wh-service-request': React.lazy(
|
||||
() => import(/* webpackChunkName: 'view-service-request' */ './ServiceRequest/ServiceRequestView')
|
||||
),
|
||||
'clearance-form': React.lazy(
|
||||
() => import(/* webpackChunkName: 'view-clearance' */ './Clearance/ClearanceView')
|
||||
),
|
||||
|
||||
@@ -35,7 +35,8 @@ const LIST_TITLE_BASE: Record<RequestTypeId, string> = {
|
||||
'recruiting-hire': 'Прием на работу',
|
||||
'recruiting-referral': 'Реферальная рекомендация',
|
||||
'recruiting-selection': 'Заявка на подбор',
|
||||
'wh-tmc-transfer': 'СЗ на перемещение ТМЦ'
|
||||
'wh-tmc-transfer': 'СЗ на перемещение ТМЦ',
|
||||
'wh-service-request': 'Заявка на услуги'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,7 @@ export {
|
||||
TextArea,
|
||||
Select,
|
||||
Toggle,
|
||||
Checkbox,
|
||||
CollapseSection,
|
||||
FormActions,
|
||||
FileDropzone,
|
||||
|
||||
Reference in New Issue
Block a user