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 && ( - - - - - } - > -