diff --git a/src/webparts/requestPortal/data/requests.ts b/src/webparts/requestPortal/data/requests.ts index 72466dd..271cefe 100644 --- a/src/webparts/requestPortal/data/requests.ts +++ b/src/webparts/requestPortal/data/requests.ts @@ -19,11 +19,12 @@ export const REQUEST_TYPES: IRequestType[] = [ { id: 'hr-unpaid-leave', categoryId: 'hr', title: 'Заявка на отпуск без содержания', icon: 'document' }, { id: 'hr-dismissal', categoryId: 'hr', title: 'Заявление на увольнение', icon: 'document' }, { id: 'clearance-form', categoryId: 'hr', title: 'Обходной лист', icon: 'clipboard' }, - { id: 'hr-transfer', categoryId: 'hr', title: 'Заявка на перемещение сотрудников', icon: 'people' }, + { id: 'hr-transfer', categoryId: 'hr', title: 'Заявка на перемещение сотрудника', icon: 'people' }, // Склад / Снабжение { id: 'wh-tmc-transfer', categoryId: 'warehouse', title: 'СЗ на перемещение ТМЦ', icon: 'send' }, - { id: 'wh-service-request', categoryId: 'warehouse', title: 'Заявка на услуги', icon: 'document' } + { id: 'wh-service-request', categoryId: 'warehouse', title: 'Заявка на услуги', icon: 'document' }, + { id: 'wh-concrete-request', categoryId: 'warehouse', title: 'Заявка на бетон', icon: 'vehicle' } ]; /** Заявки, относящиеся к указанной категории. */ diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.module.scss b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.module.scss new file mode 100644 index 0000000..e1ef36d --- /dev/null +++ b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.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/ConcreteRequest/ConcreteRequestForm.tsx b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx new file mode 100644 index 0000000..2536cc4 --- /dev/null +++ b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx @@ -0,0 +1,331 @@ +import * as React from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; +import styles from './ConcreteRequestForm.module.scss'; +import { + Alert, + FormCard, + InitiatorLine, + FormModeProvider, + Icon, + RequestNumber, + SubmitActions, + useRequestSubmit +} from '../../shared/ui'; +import { + ApprovalMatrix, + StatusBadge, + useMatrixEditor, + getPlannedMatrix, + resolveWriteListId, + notifyActiveStep, + useRequestTypeId, + REQUEST_STATUS, + isEditableStatus, + IApproverToWrite, + unassignedStepMessage +} from '../../shared/approval'; +import { IRequestFormProps } from '../../models/types'; +import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; +import { friendlyErrorMessage } from '../../shared/diagnostics'; +import { + approvalListTitle, + approvalListTitleForListId, + dataListTitle, + dataListTitleForListId +} from '../../shared/listTitles'; +import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; +import { saveConcreteRequest, concreteRequestApprovalService } from './submit'; +import { loadConcreteRequest } from './load'; +import { HeaderSection } from './sections/HeaderSection'; +import { ItemsSection } from './sections/ItemsSection'; +import { useStableCallback } from '../../shared/useStableCallback'; + +const ConcreteRequestForm: React.FC = ({ + requestType, + context, + onCancel, + onSaved, + editId, + editListId, + onCodeLoaded +}) => { + const [data, setData] = useState(EMPTY_DATA); + const [requestId, setRequestId] = useState(editId); + const [requestStatus, setRequestStatus] = useState(undefined); + const [requestCode, setRequestCode] = useState(undefined); + const submit = useRequestSubmit(); + // onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффектов идёт + // стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при + // каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); + // submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене + // saving/error), а setError стабилен — его и берём. + const { setError: setSubmitError } = submit; + + const readOnly = !isEditableStatus(requestStatus); + const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); + + // editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции + // не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь + // concreteRequestApprovalService можно звать не опасаясь исключения. + const approval = useMemo(() => concreteRequestApprovalService(editListId), [editListId]); + // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. + // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. + const requestTypeIdState = useRequestTypeId(context, requestType.id); + const requestTypeId = requestTypeIdState.id; + + // Открыта СВОЯ заявка на редактирование (черновик/возврат) — подгружаем содержимое и статус. + useEffect(() => { + if (!editId) { + return; + } + let alive = true; + loadConcreteRequest(editId, editListId) + .then((loaded) => { + if (alive) { + setData(loaded.data); + setRequestStatus(loaded.status); + setRequestCode(loaded.code || undefined); + if (loaded.code) notifyCode(loaded.code); + } + }) + .catch((e) => { + if (alive) { + setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + } + }); + return () => { + alive = false; + }; + }, [editId, editListId, notifyCode, setSubmitError]); + + // Новая заявка — по умолчанию «Составил» это текущий пользователь: подставляем + // его, его ФИО и организации (Employee/ByEmail — сотрудник может состоять + // сразу в нескольких организациях, ФИО берём из ПЕРВОЙ записи). + useEffect(() => { + if (editId) { + return; + } + let alive = true; + const user = context.pageContext.user; + setData((prev) => ({ ...prev, SiteChiefEmail: prev.SiteChiefEmail || user.email })); + getEmployeeOrganizations(context, user.email) + .then((records) => { + if (!alive) return; + setData((prev) => ({ + ...prev, + SiteChiefName: prev.SiteChiefName || records[0]?.fio || user.displayName || '', + Organization: formatEmployeeOrganizations(records) + })); + }) + .catch(() => undefined); + return () => { + alive = false; + }; + // context — единственный экземпляр WebPartContext на всё время жизни + // веб-части, так что перезапуска эффекта эта зависимость не добавляет. + }, [editId, context]); + + // Выбор другого начальника участка подтягивает его ФИО и организации. + const handleSiteChiefChange = (login: string): void => { + setData((prev) => ({ ...prev, SiteChiefEmail: login })); + if (!login) { + setData((prev) => ({ ...prev, SiteChiefName: '', Organization: '' })); + return; + } + getEmployeeOrganizations(context, login) + .then((records) => + setData((prev) => ({ + ...prev, + SiteChiefName: records[0]?.fio || prev.SiteChiefName, + Organization: formatEmployeeOrganizations(records) + })) + ) + .catch(() => undefined); + }; + + // Выбор мастера участка подтягивает только его ФИО (для печати) — на маршрут не влияет. + const handleForemanChange = (login: string): void => { + setData((prev) => ({ ...prev, ForemanEmail: login })); + if (!login) { + setData((prev) => ({ ...prev, ForemanName: '' })); + return; + } + getEmployeeOrganizations(context, login) + .then((records) => setData((prev) => ({ ...prev, ForemanName: records[0]?.fio || prev.ForemanName }))) + .catch(() => undefined); + }; + + // Маршрут согласования зависит от начальника участка И проекта (в админке + // approval-route для этого типа заявки настроено условие ByProject) — + // planned resolve без ProjectId 404-ит, поэтому матрица не готова, пока + // проект не выбран (см. matrixReady). У заявки на редактировании маршрут + // читается из уже записанных строк, поэтому проект там уже не нужен. + const projectId = data.ProjectId || null; + const matrixReady = !!editId || (!!data.SiteChiefEmail && !!projectId && !!requestTypeId); + const loadMatrix = useCallback( + () => + editId + ? approval.getMatrixForRequest(editId) + : getPlannedMatrix(context, requestTypeId as number, data.SiteChiefEmail, approval, 'Approval', null, undefined, projectId), + [context, approval, editId, data.SiteChiefEmail, requestTypeId, projectId] + ); + const matrix = useMatrixEditor( + loadMatrix, + `${data.SiteChiefEmail}-${editId ?? 'new'}-${projectId}`, + !readOnly, + matrixReady, + (login) => approval.getJobTitle(login) + ); + + const hasItems = data.Items.some((i) => i.mixClass.trim().length > 0 && i.volume.trim().length > 0); + const isValid = !!(data.RequestDate && data.SiteChiefEmail && data.Project.trim() && hasItems); + 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, + // Транзитом из маршрута в строку списка: наследование бэкенд применил + // при resolve, форма только переносит значения (см. IMatrixRow.slaHours). + slaHours: r.slaHours, + askOverdueReason: r.askOverdueReason + })); + + const save = (status: string): (() => Promise) => async (): Promise => { + // validate()/validateSubmit() уже гарантировали requestTypeId к моменту вызова + // (см. useRequestSubmit.run — валидирует ДО save), но TS этого через границу + // замыкания не видит — локальный const и явная проверка снимают обе проблемы. + const rtId = requestTypeId; + if (!rtId) { + throw new Error('Тип заявки не определён — обновите страницу и повторите.'); + } + // requestId уже задан → редактируем существующую заявку, пишем в ту же + // партицию (editListId); иначе резолвится ТЕКУЩАЯ (см. resolveWriteListId — + // именно этот запрос проверяет порог элементов и запускает архивацию). + const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId); + const { id, code } = await saveConcreteRequest( + requestId, + rtId, + data, + toApprovers(matrix.rows), + status, + writeListId + ); + setRequestId(id); + setRequestStatus(status); + if (code) { + setRequestCode(code); + } + if (onSaved) { + onSaved(); + } + // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). + // Имена списков — той же партиции, куда фактически записана заявка (writeListId), + // не текущей: поток ищет строку маршрута именно в НЕЙ. + 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, + onSiteChiefChange: handleSiteChiefChange, + onForemanChange: handleForemanChange + }; + + return ( +
+ + + + } + headerRight={ + requestId || requestStatus ? ( + <> + {requestStatus && } + {requestCode && } + + ) : undefined + } + > + {submit.savedKind && !submit.error && ( + + {submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'} + + )} + {submit.error && {submit.error}} + + + + + + + approval.searchUsers(q)} + waiting={!matrixReady} + waitingText="Укажите начальника участка и проект, чтобы построить маршрут согласования." + /> + + + +
+ ); +}; + +export default ConcreteRequestForm; diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestView.module.scss b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestView.module.scss new file mode 100644 index 0000000..b52e802 --- /dev/null +++ b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestView.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/ConcreteRequest/ConcreteRequestView.tsx b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestView.tsx new file mode 100644 index 0000000..c6576e1 --- /dev/null +++ b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestView.tsx @@ -0,0 +1,178 @@ +import * as React from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import styles from './ConcreteRequestView.module.scss'; +import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui'; +import { + ApproverService, + DecisionPanel, + StatusBadge, + ApprovalMatrix, + useMatrixEditor, + notifyActiveStep, + useRequestTypeId +} from '../../shared/approval'; +import { friendlyErrorMessage } from '../../shared/diagnostics'; +import { + approvalListTitle, + approvalListTitleForListId, + dataListTitle, + dataListTitleForListId +} from '../../shared/listTitles'; +import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; +import { loadConcreteRequest } from './load'; +import { concreteRequestApprovalService } from './submit'; +import { HeaderSection } from './sections/HeaderSection'; +import { ItemsSection } from './sections/ItemsSection'; +import { useStableCallback } from '../../shared/useStableCallback'; + +interface IConcreteRequestViewProps { + context: WebPartContext; + itemId: number; + /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ + formKey: string; + /** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */ + listId?: string; + /** Уведомление о загруженном уникальном коде заявки — см. IApprovalViewProps.onCodeLoaded. */ + onCodeLoaded?: (code: string) => void; +} + +// В режиме чтения поля не редактируются, поэтому обновление — пустышка. +const NOOP_SET: SetField = () => undefined; + +// Заявка на бетон только для просмотра (режим согласующего). Переиспользует ту +// же секцию, что и форма подачи; редактирование выключено через +// — поля сами становятся неизменяемыми. +const ConcreteRequestView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { + // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка + // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки + // идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы + // при каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); + const [data, setData] = useState(EMPTY_DATA); + const [status, setStatus] = useState(''); + const [code, setCode] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + // Какая из организаций начальника участка (см. OrganizationField) идёт в печать — + // выбор в тулбаре через PrintSplitButton, по умолчанию первая. + const [printOrgIndex, setPrintOrgIndex] = useState(0); + + // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) — + // concreteRequestApprovalService можно звать не опасаясь исключения. + const approval = useMemo(() => concreteRequestApprovalService(listId), [listId]); + const { id: requestTypeId } = useRequestTypeId(context, formKey); + // Ref списка данных для ApproverService.updateRequestStatus: listId (url) для + // архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef). + const dataListRef = listId || dataListTitle(FORM_ID); + const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]); + + // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в + // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. + const [decisionSeq, setDecisionSeq] = useState(0); + const loadApprovers = 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); + loadConcreteRequest(itemId, listId) + .then((loaded) => { + if (alive) { + setData(loaded.data); + setStatus(loaded.status); + setCode(loaded.code); + notifyCode(loaded.code); + setError(null); + setLoading(false); + } + }) + .catch((e) => { + if (alive) { + setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`); + setLoading(false); + } + }); + return () => { + alive = false; + }; + }, [itemId, listId, notifyCode]); + + if (loading) { + return
Загрузка заявки…
; + } + if (error) { + return ( +
+ {error} +
+ ); + } + + const sectionProps = { data, set: NOOP_SET, context, printOrgIndex }; + + const notify = (): void => { + // requestTypeId ещё не резолвился — решение уже применилось (строка списка + // обновлена), просто нечего уведомлять на следующем шаге; не проваливаем + // весь UI ради необязательного оповещения. + 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 ( +
+
+ +
+ +
+ + + } + headerRight={status ? : undefined} + > + + + + + + + approval.searchUsers(q)} /> +
+ + {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} +
+ approval.searchUsers(q)} + onDecided={(newStatus) => { + setStatus(newStatus); + setDecisionSeq((n) => n + 1); + notify(); + }} + onDelegated={() => { + setDecisionSeq((n) => n + 1); + notify(); + }} + /> +
+
+ ); +}; + +export default ConcreteRequestView; diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/README.md b/src/webparts/requestPortal/forms/ConcreteRequest/README.md new file mode 100644 index 0000000..672f1b4 --- /dev/null +++ b/src/webparts/requestPortal/forms/ConcreteRequest/README.md @@ -0,0 +1,63 @@ +# Заявка на бетон (wh-concrete-request) + +**Назначение:** заявка участка строительства на поставку бетонной смеси. +Бумажный первоисточник — «Заявка на поставку бетонной смеси» (таблица позиций + +подписи начальника и мастера участка). +**Категория:** Склад / Снабжение +**Списки SP:** «Заявка на бетон — данные» / «Заявка на бетон — согласование» +**RequestTypeId (внешний API маршрутов):** не хардкодится — резолвится в рантайме по +`pageUrl` («wh-concrete-request») через `useRequestTypeId` (см. «Формы больше не +хардкодят RequestTypeId» в `docs/forms.md`). Тип и маршрут нужно завести в +админке approval-route — на момент написания роль согласующего ещё не определена +заказчиком (один шаг, но кто именно — уточняется). + +## Маршрут +- Фазы: только согласование. Фазы исполнения нет — после согласования заявка + получает статус «Согласовано» (как «Заявка на отпуск»). +- **Матрица редактируема заявителем** при новой заявке / черновике / возврате: + `useMatrixEditor(editable = !readOnly)`. +- Маршрут резолвится по e-mail **начальника участка** (`SiteChiefEmail` — «Составил» + на бумаге), не мастера участка, **и по проекту** (`ProjectId`) — в админке + approval-route для этого типа заявки настроено условие `ByProject`, поэтому + `getPlannedMatrix(...)` вызывается с `projectId` седьмым аргументом (тот же + приём, что у «СЗ на перемещение ТМЦ» с `SourceProjectId`). Пока проект не + выбран, planned-resolve вернул бы 404 — матрица не грузится (`matrixReady`), + вместо неё показывается заглушка (`ApprovalMatrix waiting`). + +## Особое поведение +- **Два подписанта с одной стороны, без «Согласовано» от другого лица на бумаге:** + «Начальник участка» (`SiteChiefEmail`/`SiteChiefName`, People-picker, по умолчанию + текущий пользователь, резолвит маршрут) и «Мастер участка» + (`ForemanEmail`/`ForemanName`, People-picker) — чисто информационное поле для + печати, на маршрут не влияет. +- **Email/ФИО хранятся как обычный текст** (не колонки типа User) — тот же приём, + что у «СЗ на перемещение ТМЦ» (`RequesterEmail`/`AuthorName`): участники не + обязаны быть штатными сотрудниками с полной карточкой в SharePoint. +- **Автоподстановка организации** (`Organization`, read-only, `OrganizationField`) — + из `Employee/ByEmail` при выборе начальника участка, сотрудник может состоять + сразу в нескольких организациях (печать — как у «Заявки на отпуск», + `PrintSplitButton`/`OrgLetterhead`). +- **Проект** (`Project`/`ProjectId`, `ProjectPicker`, обязателен) — ОДИН на всю + заявку, выбирается в шапке (`HeaderSection`), а не на каждую позицию: раньше + был в табличной части, но перенесён в шапку, потому что от него зависит + маршрут согласования (см. «Маршрут» выше) — дублировать его по позициям + незачем, все позиции заявки относятся к одному проекту. +- **Табличная часть** (`ItemsEditor`, сериализуется в `ItemsJson`) — карточка на + каждую поставку: класс/марка смеси и характеристика (`mixClass`), количество + в м³ (`volume`), подвижность (`workability`, фиксированный набор П1–П5), время + (`time`, свободный текст) и примечание (`note`). Проекта в позиции больше нет + (см. выше). Не переиспользует общий `shared/ui/ItemsTable` (тот заточен под + номенклатуру ТМЦ — наименование/инв. номер/ед.изм/кол-во) — набор колонок + здесь другой, поэтому у формы свой редактор. Карточки (`FormGrid`/`FormField`), + а не строки плоской таблицы — адаптивнее на мобильных, чем строка с + горизонтальным скроллом. +- **Заголовок элемента** (`Title`): `Заявка на бетон — {ФИО начальника участка} — дд.мм.гггг`. + +## Секции формы +HeaderSection (дата поставки · начальник участка · организация (read-only) · +проект · мастер участка) + ItemsEditor (табличная часть поставок) + +## Точки входа +- rptype: `wh-concrete-request` +- Режим согласующего (просмотр): есть (`ConcreteRequestView`) +- «Создать на основе»: нет diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/approval/provisioning.json b/src/webparts/requestPortal/forms/ConcreteRequest/approval/provisioning.json new file mode 100644 index 0000000..aa70b8d --- /dev/null +++ b/src/webparts/requestPortal/forms/ConcreteRequest/approval/provisioning.json @@ -0,0 +1,16 @@ +{ + "dataListTitle": "Заявка на бетон — согласование", + "columns": [ + { "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" }, + { "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] }, + { "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Заявка на бетон — данные", "lookupShowField": "Title" }, + { "internalName": "Date", "title": "Дата", "type": "DateTime" }, + { "internalName": "Comment", "title": "Комментарий", "type": "Note" }, + { "internalName": "StepOrder", "title": "Порядок", "type": "Number" }, + { "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" }, + { "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] }, + { "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" }, + { "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" }, + { "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" } + ] +} diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/load.ts b/src/webparts/requestPortal/forms/ConcreteRequest/load.ts new file mode 100644 index 0000000..e76f527 --- /dev/null +++ b/src/webparts/requestPortal/forms/ConcreteRequest/load.ts @@ -0,0 +1,73 @@ +import { getSP } from '../../shared/pnp/pnpjs'; +import { dataListTitle } from '../../shared/listTitles'; +import { FORM_ID, IFormData, IConcreteItem, EMPTY_DATA } from './model'; + +export interface ILoadedRequest { + data: IFormData; + /** Ф.И.О. начальника участка (для заголовка режима чтения / InitiatorLine fallback). */ + employeeName: string; + status: string; + /** Уникальный код заявки; '' — если запись создана до появления поля. */ + code: string; +} + +/** Разобрать ItemsJson. Невалидный/пустой JSON → []. */ +function parseItems(raw: unknown): IConcreteItem[] { + if (typeof raw !== 'string' || raw.trim() === '') return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as IConcreteItem[]) : []; + } catch { + return []; + } +} + +/** + * Прочитать заявку на бетон по id: модель формы + статус (правка / просмотр). + * + * listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/ + * listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции; задан — идём напрямую + * по url списка, а не по имени: так не нужно резолвить, как называется архивный + * список, и запрос не зависит от реестра партиций. + */ +export async function loadConcreteRequest(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( + 'Id', + 'RequestDate', + 'SiteChiefEmail', + 'SiteChiefName', + 'ForemanEmail', + 'ForemanName', + 'Organization', + 'Project', + 'ProjectId', + 'ItemsJson', + 'RequestStatus', + 'RequestUniqueCode' + )()) as Record; + + const requestDate = (item.RequestDate as string) || ''; + + const data: IFormData = { + ...EMPTY_DATA, + RequestDate: requestDate.slice(0, 10), // ISO → YYYY-MM-DD для input type=date + SiteChiefEmail: (item.SiteChiefEmail as string) || '', + SiteChiefName: (item.SiteChiefName as string) || '', + ForemanEmail: (item.ForemanEmail as string) || '', + ForemanName: (item.ForemanName as string) || '', + Organization: (item.Organization as string) || '', + Project: (item.Project as string) || '', + ProjectId: (item.ProjectId as string) || '', + Items: parseItems(item.ItemsJson) + }; + + return { + data, + employeeName: data.SiteChiefName, + status: (item.RequestStatus as string) || '', + code: (item.RequestUniqueCode as string) || '' + }; +} diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/model.ts b/src/webparts/requestPortal/forms/ConcreteRequest/model.ts new file mode 100644 index 0000000..3741efd --- /dev/null +++ b/src/webparts/requestPortal/forms/ConcreteRequest/model.ts @@ -0,0 +1,126 @@ +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-concrete-request'; + +/** + * Строка табличной части — одна поставка бетонной смеси (бумажный бланк + * «Заявка на поставку бетонной смеси»). Все поля — строки: значения приходят + * из текстовых полей ввода, количество арифметически не считаем. + * + * Объект/проект здесь НЕ хранится — он один на всю заявку (см. IFormData.Project), + * а не на позицию: перенесён в шапку, потому что от него теперь зависит маршрут + * согласования (ByProject, см. IFormData). + */ +export interface IConcreteItem { + /** Класс/марка смеси и краткая характеристика (напр. «C20/25 F75 W6»). */ + mixClass: string; + /** Количество, м³. */ + volume: string; + /** Подвижность смеси — см. WORKABILITY_OPTIONS. */ + workability: string; + /** Время поставки (свободный текст, напр. «13:00» — своего типа времени в TextInput нет). */ + time: string; + /** Примечание. */ + note: string; +} + +export const EMPTY_CONCRETE_ITEM: IConcreteItem = { + mixClass: '', + volume: '', + workability: '', + time: '', + note: '' +}; + +/** Подвижность бетонной смеси (ГОСТ 7473) — фиксированный набор, чтобы не плодить опечатки. */ +export const WORKABILITY_OPTIONS: ISelectOption[] = [ + { value: 'П1', label: 'П1' }, + { value: 'П2', label: 'П2' }, + { value: 'П3', label: 'П3' }, + { value: 'П4', label: 'П4' }, + { value: 'П5', label: 'П5' } +]; + +/** + * Данные формы «Заявка на бетон» по бумажному шаблону («Заявка на поставку + * бетонной смеси» с участка строительства). Ключи шапки = internalName колонок + * SP-списка. Табличная часть (Items) в SP не разворачивается в колонки, а + * сериализуется в JSON-колонку ItemsJson (см. submit.ts / load.ts) — тот же + * приём, что и у «СЗ на перемещение ТМЦ». + * + * На бумаге два подписанта с одной стороны (заявителя), без отдельного + * «Согласовано» от другого лица: «Составил — Начальник участка» и «Мастер + * участка». Маршрут резолвится по e-mail НАЧАЛЬНИКА УЧАСТКА (SiteChiefEmail) — + * он же автор заявки; мастер участка — второй подписант, на маршрут не влияет. + * + * Проект (`Project`/`ProjectId`) — ОДИН на всю заявку (не на позицию, см. + * IConcreteItem): в админке approval-route для этого типа заявки настроено + * условие по проекту (ByProject), поэтому ProjectId передаётся в + * getPlannedMatrix как обязательный параметр — без него маршрут не резолвится + * (см. ConcreteRequestForm.tsx). + */ +export interface IFormData { + /** Дата поставки (ISO YYYY-MM-DD). */ + RequestDate: string; + /** + * «Составил: Начальник участка» — email заявителя (People). По нему + * резолвится маршрут согласования, поэтому заявку можно подать и за + * другого — выбор подтягивает организацию (см. Organization). + */ + SiteChiefEmail: string; + /** Ф.И.О. начальника участка — для печати, подтягивается из Employee/ByEmail. */ + SiteChiefName: string; + /** «Мастер участка» — email второго подписанта (People). На маршрут не влияет. */ + ForemanEmail: string; + /** Ф.И.О. мастера участка — для печати. */ + ForemanName: string; + /** + * Организации начальника участка (`\n`-joined «Компания — должность» на + * строку) — подтягивается автоматически из Employee/ByEmail, read-only + * (см. shared/ui/OrganizationField). + */ + Organization: string; + /** Проект (справочник /Project, см. shared/dictionary/ProjectPicker) — влияет на маршрут согласования. */ + Project: string; + /** oid проекта из справочника; '' — введён вручную без выбора. */ + ProjectId: string; + /** Табличная часть — позиции поставки (сериализуется в ItemsJson). */ + Items: IConcreteItem[]; +} + +/** Обновление одного поля формы с сохранением типа значения. */ +export type SetField = (key: K, value: IFormData[K]) => void; + +/** Пропсы секции формы: срез данных + колбэк обновления + контекст (people picker, project picker). */ +export interface ISectionProps { + data: IFormData; + set: SetField; + context: WebPartContext; + /** Индекс организации для печати (см. shared/ui/PrintSplitButton и OrganizationField.printIndex). */ + printOrgIndex?: number; + /** Выбор начальника участка подтягивает его ФИО и организации. Не передан (просмотр) — правка через set. */ + onSiteChiefChange?: (login: string) => void; + /** Выбор мастера участка подтягивает его ФИО. Не передан (просмотр) — правка через set. */ + onForemanChange?: (login: string) => void; +} + +export const SITE_CHIEF_PLACEHOLDER = 'Начните вводить Ф.И.О. начальника участка…'; +export const FOREMAN_PLACEHOLDER = 'Начните вводить Ф.И.О. мастера участка…'; + +export const EMPTY_DATA: IFormData = { + RequestDate: '', + SiteChiefEmail: '', + SiteChiefName: '', + ForemanEmail: '', + ForemanName: '', + Organization: '', + Project: '', + ProjectId: '', + Items: [] +}; diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/provisioning.json b/src/webparts/requestPortal/forms/ConcreteRequest/provisioning.json new file mode 100644 index 0000000..ff1212e --- /dev/null +++ b/src/webparts/requestPortal/forms/ConcreteRequest/provisioning.json @@ -0,0 +1,18 @@ +{ + "dataListTitle": "Заявка на бетон — данные", + "columns": [ + { "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" }, + { "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, + + { "internalName": "RequestDate", "title": "Дата поставки", "type": "DateTime", "dateOnly": true }, + { "internalName": "SiteChiefEmail", "title": "Начальник участка (email)", "type": "Text", "required": true }, + { "internalName": "SiteChiefName", "title": "Начальник участка (Ф.И.О.)", "type": "Text" }, + { "internalName": "ForemanEmail", "title": "Мастер участка (email)", "type": "Text" }, + { "internalName": "ForemanName", "title": "Мастер участка (Ф.И.О.)", "type": "Text" }, + { "internalName": "Organization", "title": "Организация", "type": "Note" }, + { "internalName": "Project", "title": "Проект", "type": "Text", "required": true }, + { "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" }, + + { "internalName": "ItemsJson", "title": "Позиции поставки (JSON)", "type": "Note" } + ] +} diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/sections/HeaderSection.tsx b/src/webparts/requestPortal/forms/ConcreteRequest/sections/HeaderSection.tsx new file mode 100644 index 0000000..7815ebe --- /dev/null +++ b/src/webparts/requestPortal/forms/ConcreteRequest/sections/HeaderSection.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui'; +import { ProjectPicker } from '../../../shared/dictionary'; +import { ISectionProps, SITE_CHIEF_PLACEHOLDER, FOREMAN_PLACEHOLDER } from '../model'; + +/** Шапка заявки на бетон: дата поставки, начальник участка (составил), мастер участка. */ +export const HeaderSection: React.FC = ({ + data, + set, + context, + printOrgIndex, + onSiteChiefChange, + onForemanChange +}) => ( + + + set('RequestDate', v)} /> + + + set('SiteChiefEmail', v))} + placeholder={SITE_CHIEF_PLACEHOLDER} + /> + + + + { + set('Project', name); + set('ProjectId', id); + }} + placeholder="Начните вводить название или код проекта…" + /> + + + set('ForemanEmail', v))} + placeholder={FOREMAN_PLACEHOLDER} + /> + + +); diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/sections/ItemsEditor.module.scss b/src/webparts/requestPortal/forms/ConcreteRequest/sections/ItemsEditor.module.scss new file mode 100644 index 0000000..0a47185 --- /dev/null +++ b/src/webparts/requestPortal/forms/ConcreteRequest/sections/ItemsEditor.module.scss @@ -0,0 +1,58 @@ +@import '../../../../../styles/design-tokens'; + +// Секция занимает обе колонки FormGrid родителя (CollapseSection). +.full { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: $sp-4; +} + +.card { + border: 1px solid $gray-200; + border-radius: $r-md; + background: $gray-50; + padding: $sp-4; +} + +.cardHead { + display: flex; + align-items: center; + justify-content: space-between; + gap: $sp-3; + margin-bottom: $sp-3; +} + +.cardIndex { + font-family: $font-sans; + font-size: $fs-sm; + font-weight: 600; + color: $gray-700; +} + +.remove { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 6px; + border: 0; + border-radius: $r-sm; + background: transparent; + color: $gray-500; + cursor: pointer; + transition: .18s; + + &:hover { + background: $red-50; + color: $red-500; + } +} + +.empty { + padding: $sp-3 0; + font-family: $font-sans; + font-size: $fs-sm; + color: $gray-500; + text-align: center; +} diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/sections/ItemsEditor.tsx b/src/webparts/requestPortal/forms/ConcreteRequest/sections/ItemsEditor.tsx new file mode 100644 index 0000000..10b1c88 --- /dev/null +++ b/src/webparts/requestPortal/forms/ConcreteRequest/sections/ItemsEditor.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; +import styles from './ItemsEditor.module.scss'; +import { Button, Icon, TextInput, Select, FormGrid, FormField, useFormReadOnly } from '../../../shared/ui'; +import { EMPTY_CONCRETE_ITEM, IConcreteItem, WORKABILITY_OPTIONS } from '../model'; + +export interface IItemsEditorProps { + value: IConcreteItem[]; + onChange: (rows: IConcreteItem[]) => void; +} + +/** + * Табличная часть заявки на бетон: класс смеси, объём, подвижность, время и + * примечание — карточка на каждую поставку. Проект/объект сюда не входит: он + * один на всю заявку и выбирается в шапке (см. HeaderSection, IFormData.Project) — + * от него зависит маршрут согласования, поэтому поле не дублируется по позициям. + * + * НЕ плоская таблица-простыня (как shared/ui/ItemsTable): карточки с обычным + * FormGrid адаптивнее узкой строки с горизонтальным скроллом на мобильных. + */ +export const ItemsEditor: React.FC = ({ value, onChange }) => { + const readOnly = useFormReadOnly(); + + const setRow = (index: number, patch: Partial): void => { + onChange(value.map((row, i) => (i === index ? { ...row, ...patch } : row))); + }; + + const addRow = (): void => onChange([...value, { ...EMPTY_CONCRETE_ITEM }]); + const removeRow = (index: number): void => onChange(value.filter((_, i) => i !== index)); + + return ( +
+ {value.length === 0 ? ( +
{readOnly ? 'Позиции не добавлены' : 'Нет позиций. Добавьте строку.'}
+ ) : ( + value.map((row, i) => ( +
+
+ Позиция {i + 1} + {!readOnly && ( + + )} +
+ + + setRow(i, { mixClass: v })} + placeholder="C20/25 F75 W6" + /> + + + setRow(i, { volume: v })} placeholder="0" /> + + +