From 3a7579a5efb8ae22bf71de90668de9305c6f1f3e Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 1 Sep 2026 11:07:42 +0500 Subject: [PATCH] =?UTF-8?q?feat(ServiceRequest):=20=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=8F=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=20=C2=AB=D0=97?= =?UTF-8?q?=D0=B0=D1=8F=D0=B2=D0=BA=D0=B0=20=D0=BD=D0=B0=20=D1=83=D1=81?= =?UTF-8?q?=D0=BB=D1=83=D0=B3=D0=B8=C2=BB=20(wh-service-request)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Электронный аналог бумажной Service Requisition Form ПТО: заявитель, проект (маршрут резолвится по нему), описание требований, табличные части «Приложения» и «Смета» (JSON-колонки, сумма считается на лету), только фаза согласования — без исполнения. Форма подачи + режим согласующего, провижининг-манифесты данных и согласования, регистрация в реестрах форм. Co-Authored-By: Claude Sonnet 5 --- docs/forms.md | 1 + src/webparts/requestPortal/data/requests.ts | 3 +- .../forms/ServiceRequest/README.md | 26 ++ .../ServiceRequestForm.module.scss | 20 + .../ServiceRequest/ServiceRequestForm.tsx | 342 ++++++++++++++++++ .../ServiceRequestView.module.scss | 17 + .../ServiceRequest/ServiceRequestView.tsx | 203 +++++++++++ .../ServiceRequest/approval/provisioning.json | 14 + .../forms/ServiceRequest/cost.ts | 30 ++ .../forms/ServiceRequest/load.ts | 105 ++++++ .../forms/ServiceRequest/model.ts | 169 +++++++++ .../forms/ServiceRequest/provisioning.json | 35 ++ .../sections/AnnexesSection.module.scss | 95 +++++ .../sections/AnnexesSection.tsx | 119 ++++++ .../sections/CostsSection.module.scss | 109 ++++++ .../ServiceRequest/sections/CostsSection.tsx | 132 +++++++ .../ServiceRequest/sections/HeaderSection.tsx | 137 +++++++ .../sections/RemarksSection.tsx | 20 + .../sections/ScopeSection.module.scss | 10 + .../ServiceRequest/sections/ScopeSection.tsx | 69 ++++ .../forms/ServiceRequest/submit.ts | 72 ++++ src/webparts/requestPortal/forms/registry.ts | 3 + .../requestPortal/forms/viewRegistry.ts | 3 + .../requestPortal/shared/listTitles.ts | 3 +- src/webparts/requestPortal/shared/ui/index.ts | 1 + 25 files changed, 1736 insertions(+), 2 deletions(-) create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/README.md create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.module.scss create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.module.scss create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/approval/provisioning.json create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/cost.ts create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/load.ts create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/model.ts create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/provisioning.json create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.module.scss create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.tsx create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.module.scss create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.tsx create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/sections/HeaderSection.tsx create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/sections/RemarksSection.tsx create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/sections/ScopeSection.module.scss create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/sections/ScopeSection.tsx create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/submit.ts diff --git a/docs/forms.md b/docs/forms.md index 879abe9..5affc37 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -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`). diff --git a/src/webparts/requestPortal/data/requests.ts b/src/webparts/requestPortal/data/requests.ts index cdfb6fb..72466dd 100644 --- a/src/webparts/requestPortal/data/requests.ts +++ b/src/webparts/requestPortal/data/requests.ts @@ -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' } ]; /** Заявки, относящиеся к указанной категории. */ diff --git a/src/webparts/requestPortal/forms/ServiceRequest/README.md b/src/webparts/requestPortal/forms/ServiceRequest/README.md new file mode 100644 index 0000000..8e7270a --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/README.md @@ -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`) +- «Создать на основе»: — diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.module.scss b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.module.scss new file mode 100644 index 0000000..e1ef36d --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.module.scss @@ -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; + } +} diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx new file mode 100644 index 0000000..2fe9b3d --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx @@ -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 = ({ + requestType, + context, + onCancel, + onSaved, + editId, + editListId, + onCodeLoaded +}) => { + const [data, setData] = useState(EMPTY_DATA); + const [files, setFiles] = useState([]); + const [existingAttachments, setExistingAttachments] = useState([]); + const [requestId, setRequestId] = useState(editId); + const [requestStatus, setRequestStatus] = useState(undefined); + const [requestCode, setRequestCode] = useState(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) => async (): Promise => { + // 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 => submit.run('draft', save(REQUEST_STATUS.draft), validate); + const handleSubmit = (): Promise => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); + + const sectionProps = { data, set, context, onRequesterChange: handleRequesterChange }; + + return ( +
+ + + + } + headerRight={ + requestId || requestStatus ? ( + <> + {requestStatus && } + {requestCode && } + + ) : undefined + } + > + {submit.savedKind && !submit.error && ( + + {submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'} + + )} + {submit.error && {submit.error}} + + + + + + + + + + {!readOnly && ( + + {existingAttachments.length > 0 && ( + + + + )} + 0 ? 'Добавить файлы' : 'Файлы'} full> + + + + )} + + approval.searchUsers(q)} + waiting={!matrixReady} + waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования." + /> + + + +
+ ); +}; + +export default ServiceRequestForm; diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.module.scss b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.module.scss new file mode 100644 index 0000000..b52e802 --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.module.scss @@ -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; +} diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx new file mode 100644 index 0000000..70a7c9c --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx @@ -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 = ({ context, itemId, formKey, listId, onCodeLoaded }) => { + const [data, setData] = useState(EMPTY_DATA); + const [status, setStatus] = useState(''); + const [code, setCode] = useState(''); + const [attachments, setAttachments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + // Какая из организаций заявителя (см. OrganizationField) идёт в печать — + // выбор в тулбаре через PrintSplitButton, по умолчанию первая. + const [printOrgIndex, setPrintOrgIndex] = useState(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(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
Загрузка заявки…
; + } + if (error) { + return ( +
+ {error} +
+ ); + } + + 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 ( +
+
+ {isEditableStatus(status) && ( + + )} + +
+ +
+ + + } + headerRight={status ? : undefined} + > + + + + + + + + +
+ + + + + +
+
+ + approval.searchUsers(q)} /> +
+ +
+ approval.searchUsers(q)} + onDecided={(newStatus) => { + setStatus(newStatus); + setDecisionSeq((n) => n + 1); + notify(); + }} + onDelegated={() => { + setDecisionSeq((n) => n + 1); + notify(); + }} + /> +
+
+ ); +}; + +export default ServiceRequestView; diff --git a/src/webparts/requestPortal/forms/ServiceRequest/approval/provisioning.json b/src/webparts/requestPortal/forms/ServiceRequest/approval/provisioning.json new file mode 100644 index 0000000..18bb7b5 --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/approval/provisioning.json @@ -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" } + ] +} diff --git a/src/webparts/requestPortal/forms/ServiceRequest/cost.ts b/src/webparts/requestPortal/forms/ServiceRequest/cost.ts new file mode 100644 index 0000000..40ccf6d --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/cost.ts @@ -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); +} diff --git a/src/webparts/requestPortal/forms/ServiceRequest/load.ts b/src/webparts/requestPortal/forms/ServiceRequest/load.ts new file mode 100644 index 0000000..4efe51b --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/load.ts @@ -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(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 { + 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; + + 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(item.AnnexesJson), + Costs: parseRows(item.CostsJson) + }; + + return { + data, + status: (item.RequestStatus as string) || '', + code: (item.RequestUniqueCode as string) || '' + }; +} diff --git a/src/webparts/requestPortal/forms/ServiceRequest/model.ts b/src/webparts/requestPortal/forms/ServiceRequest/model.ts new file mode 100644 index 0000000..17fa761 --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/model.ts @@ -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 = (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: [] +}; diff --git a/src/webparts/requestPortal/forms/ServiceRequest/provisioning.json b/src/webparts/requestPortal/forms/ServiceRequest/provisioning.json new file mode 100644 index 0000000..61ca799 --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/provisioning.json @@ -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 } + ] +} diff --git a/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.module.scss b/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.module.scss new file mode 100644 index 0000000..9f47d7a --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.module.scss @@ -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; +} diff --git a/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.tsx b/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.tsx new file mode 100644 index 0000000..7bcd164 --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.tsx @@ -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 = ({ data, set }) => { + const readOnly = useFormReadOnly(); + + const setRow = (index: number, patch: Partial): 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 ( + +
+
+ + + + + + + + + {!readOnly && + + + {data.Annexes.length === 0 ? ( + + + + ) : ( + data.Annexes.map((row, i) => + readOnly ? ( + + + + + + + + ) : ( + + + + + + + + + ) + ) + )} + +
#Описание документаПриложение №Кол-во стр.Подтверждение} +
+ Приложения не добавлены +
{i + 1}{row.description}{row.annexNo}{row.pages} + undefined} readOnly ariaLabel="Подтверждено" /> +
{i + 1} + setRow(i, { description: v })} + ariaLabel="Описание документа" + borderless + /> + + setRow(i, { annexNo: v })} + ariaLabel="Приложение №" + borderless + /> + + setRow(i, { pages: v })} + ariaLabel="Кол-во страниц" + borderless + /> + + setRow(i, { confirmed: v })} + ariaLabel="Подтверждено" + /> + + +
+
+ + {!readOnly && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.module.scss b/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.module.scss new file mode 100644 index 0000000..c41b0d0 --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.module.scss @@ -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; +} diff --git a/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.tsx b/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.tsx new file mode 100644 index 0000000..f4af2e1 --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.tsx @@ -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 = ({ data, set }) => { + const readOnly = useFormReadOnly(); + + const setRow = (index: number, patch: Partial): 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 ( + +
+
+ + + + + + + + + + {!readOnly && + + + {data.Costs.length === 0 ? ( + + + + ) : ( + data.Costs.map((row, i) => + readOnly ? ( + + + + + + + + + ) : ( + + + + + + + + + + ) + ) + )} + + {data.Costs.length > 0 && ( + + + + + {!readOnly && + + )} +
№Наименование работ / услугЕд.изм.Кол-воЦена за ед. (KZT)Сумма без НДС (KZT)} +
+ Позиции не добавлены +
{i + 1}{row.description}{row.unit}{row.qty}{formatAmount(Number(row.rate.replace(',', '.')) || 0)}{formatAmount(rowAmount(row))}
{i + 1} + setRow(i, { description: v })} + ariaLabel="Наименование работ / услуг" + borderless + /> + + setRow(i, { unit: v })} + ariaLabel="Единица измерения" + borderless + /> + + setRow(i, { qty: v })} + ariaLabel="Количество" + borderless + /> + + setRow(i, { rate: v })} + ariaLabel="Цена за единицу" + borderless + /> + {formatAmount(rowAmount(row))} + +
+ Итого без НДС + {formatAmount(costTotal(data.Costs))}} +
+
+ + {!readOnly && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/src/webparts/requestPortal/forms/ServiceRequest/sections/HeaderSection.tsx b/src/webparts/requestPortal/forms/ServiceRequest/sections/HeaderSection.tsx new file mode 100644 index 0000000..22617ab --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/HeaderSection.tsx @@ -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 = ({ + data, + set, + context, + printOrgIndex, + onRequesterChange +}) => ( + + + set('RequesterEmail', v))} + placeholder={REQUESTER_PLACEHOLDER} + /> + + + + + set('RequesterJobTitle', v)} + placeholder="Напр. вед. инженер ПТО" + /> + + + { + set('Department', name); + set('DepartmentId', id); + }} + /> + + + + set('RequestDate', v)} + /> + + + set('Revision', v)} + placeholder="00" + /> + + + + { + set('ProjectName', name); + set('ProjectId', id); + set('ProjectManager', project ? project.projectManager || '' : ''); + if (project && project.customerName) { + set('Customer', project.customerName); + } + }} + /> + + + set('Customer', v)} /> + + + + set('ActivityId', v)} + placeholder="Напр. КОД СМР 2.3" + /> + + + set('ContractAction', v)} + options={CONTRACT_ACTION_OPTIONS} + placeholder="Выберите…" + /> + + + +