From cc2efd877119c71ff089c87bea7a7a58d36c2089 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 1 Sep 2026 12:48:41 +0000 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D1=8F=D0=B2=D0=BA=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=83=D1=81=D0=BB=D1=83=D0=B3=D0=B8=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gorbenko.pavel Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/18 --- .eslintrc.js | 33 ++ docs/forms.md | 1 + src/webparts/requestPortal/data/requests.ts | 3 +- .../features/myRequests/RequestsTableView.tsx | 2 +- .../forms/Clearance/ClearanceForm.tsx | 16 +- .../forms/Clearance/ClearanceView.tsx | 10 +- .../forms/Dismissal/DismissalForm.tsx | 21 +- .../forms/Dismissal/DismissalView.tsx | 10 +- .../RecruitingHire/RecruitingHireForm.tsx | 15 +- .../RecruitingHire/RecruitingHireView.tsx | 10 +- .../RecruitingSelectionForm.tsx | 21 +- .../RecruitingSelectionView.tsx | 10 +- .../forms/Referral/ReferralForm.tsx | 22 +- .../forms/Referral/ReferralView.tsx | 10 +- .../forms/ServiceRequest/README.md | 28 ++ .../ServiceRequestForm.module.scss | 20 + .../ServiceRequest/ServiceRequestForm.tsx | 384 ++++++++++++++++++ .../ServiceRequestView.module.scss | 17 + .../ServiceRequest/ServiceRequestView.tsx | 204 ++++++++++ .../ServiceRequest/approval/provisioning.json | 16 + .../forms/ServiceRequest/cost.ts | 77 ++++ .../forms/ServiceRequest/load.ts | 110 +++++ .../forms/ServiceRequest/model.ts | 217 ++++++++++ .../forms/ServiceRequest/provisioning.json | 35 ++ .../sections/AnnexesSection.module.scss | 174 ++++++++ .../sections/AnnexesSection.tsx | 177 ++++++++ .../sections/CostGroupsEditor.module.scss | 175 ++++++++ .../sections/CostGroupsEditor.tsx | 195 +++++++++ .../ServiceRequest/sections/CostsSection.tsx | 15 + .../ServiceRequest/sections/HeaderSection.tsx | 137 +++++++ .../sections/RemarksSection.tsx | 18 + .../sections/ScopeSection.module.scss | 26 ++ .../ServiceRequest/sections/ScopeSection.tsx | 78 ++++ .../forms/ServiceRequest/submit.ts | 69 ++++ .../forms/Ticket1C/Ticket1CForm.tsx | 21 +- .../forms/Ticket1C/Ticket1CView.tsx | 10 +- .../forms/TmcTransfer/TmcTransferForm.tsx | 15 +- .../forms/TmcTransfer/TmcTransferView.tsx | 10 +- .../TmcTransfer/approval/provisioning.json | 2 + .../forms/UnpaidLeave/UnpaidLeaveForm.tsx | 21 +- .../forms/UnpaidLeave/UnpaidLeaveView.tsx | 10 +- .../forms/Vacation/VacationForm.tsx | 21 +- .../forms/Vacation/VacationView.tsx | 10 +- .../WorkplaceSetup/WorkplaceSetupForm.tsx | 24 +- .../WorkplaceSetup/WorkplaceSetupView.tsx | 10 +- src/webparts/requestPortal/forms/registry.ts | 3 + .../requestPortal/forms/viewRegistry.ts | 3 + .../propertyPane/DebugUserField.tsx | 20 +- .../shared/approval/ui/approval.module.scss | 14 + .../shared/approval/useMatrixEditor.ts | 14 +- .../shared/approval/useRequestTypeId.ts | 5 +- .../requestPortal/shared/listTitles.ts | 3 +- .../ui/ItemsTable/ItemsTable.module.scss | 9 + .../shared/ui/useRequestSubmit.ts | 27 +- .../requestPortal/shared/usePersistedQuery.ts | 13 +- .../requestPortal/shared/useStableCallback.ts | 33 ++ 56 files changed, 2557 insertions(+), 97 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/CostGroupsEditor.module.scss create mode 100644 src/webparts/requestPortal/forms/ServiceRequest/sections/CostGroupsEditor.tsx 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 create mode 100644 src/webparts/requestPortal/shared/useStableCallback.ts diff --git a/.eslintrc.js b/.eslintrc.js index cb42e16..ade45ab 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -11,7 +11,24 @@ module.exports = { 'ecmaVersion': 2018, 'sourceType': 'module' }, + plugins: ['react-hooks'], rules: { + // RATIONALE: Хук, вызванный не на каждом рендере (например, useMemo + // ПОСЛЕ раннего `return` для loading/error), ломает порядок + // хуков и валит компонент в рантайме с React #310 «rendered + // more hooks than during the previous render» — ошибка, + // которую видно только при открытии конкретного экрана. + // Правило ловит это на сборке. eslint-plugin-react-hooks + // давно в зависимостях, а в коде есть eslint-disable для + // exhaustive-deps — то есть правила подразумевались, но + // включить их в конфиг забыли (поймали на ServiceRequestView). + 'react-hooks/rules-of-hooks': 2, + // Зависимости эффектов — ВЫКЛЮЧЕНО намеренно. Правило полезное, но на + // текущем коде даёт 16 предупреждений (в основном намеренно опущенные + // onCodeLoaded/submit во всех формах). Шум такого объёма в каждой сборке + // приучает не читать варнинги вовсе — включать имеет смысл вместе с + // разбором этих мест, отдельной задачей, а не заодно с фиксом падения. + 'react-hooks/exhaustive-deps': 0, // Prevent usage of the JavaScript null value, while allowing code to access existing APIs that may require null. https://www.npmjs.com/package/@rushstack/eslint-plugin '@rushstack/no-new-null': 1, // Require Jest module mocking APIs to be called before any other statements in their code block. https://www.npmjs.com/package/@rushstack/eslint-plugin @@ -290,6 +307,22 @@ module.exports = { // Rationale to disable: !!{} 'no-extra-boolean-cast': 0, // ==================================================================== + // eslint-plugin-react-hooks (плагин подключён профилем + // @microsoft/eslint-config-spfx/lib/profiles/react, но правил он не задаёт) + // ==================================================================== + // RATIONALE: Хуки нельзя звать условно/в циклах — иначе React + // перепутает состояние между рендерами. + 'react-hooks/rules-of-hooks': 2, + // RATIONALE: Неполный массив зависимостей useEffect/useCallback/ + // useMemo — это устаревшее замыкание: эффект продолжает + // видеть старые пропсы и state. Держим предупреждением: + // там, где отступление осознанное (например, эффект + // должен сработать только на смену id, а не на каждое + // изменение колбэка), рядом стоит + // // eslint-disable-next-line react-hooks/exhaustive-deps + // с объяснением причины. + 'react-hooks/exhaustive-deps': 1, + // ==================================================================== // @microsoft/eslint-plugin-spfx // ==================================================================== '@microsoft/spfx/no-require-ensure': 2, 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/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index bab5da9..87396d5 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -477,7 +477,7 @@ export const RequestsTableView: React.FC = ({ unsubscribe(); window.clearInterval(timer); }; - }, [context, types, view, statusFilter, debouncedQuery, page]); + }, [context, types, view, statusFilter, debouncedQuery, page, dataKey]); // Все ветки отдают уже нарезанную страницу — просто показываем как есть. const q = debouncedQuery.trim(); diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx index f7a61c0..1f61330 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx @@ -38,6 +38,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { saveClearanceRequest, clearanceApprovalService } from './submit'; import { loadClearanceRequest } from './load'; import { RequestSection } from './sections/RequestSection'; +import { useStableCallback } from '../../shared/useStableCallback'; const ClearanceForm: React.FC = ({ requestType, @@ -53,6 +54,14 @@ const ClearanceForm: React.FC = ({ 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 })); @@ -78,19 +87,18 @@ const ClearanceForm: React.FC = ({ setData(loaded.data); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); - if (loaded.code) onCodeLoaded?.(loaded.code); + if (loaded.code) notifyCode(loaded.code); } }) .catch((e) => { if (alive) { - submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); } }); return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId, editListId]); + }, [editId, editListId, notifyCode, setSubmitError]); // Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай: // обходной лист оформляют в день увольнения). При открытии на редактирование diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx index 22a8470..447e52a 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx @@ -31,6 +31,7 @@ import { } from './submit'; import { canEditHrRecordsFields, canEditParkingField } from './approverRights'; import { RequestSection } from './sections/RequestSection'; +import { useStableCallback } from '../../shared/useStableCallback'; interface IClearanceViewProps { context: WebPartContext; @@ -49,6 +50,11 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error'; // заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения, // отдел подбора — парковочная карта (см. approverRights.ts). const ClearanceView: 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(''); @@ -115,7 +121,7 @@ const ClearanceView: React.FC = ({ context, itemId, formKey setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); - onCodeLoaded?.(loaded.code); + notifyCode(loaded.code); setError(null); setLoading(false); setLockedWriteOffs( @@ -132,7 +138,7 @@ const ClearanceView: React.FC = ({ context, itemId, formKey return () => { alive = false; }; - }, [itemId, listId]); + }, [itemId, listId, notifyCode]); // Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя. // isDelegator намеренно не даёт прав: делегировавший не решает за делегата diff --git a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx index 0503374..2be4313 100644 --- a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx @@ -38,6 +38,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_MONTHS, IOrganizat import { saveDismissalRequest, dismissalApprovalService } from './submit'; import { loadDismissalRequest } from './load'; import { RequestSection } from './sections/RequestSection'; +import { useStableCallback } from '../../shared/useStableCallback'; const DismissalForm: React.FC = ({ requestType, @@ -53,6 +54,14 @@ const DismissalForm: React.FC = ({ 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 })); @@ -82,19 +91,18 @@ const DismissalForm: React.FC = ({ setData(loaded.data); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); - if (loaded.code) onCodeLoaded?.(loaded.code); + if (loaded.code) notifyCode(loaded.code); } }) .catch((e) => { if (alive) { - submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); } }); return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId, editListId]); + }, [editId, editListId, notifyCode, setSubmitError]); /** Employee/ByEmail → таблица организаций (см. IOrganizationRecord). */ const toOrganization = (records: IEmployeeOrganization[]): IOrganizationRecord[] => @@ -124,8 +132,9 @@ const DismissalForm: React.FC = ({ return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId]); + // context — единственный экземпляр WebPartContext на всё время жизни + // веб-части, так что перезапуска эффекта эта зависимость не добавляет. + }, [editId, context]); // Выбор другого сотрудника подтягивает его таблицу организаций. const handleEmployeeChange = (login: string): void => { diff --git a/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx b/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx index f8cdaba..9d897c2 100644 --- a/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx @@ -29,6 +29,7 @@ import { dismissalApprovalService, updateAssetsAcceptor } from './submit'; import { loadClearanceRequest } from '../Clearance/load'; import { ensureClearanceForDismissal } from './createClearance'; import { RequestSection } from './sections/RequestSection'; +import { useStableCallback } from '../../shared/useStableCallback'; /** * Статусы обходного листа, при которых его СОГЛАСОВАНИЕ уже пройдено целиком @@ -56,6 +57,11 @@ interface IDismissalViewProps { // Переиспользует ту же секцию, что и форма подачи; редактирование выключено через // — поля сами становятся неизменяемыми. const DismissalView: 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(''); @@ -94,7 +100,7 @@ const DismissalView: React.FC = ({ context, itemId, formKey setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); - onCodeLoaded?.(loaded.code); + notifyCode(loaded.code); setError(null); setLoading(false); } @@ -108,7 +114,7 @@ const DismissalView: React.FC = ({ context, itemId, formKey return () => { alive = false; }; - }, [itemId, listId]); + }, [itemId, listId, notifyCode]); // Печать заявления запрещена до согласования ПАРНОГО обходного листа: сначала // должны отработать/подтвердиться все связанные с увольнением отметки (карты, diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index 44129b5..94287db 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -60,6 +60,7 @@ import { CandidateSection } from './sections/CandidateSection'; import { PpeSection } from './sections/PpeSection'; import { ConditionsSection } from './sections/ConditionsSection'; import { AgencySection } from './sections/AgencySection'; +import { useStableCallback } from '../../shared/useStableCallback'; const RecruitingHireForm: React.FC = ({ requestType, @@ -83,6 +84,14 @@ const RecruitingHireForm: React.FC = ({ 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); @@ -101,19 +110,19 @@ const RecruitingHireForm: React.FC = ({ setData(loaded.data); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); - if (loaded.code) onCodeLoaded?.(loaded.code); + if (loaded.code) notifyCode(loaded.code); setExistingAttachments(atts); } }) .catch((e) => { if (alive) { - submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); } }); return () => { alive = false; }; - }, [editId, editListId]); + }, [editId, editListId, notifyCode, setSubmitError]); // Маршрут согласования (планируемый) резолвится по email инициатора — текущего // пользователя (кандидата ещё нет в системе). У заявки, открытой на diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index 93f0267..67c8f01 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -33,6 +33,7 @@ import { CandidateSection } from './sections/CandidateSection'; import { PpeSection } from './sections/PpeSection'; import { ConditionsSection } from './sections/ConditionsSection'; import { AgencySection } from './sections/AgencySection'; +import { useStableCallback } from '../../shared/useStableCallback'; interface IRecruitingHireViewProps { context: WebPartContext; @@ -56,6 +57,11 @@ const NOOP_SET: SetField = () => undefined; // Переиспользует те же секции, что и форма подачи; редактирование выключено // через — поля сами становятся неизменяемыми. const RecruitingHireView: 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(''); @@ -116,7 +122,7 @@ const RecruitingHireView: React.FC = ({ context, itemI setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); - onCodeLoaded?.(loaded.code); + notifyCode(loaded.code); setAttachments(files); setError(null); setLoading(false); @@ -131,7 +137,7 @@ const RecruitingHireView: React.FC = ({ context, itemI return () => { alive = false; }; - }, [itemId, listId]); + }, [itemId, listId, notifyCode]); if (loading) { return
Загрузка заявки…
; diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index ef1a087..c7565ff 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -52,6 +52,7 @@ import { DepartmentSection } from './sections/DepartmentSection'; import { VacancySection } from './sections/VacancySection'; import { HealthTripsSection } from './sections/HealthTripsSection'; import { ConditionsSection } from './sections/ConditionsSection'; +import { useStableCallback } from '../../shared/useStableCallback'; const RecruitingSelectionForm: React.FC = ({ requestType, @@ -69,6 +70,14 @@ const RecruitingSelectionForm: React.FC = ({ 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 })); @@ -86,20 +95,19 @@ const RecruitingSelectionForm: React.FC = ({ setData(loaded.data); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); - if (loaded.code) onCodeLoaded?.(loaded.code); + if (loaded.code) notifyCode(loaded.code); setExistingAttachments(atts); } }) .catch((e) => { if (alive) { - submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); } }); return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId, editListId]); + }, [editId, editListId, notifyCode, setSubmitError]); // Компании ответственного (Employee/ByEmail.company/companyId) → предзаполнение // «1.6 Организация» (Companies/CompanyPickerMulti). companyId из Employee/ByEmail @@ -132,8 +140,9 @@ const RecruitingSelectionForm: React.FC = ({ return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId]); + // context — единственный экземпляр WebPartContext на всё время жизни + // веб-части, так что перезапуска эффекта эта зависимость не добавляет. + }, [editId, context]); // Выбор другого ответственного подтягивает его подразделение и организацию. const handleResponsibleChange = (login: string): void => { diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx index d0d8e61..8ad9098 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx @@ -29,6 +29,7 @@ import { DepartmentSection } from './sections/DepartmentSection'; import { VacancySection } from './sections/VacancySection'; import { HealthTripsSection } from './sections/HealthTripsSection'; import { ConditionsSection } from './sections/ConditionsSection'; +import { useStableCallback } from '../../shared/useStableCallback'; interface IRecruitingSelectionViewProps { context: WebPartContext; @@ -47,6 +48,11 @@ const NOOP_SET: SetField = () => undefined; // Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же // секции, что и форма подачи; редактирование выключено через . const RecruitingSelectionView: 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(''); @@ -80,7 +86,7 @@ const RecruitingSelectionView: React.FC = ({ cont setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); - onCodeLoaded?.(loaded.code); + notifyCode(loaded.code); setAttachments(files); setError(null); setLoading(false); @@ -95,7 +101,7 @@ const RecruitingSelectionView: React.FC = ({ cont return () => { alive = false; }; - }, [itemId, listId]); + }, [itemId, listId, notifyCode]); if (loading) { return
Загрузка заявки…
; diff --git a/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx index 14b6032..c5f31ed 100644 --- a/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx +++ b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx @@ -43,6 +43,7 @@ import { CandidateSection } from './sections/CandidateSection'; import { VacancySection } from './sections/VacancySection'; import { CandidateInfoSection } from './sections/CandidateInfoSection'; import { AssessmentSection } from './sections/AssessmentSection'; +import { useStableCallback } from '../../shared/useStableCallback'; const ReferralForm: React.FC = ({ requestType, @@ -58,6 +59,14 @@ const ReferralForm: React.FC = ({ 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 })); @@ -83,19 +92,18 @@ const ReferralForm: React.FC = ({ setData(loaded.data); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); - if (loaded.code) onCodeLoaded?.(loaded.code); + if (loaded.code) notifyCode(loaded.code); } }) .catch((e) => { if (alive) { - submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); } }); return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId, editListId]); + }, [editId, editListId, notifyCode, setSubmitError]); // Новая заявка: рекомендатель — сам подающий (подставляем его; подразделение — // из профиля SharePoint SPS-Department, как в «Обходном листе»; должность и @@ -136,8 +144,10 @@ const ReferralForm: React.FC = ({ return () => { alive = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editId]); + // context — единственный экземпляр WebPartContext на всё время жизни + // веб-части, approval мемоизирован по editListId: перезапуска эффекта эти + // зависимости не добавляют. + }, [editId, context, approval]); // Выбор другого рекомендателя подтягивает его подразделение из профиля, // должность и организации — из Employee/ByEmail. Оба поля (Position/ diff --git a/src/webparts/requestPortal/forms/Referral/ReferralView.tsx b/src/webparts/requestPortal/forms/Referral/ReferralView.tsx index 15cda2e..55ce994 100644 --- a/src/webparts/requestPortal/forms/Referral/ReferralView.tsx +++ b/src/webparts/requestPortal/forms/Referral/ReferralView.tsx @@ -29,6 +29,7 @@ import { CandidateSection } from './sections/CandidateSection'; import { VacancySection } from './sections/VacancySection'; import { CandidateInfoSection } from './sections/CandidateInfoSection'; import { AssessmentSection } from './sections/AssessmentSection'; +import { useStableCallback } from '../../shared/useStableCallback'; interface IReferralViewProps { context: WebPartContext; @@ -48,6 +49,11 @@ const NOOP_SET: SetField = () => undefined; // те же секции, что и форма подачи; редактирование выключено через // — поля сами становятся неизменяемыми. const ReferralView: 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(''); @@ -101,7 +107,7 @@ const ReferralView: React.FC = ({ context, itemId, formKey, setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); - onCodeLoaded?.(loaded.code); + notifyCode(loaded.code); setError(null); setLoading(false); } @@ -115,7 +121,7 @@ const ReferralView: React.FC = ({ context, itemId, formKey, return () => { alive = false; }; - }, [itemId, listId]); + }, [itemId, listId, notifyCode]); if (loading) { return
Загрузка заявки…
; diff --git a/src/webparts/requestPortal/forms/ServiceRequest/README.md b/src/webparts/requestPortal/forms/ServiceRequest/README.md new file mode 100644 index 0000000..f7576d1 --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/README.md @@ -0,0 +1,28 @@ +# Заявка на услуги (`wh-service-request`) + +**Назначение:** электронный аналог бумажной Service Requisition Form ПТО — заявка на договор/услуги/работы (субподряд, поставка, аренда) по проекту. +**Категория:** Склад / Снабжение +**Списки SP:** «Заявка на услуги — данные», «Заявка на услуги — согласование» +**RequestTypeId (внешний API маршрутов):** резолвится в рантайме по `pageUrl` (см. `docs/forms.md`) + +## Маршрут +- Фазы: согласование (без фазы исполнения — договор дальше ведётся вне портала) +- Особенности: маршрут зависит от выбранного проекта (`ProjectId`, oid из `ProjectPicker`) — пока проект не выбран, `resolveRoute` отвечает 404, и матрица не резолвится (см. `matrixReady` в `ServiceRequestForm.tsx`, тот же приём, что в `TmcTransferForm`) + +## Особое поведение +- Подача за другого: заявитель — `PeopleField`, по умолчанию текущий пользователь, можно выбрать любого — маршрут резолвится на ВЫБРАННОГО (см. `handleRequesterChange`). +- Табличная часть «Приложения» (`Annexes`) — описание документа + номер приложения + сам файл, прикреплённый прямо к строке (`fileName`, хранится как встроенное вложение элемента списка — см. shared/attachments). Отдельного общего блока «Вложения» у формы нет: каждый файл привязан к своей строке. +- Табличная часть «Смета» (`Costs`) — список групп (разделов работ), внутри каждой свои позиции; название группы необязательно. Сумма по позиции, по группе и общий итог считаются на лету (`qty × rate`, см. `cost.ts`), не хранятся в структуре — правка количества/цены не может их рассинхронизировать. +- Под «Объём работ» (текстовое поле) — детализация по позициям в ТОМ ЖЕ формате, что и смета: группы/позиции/цена/сумма (`WorkVolumes`, см. `CostGroupsEditor.tsx` — общий редактор, переиспользуется и в CostsSection, и здесь). Дополняет текст итоговыми цифрами, не заменяет его. +- `Costs`/`WorkVolumes` сериализуются в JSON-колонки (`EstimateJson`/`WorkVolumesJson`) ОБЪЕКТОМ `{ groups, total }` — готовый итог кладётся прямо в JSON рядом с группами (см. `serializeGroups`/`parseGroupedJson` в `cost.ts`), а не отдельной числовой колонкой: он и так пересчитывается из groups при каждом сохранении, отдельная колонка была бы лишней схемой ради того же значения. Сама форма читает только `groups` — total из хранилища не доверяет, всегда считает заново (`costTotal`); `total` в JSON нужен тем, кто читает колонку напрямую (отчёты, sp-query), не поднимая форму. +- `Annexes` сериализуется в `AnnexesJson` просто массивом (без обёртки total — там считать нечего) — тот же приём, что в `TmcTransfer` (`ItemsJson`). +- Продолжительность работ под датами «начало/окончание» — производная величина (считается на лету), в SharePoint не пишется. +- Фазы исполнения нет: `saveServiceRequest` пишет маршрут с пустым списком исполнителей. + +## Секции формы +HeaderSection, ScopeSection, AnnexesSection, CostsSection, RemarksSection + +## Точки входа +- rptype: `wh-service-request` +- Режим согласующего (просмотр): есть (`ServiceRequestView`) +- «Создать на основе»: — 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..a4e1e73 --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx @@ -0,0 +1,384 @@ +import * as React from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; +import styles from './ServiceRequestForm.module.scss'; +import { + Alert, + FormCard, + InitiatorLine, + FormModeProvider, + Icon, + RequestNumber, + SubmitActions, + useRequestSubmit +} from '../../shared/ui'; +import { + ApprovalMatrix, + StatusBadge, + useMatrixEditor, + getPlannedMatrix, + notifyActiveStep, + useRequestTypeId, + REQUEST_STATUS, + isEditableStatus, + IApproverToWrite, + unassignedStepMessage, + resolveWriteListId +} from '../../shared/approval'; +import { + uploadAttachments, + getAttachments, + deleteAttachment, + IAttachmentInfo +} from '../../shared/attachments/attachments.service'; +import { effectiveUserEmail } from '../../shared/impersonation'; +import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; +import { friendlyErrorMessage } from '../../shared/diagnostics'; +import { IRequestFormProps } from '../../models/types'; +import { + approvalListTitle, + approvalListTitleForListId, + dataListTitle, + dataListTitleForListId +} from '../../shared/listTitles'; +import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; +import { hasAnyCostItem } from './cost'; +import { saveServiceRequest, serviceRequestApprovalService } from './submit'; +import { loadServiceRequest } from './load'; +import { HeaderSection } from './sections/HeaderSection'; +import { ScopeSection } from './sections/ScopeSection'; +import { AnnexesSection } from './sections/AnnexesSection'; +import { CostsSection } from './sections/CostsSection'; +import { RemarksSection } from './sections/RemarksSection'; + +const ServiceRequestForm: React.FC = ({ + requestType, + context, + onCancel, + onSaved, + editId, + editListId, + onCodeLoaded +}) => { + const [data, setData] = useState(EMPTY_DATA); + // Файлы, выбранные для строк «Приложения», ещё не загруженные на сервер (нужен + // id элемента — см. save()). Ключ — fileName (= выбранное имя строки), не индекс + // строки: индекс «плывёт» при удалении строк, а имя однозначно и переживает это. + const [annexFiles, setAnnexFiles] = 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 = hasAnyCostItem(data.Costs); + const isValid = + !!data.RequesterEmail && + data.ProjectName.trim().length > 0 && + data.ContractKind.trim().length > 0 && + data.ContractAction.trim().length > 0 && + data.ActivityName.trim().length > 0 && + data.ScopeOfWork.trim().length > 0 && + hasCosts; + const validate = (): string | undefined => { + if (!isValid) { + return 'Заполните обязательные поля: заявитель, проект, предмет заявки, действие с договором, наименование и объём работ, хотя бы одну строку сметы.'; + } + if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; + if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; + return undefined; + }; + + const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => + rows.map((r) => ({ + email: r.email, + position: r.position, + allowReassign: r.allowReassign, + order: r.order + })); + + // Ссылки на скачивание для уже загруженных файлов строк «Приложения». + const attachmentUrls = useMemo(() => { + const map: Record = {}; + existingAttachments.forEach((a) => { + map[a.fileName] = a.url; + }); + return map; + }, [existingAttachments]); + + // Выбрали файл для строки приложения — сохраняем в памяти до save() (нужен id + // элемента, см. saveServiceRequest). Если у строки уже был загруженный файл — + // сразу открепляем старый, иначе на элементе повиснет лишнее вложение. + const handlePickAnnexFile = (rowIndex: number, file: File): void => { + const prevFileName = data.Annexes[rowIndex]?.fileName; + if (prevFileName && requestId !== undefined && existingAttachments.some((a) => a.fileName === prevFileName)) { + deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, prevFileName) + .then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== prevFileName))) + .catch(() => undefined); + } + setData((prev) => ({ + ...prev, + Annexes: prev.Annexes.map((row, i) => (i === rowIndex ? { ...row, fileName: file.name } : row)) + })); + setAnnexFiles((prev) => ({ ...prev, [file.name]: file })); + }; + + // Открепить файл строки: если он уже на сервере — удаляем вложение сразу, + // иначе просто забываем ещё не загруженный выбор. + const handleRemoveAnnexFile = (rowIndex: number): void => { + const fileName = data.Annexes[rowIndex]?.fileName; + setData((prev) => ({ + ...prev, + Annexes: prev.Annexes.map((row, i) => (i === rowIndex ? { ...row, fileName: '' } : row)) + })); + if (!fileName) { + return; + } + setAnnexFiles((prev) => { + const next = { ...prev }; + delete next[fileName]; + return next; + }); + if (requestId !== undefined && existingAttachments.some((a) => a.fileName === fileName)) { + deleteAttachment(editListId || dataListTitle(FORM_ID), requestId, fileName) + .then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName))) + .catch(() => undefined); + } + }; + + const save = (status: string): (() => Promise) => 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); + } + // Файлы строк «Приложения» — только те, чья строка ещё существует (строку + // могли удалить, не открепляя файл явно — см. AnnexesSection.removeRow) и + // чьё имя всё ещё совпадает (замену уже отправляет handlePickAnnexFile). + const annexFileNames = new Set(data.Annexes.map((r) => r.fileName).filter(Boolean)); + const pendingAnnexFiles = Object.entries(annexFiles) + .filter(([fileName]) => annexFileNames.has(fileName)) + .map(([, file]) => file); + if (pendingAnnexFiles.length > 0) { + await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, pendingAnnexFiles); + setAnnexFiles({}); + setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id)); + } + if (onSaved) { + onSaved(); + } + if (status === REQUEST_STATUS.onApproval) { + await notifyActiveStep(context, approval, id, { + requestTypeId: rtId, + dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), + approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID) + }); + } + }; + + const validateSubmit = (): string | undefined => { + const formError = validate(); + if (formError) return formError; + if (matrix.error) { + return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; + } + const unassignedError = unassignedStepMessage( + matrix.rows, + (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` + ); + if (unassignedError) return unassignedError; + return undefined; + }; + + const handleSaveDraft = (): Promise => 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, + attachmentUrls, + onPickAnnexFile: handlePickAnnexFile, + onRemoveAnnexFile: handleRemoveAnnexFile + }; + + 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 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..70f1ace --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx @@ -0,0 +1,204 @@ +import * as React from 'react'; +import { useState, useEffect, useMemo } from 'react'; +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import styles from './ServiceRequestView.module.scss'; +import { + Alert, + Button, + FormCard, + InitiatorLine, + FormModeProvider, + PrintSplitButton, + OrgLetterhead, + Icon +} from '../../shared/ui'; +import { navigate } from '../../shared/routing'; +import { + ApproverService, + DecisionPanel, + StatusBadge, + ApprovalMatrix, + useMatrixEditor, + notifyActiveStep, + useRequestTypeId, + isEditableStatus +} from '../../shared/approval'; +import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; +import { friendlyErrorMessage } from '../../shared/diagnostics'; +import { + approvalListTitle, + approvalListTitleForListId, + dataListTitle, + dataListTitleForListId +} from '../../shared/listTitles'; +import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; +import { loadServiceRequest } from './load'; +import { serviceRequestApprovalService } from './submit'; +import { HeaderSection } from './sections/HeaderSection'; +import { ScopeSection } from './sections/ScopeSection'; +import { AnnexesSection } from './sections/AnnexesSection'; +import { CostsSection } from './sections/CostsSection'; +import { RemarksSection } from './sections/RemarksSection'; + +interface IServiceRequestViewProps { + context: WebPartContext; + itemId: number; + /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ + formKey: string; + /** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */ + listId?: string; + /** Уведомление о загруженном уникальном коде заявки — см. IApprovalViewProps.onCodeLoaded. */ + onCodeLoaded?: (code: string) => void; +} + +// Заявка «Заявка на услуги» только для просмотра (режим согласующего). +const ServiceRequestView: React.FC = ({ 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]); + + // Ссылки на скачивание для файлов строк «Приложения» (см. AnnexesSection). + // Считаем ДО ранних return'ов ниже: хук, вызванный только на части рендеров, + // ломает порядок хуков (React #310 — «rendered more hooks than during the + // previous render»), потому что на первом рендере (loading) его бы не было. + const attachmentUrls = useMemo(() => { + const map: Record = {}; + attachments.forEach((a) => { + map[a.fileName] = a.url; + }); + return map; + }, [attachments]); + + if (loading) { + return
Загрузка заявки…
; + } + if (error) { + return ( +
+ {error} +
+ ); + } + + const sectionProps = { data, set, context, printOrgIndex, attachmentUrls }; + + const notify = (): void => { + if (!requestTypeId) return; + notifyActiveStep(context, approval, itemId, { + requestTypeId, + dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID), + approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID) + }).catch(() => undefined); + }; + + return ( +
+
+ {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..eafb929 --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/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/ServiceRequest/cost.ts b/src/webparts/requestPortal/forms/ServiceRequest/cost.ts new file mode 100644 index 0000000..6bf813b --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/cost.ts @@ -0,0 +1,77 @@ +import { ICostGroup, ICostRow } from './model'; + +/** + * Число из поля ввода: пустое/нечисловое — 0. Пробелы убираем (их вставляет + * копипаста из Excel), запятую приводим к точке — в русской раскладке + * десятичный разделитель именно запятая. + */ +function toNumber(raw: string): number { + const normalized = raw.replace(/\s/g, '').replace(',', '.'); + const value = Number(normalized); + return Number.isFinite(value) ? value : 0; +} + +/** Сумма по строке сметы без НДС: количество × цена. */ +export function rowAmount(row: ICostRow): number { + return toNumber(row.qty) * toNumber(row.rate); +} + +/** Сумма по группе сметы без НДС. */ +export function groupAmount(group: ICostGroup): number { + return group.items.reduce((sum, row) => sum + rowAmount(row), 0); +} + +/** Итог по всей смете без НДС (сумма по всем группам). */ +export function costTotal(groups: ICostGroup[]): number { + return groups.reduce((sum, group) => sum + groupAmount(group), 0); +} + +/** Есть хотя бы одна заполненная позиция хоть в одной группе — минимум для отправки. */ +export function hasAnyCostItem(groups: ICostGroup[]): boolean { + return groups.some((group) => group.items.some((row) => row.description.trim().length > 0)); +} + +/** + * Форма JSON-колонки для групп (EstimateJson / WorkVolumesJson): группы + + * готовый итог. Итог кладём РЯДОМ с группами в тот же JSON, а не отдельной + * числовой колонкой SharePoint — он и так пересчитывается из groups при + * каждом сохранении (см. serializeGroups), отдельная колонка была бы просто + * ещё одним местом, которое нужно провижинить и держать в курсе схемы. + */ +export interface IGroupedJson { + groups: ICostGroup[]; + total: number; +} + +/** Собрать JSON-колонку: группы как есть + пересчитанный итог. */ +export function serializeGroups(groups: ICostGroup[]): IGroupedJson { + return { groups, total: costTotal(groups) }; +} + +/** + * Разобрать JSON-колонку групп. Итог из хранилища НЕ используется формой — + * она всегда считает его заново из groups (см. costTotal); в разобранном + * объекте total пригождается только тем, кто читает эту колонку напрямую + * (отчёты, sp-query), не поднимая форму. + */ +export function parseGroupedJson(raw: unknown): ICostGroup[] { + if (typeof raw !== 'string' || raw.trim() === '') return []; + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed as ICostGroup[]; // старый формат (просто массив), до появления total + if (parsed && typeof parsed === 'object' && Array.isArray((parsed as IGroupedJson).groups)) { + return (parsed as IGroupedJson).groups; + } + return []; + } catch { + return []; + } +} + +/** + * Сумма для показа: «1 234 567,89». Без символа валюты — валюта подписана в + * заголовке колонки (тенге), как в бумажной форме. + */ +export function formatAmount(value: number): string { + return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value); +} diff --git a/src/webparts/requestPortal/forms/ServiceRequest/load.ts b/src/webparts/requestPortal/forms/ServiceRequest/load.ts new file mode 100644 index 0000000..7d172ce --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/load.ts @@ -0,0 +1,110 @@ +import { getSP } from '../../shared/pnp/pnpjs'; +import { dataListTitle } from '../../shared/listTitles'; +import { parseGroupedJson } from './cost'; +import { FORM_ID, IFormData, EMPTY_DATA, EMPTY_ANNEX_ROW, IAnnexRow } from './model'; + +export interface ILoadedRequest { + data: IFormData; + status: string; + /** Уникальный код заявки; '' — если запись создана до появления поля. */ + code: string; +} + +/** Колонки списка данных, из которых собирается модель формы. */ +const SELECT_FIELDS = [ + 'Id', + 'RequesterEmail', + 'RequesterName', + 'RequesterJobTitle', + 'Organization', + 'Department', + 'DepartmentId', + 'RequestDate', + 'Revision', + 'ProjectName', + 'ProjectId', + 'ProjectManager', + 'Customer', + 'ActivityId', + 'ActivityName', + 'ContractKind', + 'ContractAction', + 'ScopeOfWork', + 'SuggestedSuppliers', + 'EstimatedStartDate', + 'EstimatedFinishDate', + 'GeneralRemarks', + 'AnnexesJson', + 'WorkVolumesJson', + 'EstimateJson', + 'RequestStatus', + 'RequestUniqueCode' +]; + +/** + * Безопасный разбор JSON-колонки табличной части: пустой массив при пустом + * значении или битом JSON — заявка должна открываться даже с испорченной + * таблицей, иначе её нельзя было бы починить через форму. + */ +function parseRows(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) || '', + WorkVolumes: parseGroupedJson(item.WorkVolumesJson), + SuggestedSuppliers: (item.SuggestedSuppliers as string) || '', + EstimatedStartDate: toDateInput(item.EstimatedStartDate), + EstimatedFinishDate: toDateInput(item.EstimatedFinishDate), + GeneralRemarks: (item.GeneralRemarks as string) || '', + // fileName — через spread с дефолтом: заявки, сохранённые до появления + // прикреплённых файлов у строки приложения, не имеют его в JSON вовсе. + Annexes: parseRows(item.AnnexesJson).map((row) => ({ ...EMPTY_ANNEX_ROW, ...row })), + Costs: parseGroupedJson(item.EstimateJson) + }; + + 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..76edf35 --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/model.ts @@ -0,0 +1,217 @@ +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { ISelectOption } from '../../shared/ui'; +import { RequestTypeId } from '../../models/types'; + +/** + * Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков + * SharePoint выводятся из него, см. shared/listTitles.ts. + */ +export const FORM_ID: RequestTypeId = 'wh-service-request'; + +/** + * Строка таблицы приложений: документ, который заявитель прикладывает к + * заявке (чертежи, ТЗ, ведомости объёмов, перечень материалов…). + */ +export interface IAnnexRow { + /** Описание документа. */ + description: string; + /** Номер приложения / шифр проекта (напр. «ASL-1812-2025-5-КЖ»). */ + annexNo: string; + /** + * Имя реально прикреплённого файла — привязывает строку к одному из вложений + * элемента списка (см. shared/attachments). '' — файл ещё не выбран. + * Отдельного списка/библиотеки под это не заводим: вложения — встроенная + * возможность любого списка SharePoint, форма использует её напрямую, без + * отдельного общего блока «Вложения» — каждый файл привязан к своей строке. + */ + fileName: string; +} + +/** + * Строка сметы. Сумму по строке НЕ храним — она считается из qty × rate + * (см. cost.ts): иначе при правке количества сумма молча расходилась бы. + */ +export interface ICostRow { + /** Наименование позиции. */ + description: string; + /** Единица измерения. */ + unit: string; + /** Количество. */ + qty: string; + /** Цена за единицу без НДС, тенге. */ + rate: string; +} + +/** + * Группа сметы — раздел работ (напр. «Бетонная подготовка», «Монолитные + * фундаменты»), внутри которого свои позиции. У бумажной формы такого деления + * нет (там одна плоская таблица), но реальные сметы почти всегда идут по + * разделам — группировка нужна, чтобы длинная смета читалась. Название группы + * необязательно (можно оставить одну безымянную «Группа 1» на всю смету). + */ +export interface ICostGroup { + /** Название раздела сметы; '' — группа без названия. */ + name: string; + items: ICostRow[]; +} + +export const EMPTY_ANNEX_ROW: IAnnexRow = { description: '', annexNo: '', fileName: '' }; +/** Пустая строка сметы — количество/цена вводятся руками, без значений по умолчанию. */ +export const EMPTY_COST_ROW: ICostRow = { description: '', unit: '', qty: '', rate: '' }; +/** Новая группа сметы — сразу с одной пустой позицией, чтобы не жать «Добавить позицию» отдельно. */ +export const EMPTY_COST_GROUP: ICostGroup = { name: '', items: [{ ...EMPTY_COST_ROW }] }; + +/** Предмет заявки (чем закрывается потребность). */ +export const CONTRACT_KIND_OPTIONS: ISelectOption[] = [ + { value: 'Субподряд', label: 'Субподряд' }, + { value: 'Услуги', label: 'Услуги' }, + { value: 'Поставка', label: 'Поставка' }, + { value: 'Аренда', label: 'Аренда' }, + { value: 'Прочее', label: 'Прочее' } +]; + +/** Что делаем с договором — в бумажной форме это ряд взаимоисключающих галочек. */ +export const CONTRACT_ACTION_OPTIONS: ISelectOption[] = [ + { value: 'Новый договор', label: 'Новый договор' }, + { value: 'Доп. соглашение', label: 'Доп. соглашение' }, + { value: 'Возобновление договора', label: 'Возобновление договора' }, + { value: 'Пролонгация договора', label: 'Пролонгация договора' }, + { value: 'Расторжение договора', label: 'Расторжение договора' } +]; + +/** + * Данные формы «Заявка на услуги» (бумажный аналог — Service Requisition Form + * ПТО). Ключи = internalName колонок SP-списка «Заявка на услуги — данные». + * Табличные части (Annexes / Costs) в колонки не разворачиваются, а + * сериализуются в JSON-колонки AnnexesJson / EstimateJson (см. submit.ts / load.ts). + */ +export interface IFormData { + // --- Заявитель --- + /** Email заявителя; по нему резолвится маршрут согласования (можно подать за другого). */ + RequesterEmail: string; + /** Ф.И.О. заявителя — для печати; подтягивается из Employee/ByEmail. */ + RequesterName: string; + /** Должность заявителя (из справочника, можно поправить — справочник не всегда точен). */ + RequesterJobTitle: string; + /** Организации заявителя (`\n`-joined «Компания — должность»), read-only. */ + Organization: string; + /** Отдел (справочник Dictionary/Departments) — на маршрут не влияет. */ + Department: string; + /** Id отдела из справочника; '' — если введён вручную. */ + DepartmentId: string; + /** Дата заявки (YYYY-MM-DD). */ + RequestDate: string; + /** Ревизия заявки: бумажная форма живёт ревизиями (00, U01, U02…). */ + Revision: string; + + // --- Проект и работы --- + ProjectName: string; + /** oid проекта ('' — если введён вручную); по нему бэкенд резолвит руководителя проекта. */ + ProjectId: string; + /** Снимок email руководителя проекта — для разбора маршрута постфактум. */ + ProjectManager: string; + /** Заказчик проекта (подставляется из справочника проектов, можно поправить). */ + Customer: string; + /** Идентификация работ / Activity ID (напр. «КОД СМР 2.3»). */ + ActivityId: string; + /** Наименование работ. */ + ActivityName: string; + /** Предмет заявки — см. CONTRACT_KIND_OPTIONS. */ + ContractKind: string; + /** Действие с договором — см. CONTRACT_ACTION_OPTIONS. */ + ContractAction: string; + + // --- Описание требований --- + /** Объём работ — основное содержание заявки, длинный текст. */ + ScopeOfWork: string; + /** + * Детализация объёмов работ по разделам (группам) — тот же формат + * группа/позиция/цена/сумма, что и у сметы (см. CostGroupsEditor, + * сериализуется в WorkVolumesJson). Дополняет текстовое описание + * ScopeOfWork итоговыми цифрами по каждой позиции, а не заменяет его. + */ + WorkVolumes: ICostGroup[]; + /** Предлагаемые поставщики / требования к субподрядчику. */ + SuggestedSuppliers: string; + EstimatedStartDate: string; + EstimatedFinishDate: string; + + /** Общие замечания. */ + GeneralRemarks: string; + + // --- Табличные части --- + /** Приложения к заявке (сериализуются в AnnexesJson). */ + Annexes: IAnnexRow[]; + /** Сметная стоимость по группам (сериализуется в EstimateJson). */ + Costs: ICostGroup[]; +} + +/** Обновление одного поля формы с сохранением типа значения. */ +export type SetField = (key: K, value: IFormData[K]) => void; + +/** Пропсы каждой секции формы. */ +export interface ISectionProps { + data: IFormData; + set: SetField; + context: WebPartContext; + /** Индекс организации для печати (см. shared/ui/PrintSplitButton и OrganizationField.printIndex). */ + printOrgIndex?: number; + /** + * Выбор заявителя подтягивает его ФИО, должность и организации — поэтому у + * поля отдельный обработчик. Не передан (режим чтения) — правка через set. + */ + onRequesterChange?: (login: string) => void; + /** + * Только для AnnexesSection: ссылки на скачивание уже прикреплённых файлов + * (fileName → ServerRelativeUrl). Не передан — ссылки не показываются + * (напр. новая заявка до первого сохранения, там вложений ещё нет). + */ + attachmentUrls?: Record; + /** + * Только для AnnexesSection: выбрать файл для строки приложения — форма + * держит его в памяти (см. ServiceRequestForm.annexFiles) и реально + * загружает при сохранении (нужен id элемента). Не передан — прикрепление + * недоступно (режим просмотра). + */ + onPickAnnexFile?: (rowIndex: number, file: File) => void; + /** + * Только для AnnexesSection: открепить файл строки. Если он уже загружен на + * сервер — форма сразу удаляет вложение элемента, иначе просто забывает + * выбор (ещё не сохранённый). Не передан — открепление недоступно. + */ + onRemoveAnnexFile?: (rowIndex: number) => void; +} + +/** Ревизия по умолчанию — как в бумажной форме («Рев:00»). */ +export const DEFAULT_REVISION = '00'; +export const REQUESTER_PLACEHOLDER = 'Начните вводить Ф.И.О. заявителя…'; + +export const EMPTY_DATA: IFormData = { + RequesterEmail: '', + RequesterName: '', + RequesterJobTitle: '', + Organization: '', + Department: '', + DepartmentId: '', + RequestDate: '', + Revision: DEFAULT_REVISION, + ProjectName: '', + ProjectId: '', + ProjectManager: '', + Customer: '', + ActivityId: '', + ActivityName: '', + ContractKind: '', + ContractAction: '', + ScopeOfWork: '', + // Новая заявка сразу показывает одну пустую группу (не «пустое место с + // кнопкой») — так понятнее, что заполнять; загруженная заявка эти значения + // всё равно перезапишет (см. load.ts). Тот же приём — у Costs ниже. + WorkVolumes: [{ name: '', items: [{ ...EMPTY_COST_ROW }] }], + SuggestedSuppliers: '', + EstimatedStartDate: '', + EstimatedFinishDate: '', + GeneralRemarks: '', + Annexes: [], + Costs: [{ name: '', items: [{ ...EMPTY_COST_ROW }] }] +}; diff --git a/src/webparts/requestPortal/forms/ServiceRequest/provisioning.json b/src/webparts/requestPortal/forms/ServiceRequest/provisioning.json new file mode 100644 index 0000000..6d77ce3 --- /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": "WorkVolumesJson", "title": "Объёмы работ по позициям (JSON, с итогом)", "type": "Note" }, + { "internalName": "SuggestedSuppliers", "title": "Предлагаемые поставщики", "type": "Note" }, + { "internalName": "EstimatedStartDate", "title": "Предполагаемая дата начала", "type": "DateTime", "dateOnly": true }, + { "internalName": "EstimatedFinishDate", "title": "Предполагаемая дата окончания", "type": "DateTime", "dateOnly": true }, + { "internalName": "GeneralRemarks", "title": "Общие замечания", "type": "Note" }, + + { "internalName": "AnnexesJson", "title": "Приложения (JSON)", "type": "Note" }, + { "internalName": "EstimateJson", "title": "Сметная стоимость (JSON, с итогом)", "type": "Note" } + ] +} 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..a415fef --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.module.scss @@ -0,0 +1,174 @@ +@import '../../../../../styles/design-tokens'; + +// Тело CollapseSection — грид 1fr 1fr; таблица должна занимать обе колонки. +.full { + grid-column: 1 / -1; +} + +.tableWrap { + width: 100%; + overflow-x: auto; +} + + +.table { + width: 100%; + border-collapse: collapse; + font-family: $font-sans; + font-size: $fs-sm; + color: $gray-900; + + th, + td { + border: 1px solid $gray-200; + padding: 0; + text-align: left; + vertical-align: middle; + } + + th { + padding: 7px 9px; + background: $navy-50; + font-weight: 600; + font-size: $fs-caption; + color: $gray-700; + white-space: nowrap; + } + + td { + padding: 4px 6px; + } +} + +.num { + width: 36px; + text-align: center; +} + +.annexNo { + width: 180px; +} + +.file { + width: 220px; +} + +.act { + width: 40px; + text-align: center; +} + +// Скрытый нативный — открывается кликом по кнопке .attach. +.fileInput { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + opacity: 0; + pointer-events: none; +} + +.attach { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 9px; + border: 1px dashed $gray-300; + border-radius: $r-sm; + background: transparent; + color: $gray-700; + font-family: $font-sans; + font-size: $fs-caption; + cursor: pointer; + transition: .18s; + + &:hover { + border-color: $gold-400; + color: $gold-600; + } +} + +.fileName { + display: inline-flex; + align-items: center; + gap: $sp-2; + max-width: 100%; + + a, + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: $font-sans; + font-size: $fs-sm; + } + + a { + color: $navy-700; + + &:hover { + color: $gold-600; + } + } +} + +.removeFile { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 4px; + border: 0; + border-radius: $r-sm; + background: transparent; + color: $gray-500; + cursor: pointer; + transition: .18s; + + &:hover { + background: $red-50; + color: $red-500; + } +} + +.noFile { + color: $gray-400; +} + +.remove { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 5px; + border: 0; + border-radius: $r-sm; + background: transparent; + color: $gray-500; + cursor: pointer; + transition: .18s; + + &:hover { + background: $red-50; + color: $red-500; + } +} + +.empty { + padding: 14px !important; + text-align: center; + color: $gray-500; +} + +.toolbar { + display: flex; + align-items: center; + gap: $sp-2; + margin-top: $sp-3; +} + +// См. CostGroupsEditor: на бумаге прокрутки нет, уехавшее вбок теряется. +@media print { + .tableWrap { + overflow: visible; + } +} 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..2145dfa --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/AnnexesSection.tsx @@ -0,0 +1,177 @@ +import * as React from 'react'; +import styles from './AnnexesSection.module.scss'; +import { Button, CollapseSection, Icon, TextInput, useFormReadOnly } from '../../../shared/ui'; +import { EMPTY_ANNEX_ROW, IAnnexRow, ISectionProps } from '../model'; + +/** Файл строки: ссылка на скачивание, если уже загружен, иначе — прикрепить/открепить. */ +const AnnexFileCell: React.FC<{ + row: IAnnexRow; + readOnly: boolean; + url?: string; + onPick?: (file: File) => void; + onRemove?: () => void; +}> = ({ row, readOnly, url, onPick, onRemove }) => { + const inputRef = React.useRef(null); + + if (!row.fileName) { + if (readOnly || !onPick) { + return —; + } + return ( + <> + + { + const file = e.target.files?.[0]; + if (file) onPick(file); + e.target.value = ''; + }} + /> + + ); + } + + return ( + + {url ? ( + + {row.fileName} + + ) : ( + {row.fileName} + )} + {!readOnly && onRemove && ( + + )} + + ); +}; + +/** + * Приложения к заявке (проектные чертежи, ТЗ, ведомости объёмов, перечень + * материалов…) — описание документа, номер приложения и сам файл. Файл + * физически хранится как вложение элемента списка (та же встроенная + * возможность SharePoint, что раньше использовал общий блок «Вложения» — + * его убрали, каждый файл теперь привязан к своей строке напрямую, отдельная + * библиотека под это не нужна). + */ +export const AnnexesSection: React.FC = ({ + data, + set, + attachmentUrls, + onPickAnnexFile, + onRemoveAnnexFile +}) => { + 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 => { + // Строка с файлом (уже загруженным или ещё нет) — сначала открепляем через + // родителя (см. ServiceRequestForm.handleRemoveAnnexFile), иначе загруженное + // вложение останется висеть на элементе без ссылающейся на него строки. + if (data.Annexes[index]?.fileName) { + onRemoveAnnexFile?.(index); + } + 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} + +
{i + 1} + setRow(i, { description: v })} + ariaLabel="Описание документа" + borderless + /> + + setRow(i, { annexNo: v })} + ariaLabel="Приложение №" + borderless + /> + + onPickAnnexFile(i, file) : undefined} + onRemove={onRemoveAnnexFile ? () => onRemoveAnnexFile(i) : undefined} + /> + + +
+
+ + {!readOnly && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/src/webparts/requestPortal/forms/ServiceRequest/sections/CostGroupsEditor.module.scss b/src/webparts/requestPortal/forms/ServiceRequest/sections/CostGroupsEditor.module.scss new file mode 100644 index 0000000..d3f1277 --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/CostGroupsEditor.module.scss @@ -0,0 +1,175 @@ +@import '../../../../../styles/design-tokens'; + +// Тело CollapseSection — грид 1fr 1fr; смета должна занимать обе колонки. +.full { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: $sp-4; +} + +.group { + border: 1px solid $gray-200; + border-radius: $r-md; + background: $gray-50; + padding: $sp-4; +} + +.groupHead { + display: flex; + align-items: center; + gap: $sp-3; + margin-bottom: $sp-3; +} + +.groupLabel { + flex-shrink: 0; + font-family: $font-sans; + font-size: $fs-sm; + font-weight: 600; + color: $gray-700; +} + +.groupHead input { + flex: 1; + min-width: 0; +} + +.removeGroup { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 6px; + border: 0; + border-radius: $r-sm; + background: transparent; + color: $gray-500; + cursor: pointer; + transition: .18s; + + &:hover { + background: $red-50; + color: $red-500; + } +} + +// Строка целиком в фикс-колонках (ед.изм/кол-во/цена/сумма) физически не +// влезает в узкую панель — форма открывается в Drawer сбоку, а не на всю +// страницу, и его ширина не связана с шириной окна браузера (в отличие от +// брейкпоинтов CollapseSection/FormGrid, которые следят за viewport, а не за +// шириной своего контейнера). Вместо того чтобы сжимать поля до +// нечитаемости, даём строке её естественную минимальную ширину и скроллим +// горизонтально контейнером — тот же приём, что раньше был у плоской таблицы +// (см. AnnexesSection .tableWrap), а не «резиновая» вёрстка, которая сама себя +// сжать до бесконечности не умеет (grid-колонки по умолчанию не сжимаются +// уже своего содержимого). +.rowsWrap { + width: 100%; + overflow-x: auto; +} + +.row { + display: grid; + grid-template-columns: minmax(160px, 1fr) 84px 96px 128px 128px 30px; + gap: $sp-2; + align-items: center; + min-width: 640px; + + & + & { + margin-top: $sp-2; + } +} + +.colHead { + font-family: $font-sans; + font-size: 11px; + font-weight: 600; + letter-spacing: .03em; + text-transform: uppercase; + color: $gray-500; +} + +.cellStatic { + padding: 9px 12px; + font-family: $font-sans; + font-size: $fs-sm; + color: $gray-900; +} + +.cellAmount { + padding: 9px 12px; + font-family: $font-sans; + font-size: $fs-sm; + color: $gray-500; + text-align: right; + white-space: nowrap; +} + +.removeItem { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px; + border: 0; + border-radius: $r-sm; + background: transparent; + color: $gray-500; + cursor: pointer; + transition: .18s; + + &:hover { + background: $red-50; + color: $red-500; + } +} + +.empty { + padding: $sp-3 0; + font-family: $font-sans; + font-size: $fs-sm; + color: $gray-500; + text-align: center; +} + +.groupFoot { + display: flex; + align-items: center; + justify-content: space-between; + gap: $sp-3; + margin-top: $sp-3; + flex-wrap: wrap; +} + +.groupTotal { + font-family: $font-sans; + font-size: $fs-sm; + font-weight: 600; + color: $gray-700; +} + +.total { + align-self: flex-end; + font-family: $font-sans; + font-size: $fs-body; + font-weight: 700; + color: $navy-700; +} + +// ---- Печать ---- +// На бумаге прокрутки нет: «уехавшее вбок» просто потерялось бы, а сам +// контейнер печатается вместе с полосой прокрутки (видно на печати заявки). +// Отпускаем ширину — колонки строки суммарно уже A4, так что она укладывается +// в лист целиком. +// +// Блок ДОЛЖЕН стоять в конце файла: @media print не повышает специфичность, и +// базовое `.row { min-width }` выше перебило бы это правило, стой оно раньше. +@media print { + .rowsWrap { + overflow: visible; + } + + .row { + min-width: 0; + } +} diff --git a/src/webparts/requestPortal/forms/ServiceRequest/sections/CostGroupsEditor.tsx b/src/webparts/requestPortal/forms/ServiceRequest/sections/CostGroupsEditor.tsx new file mode 100644 index 0000000..e34bb6c --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/CostGroupsEditor.tsx @@ -0,0 +1,195 @@ +import * as React from 'react'; +import styles from './CostGroupsEditor.module.scss'; +import { Button, Icon, TextInput, useFormReadOnly } from '../../../shared/ui'; +import { costTotal, formatAmount, groupAmount, rowAmount } from '../cost'; +import { EMPTY_COST_GROUP, EMPTY_COST_ROW, ICostGroup, ICostRow } from '../model'; + +interface IGroupProps { + group: ICostGroup; + index: number; + readOnly: boolean; + onChangeName: (name: string) => void; + onChangeItem: (itemIndex: number, patch: Partial) => void; + onAddItem: () => void; + onRemoveItem: (itemIndex: number) => void; + onRemoveGroup: () => void; + canRemoveGroup: boolean; +} + +/** Один раздел (группа): название + таблица позиций внутри неё. */ +const CostGroup: React.FC = ({ + group, + index, + readOnly, + onChangeName, + onChangeItem, + onAddItem, + onRemoveItem, + onRemoveGroup, + canRemoveGroup +}) => ( +
+
+ Группа {index + 1} + + {!readOnly && canRemoveGroup && ( + + )} +
+ +
+
+ Наименование позиции + Ед. изм. + Количество + Цена + Сумма + {!readOnly && } +
+ + {group.items.length === 0 ? ( +
Позиции не добавлены
+ ) : ( + group.items.map((item, i) => + readOnly ? ( +
+ {item.description || '—'} + {item.unit || '—'} + {item.qty || '—'} + {item.rate || '—'} + {formatAmount(rowAmount(item))} +
+ ) : ( +
+ onChangeItem(i, { description: v })} + placeholder="Наименование" + ariaLabel="Наименование позиции" + /> + onChangeItem(i, { unit: v })} + placeholder="шт." + ariaLabel="Единица измерения" + /> + onChangeItem(i, { qty: v })} + placeholder="0" + ariaLabel="Количество" + /> + onChangeItem(i, { rate: v })} + placeholder="0" + ariaLabel="Цена за единицу" + /> + {item.qty || item.rate ? formatAmount(rowAmount(item)) : 'авто'} + +
+ ) + ) + )} +
+ +
+ {!readOnly && ( + + )} + Сумма по группе: {formatAmount(groupAmount(group))} +
+
+); + +export interface ICostGroupsEditorProps { + groups: ICostGroup[]; + onChange: (groups: ICostGroup[]) => void; + /** Подпись итоговой строки. По умолчанию — «Итого без НДС». */ + totalLabel?: string; + /** Текст при пустом списке групп в режиме просмотра. */ + emptyReadOnlyText?: string; +} + +/** + * Редактор таблицы «группы → позиции с ценой и количеством»: используется и + * для сметной стоимости (CostsSection), и для детализации объёмов работ + * (ScopeSection) — один и тот же формат, разные поля модели формы. Сумма по + * позиции, по группе и общий итог — производные (qty × цена), считаются на + * лету и НЕ хранятся построчно: правка количества/цены не может их + * рассинхронизировать (см. cost.ts). + */ +export const CostGroupsEditor: React.FC = ({ + groups, + onChange, + totalLabel = 'Итого без НДС', + emptyReadOnlyText = 'Не заполнено' +}) => { + const readOnly = useFormReadOnly(); + + const setGroup = (index: number, patch: Partial): void => { + onChange(groups.map((group, i) => (i === index ? { ...group, ...patch } : group))); + }; + + const setItem = (groupIndex: number, itemIndex: number, patch: Partial): void => { + setGroup(groupIndex, { + items: groups[groupIndex].items.map((row, i) => (i === itemIndex ? { ...row, ...patch } : row)) + }); + }; + + const addItem = (groupIndex: number): void => + setGroup(groupIndex, { items: [...groups[groupIndex].items, { ...EMPTY_COST_ROW }] }); + + const removeItem = (groupIndex: number, itemIndex: number): void => + setGroup(groupIndex, { items: groups[groupIndex].items.filter((_, i) => i !== itemIndex) }); + + const addGroup = (): void => onChange([...groups, { ...EMPTY_COST_GROUP, items: [{ ...EMPTY_COST_ROW }] }]); + + const removeGroup = (groupIndex: number): void => onChange(groups.filter((_, i) => i !== groupIndex)); + + return ( +
+ {groups.map((group, i) => ( + setGroup(i, { name })} + onChangeItem={(itemIndex, patch) => setItem(i, itemIndex, patch)} + onAddItem={() => addItem(i)} + onRemoveItem={(itemIndex) => removeItem(i, itemIndex)} + onRemoveGroup={() => removeGroup(i)} + canRemoveGroup={groups.length > 1} + /> + ))} + + {groups.length === 0 && readOnly &&
{emptyReadOnlyText}
} + + {!readOnly && ( + + )} + + {groups.length > 0 && ( +
+ {totalLabel}: {formatAmount(costTotal(groups))} +
+ )} +
+ ); +}; 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..0d3934e --- /dev/null +++ b/src/webparts/requestPortal/forms/ServiceRequest/sections/CostsSection.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import { CollapseSection } from '../../../shared/ui'; +import { CostGroupsEditor } from './CostGroupsEditor'; +import { ISectionProps } from '../model'; + +/** Сметная стоимость услуг-работ по разделам (группам) — см. CostGroupsEditor. */ +export const CostsSection: React.FC = ({ data, set }) => ( + + set('Costs', groups)} + emptyReadOnlyText="Смета не заполнена" + /> + +); 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="Выберите…" + /> + + + +