From 37bc153b9852ce3335528c77f75b600ff44f0348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Fri, 11 Sep 2026 16:55:21 +0500 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=20=D0=BC=D0=B0=D1=82=D1=80=D0=B8=D1=86=D1=83=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D1=81=D0=B5=D1=85=20=D1=82?= =?UTF-8?q?=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=20=D1=81=D0=BE=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=BE=D0=B2=20=D0=BD=D0=B0=20=D0=B1=D1=8D?= =?UTF-8?q?=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- provisioning/manifests/ct.provisioning.json | 11 +- provisioning/manifests/kps.provisioning.json | 14 + provisioning/manifests/oid.provisioning.json | 12 +- .../forms/ct/components/CtCreateForm.tsx | 34 +- .../Step2CommercialOffers.tsx | 4 +- .../Step2CommercialOffers/step2Persist.ts | 41 +- .../InlineApprovalChain.tsx | 206 ++------ .../Step3ComparisonTable.tsx | 14 +- .../Step3ComparisonTable/useStep3Approval.ts | 221 -------- .../Step4Approval/Step4Approval.module.scss | 252 +-------- .../Step4Approval/Step4Approval.tsx | 486 +++++------------ .../ct/components/Step4Approval/useIncore.ts | 101 ++++ .../Step4Approval/useStep4Approval.ts | 488 ------------------ .../forms/ct/components/hooks/useCtForm.ts | 9 +- .../forms/ct/components/types/domain.ts | 26 +- .../forms/ct/services/CtService.ts | 464 ++++------------- .../forms/kps/components/KpsCtForm.tsx | 23 +- .../Step4Approval/InlineApprovalChain.tsx | 116 ++--- .../Step4Approval/Step4Approval.module.scss | 135 ----- .../Step4Approval/Step4Approval.tsx | 264 ++++------ .../Step4Approval/useStep4Approval.ts | 396 -------------- .../common/ApprovalChain.module.scss | 71 --- .../kps/components/common/ApprovalChain.tsx | 39 -- .../forms/kps/components/types/domain.ts | 38 +- .../forms/kps/services/KpsCtFormService.ts | 279 +++------- .../forms/oid/components/OidCreateForm.tsx | 3 - .../Step1WorksInput/useStep1WorksInput.ts | 2 + .../Step2CommercialOffers.tsx | 3 - .../Step2CommercialOffers/step2Persist.ts | 39 +- .../InlineApprovalChain.tsx | 241 ++------- .../Step3ComparisonTable.tsx | 10 +- .../Step3ComparisonTable/useStep3Approval.ts | 258 --------- .../Step3ComparisonTable/useStep3Matrix.ts | 2 +- .../Step4Approval/Step4Approval.module.scss | 238 +-------- .../Step4Approval/Step4Approval.tsx | 457 +++++----------- .../Step4Approval/useStep4Approval.ts | 488 ------------------ .../forms/oid/components/hooks/useOidForm.ts | 15 +- .../forms/oid/components/types/domain.ts | 30 +- .../forms/oid/services/OidService.ts | 378 +++----------- .../shared/approval/approvalList.service.ts | 370 +++++++++++++ .../shared/approval/approvalRoute.service.ts | 89 ++++ .../approval/approvalSubmission.service.ts | 49 ++ .../approval/approverDecision.service.ts | 105 ++++ .../ctFormsPortal/shared/approval/index.ts | 21 + .../approval/models/IApprovalRouteApi.ts | 46 ++ .../shared/approval/models/IApprovalRow.ts | 14 + .../shared/approval/models/IMatrixRow.ts | 20 + .../shared/approval/models/ISiteUser.ts | 7 + .../shared/approval/models/statuses.ts | 24 + .../shared/approval/ui/ApprovalMatrix.tsx | 126 +++++ .../approval/ui/DecisionPanel.module.scss | 111 ++++ .../shared/approval/ui/DecisionPanel.tsx | 224 ++++++++ .../shared/approval/ui/StatusBadge.tsx | 23 + .../shared/approval/ui/UserPicker.tsx | 68 +++ .../shared/approval/ui/approval.module.scss | 273 ++++++++++ .../shared/approval/useMatrixEditor.ts | 146 ++++++ src/webparts/ctFormsPortal/shared/config.ts | 6 + 57 files changed, 2722 insertions(+), 4908 deletions(-) delete mode 100644 src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Approval.ts create mode 100644 src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/useIncore.ts delete mode 100644 src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/useStep4Approval.ts delete mode 100644 src/webparts/ctFormsPortal/forms/kps/components/Step4Approval/useStep4Approval.ts delete mode 100644 src/webparts/ctFormsPortal/forms/kps/components/common/ApprovalChain.module.scss delete mode 100644 src/webparts/ctFormsPortal/forms/kps/components/common/ApprovalChain.tsx delete mode 100644 src/webparts/ctFormsPortal/forms/oid/components/Step3ComparisonTable/useStep3Approval.ts delete mode 100644 src/webparts/ctFormsPortal/forms/oid/components/Step4Approval/useStep4Approval.ts create mode 100644 src/webparts/ctFormsPortal/shared/approval/approvalList.service.ts create mode 100644 src/webparts/ctFormsPortal/shared/approval/approvalRoute.service.ts create mode 100644 src/webparts/ctFormsPortal/shared/approval/approvalSubmission.service.ts create mode 100644 src/webparts/ctFormsPortal/shared/approval/approverDecision.service.ts create mode 100644 src/webparts/ctFormsPortal/shared/approval/index.ts create mode 100644 src/webparts/ctFormsPortal/shared/approval/models/IApprovalRouteApi.ts create mode 100644 src/webparts/ctFormsPortal/shared/approval/models/IApprovalRow.ts create mode 100644 src/webparts/ctFormsPortal/shared/approval/models/IMatrixRow.ts create mode 100644 src/webparts/ctFormsPortal/shared/approval/models/ISiteUser.ts create mode 100644 src/webparts/ctFormsPortal/shared/approval/models/statuses.ts create mode 100644 src/webparts/ctFormsPortal/shared/approval/ui/ApprovalMatrix.tsx create mode 100644 src/webparts/ctFormsPortal/shared/approval/ui/DecisionPanel.module.scss create mode 100644 src/webparts/ctFormsPortal/shared/approval/ui/DecisionPanel.tsx create mode 100644 src/webparts/ctFormsPortal/shared/approval/ui/StatusBadge.tsx create mode 100644 src/webparts/ctFormsPortal/shared/approval/ui/UserPicker.tsx create mode 100644 src/webparts/ctFormsPortal/shared/approval/ui/approval.module.scss create mode 100644 src/webparts/ctFormsPortal/shared/approval/useMatrixEditor.ts create mode 100644 src/webparts/ctFormsPortal/shared/config.ts diff --git a/provisioning/manifests/ct.provisioning.json b/provisioning/manifests/ct.provisioning.json index a1c3921..c917f72 100644 --- a/provisioning/manifests/ct.provisioning.json +++ b/provisioning/manifests/ct.provisioning.json @@ -27,7 +27,7 @@ "internalName": "status", "title": "Статус", "type": "Choice", - "choices": ["Черновик", "Согласовано", "На согласовании"] + "choices": ["Черновик", "Согласовано", "На согласовании", "Отклонено"] }, { "internalName": "winSupplier", @@ -286,7 +286,8 @@ "Выполнено", "Рекомендую", "Черновик", - "Отклонено" + "Отклонено", + "Ждёт очереди" ] }, { @@ -310,6 +311,12 @@ "internalName": "canAddApprover", "title": "Добавить делеганта", "type": "Boolean" + }, + { + "internalName": "delegatedBy", + "title": "Кем переназначено", + "type": "User", + "userSelectionMode": "PeopleOnly" } ] }, diff --git a/provisioning/manifests/kps.provisioning.json b/provisioning/manifests/kps.provisioning.json index 7b170a9..04c441f 100644 --- a/provisioning/manifests/kps.provisioning.json +++ b/provisioning/manifests/kps.provisioning.json @@ -267,8 +267,11 @@ "title": "Статус", "type": "Choice", "choices": [ + "Черновик", + "Ждёт очереди", "На согласовании", "Согласовано", + "Отклонено", "На исполнении", "Выполнено" ] @@ -282,6 +285,17 @@ "internalName": "comment", "title": "Комментарий", "type": "Text" + }, + { + "internalName": "allowReassign", + "title": "Можно перепоручать", + "type": "Boolean" + }, + { + "internalName": "delegatedBy", + "title": "Кем переназначено", + "type": "User", + "userSelectionMode": "PeopleOnly" } ] }, diff --git a/provisioning/manifests/oid.provisioning.json b/provisioning/manifests/oid.provisioning.json index 4f76625..abcab3f 100644 --- a/provisioning/manifests/oid.provisioning.json +++ b/provisioning/manifests/oid.provisioning.json @@ -35,7 +35,8 @@ "choices": [ "Черновик", "Согласовано", - "На согласовании" + "На согласовании", + "Отклонено" ] }, { @@ -255,7 +256,8 @@ "Выполнено", "Рекомендую", "Черновик", - "Отклонено" + "Отклонено", + "Ждёт очереди" ] }, { @@ -272,6 +274,12 @@ "internalName": "canAddAproover", "title": "Добавить делеганта", "type": "Boolean" + }, + { + "internalName": "delegatedBy", + "title": "Кем переназначено", + "type": "User", + "userSelectionMode": "PeopleOnly" } ] }, diff --git a/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx b/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx index a780040..f984e3e 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx @@ -8,20 +8,9 @@ import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSe import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers'; import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable'; import { Step4Approval } from './Step4Approval/Step4Approval'; -import { ApprovalMember, CTStatus } from './types/domain'; import styles from './CtCreateForm.module.scss'; import '@tnsu/ui-kit/styles.css'; -function deriveCtStatus(chain: ApprovalMember[]): CTStatus { - if (chain.length === 0) return 'formed'; - if (chain.some(m => m.status === 'rejected')) return 'rejected'; - if (chain.every(m => m.status === 'approved' || m.status === 'recommends' || m.status === 'skipped')) return 'approved'; - // Chain is activated only when the initiator has 'recommends' (Рекомендую). - // All-pending means chain was saved as draft but not yet activated via Step 4. - if (chain.some(m => m.status === 'recommends')) return 'on_approval'; - return 'formed'; -} - const CtCreateForm: React.FC = ({ context, userDisplayName, isDarkTheme, siteUrl }) => { const service = useMemo(() => new CtService(context, siteUrl), [context, siteUrl]); const [isRestrictedView, setIsRestrictedView] = useState(false); @@ -46,7 +35,7 @@ const CtCreateForm: React.FC = ({ context, userDisplayName, // astel-k (isRestrictedView) — независимое ограничение, складывается с этим: если его уберут, // правило по цепочке согласования останется. Сопоставление участника — по отображаемому имени // (пока тестируем; при необходимости переключим на ID). - const userInApprovalChain = (formData.approvalChain ?? []).some(m => m.userName === userDisplayName); + const userInApprovalChain = (formData.approvalMatrix ?? []).some(m => m.who === userDisplayName); const hidePrices = isRestrictedView || (formData.approvalSent === true && !userInApprovalChain); useEffect(() => { @@ -54,7 +43,11 @@ const CtCreateForm: React.FC = ({ context, userDisplayName, .then(profile => updateFormData({ initiatorDepartment: profile.department })) .catch(() => undefined); service.getCurrentUserCompany() - .then(company => setIsRestrictedView(company.toLowerCase() === 'astel-k')) + .then(company => { + setIsRestrictedView(company.toLowerCase() === 'astel-k'); + // Компания инициатора — условие маршрута согласования (см. CtService.getPlannedApprovalMatrix). + updateFormData({ initiatorCompany: company }); + }) .catch(() => undefined); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -65,9 +58,9 @@ const CtCreateForm: React.FC = ({ context, userDisplayName, if (!initialCtId) return; Promise.all([ service.loadCTById(initialCtId), - service.loadApprovalChain(initialCtId), + service.getApprovalMatrixForRequest(initialCtId), service.loadPositionsForCT(initialCtId), - ]).then(async ([ct, approvalChain, positions]) => { + ]).then(async ([ct, approvalMatrix, positions]) => { const positionSpIds = positions.map(p => p.spId).filter((id): id is number => id !== undefined); const rawOffers = await service.loadFullOffersForPositions(positionSpIds); @@ -157,10 +150,9 @@ const CtCreateForm: React.FC = ({ context, userDisplayName, } } - const derivedStatus = deriveCtStatus(approvalChain); updateFormData({ - ct: { ...ct, status: derivedStatus }, - approvalChain, + ct, + approvalMatrix, // Preserve session selectedPositions — overwriting changes pos.id (Запросы ID → Позиции СТ ID), // which breaks edits keys built from buildOfferKey(sup.id, pos.id) in Step 3. ...(isNotificationLink || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}), @@ -179,10 +171,10 @@ const CtCreateForm: React.FC = ({ context, userDisplayName, ensureHighestStep(4); markStepComplete(3); markStepComplete(4); - } else if (approvalChain.length > 0) { + } else if (approvalMatrix.length > 0) { // Regular reload — make Step 4 accessible; only mark complete if CT is fully resolved ensureHighestStep(4); - if (derivedStatus === 'approved' || derivedStatus === 'rejected') { + if (ct.status === 'approved' || ct.status === 'rejected') { markStepComplete(4); } } @@ -208,7 +200,6 @@ const CtCreateForm: React.FC = ({ context, userDisplayName, = ({ context, userDisplayName, = ({ service, formData, - userDisplayName, isNotificationLink, onSave, onNext, @@ -78,7 +76,7 @@ export const Step2CommercialOffers: React.FC = ({ setIsSaving(true); setSaveError(undefined); try { - const patch = await persistStep2({ service, formData, kpFiles: kp.kpFiles, currencyRates, noOfferSuppliers, userDisplayName }); + const patch = await persistStep2({ service, formData, kpFiles: kp.kpFiles, currencyRates, noOfferSuppliers }); onSave(patch); onStepComplete(2); onNext(); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts index 3b78d48..ed1bb99 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts @@ -1,4 +1,4 @@ -import { ApprovalMember, ApprovalMemberStatus, CurrencyRate, Supplier, SupplierOffer } from '../types/domain'; +import { CurrencyRate, Supplier, SupplierOffer } from '../types/domain'; import { CtService } from '../../services/CtService'; import { CTFormData } from '../hooks/useCtForm'; import { KpFile, NoOfferEntry } from './kpTypes'; @@ -9,7 +9,6 @@ export interface PersistStep2Args { kpFiles: KpFile[]; currencyRates: CurrencyRate[]; noOfferSuppliers: NoOfferEntry[]; - userDisplayName: string; } // Строит поставщиков с КП и их офферы из результатов потока (Syntex). Вынесено из persistStep2, @@ -64,7 +63,7 @@ export function buildKpSuppliersAndOffers( // Сохранение Шага 2: курсы валют, поставщики без КП, поставщики с КП + офферы из результатов потока, // пересборка цепочки согласования из матрицы. Возвращает patch для onSave. Логика 1:1 из исходного handleSave. export async function persistStep2(args: PersistStep2Args): Promise> { - const { service, formData, kpFiles, currencyRates, noOfferSuppliers, userDisplayName } = args; + const { service, formData, kpFiles, currencyRates, noOfferSuppliers } = args; let savedRates = currencyRates; if (currencyRates.length > 0) { @@ -117,43 +116,13 @@ export async function persistStep2(args: PersistStep2Args): Promise !s.hasOffer && !newNoOfferNames.has(s.name.trim())); - let approvalChain: ApprovalMember[] = []; - if (formData.ct?.id) { - await service.deleteApprovalChainByCt(formData.ct.id); - let matrix = formData.initiatorDepartment - ? await service.loadApprovalMatrix(formData.initiatorCompany, formData.initiatorDepartment) - : []; - if (matrix.length === 0) { - matrix = await service.loadApprovalMatrix(formData.initiatorCompany, ''); - } - if (matrix.length > 0) { - approvalChain = [ - { - id: 'chain_1', - order: 1, - userId: String(service.currentUserSpId), - userName: userDisplayName, - role: 'Инициатор', - status: 'pending', - }, - ...matrix.map((m, idx) => ({ - id: `chain_${idx + 2}`, - order: idx + 2, - userId: String(m.approverSpId), - userName: m.approverName, - role: m.post, - status: 'pending' as ApprovalMemberStatus, - canAddApprover: m.canAddApprover, - })), - ]; - approvalChain = await service.saveApprovalChain(approvalChain, formData.ct.id); - } - } + // Цепочка согласования БОЛЬШЕ не сеется черновиком на Шаге 2: в новом механизме (approval-route + // API, см. план миграции согласования) маршрут — это планируемый resolve, который Step4Approval + // резолвит «на лету» при открытии шага (CtService.getPlannedApprovalMatrix), писать в SP заранее не нужно. return { currencyRates: savedRates, suppliers: [...mergedSuppliersWithOffer, ...preservedNoOfferSuppliers, ...suppliersWithoutOffer], offers: mergedOffers, - approvalChain, }; } diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/InlineApprovalChain.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/InlineApprovalChain.tsx index 71e47d5..871c391 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/InlineApprovalChain.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/InlineApprovalChain.tsx @@ -1,168 +1,62 @@ import * as React from 'react'; -import { Button, Alert, Spinner, Dialog, TextArea, TextInput } from '@tnsu/ui-kit'; -import { ApprovalMemberStatus } from '../types/domain'; -import { useStep3Approval } from './useStep3Approval'; -import styles from './Step3ComparisonTable.module.scss'; - -const APPROVAL_STATUS_LABELS: Record = { - pending: 'На согласовании', - recommends: 'Рекомендую', - approved: 'Согласовано', - rejected: 'Отклонено', - skipped: 'Пропущено', -}; - -const APPROVAL_STATUS_STYLE: Record = { - pending: { bg: '#f3f2f1', color: '#605e5c' }, - recommends: { bg: '#dff6dd', color: '#107c10' }, - approved: { bg: '#dff6dd', color: '#107c10' }, - rejected: { bg: '#fde7e9', color: '#a4262c' }, - skipped: { bg: '#edebe9', color: '#a19f9d' }, -}; - -function formatApprovalDate(iso: string): string { - return new Date(iso).toLocaleString('ru-RU', { - day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', - }); -} +import { useMemo, useState } from 'react'; +import { CTStatus } from '../types/domain'; +import { CtService } from '../../services/CtService'; +import { CTFormData } from '../hooks/useCtForm'; +import { ApprovalMatrix, DecisionPanel, IMatrixRow, useMatrixEditor } from '../../../../shared/approval'; interface Props { - approval: ReturnType; + service: CtService; + formData: CTFormData; + onSave: (partial: Partial) => void; isNotificationLink: boolean; isReadOnly: boolean; } -// Инлайн-цепочка согласования Шага 3 + диалоги (решение/делегат) + баннер уведомлений. -// Показывается только по Teams-ссылке (?ctid) в read-only. Логика — в useStep3Approval. -export const InlineApprovalChain: React.FC = ({ approval: a, isNotificationLink, isReadOnly }) => { +const RU_STATUS_TO_CT_STATUS: Record = { + Отклонено: 'rejected', + Согласовано: 'approved', + 'На согласовании': 'on_approval', +}; + +// Инлайн-цепочка согласования Шага 3 — для согласующего, открывшего СТ по Teams-ссылке (?ctid=), +// read-only маршрут + DecisionPanel своей строки. Правка состава/отправка — это Шаг 4, здесь их нет. +export const InlineApprovalChain: React.FC = ({ service, formData, onSave, isNotificationLink, isReadOnly }) => { + const { ct } = formData; + const ctId = ct?.id; + const [decisionSeq, setDecisionSeq] = useState(0); + + const loadMatrix = useMemo( + () => (): Promise => (ctId ? service.getApprovalMatrixForRequest(ctId) : Promise.resolve([])), + [service, ctId] + ); + const editor = useMatrixEditor(loadMatrix, `${ctId ?? ''}-${decisionSeq}`, false, !!ctId && isNotificationLink && isReadOnly, (loginName) => + service.getJobTitle(loginName) + ); + const approverService = useMemo(() => (ctId ? service.getApproverService(ctId) : undefined), [service, ctId]); + + if (!isNotificationLink || !isReadOnly || !ctId || editor.loading || editor.rows.length === 0) { + return null; // цепочка ещё грузится из SP или её нет — блок не показываем + } + + async function handleDecided(newStatus: string): Promise { + if (!ct) return; + onSave({ ct: { ...ct, status: RU_STATUS_TO_CT_STATUS[newStatus] || ct.status } }); + setDecisionSeq((n) => n + 1); + await service.notifyActiveApprover(ctId as string).catch(() => undefined); + } + return ( <> - {a.notifyWarning && {a.notifyWarning}} - - {/* Цепочка согласования — только по Teams-ссылке (ctid); обычный reload → согласование на Шаге 4 */} - {isNotificationLink && isReadOnly && a.approvalChain.length > 0 && ( -
-

Цепочка согласования

-
    - {a.approvalChain.map((member) => { - const st = APPROVAL_STATUS_STYLE[member.status]; - return ( -
  1. - {member.order} - {member.userName} - {member.role && {member.role}} - - {APPROVAL_STATUS_LABELS[member.status]} - - {member.comment && «{member.comment}»} - {member.actionDate && {formatApprovalDate(member.actionDate)}} - {a.isOnApproval && a.currentUserMember?.id === member.id && ( -
    - {member.canAddApprover && ( - - )} - - -
    - )} -
  2. - ); - })} -
-
- )} - - {a.delegateDialog && ( - - - - - } - > -
- - {a.delegateDialog.suggestions.length > 0 && ( -
    - {a.delegateDialog.suggestions.map((s) => ( -
  • a.selectDelegate(s)}> - {s.displayName} - {s.email} -
  • - ))} -
- )} -
- {a.delegateError && ( -
- {a.delegateError} -
- )} -
- )} - - {a.actionDialog && ( - - - - - } - > -