fix: перенес матрицу согласования для всех таблиц со списков на бэк
This commit is contained in:
@@ -27,7 +27,7 @@
|
|||||||
"internalName": "status",
|
"internalName": "status",
|
||||||
"title": "Статус",
|
"title": "Статус",
|
||||||
"type": "Choice",
|
"type": "Choice",
|
||||||
"choices": ["Черновик", "Согласовано", "На согласовании"]
|
"choices": ["Черновик", "Согласовано", "На согласовании", "Отклонено"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"internalName": "winSupplier",
|
"internalName": "winSupplier",
|
||||||
@@ -286,7 +286,8 @@
|
|||||||
"Выполнено",
|
"Выполнено",
|
||||||
"Рекомендую",
|
"Рекомендую",
|
||||||
"Черновик",
|
"Черновик",
|
||||||
"Отклонено"
|
"Отклонено",
|
||||||
|
"Ждёт очереди"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -310,6 +311,12 @@
|
|||||||
"internalName": "canAddApprover",
|
"internalName": "canAddApprover",
|
||||||
"title": "Добавить делеганта",
|
"title": "Добавить делеганта",
|
||||||
"type": "Boolean"
|
"type": "Boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"internalName": "delegatedBy",
|
||||||
|
"title": "Кем переназначено",
|
||||||
|
"type": "User",
|
||||||
|
"userSelectionMode": "PeopleOnly"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -267,8 +267,11 @@
|
|||||||
"title": "Статус",
|
"title": "Статус",
|
||||||
"type": "Choice",
|
"type": "Choice",
|
||||||
"choices": [
|
"choices": [
|
||||||
|
"Черновик",
|
||||||
|
"Ждёт очереди",
|
||||||
"На согласовании",
|
"На согласовании",
|
||||||
"Согласовано",
|
"Согласовано",
|
||||||
|
"Отклонено",
|
||||||
"На исполнении",
|
"На исполнении",
|
||||||
"Выполнено"
|
"Выполнено"
|
||||||
]
|
]
|
||||||
@@ -282,6 +285,17 @@
|
|||||||
"internalName": "comment",
|
"internalName": "comment",
|
||||||
"title": "Комментарий",
|
"title": "Комментарий",
|
||||||
"type": "Text"
|
"type": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"internalName": "allowReassign",
|
||||||
|
"title": "Можно перепоручать",
|
||||||
|
"type": "Boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"internalName": "delegatedBy",
|
||||||
|
"title": "Кем переназначено",
|
||||||
|
"type": "User",
|
||||||
|
"userSelectionMode": "PeopleOnly"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,7 +35,8 @@
|
|||||||
"choices": [
|
"choices": [
|
||||||
"Черновик",
|
"Черновик",
|
||||||
"Согласовано",
|
"Согласовано",
|
||||||
"На согласовании"
|
"На согласовании",
|
||||||
|
"Отклонено"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -255,7 +256,8 @@
|
|||||||
"Выполнено",
|
"Выполнено",
|
||||||
"Рекомендую",
|
"Рекомендую",
|
||||||
"Черновик",
|
"Черновик",
|
||||||
"Отклонено"
|
"Отклонено",
|
||||||
|
"Ждёт очереди"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -272,6 +274,12 @@
|
|||||||
"internalName": "canAddAproover",
|
"internalName": "canAddAproover",
|
||||||
"title": "Добавить делеганта",
|
"title": "Добавить делеганта",
|
||||||
"type": "Boolean"
|
"type": "Boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"internalName": "delegatedBy",
|
||||||
|
"title": "Кем переназначено",
|
||||||
|
"type": "User",
|
||||||
|
"userSelectionMode": "PeopleOnly"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,20 +8,9 @@ import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSe
|
|||||||
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
|
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
|
||||||
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
|
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
|
||||||
import { Step4Approval } from './Step4Approval/Step4Approval';
|
import { Step4Approval } from './Step4Approval/Step4Approval';
|
||||||
import { ApprovalMember, CTStatus } from './types/domain';
|
|
||||||
import styles from './CtCreateForm.module.scss';
|
import styles from './CtCreateForm.module.scss';
|
||||||
import '@tnsu/ui-kit/styles.css';
|
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<ICtCreateFormProps> = ({ context, userDisplayName, isDarkTheme, siteUrl }) => {
|
const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName, isDarkTheme, siteUrl }) => {
|
||||||
const service = useMemo(() => new CtService(context, siteUrl), [context, siteUrl]);
|
const service = useMemo(() => new CtService(context, siteUrl), [context, siteUrl]);
|
||||||
const [isRestrictedView, setIsRestrictedView] = useState(false);
|
const [isRestrictedView, setIsRestrictedView] = useState(false);
|
||||||
@@ -46,7 +35,7 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
// astel-k (isRestrictedView) — независимое ограничение, складывается с этим: если его уберут,
|
// astel-k (isRestrictedView) — независимое ограничение, складывается с этим: если его уберут,
|
||||||
// правило по цепочке согласования останется. Сопоставление участника — по отображаемому имени
|
// правило по цепочке согласования останется. Сопоставление участника — по отображаемому имени
|
||||||
// (пока тестируем; при необходимости переключим на ID).
|
// (пока тестируем; при необходимости переключим на 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);
|
const hidePrices = isRestrictedView || (formData.approvalSent === true && !userInApprovalChain);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -54,7 +43,11 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
.then(profile => updateFormData({ initiatorDepartment: profile.department }))
|
.then(profile => updateFormData({ initiatorDepartment: profile.department }))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
service.getCurrentUserCompany()
|
service.getCurrentUserCompany()
|
||||||
.then(company => setIsRestrictedView(company.toLowerCase() === 'astel-k'))
|
.then(company => {
|
||||||
|
setIsRestrictedView(company.toLowerCase() === 'astel-k');
|
||||||
|
// Компания инициатора — условие маршрута согласования (см. CtService.getPlannedApprovalMatrix).
|
||||||
|
updateFormData({ initiatorCompany: company });
|
||||||
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -65,9 +58,9 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
if (!initialCtId) return;
|
if (!initialCtId) return;
|
||||||
Promise.all([
|
Promise.all([
|
||||||
service.loadCTById(initialCtId),
|
service.loadCTById(initialCtId),
|
||||||
service.loadApprovalChain(initialCtId),
|
service.getApprovalMatrixForRequest(initialCtId),
|
||||||
service.loadPositionsForCT(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 positionSpIds = positions.map(p => p.spId).filter((id): id is number => id !== undefined);
|
||||||
const rawOffers = await service.loadFullOffersForPositions(positionSpIds);
|
const rawOffers = await service.loadFullOffersForPositions(positionSpIds);
|
||||||
|
|
||||||
@@ -157,10 +150,9 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const derivedStatus = deriveCtStatus(approvalChain);
|
|
||||||
updateFormData({
|
updateFormData({
|
||||||
ct: { ...ct, status: derivedStatus },
|
ct,
|
||||||
approvalChain,
|
approvalMatrix,
|
||||||
// Preserve session selectedPositions — overwriting changes pos.id (Запросы ID → Позиции СТ ID),
|
// Preserve session selectedPositions — overwriting changes pos.id (Запросы ID → Позиции СТ ID),
|
||||||
// which breaks edits keys built from buildOfferKey(sup.id, pos.id) in Step 3.
|
// which breaks edits keys built from buildOfferKey(sup.id, pos.id) in Step 3.
|
||||||
...(isNotificationLink || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}),
|
...(isNotificationLink || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}),
|
||||||
@@ -179,10 +171,10 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
ensureHighestStep(4);
|
ensureHighestStep(4);
|
||||||
markStepComplete(3);
|
markStepComplete(3);
|
||||||
markStepComplete(4);
|
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
|
// Regular reload — make Step 4 accessible; only mark complete if CT is fully resolved
|
||||||
ensureHighestStep(4);
|
ensureHighestStep(4);
|
||||||
if (derivedStatus === 'approved' || derivedStatus === 'rejected') {
|
if (ct.status === 'approved' || ct.status === 'rejected') {
|
||||||
markStepComplete(4);
|
markStepComplete(4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,7 +200,6 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
<Step2CommercialOffers
|
<Step2CommercialOffers
|
||||||
service={service}
|
service={service}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
userDisplayName={userDisplayName}
|
|
||||||
isNotificationLink={isNotificationLink}
|
isNotificationLink={isNotificationLink}
|
||||||
isReadOnly={formData.ct?.status === 'on_approval' || formData.ct?.status === 'approved' || formData.ct?.status === 'rejected'}
|
isReadOnly={formData.ct?.status === 'on_approval' || formData.ct?.status === 'approved' || formData.ct?.status === 'rejected'}
|
||||||
isRestrictedView={hidePrices}
|
isRestrictedView={hidePrices}
|
||||||
@@ -223,7 +214,6 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
|||||||
<Step3ComparisonTable
|
<Step3ComparisonTable
|
||||||
service={service}
|
service={service}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
userDisplayName={userDisplayName}
|
|
||||||
isNotificationLink={isNotificationLink}
|
isNotificationLink={isNotificationLink}
|
||||||
isRestrictedView={hidePrices}
|
isRestrictedView={hidePrices}
|
||||||
onSave={updateFormData}
|
onSave={updateFormData}
|
||||||
|
|||||||
+1
-3
@@ -16,7 +16,6 @@ import styles from './Step2CommercialOffers.module.scss';
|
|||||||
interface Props {
|
interface Props {
|
||||||
service: CtService;
|
service: CtService;
|
||||||
formData: CTFormData;
|
formData: CTFormData;
|
||||||
userDisplayName: string;
|
|
||||||
isNotificationLink?: boolean;
|
isNotificationLink?: boolean;
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
isRestrictedView?: boolean;
|
isRestrictedView?: boolean;
|
||||||
@@ -31,7 +30,6 @@ interface Props {
|
|||||||
export const Step2CommercialOffers: React.FC<Props> = ({
|
export const Step2CommercialOffers: React.FC<Props> = ({
|
||||||
service,
|
service,
|
||||||
formData,
|
formData,
|
||||||
userDisplayName,
|
|
||||||
isNotificationLink,
|
isNotificationLink,
|
||||||
onSave,
|
onSave,
|
||||||
onNext,
|
onNext,
|
||||||
@@ -78,7 +76,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setSaveError(undefined);
|
setSaveError(undefined);
|
||||||
try {
|
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);
|
onSave(patch);
|
||||||
onStepComplete(2);
|
onStepComplete(2);
|
||||||
onNext();
|
onNext();
|
||||||
|
|||||||
+5
-36
@@ -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 { CtService } from '../../services/CtService';
|
||||||
import { CTFormData } from '../hooks/useCtForm';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
import { KpFile, NoOfferEntry } from './kpTypes';
|
import { KpFile, NoOfferEntry } from './kpTypes';
|
||||||
@@ -9,7 +9,6 @@ export interface PersistStep2Args {
|
|||||||
kpFiles: KpFile[];
|
kpFiles: KpFile[];
|
||||||
currencyRates: CurrencyRate[];
|
currencyRates: CurrencyRate[];
|
||||||
noOfferSuppliers: NoOfferEntry[];
|
noOfferSuppliers: NoOfferEntry[];
|
||||||
userDisplayName: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Строит поставщиков с КП и их офферы из результатов потока (Syntex). Вынесено из persistStep2,
|
// Строит поставщиков с КП и их офферы из результатов потока (Syntex). Вынесено из persistStep2,
|
||||||
@@ -64,7 +63,7 @@ export function buildKpSuppliersAndOffers(
|
|||||||
// Сохранение Шага 2: курсы валют, поставщики без КП, поставщики с КП + офферы из результатов потока,
|
// Сохранение Шага 2: курсы валют, поставщики без КП, поставщики с КП + офферы из результатов потока,
|
||||||
// пересборка цепочки согласования из матрицы. Возвращает patch для onSave. Логика 1:1 из исходного handleSave.
|
// пересборка цепочки согласования из матрицы. Возвращает patch для onSave. Логика 1:1 из исходного handleSave.
|
||||||
export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFormData>> {
|
export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFormData>> {
|
||||||
const { service, formData, kpFiles, currencyRates, noOfferSuppliers, userDisplayName } = args;
|
const { service, formData, kpFiles, currencyRates, noOfferSuppliers } = args;
|
||||||
|
|
||||||
let savedRates = currencyRates;
|
let savedRates = currencyRates;
|
||||||
if (currencyRates.length > 0) {
|
if (currencyRates.length > 0) {
|
||||||
@@ -117,43 +116,13 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
|
|||||||
const preservedNoOfferSuppliers = (formData.suppliers ?? [])
|
const preservedNoOfferSuppliers = (formData.suppliers ?? [])
|
||||||
.filter(s => !s.hasOffer && !newNoOfferNames.has(s.name.trim()));
|
.filter(s => !s.hasOffer && !newNoOfferNames.has(s.name.trim()));
|
||||||
|
|
||||||
let approvalChain: ApprovalMember[] = [];
|
// Цепочка согласования БОЛЬШЕ не сеется черновиком на Шаге 2: в новом механизме (approval-route
|
||||||
if (formData.ct?.id) {
|
// API, см. план миграции согласования) маршрут — это планируемый resolve, который Step4Approval
|
||||||
await service.deleteApprovalChainByCt(formData.ct.id);
|
// резолвит «на лету» при открытии шага (CtService.getPlannedApprovalMatrix), писать в SP заранее не нужно.
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currencyRates: savedRates,
|
currencyRates: savedRates,
|
||||||
suppliers: [...mergedSuppliersWithOffer, ...preservedNoOfferSuppliers, ...suppliersWithoutOffer],
|
suppliers: [...mergedSuppliersWithOffer, ...preservedNoOfferSuppliers, ...suppliersWithoutOffer],
|
||||||
offers: mergedOffers,
|
offers: mergedOffers,
|
||||||
approvalChain,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-156
@@ -1,168 +1,62 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Button, Alert, Spinner, Dialog, TextArea, TextInput } from '@tnsu/ui-kit';
|
import { useMemo, useState } from 'react';
|
||||||
import { ApprovalMemberStatus } from '../types/domain';
|
import { CTStatus } from '../types/domain';
|
||||||
import { useStep3Approval } from './useStep3Approval';
|
import { CtService } from '../../services/CtService';
|
||||||
import styles from './Step3ComparisonTable.module.scss';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
|
import { ApprovalMatrix, DecisionPanel, IMatrixRow, useMatrixEditor } from '../../../../shared/approval';
|
||||||
const APPROVAL_STATUS_LABELS: Record<ApprovalMemberStatus, string> = {
|
|
||||||
pending: 'На согласовании',
|
|
||||||
recommends: 'Рекомендую',
|
|
||||||
approved: 'Согласовано',
|
|
||||||
rejected: 'Отклонено',
|
|
||||||
skipped: 'Пропущено',
|
|
||||||
};
|
|
||||||
|
|
||||||
const APPROVAL_STATUS_STYLE: Record<ApprovalMemberStatus, { bg: string; color: string }> = {
|
|
||||||
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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
approval: ReturnType<typeof useStep3Approval>;
|
service: CtService;
|
||||||
|
formData: CTFormData;
|
||||||
|
onSave: (partial: Partial<CTFormData>) => void;
|
||||||
isNotificationLink: boolean;
|
isNotificationLink: boolean;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инлайн-цепочка согласования Шага 3 + диалоги (решение/делегат) + баннер уведомлений.
|
const RU_STATUS_TO_CT_STATUS: Record<string, CTStatus> = {
|
||||||
// Показывается только по Teams-ссылке (?ctid) в read-only. Логика — в useStep3Approval.
|
Отклонено: 'rejected',
|
||||||
export const InlineApprovalChain: React.FC<Props> = ({ approval: a, isNotificationLink, isReadOnly }) => {
|
Согласовано: 'approved',
|
||||||
|
'На согласовании': 'on_approval',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Инлайн-цепочка согласования Шага 3 — для согласующего, открывшего СТ по Teams-ссылке (?ctid=),
|
||||||
|
// read-only маршрут + DecisionPanel своей строки. Правка состава/отправка — это Шаг 4, здесь их нет.
|
||||||
|
export const InlineApprovalChain: React.FC<Props> = ({ service, formData, onSave, isNotificationLink, isReadOnly }) => {
|
||||||
|
const { ct } = formData;
|
||||||
|
const ctId = ct?.id;
|
||||||
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
|
|
||||||
|
const loadMatrix = useMemo(
|
||||||
|
() => (): Promise<IMatrixRow[]> => (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<void> {
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{a.notifyWarning && <Alert variant="warn">{a.notifyWarning}</Alert>}
|
<ApprovalMatrix editor={editor} search={(q) => service.searchUsers(q)} />
|
||||||
|
{approverService && (
|
||||||
{/* Цепочка согласования — только по Teams-ссылке (ctid); обычный reload → согласование на Шаге 4 */}
|
<DecisionPanel
|
||||||
{isNotificationLink && isReadOnly && a.approvalChain.length > 0 && (
|
service={approverService}
|
||||||
<div className={styles.approvalChainSection}>
|
requestId={parseInt(ctId, 10)}
|
||||||
<h4 className={styles.approvalChainTitle}>Цепочка согласования</h4>
|
onDecided={handleDecided}
|
||||||
<ol className={styles.approvalChainList}>
|
search={(q) => service.searchUsers(q)}
|
||||||
{a.approvalChain.map((member) => {
|
onDelegated={() => setDecisionSeq((n) => n + 1)}
|
||||||
const st = APPROVAL_STATUS_STYLE[member.status];
|
/>
|
||||||
return (
|
|
||||||
<li key={member.id} className={styles.approvalChainRow}>
|
|
||||||
<span className={styles.approvalChainOrder}>{member.order}</span>
|
|
||||||
<span className={styles.approvalChainName}>{member.userName}</span>
|
|
||||||
{member.role && <span className={styles.approvalChainRole}>{member.role}</span>}
|
|
||||||
<span className={styles.approvalChainStatusBadge} style={{ background: st.bg, color: st.color }}>
|
|
||||||
{APPROVAL_STATUS_LABELS[member.status]}
|
|
||||||
</span>
|
|
||||||
{member.comment && <span className={styles.approvalChainComment}>«{member.comment}»</span>}
|
|
||||||
{member.actionDate && <span className={styles.approvalChainDate}>{formatApprovalDate(member.actionDate)}</span>}
|
|
||||||
{a.isOnApproval && a.currentUserMember?.id === member.id && (
|
|
||||||
<div className={styles.approvalChainActions}>
|
|
||||||
{member.canAddApprover && (
|
|
||||||
<Button variant="secondary" onClick={() => a.openDelegate(member)}>
|
|
||||||
Добавить делегата
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="primary" onClick={() => a.openApprove(member)}>
|
|
||||||
Согласовать
|
|
||||||
</Button>
|
|
||||||
<Button variant="danger" onClick={() => a.openReject(member)}>
|
|
||||||
Отклонить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{a.delegateDialog && (
|
|
||||||
<Dialog
|
|
||||||
open
|
|
||||||
title="Добавить делегата"
|
|
||||||
subText="Выбранный пользователь будет вставлен перед вами в цепочку согласования."
|
|
||||||
onDismiss={a.closeDelegate}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={a.closeDelegate} disabled={a.isDelegateSubmitting}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={a.handleAddDelegate}
|
|
||||||
disabled={a.isDelegateSubmitting || !a.delegateDialog.selected}
|
|
||||||
>
|
|
||||||
{a.isDelegateSubmitting ? <Spinner size="small" /> : 'Добавить'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={styles.searchBox}>
|
|
||||||
<TextInput
|
|
||||||
placeholder="Поиск делегата (имя)…"
|
|
||||||
ariaLabel="Поиск делегата"
|
|
||||||
value={a.delegateDialog.query}
|
|
||||||
onChange={a.setDelegateQuery}
|
|
||||||
/>
|
|
||||||
{a.delegateDialog.suggestions.length > 0 && (
|
|
||||||
<ul className={styles.suggestions}>
|
|
||||||
{a.delegateDialog.suggestions.map((s) => (
|
|
||||||
<li key={s.spId} className={styles.suggestion} onClick={() => a.selectDelegate(s)}>
|
|
||||||
<span className={styles.suggestionName}>{s.displayName}</span>
|
|
||||||
<span className={styles.suggestionEmail}>{s.email}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{a.delegateError && (
|
|
||||||
<div className={styles.approvalDialogError}>
|
|
||||||
<Alert variant="err">{a.delegateError}</Alert>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{a.actionDialog && (
|
|
||||||
<Dialog
|
|
||||||
open
|
|
||||||
title={a.actionDialog.action === 'approve' ? 'Согласование' : 'Отклонение'}
|
|
||||||
subText={
|
|
||||||
a.actionDialog.action === 'approve'
|
|
||||||
? 'Вы подтверждаете согласование СТ? Комментарий необязателен.'
|
|
||||||
: 'Укажите причину отклонения (обязательно).'
|
|
||||||
}
|
|
||||||
onDismiss={a.closeAction}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={a.closeAction} disabled={a.isApprovalSubmitting}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={a.actionDialog.action === 'approve' ? 'primary' : 'danger'}
|
|
||||||
onClick={a.handleApprovalConfirm}
|
|
||||||
disabled={a.isApprovalSubmitting || (a.actionDialog.action === 'reject' && !a.actionDialog.comment.trim())}
|
|
||||||
>
|
|
||||||
{a.isApprovalSubmitting ? <Spinner size="small" /> : a.actionDialog.action === 'approve' ? 'Согласовать' : 'Отклонить'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TextArea
|
|
||||||
value={a.actionDialog.comment}
|
|
||||||
onChange={a.setActionComment}
|
|
||||||
rows={3}
|
|
||||||
ariaLabel="Комментарий"
|
|
||||||
placeholder={a.actionDialog.action === 'reject' ? 'Укажите причину отклонения...' : 'Комментарий (необязательно)...'}
|
|
||||||
/>
|
|
||||||
{a.approvalError && (
|
|
||||||
<div className={styles.approvalDialogError}>
|
|
||||||
<Alert variant="err">{a.approvalError}</Alert>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+7
-7
@@ -4,7 +4,6 @@ import { FormStep } from '../types/domain';
|
|||||||
import { CtService } from '../../services/CtService';
|
import { CtService } from '../../services/CtService';
|
||||||
import { CTFormData } from '../hooks/useCtForm';
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
import styles from './Step3ComparisonTable.module.scss';
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
import { useStep3Approval } from './useStep3Approval';
|
|
||||||
import { InlineApprovalChain } from './InlineApprovalChain';
|
import { InlineApprovalChain } from './InlineApprovalChain';
|
||||||
import { useAiComment } from './useAiComment';
|
import { useAiComment } from './useAiComment';
|
||||||
import { AiCommentBlock } from './AiCommentBlock';
|
import { AiCommentBlock } from './AiCommentBlock';
|
||||||
@@ -17,7 +16,6 @@ import { useRecommendedPick } from './useRecommendedPick';
|
|||||||
interface Props {
|
interface Props {
|
||||||
service: CtService;
|
service: CtService;
|
||||||
formData: CTFormData;
|
formData: CTFormData;
|
||||||
userDisplayName: string;
|
|
||||||
isNotificationLink: boolean;
|
isNotificationLink: boolean;
|
||||||
isRestrictedView?: boolean;
|
isRestrictedView?: boolean;
|
||||||
onSave: (partial: Partial<CTFormData>) => void;
|
onSave: (partial: Partial<CTFormData>) => void;
|
||||||
@@ -29,7 +27,6 @@ interface Props {
|
|||||||
export const Step3ComparisonTable: React.FC<Props> = ({
|
export const Step3ComparisonTable: React.FC<Props> = ({
|
||||||
service,
|
service,
|
||||||
formData,
|
formData,
|
||||||
userDisplayName,
|
|
||||||
isNotificationLink,
|
isNotificationLink,
|
||||||
isRestrictedView = false,
|
isRestrictedView = false,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -55,9 +52,6 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
// Выбор рекомендованного поставщика по формуле 80% цена / 20% условия оплаты вынесен в хук.
|
// Выбор рекомендованного поставщика по формуле 80% цена / 20% условия оплаты вынесен в хук.
|
||||||
const recPick = useRecommendedPick(service, matrix);
|
const recPick = useRecommendedPick(service, matrix);
|
||||||
|
|
||||||
// Согласование (инлайн-цепочка Teams-ссылки) вынесено в хук.
|
|
||||||
const approval = useStep3Approval({ service, formData, userDisplayName, onSave, onStepComplete });
|
|
||||||
|
|
||||||
// AI-комментарий + печать PDF вынесены в хук.
|
// AI-комментарий + печать PDF вынесены в хук.
|
||||||
const ai = useAiComment({
|
const ai = useAiComment({
|
||||||
service,
|
service,
|
||||||
@@ -169,7 +163,13 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
<InlineApprovalChain approval={approval} isNotificationLink={isNotificationLink} isReadOnly={isReadOnly} />
|
<InlineApprovalChain
|
||||||
|
service={service}
|
||||||
|
formData={formData}
|
||||||
|
onSave={onSave}
|
||||||
|
isNotificationLink={isNotificationLink}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
<Button variant="secondary" onClick={onPrev} disabled={isReadOnly}>
|
<Button variant="secondary" onClick={onPrev} disabled={isReadOnly}>
|
||||||
|
|||||||
-221
@@ -1,221 +0,0 @@
|
|||||||
import { useRef, useState } from 'react';
|
|
||||||
import { ApprovalMember, ApprovalMemberStatus, FormStep } from '../types/domain';
|
|
||||||
import { CtService } from '../../services/CtService';
|
|
||||||
import { CTFormData } from '../hooks/useCtForm';
|
|
||||||
|
|
||||||
export type ApprovalAction = 'approve' | 'reject';
|
|
||||||
|
|
||||||
export interface UserSuggestion {
|
|
||||||
spId: number;
|
|
||||||
displayName: string;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActionDialogState {
|
|
||||||
memberId: string;
|
|
||||||
memberUserId: string;
|
|
||||||
memberName: string;
|
|
||||||
action: ApprovalAction;
|
|
||||||
comment: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DelegateDialogState {
|
|
||||||
memberId: string;
|
|
||||||
memberOrder: number;
|
|
||||||
spApprovalId: number;
|
|
||||||
query: string;
|
|
||||||
suggestions: UserSuggestion[];
|
|
||||||
selected?: UserSuggestion;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseStep3ApprovalArgs {
|
|
||||||
service: CtService;
|
|
||||||
formData: CTFormData;
|
|
||||||
userDisplayName: string;
|
|
||||||
onSave: (partial: Partial<CTFormData>) => void;
|
|
||||||
onStepComplete: (step: FormStep) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Логика инлайн-цепочки согласования на Шаге 3 (для согласующего, открывшего СТ по Teams-ссылке ?ctid=):
|
|
||||||
// голосование approve/reject + нотификации, добавление делегата. Работает поверх formData.approvalChain
|
|
||||||
// через onSave, клубок рефов матрицы не трогает. Перенесено 1:1 из Step3ComparisonTable.
|
|
||||||
export function useStep3Approval(args: UseStep3ApprovalArgs) {
|
|
||||||
const { service, formData, userDisplayName, onSave, onStepComplete } = args;
|
|
||||||
const { ct } = formData;
|
|
||||||
|
|
||||||
const [actionDialog, setActionDialog] = useState<ActionDialogState | null>(null);
|
|
||||||
const [isApprovalSubmitting, setIsApprovalSubmitting] = useState(false);
|
|
||||||
const [approvalError, setApprovalError] = useState<string | null>(null);
|
|
||||||
const [notifyWarning, setNotifyWarning] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [delegateDialog, setDelegateDialog] = useState<DelegateDialogState | null>(null);
|
|
||||||
const [isDelegateSubmitting, setIsDelegateSubmitting] = useState(false);
|
|
||||||
const [delegateError, setDelegateError] = useState<string | null>(null);
|
|
||||||
const delegateSearchTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
||||||
|
|
||||||
const approvalChain = formData.approvalChain ?? [];
|
|
||||||
const isOnApproval = ct?.status === 'on_approval' || ct?.status === 'approved';
|
|
||||||
const currentUserMember = approvalChain.find(
|
|
||||||
(m) =>
|
|
||||||
m.userName === userDisplayName &&
|
|
||||||
m.status === 'pending' &&
|
|
||||||
m.role !== 'Инициатор' &&
|
|
||||||
approvalChain.filter((prev) => prev.order < m.order).every((prev) => prev.status !== 'pending'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleApprovalConfirm = async (): Promise<void> => {
|
|
||||||
if (!actionDialog || !ct) return;
|
|
||||||
const { memberId, action, comment } = actionDialog;
|
|
||||||
if (action === 'reject' && !comment.trim()) return;
|
|
||||||
|
|
||||||
setIsApprovalSubmitting(true);
|
|
||||||
setApprovalError(null);
|
|
||||||
const apiStatus = action === 'approve' ? 'approved' : 'rejected';
|
|
||||||
const currentMember = approvalChain.find((m) => m.id === memberId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!currentMember?.spApprovalId) throw new Error('Не найден ID записи согласования');
|
|
||||||
await service.submitApprovalStep(currentMember.spApprovalId, apiStatus, comment.trim());
|
|
||||||
|
|
||||||
const notify = async (spId: number): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await service.triggerApprovalNotification(spId);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[Step3] triggerApprovalNotification failed for spId', spId, e);
|
|
||||||
setNotifyWarning('Голос записан, но уведомление в Teams не удалось отправить. Проверьте Power Automate.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (action === 'approve') {
|
|
||||||
const updatedChain = approvalChain.map((m) =>
|
|
||||||
m.id === memberId
|
|
||||||
? { ...m, status: 'approved' as ApprovalMemberStatus, comment: comment.trim(), actionDate: new Date().toISOString() }
|
|
||||||
: m,
|
|
||||||
);
|
|
||||||
onSave({ approvalChain: updatedChain });
|
|
||||||
const nextPending = updatedChain.find((m) => m.status === 'pending');
|
|
||||||
if (nextPending?.spApprovalId) {
|
|
||||||
await notify(nextPending.spApprovalId);
|
|
||||||
}
|
|
||||||
const allApproved = updatedChain.every((m) => m.status === 'approved' || m.status === 'skipped' || m.status === 'recommends');
|
|
||||||
const initiator = updatedChain.find((m) => m.role === 'Инициатор');
|
|
||||||
if (allApproved) {
|
|
||||||
await service.updateCTStatus(ct.id, 'approved');
|
|
||||||
onSave({ ct: { ...ct, status: 'approved' } });
|
|
||||||
if (initiator?.spApprovalId) await notify(initiator.spApprovalId);
|
|
||||||
onStepComplete(4);
|
|
||||||
} else if (initiator?.spApprovalId) {
|
|
||||||
await notify(initiator.spApprovalId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const othersToDelete = approvalChain.filter((m) => m.id !== memberId && m.status === 'pending' && m.spApprovalId);
|
|
||||||
if (othersToDelete.length > 0) {
|
|
||||||
await service.deleteApprovalMembers(othersToDelete.map((m) => m.spApprovalId!));
|
|
||||||
}
|
|
||||||
const finalChain = approvalChain
|
|
||||||
.filter((m) => m.status !== 'pending' || m.id === memberId)
|
|
||||||
.map((m) =>
|
|
||||||
m.id === memberId
|
|
||||||
? { ...m, status: 'rejected' as ApprovalMemberStatus, comment: comment.trim(), actionDate: new Date().toISOString() }
|
|
||||||
: m,
|
|
||||||
);
|
|
||||||
await service.updateCTStatus(ct.id, 'rejected');
|
|
||||||
onSave({ approvalChain: finalChain, ct: { ...ct, status: 'rejected' } });
|
|
||||||
const initiator = finalChain.find((m) => m.role === 'Инициатор');
|
|
||||||
if (initiator?.spApprovalId) await notify(initiator.spApprovalId);
|
|
||||||
}
|
|
||||||
setActionDialog(null);
|
|
||||||
} catch (err) {
|
|
||||||
setApprovalError((err as Error).message ?? 'Ошибка выполнения действия');
|
|
||||||
} finally {
|
|
||||||
setIsApprovalSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setDelegateQuery = (q: string): void => {
|
|
||||||
setDelegateDialog((prev) => (prev ? { ...prev, query: q, suggestions: [], selected: undefined } : prev));
|
|
||||||
if (delegateSearchTimer.current) {
|
|
||||||
clearTimeout(delegateSearchTimer.current);
|
|
||||||
}
|
|
||||||
if (q.trim().length < 2) return;
|
|
||||||
delegateSearchTimer.current = setTimeout(() => {
|
|
||||||
service
|
|
||||||
.searchUsers(q.trim())
|
|
||||||
.then((res) => setDelegateDialog((prev) => (prev ? { ...prev, suggestions: res } : prev)))
|
|
||||||
.catch(() => undefined);
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectDelegate = (u: UserSuggestion): void =>
|
|
||||||
setDelegateDialog((prev) => (prev ? { ...prev, selected: u, query: u.displayName, suggestions: [] } : prev));
|
|
||||||
|
|
||||||
const handleAddDelegate = async (): Promise<void> => {
|
|
||||||
if (!delegateDialog || !ct || !delegateDialog.selected) return;
|
|
||||||
const delegateUserId = delegateDialog.selected.spId;
|
|
||||||
const delegateName = delegateDialog.selected.displayName;
|
|
||||||
|
|
||||||
setIsDelegateSubmitting(true);
|
|
||||||
setDelegateError(null);
|
|
||||||
try {
|
|
||||||
const newMember = await service.addApprovalDelegate(ct.id, delegateDialog.memberOrder, delegateUserId, delegateName);
|
|
||||||
|
|
||||||
const updatedChain = approvalChain
|
|
||||||
.map((m) => (m.order >= delegateDialog.memberOrder ? { ...m, order: m.order + 1 } : m))
|
|
||||||
.concat(newMember)
|
|
||||||
.sort((a, b) => a.order - b.order);
|
|
||||||
|
|
||||||
onSave({ approvalChain: updatedChain });
|
|
||||||
|
|
||||||
if (newMember.spApprovalId) {
|
|
||||||
try {
|
|
||||||
await service.triggerApprovalNotification(newMember.spApprovalId);
|
|
||||||
} catch {
|
|
||||||
setNotifyWarning('Делегат добавлен, но уведомление в Teams не удалось отправить. Проверьте Power Automate.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setDelegateDialog(null);
|
|
||||||
} catch (err) {
|
|
||||||
setDelegateError((err as Error).message ?? 'Ошибка добавления делегата');
|
|
||||||
} finally {
|
|
||||||
setIsDelegateSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Удобные открыватели диалогов (view не дёргает сырые сеттеры).
|
|
||||||
const openApprove = (member: ApprovalMember): void =>
|
|
||||||
setActionDialog({ memberId: member.id, memberUserId: member.userId, memberName: member.userName, action: 'approve', comment: '' });
|
|
||||||
const openReject = (member: ApprovalMember): void =>
|
|
||||||
setActionDialog({ memberId: member.id, memberUserId: member.userId, memberName: member.userName, action: 'reject', comment: '' });
|
|
||||||
const setActionComment = (val: string): void =>
|
|
||||||
setActionDialog((prev) => (prev ? { ...prev, comment: val } : null));
|
|
||||||
const openDelegate = (member: ApprovalMember): void => {
|
|
||||||
setDelegateDialog({ memberId: member.id, memberOrder: member.order, spApprovalId: member.spApprovalId!, query: '', suggestions: [] });
|
|
||||||
setDelegateError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
approvalChain,
|
|
||||||
isOnApproval,
|
|
||||||
currentUserMember,
|
|
||||||
notifyWarning,
|
|
||||||
setNotifyWarning,
|
|
||||||
// action dialog
|
|
||||||
actionDialog,
|
|
||||||
isApprovalSubmitting,
|
|
||||||
approvalError,
|
|
||||||
openApprove,
|
|
||||||
openReject,
|
|
||||||
setActionComment,
|
|
||||||
closeAction: () => setActionDialog(null),
|
|
||||||
handleApprovalConfirm,
|
|
||||||
// delegate dialog
|
|
||||||
delegateDialog,
|
|
||||||
setDelegateQuery,
|
|
||||||
selectDelegate,
|
|
||||||
isDelegateSubmitting,
|
|
||||||
delegateError,
|
|
||||||
openDelegate,
|
|
||||||
closeDelegate: () => setDelegateDialog(null),
|
|
||||||
handleAddDelegate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+1
-251
@@ -1,4 +1,5 @@
|
|||||||
@import '../common/theme';
|
@import '../common/theme';
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -6,253 +7,6 @@
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $text;
|
|
||||||
margin: 0;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
border-bottom: 1px solid $border;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyChain {
|
|
||||||
font-size: 13px;
|
|
||||||
color: $muted-2;
|
|
||||||
font-style: italic;
|
|
||||||
margin: 0;
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chainList {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
max-width: 860px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chainItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: $surface;
|
|
||||||
border: 1px solid $border;
|
|
||||||
border-radius: 4px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $head-bg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.orderBadge {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: $border;
|
|
||||||
color: $muted;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memberInfo {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memberName {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memberRole {
|
|
||||||
font-size: 12px;
|
|
||||||
color: $muted;
|
|
||||||
background: $hover;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusBadge {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 2px 9px;
|
|
||||||
border-radius: 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusPending {
|
|
||||||
background: $hover;
|
|
||||||
color: $muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusApproved {
|
|
||||||
background: $ok-bg;
|
|
||||||
color: $ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusRejected {
|
|
||||||
background: $danger-bg;
|
|
||||||
color: $danger;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusSkipped {
|
|
||||||
background: $hover;
|
|
||||||
color: $muted-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memberComment {
|
|
||||||
font-size: 12px;
|
|
||||||
color: $muted;
|
|
||||||
font-style: italic;
|
|
||||||
max-width: 200px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionDate {
|
|
||||||
font-size: 11px;
|
|
||||||
color: $muted-2;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memberActions {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.approvalActions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rejectBtn {
|
|
||||||
color: $danger;
|
|
||||||
border-color: $danger;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $danger-bg;
|
|
||||||
color: $danger;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Add member ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.addMemberRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
max-width: 30%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memberNameInput {
|
|
||||||
flex: 2;
|
|
||||||
min-width: 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memberRoleInput {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.addRowBtn {
|
|
||||||
align-self: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── People search suggestions ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
.suggestions {
|
|
||||||
list-style: none;
|
|
||||||
margin: 2px 0 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid $border;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: $surface;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
|
||||||
position: absolute;
|
|
||||||
z-index: 100;
|
|
||||||
width: 100%;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Модификатор для списка внутри диалога (замена согласующего): absolute-подсказки
|
|
||||||
// обрезались телом диалога (overflow-y: auto). В потоке список раздвигает диалог,
|
|
||||||
// а при длинном списке скроллится внутри него.
|
|
||||||
.suggestionsInline {
|
|
||||||
position: static;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestionItem {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
border-bottom: 1px solid $hover;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $hover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestionName {
|
|
||||||
font-weight: 500;
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestionEmail {
|
|
||||||
font-size: 11px;
|
|
||||||
color: $muted-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Row action buttons ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.replaceBtn {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deleteBtn {
|
|
||||||
color: $danger;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $danger-bg;
|
|
||||||
color: $danger;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Footer ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -270,10 +24,6 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialogError {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.incoreProgress {
|
.incoreProgress {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Button, Alert, Spinner, IconButton, Dialog, TextInput, TextArea, Icon, FormField } from "@tnsu/ui-kit";
|
import { useMemo, useState } from "react";
|
||||||
import { ApprovalMemberStatus, FormStep } from "../types/domain";
|
import { Button, Alert, Spinner, Dialog, Icon } from "@tnsu/ui-kit";
|
||||||
|
import { CTStatus, FormStep } from "../types/domain";
|
||||||
import { CtService } from "../../services/CtService";
|
import { CtService } from "../../services/CtService";
|
||||||
import { CTFormData } from "../hooks/useCtForm";
|
import { CTFormData } from "../hooks/useCtForm";
|
||||||
import { useStep4Approval, IncoreStep } from "./useStep4Approval";
|
import { useIncore } from "./useIncore";
|
||||||
|
import {
|
||||||
|
ApprovalMatrix,
|
||||||
|
DecisionPanel,
|
||||||
|
IApproverToWrite,
|
||||||
|
IMatrixRow,
|
||||||
|
useMatrixEditor,
|
||||||
|
findUnassignedStep,
|
||||||
|
} from "../../../../shared/approval";
|
||||||
import styles from "./Step4Approval.module.scss";
|
import styles from "./Step4Approval.module.scss";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -15,15 +24,15 @@ interface Props {
|
|||||||
onStepComplete: (step: FormStep) => void;
|
onStepComplete: (step: FormStep) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<ApprovalMemberStatus, string> = {
|
const SUBMITTED_STATUSES: ReadonlySet<CTStatus> = new Set<CTStatus>(["on_approval", "approved", "rejected"]);
|
||||||
pending: "На согласовании",
|
|
||||||
recommends: "Рекомендую",
|
const RU_STATUS_TO_CT_STATUS: Record<string, CTStatus> = {
|
||||||
approved: "Согласовано",
|
Отклонено: "rejected",
|
||||||
rejected: "Отклонено",
|
Согласовано: "approved",
|
||||||
skipped: "Пропущено",
|
"На согласовании": "on_approval",
|
||||||
};
|
};
|
||||||
|
|
||||||
const INCORE_STEP_LABELS: Record<IncoreStep, string> = {
|
const INCORE_STEP_LABELS: Record<string, string> = {
|
||||||
report: "Формирование отчёта СТ…",
|
report: "Формирование отчёта СТ…",
|
||||||
connecting: "Подключение к NCALayer…",
|
connecting: "Подключение к NCALayer…",
|
||||||
signing: "Ожидание подписи в NCALayer…",
|
signing: "Ожидание подписи в NCALayer…",
|
||||||
@@ -32,229 +41,114 @@ const INCORE_STEP_LABELS: Record<IncoreStep, string> = {
|
|||||||
building: "Формирование карточки с ЭЦП…",
|
building: "Формирование карточки с ЭЦП…",
|
||||||
};
|
};
|
||||||
|
|
||||||
function getStatusClass(status: ApprovalMemberStatus): string {
|
// Шаг 4 «Согласование» (см. план миграции согласования на approval-route API): маршрут — планируемый
|
||||||
const map: Record<ApprovalMemberStatus, string> = {
|
// (до отправки, resolve по email/отделу/компании инициатора) или уже записанный (после отправки).
|
||||||
pending: styles.statusPending,
|
// Редактирование состава/решения — общие компоненты shared/approval. Постобработка ЭЦП/INCORE — see useIncore.
|
||||||
recommends: styles.statusApproved,
|
export const Step4Approval: React.FC<Props> = ({ service, formData, onPrev, onSave, onStepComplete }) => {
|
||||||
approved: styles.statusApproved,
|
const { ct } = formData;
|
||||||
rejected: styles.statusRejected,
|
const ctId = ct?.id;
|
||||||
skipped: styles.statusSkipped,
|
const status: CTStatus = ct?.status ?? "formed";
|
||||||
};
|
const isSubmitted = SUBMITTED_STATUSES.has(status);
|
||||||
return map[status];
|
const isInitiator = !!ct?.authorEmail && ct.authorEmail === service.currentUserEmail;
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso?: string): string {
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
if (!iso) return "";
|
const [sending, setSending] = useState<boolean>(false);
|
||||||
return new Date(iso).toLocaleString("ru-RU", {
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Step4Approval: React.FC<Props> = (props) => {
|
const incore = useIncore({ service, formData, isInitiator });
|
||||||
const { onPrev } = props;
|
|
||||||
const a = useStep4Approval(props);
|
const loadMatrix = useMemo(
|
||||||
|
() => (): Promise<IMatrixRow[]> =>
|
||||||
|
isSubmitted && ctId
|
||||||
|
? service.getApprovalMatrixForRequest(ctId)
|
||||||
|
: service.getPlannedApprovalMatrix(service.currentUserEmail, formData.initiatorDepartment, formData.initiatorCompany),
|
||||||
|
[service, ctId, isSubmitted, formData.initiatorDepartment, formData.initiatorCompany]
|
||||||
|
);
|
||||||
|
|
||||||
|
const editor = useMatrixEditor(loadMatrix, `${ctId ?? "draft"}-${decisionSeq}`, !isSubmitted, true, (loginName) =>
|
||||||
|
service.getJobTitle(loginName)
|
||||||
|
);
|
||||||
|
|
||||||
|
const approverService = useMemo(() => (ctId ? service.getApproverService(ctId) : undefined), [service, ctId]);
|
||||||
|
|
||||||
|
async function handleSubmit(): Promise<void> {
|
||||||
|
if (!ctId || !ct) return;
|
||||||
|
if (editor.rows.length === 0) {
|
||||||
|
setError("Маршрут согласования пуст.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const unassigned = findUnassignedStep(editor.rows);
|
||||||
|
if (unassigned) {
|
||||||
|
setError(
|
||||||
|
unassigned.warning ||
|
||||||
|
`Шаг «${unassigned.position || unassigned.order}» без назначенного согласующего — обратитесь к администратору маршрутов.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSending(true);
|
||||||
|
setError(undefined);
|
||||||
|
const approvers: IApproverToWrite[] = editor.rows.map((r) => ({
|
||||||
|
email: r.email,
|
||||||
|
position: r.position,
|
||||||
|
allowReassign: r.allowReassign,
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
await service.submitApprovalRoute(ctId, approvers);
|
||||||
|
await service.updateCTStatus(ctId, "on_approval");
|
||||||
|
onSave({ ct: { ...ct, status: "on_approval" } });
|
||||||
|
onStepComplete(4);
|
||||||
|
await service.notifyActiveApprover(ctId).catch(() => undefined);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDecided(newStatus: string): Promise<void> {
|
||||||
|
if (!ct) return;
|
||||||
|
onSave({ ct: { ...ct, status: RU_STATUS_TO_CT_STATUS[newStatus] || status } });
|
||||||
|
setDecisionSeq((n) => n + 1);
|
||||||
|
if (ctId) {
|
||||||
|
await service.notifyActiveApprover(ctId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestart(): Promise<void> {
|
||||||
|
if (!ctId || !ct) return;
|
||||||
|
setSending(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
await service.updateCTStatus(ctId, "formed");
|
||||||
|
onSave({ ct: { ...ct, status: "formed" } });
|
||||||
|
setDecisionSeq((n) => n + 1);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
{/* Current approval status banner */}
|
{status === "approved" && <Alert variant="ok">СТ согласована всеми участниками.</Alert>}
|
||||||
{a.ct?.status === "approved" && <Alert variant="ok">СТ согласована всеми участниками.</Alert>}
|
{status === "rejected" && <Alert variant="err">СТ отклонена. Требуется корректировка и повторная отправка.</Alert>}
|
||||||
{a.ct?.status === "rejected" && (
|
{status === "on_approval" && <Alert variant="info">СТ находится на согласовании.</Alert>}
|
||||||
<Alert variant="err">СТ отклонена. Требуется корректировка и повторная отправка.</Alert>
|
|
||||||
)}
|
|
||||||
{a.ct?.status === "on_approval" && <Alert variant="info">СТ находится на согласовании.</Alert>}
|
|
||||||
|
|
||||||
|
<ApprovalMatrix editor={editor} search={(q) => service.searchUsers(q)} />
|
||||||
|
|
||||||
{/* Approval chain */}
|
{error && <Alert variant="err">{error}</Alert>}
|
||||||
<section className={styles.section}>
|
|
||||||
<h3 className={styles.sectionTitle}>Цепочка согласования</h3>
|
|
||||||
|
|
||||||
{a.chain.length === 0 && (
|
|
||||||
<p className={styles.emptyChain}>
|
|
||||||
Добавьте участников для согласования
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{a.chain.length > 0 && (
|
|
||||||
<ol className={styles.chainList}>
|
|
||||||
{a.chain.map((member, index) => (
|
|
||||||
<li key={member.id} className={styles.chainItem}>
|
|
||||||
<span className={styles.orderBadge}>{member.order}</span>
|
|
||||||
|
|
||||||
<div className={styles.memberInfo}>
|
|
||||||
<span className={styles.memberName}>{member.userName}</span>
|
|
||||||
{member.role && (
|
|
||||||
<span className={styles.memberRole}>{member.role}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{a.showStatuses && (
|
|
||||||
<span
|
|
||||||
className={[
|
|
||||||
styles.statusBadge,
|
|
||||||
getStatusClass(member.status),
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{STATUS_LABELS[member.status]}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{member.comment && (
|
|
||||||
<span className={styles.memberComment} title={member.comment}>
|
|
||||||
«{member.comment}»
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{member.actionDate && (
|
|
||||||
<span className={styles.actionDate}>
|
|
||||||
{formatDate(member.actionDate)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!a.isOnApproval &&
|
|
||||||
member.status === "pending" &&
|
|
||||||
member.role !== "Инициатор" && (
|
|
||||||
<div className={styles.memberActions}>
|
|
||||||
<IconButton
|
|
||||||
icon="arrow-up"
|
|
||||||
disabled={index === 0}
|
|
||||||
onClick={() => a.moveMember(member.id, "up")}
|
|
||||||
title="Переместить вверх"
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon="arrow-down"
|
|
||||||
disabled={index === a.chain.length - 1}
|
|
||||||
onClick={() => a.moveMember(member.id, "down")}
|
|
||||||
title="Переместить вниз"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Replace button - non-initiator pending members, both before and during approval */}
|
|
||||||
{member.role !== "Инициатор" &&
|
|
||||||
member.status === "pending" &&
|
|
||||||
!(a.isOnApproval && a.currentUserMember?.id === member.id) && (
|
|
||||||
<Button variant="secondary" className={styles.replaceBtn} onClick={() => a.openReplace(member)}>
|
|
||||||
<Icon name="swap" size="sm" />
|
|
||||||
Заменить
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete button - only before approval, non-initiator */}
|
|
||||||
{!a.isOnApproval && member.role !== "Инициатор" && (
|
|
||||||
<IconButton
|
|
||||||
icon="trash"
|
|
||||||
danger
|
|
||||||
className={styles.deleteBtn}
|
|
||||||
onClick={() => a.handleDeleteMember(member)}
|
|
||||||
title="Удалить согласующего"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions for the current user when it's their turn */}
|
|
||||||
{a.isOnApproval && a.currentUserMember?.id === member.id && (
|
|
||||||
<div className={styles.approvalActions}>
|
|
||||||
<Button variant="primary" onClick={() => a.openActionDialog(member, "approve")}>
|
|
||||||
<Icon name="check" size="sm" />
|
|
||||||
Согласовать
|
|
||||||
</Button>
|
|
||||||
<Button variant="danger" className={styles.rejectBtn} onClick={() => a.openActionDialog(member, "reject")}>
|
|
||||||
<Icon name="close" size="sm" />
|
|
||||||
Отклонить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add member */}
|
|
||||||
{!a.isOnApproval && (
|
|
||||||
<div>
|
|
||||||
{a.showAddRow ? (
|
|
||||||
<div className={styles.addMemberRow}>
|
|
||||||
<div className={styles.memberNameInput}>
|
|
||||||
<FormField label="Поиск участника">
|
|
||||||
<TextInput
|
|
||||||
value={a.addQuery}
|
|
||||||
onChange={a.handleAddQueryChange}
|
|
||||||
placeholder="Введите имя для поиска..."
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
{a.addSuggestions.length > 0 && !a.addSelected && (
|
|
||||||
<ul className={styles.suggestions}>
|
|
||||||
{a.addSuggestions.map((s) => (
|
|
||||||
<li
|
|
||||||
key={s.spId}
|
|
||||||
className={styles.suggestionItem}
|
|
||||||
onClick={() => a.selectAddSuggestion(s)}
|
|
||||||
>
|
|
||||||
<span className={styles.suggestionName}>{s.displayName}</span>
|
|
||||||
<span className={styles.suggestionEmail}>{s.email}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.memberRoleInput}>
|
|
||||||
<FormField label="Роль (необязательно)">
|
|
||||||
<TextInput
|
|
||||||
value={a.addRole}
|
|
||||||
onChange={a.setAddRole}
|
|
||||||
placeholder="Напр.: Руководитель"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
className={styles.addRowBtn}
|
|
||||||
disabled={!a.addSelected || a.isAddingMember}
|
|
||||||
onClick={a.handleAddMember}
|
|
||||||
>
|
|
||||||
{a.isAddingMember ? (
|
|
||||||
<Spinner size="small" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="plus" size="sm" />
|
|
||||||
Добавить
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" className={styles.addRowBtn} onClick={a.cancelAddRow}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button variant="secondary" onClick={() => a.setShowAddRow(true)}>
|
|
||||||
<Icon name="plus" size="sm" />
|
|
||||||
Добавить согласующего
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Footer actions */}
|
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
<Button variant="secondary" onClick={onPrev} disabled={a.isOnApproval}>
|
<Button variant="secondary" onClick={onPrev} disabled={isSubmitted}>
|
||||||
<Icon name="arrow-left" size="sm" />
|
<Icon name="arrow-left" size="sm" />
|
||||||
Назад
|
Назад
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className={styles.footerRight}>
|
<div className={styles.footerRight}>
|
||||||
{a.submitError && <Alert variant="err">{a.submitError}</Alert>}
|
{!isSubmitted && (
|
||||||
|
<Button variant="primary" onClick={handleSubmit} disabled={sending || editor.rows.length === 0}>
|
||||||
{!a.isOnApproval && (
|
{sending ? (
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={a.handleSendForApproval}
|
|
||||||
disabled={a.chain.length === 0 || a.isSubmitting || !a.ct}
|
|
||||||
>
|
|
||||||
{a.isSubmitting ? (
|
|
||||||
<Spinner size="small" />
|
<Spinner size="small" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -265,23 +159,21 @@ export const Step4Approval: React.FC<Props> = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* INCORE: согласованная СТ — финал. Есть карточка во вложении → показываем файл;
|
{isInitiator && status === "approved" && !incore.isLoadingCard && (
|
||||||
нет → кнопка формирования. Только для инициатора. */}
|
incore.cardAttachment ? (
|
||||||
{a.isInitiator && a.ct?.status === "approved" && !a.isLoadingCard && (
|
|
||||||
a.cardAttachment ? (
|
|
||||||
<a
|
<a
|
||||||
className={styles.cardLink}
|
className={styles.cardLink}
|
||||||
href={a.cardAttachment.url}
|
href={incore.cardAttachment.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
title="Открыть карточку электронного документа"
|
title="Открыть карточку электронного документа"
|
||||||
>
|
>
|
||||||
<Icon name="download" size="sm" />
|
<Icon name="download" size="sm" />
|
||||||
{a.cardAttachment.name}
|
{incore.cardAttachment.name}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="secondary" onClick={a.handleSendToIncore} disabled={a.isSendingToIncore}>
|
<Button variant="secondary" onClick={incore.handleSendToIncore} disabled={incore.isSendingToIncore}>
|
||||||
{a.isSendingToIncore ? (
|
{incore.isSendingToIncore ? (
|
||||||
<Spinner size="small" />
|
<Spinner size="small" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -293,9 +185,8 @@ export const Step4Approval: React.FC<Props> = (props) => {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Перезапуск — пока СТ на согласовании (ещё не согласована) */}
|
{isSubmitted && isInitiator && status !== "approved" && (
|
||||||
{a.isOnApproval && a.isInitiator && a.ct?.status !== "approved" && (
|
<Button variant="secondary" onClick={handleRestart} disabled={sending}>
|
||||||
<Button variant="secondary" onClick={a.handleRestart} disabled={a.isSubmitting}>
|
|
||||||
<Icon name="refresh" size="sm" />
|
<Icon name="refresh" size="sm" />
|
||||||
Перезапустить согласование
|
Перезапустить согласование
|
||||||
</Button>
|
</Button>
|
||||||
@@ -303,141 +194,40 @@ export const Step4Approval: React.FC<Props> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Replace member dialog */}
|
{ctId && approverService && (
|
||||||
{a.replaceDialog && (
|
<DecisionPanel
|
||||||
<Dialog
|
service={approverService}
|
||||||
open
|
requestId={parseInt(ctId, 10)}
|
||||||
title="Заменить согласующего"
|
onDecided={handleDecided}
|
||||||
subText="Найдите нового участника для замены."
|
search={(q) => service.searchUsers(q)}
|
||||||
onDismiss={a.closeReplace}
|
onDelegated={() => setDecisionSeq((n) => n + 1)}
|
||||||
footer={
|
/>
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={a.closeReplace}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={a.handleReplaceConfirm}
|
|
||||||
disabled={!a.replaceDialog.selected || a.isReplacing}
|
|
||||||
>
|
|
||||||
{a.isReplacing ? <Spinner size="small" /> : "Заменить"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={styles.memberNameInput}>
|
|
||||||
<FormField label="Поиск нового участника">
|
|
||||||
<TextInput
|
|
||||||
value={a.replaceDialog.query}
|
|
||||||
onChange={a.handleReplaceQueryChange}
|
|
||||||
placeholder="Введите имя для поиска..."
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
{a.replaceDialog.suggestions.length > 0 && !a.replaceDialog.selected && (
|
|
||||||
<ul className={`${styles.suggestions} ${styles.suggestionsInline}`}>
|
|
||||||
{a.replaceDialog.suggestions.map((s) => (
|
|
||||||
<li
|
|
||||||
key={s.spId}
|
|
||||||
className={styles.suggestionItem}
|
|
||||||
onClick={() => a.selectReplaceSuggestion(s)}
|
|
||||||
>
|
|
||||||
<span className={styles.suggestionName}>{s.displayName}</span>
|
|
||||||
<span className={styles.suggestionEmail}>{s.email}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Approve / Reject dialog */}
|
{incore.incoreStep && (
|
||||||
{a.actionDialog && (
|
<Dialog open title="Подписание и выгрузка в INCORE" onDismiss={() => undefined}>
|
||||||
<Dialog
|
|
||||||
open
|
|
||||||
title={a.actionDialog.action === "approve" ? "Согласование" : "Отклонение"}
|
|
||||||
subText={
|
|
||||||
a.actionDialog.action === "approve"
|
|
||||||
? "Вы подтверждаете согласование СТ? Комментарий необязателен."
|
|
||||||
: "Укажите причину отклонения (обязательно)."
|
|
||||||
}
|
|
||||||
onDismiss={a.closeActionDialog}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={a.closeActionDialog} disabled={a.isSubmitting}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={a.actionDialog.action === "approve" ? "primary" : "danger"}
|
|
||||||
onClick={a.handleActionConfirm}
|
|
||||||
disabled={
|
|
||||||
a.isSubmitting ||
|
|
||||||
(a.actionDialog.action === "reject" && !a.actionDialog.comment.trim())
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{a.isSubmitting ? (
|
|
||||||
<Spinner size="small" />
|
|
||||||
) : a.actionDialog.action === "approve" ? (
|
|
||||||
"Согласовать"
|
|
||||||
) : (
|
|
||||||
"Отклонить"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TextArea
|
|
||||||
value={a.actionDialog.comment}
|
|
||||||
onChange={a.setActionComment}
|
|
||||||
rows={3}
|
|
||||||
ariaLabel="Комментарий"
|
|
||||||
placeholder={
|
|
||||||
a.actionDialog.action === "reject"
|
|
||||||
? "Укажите причину отклонения..."
|
|
||||||
: "Комментарий (необязательно)..."
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{a.submitError && (
|
|
||||||
<div className={styles.dialogError}>
|
|
||||||
<Alert variant="err">{a.submitError}</Alert>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* INCORE: прогресс подписания и сборки карточки */}
|
|
||||||
{a.incoreStep && (
|
|
||||||
<Dialog
|
|
||||||
open
|
|
||||||
title="Подписание и выгрузка в INCORE"
|
|
||||||
onDismiss={() => undefined}
|
|
||||||
>
|
|
||||||
<div className={styles.incoreProgress}>
|
<div className={styles.incoreProgress}>
|
||||||
<Spinner size="small" />
|
<Spinner size="small" />
|
||||||
<span>{INCORE_STEP_LABELS[a.incoreStep]}</span>
|
<span>{INCORE_STEP_LABELS[incore.incoreStep]}</span>
|
||||||
</div>
|
</div>
|
||||||
{a.incoreStep === "signing" && (
|
{incore.incoreStep === "signing" && (
|
||||||
<p className={styles.incoreHint}>
|
<p className={styles.incoreHint}>Выберите сертификат ЭЦП и введите PIN-код в открывшемся окне NCALayer.</p>
|
||||||
Выберите сертификат ЭЦП и введите PIN-код в открывшемся окне
|
|
||||||
NCALayer.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* INCORE: ошибка подписания */}
|
{incore.incoreError && (
|
||||||
{a.incoreError && (
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
title="Ошибка выгрузки в INCORE"
|
title="Ошибка выгрузки в INCORE"
|
||||||
onDismiss={a.dismissIncoreError}
|
onDismiss={incore.dismissIncoreError}
|
||||||
footer={
|
footer={
|
||||||
<Button variant="secondary" onClick={a.dismissIncoreError}>
|
<Button variant="secondary" onClick={incore.dismissIncoreError}>
|
||||||
Закрыть
|
Закрыть
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Alert variant="err">{a.incoreError}</Alert>
|
<Alert variant="err">{incore.incoreError}</Alert>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { CtService } from '../../services/CtService';
|
||||||
|
import { SigexSignService, SigexSignStep } from '../../services/SigexSignService';
|
||||||
|
import { CTFormData } from '../hooks/useCtForm';
|
||||||
|
|
||||||
|
// Этапы выгрузки пакета в INCORE: формирование отчёта + этапы подписания SIGEX.
|
||||||
|
export type IncoreStep = 'report' | SigexSignStep;
|
||||||
|
|
||||||
|
// Подпись через SIGEX без состояния — один экземпляр на модуль.
|
||||||
|
const sigexSign = new SigexSignService();
|
||||||
|
|
||||||
|
export interface UseIncoreArgs {
|
||||||
|
service: CtService;
|
||||||
|
formData: CTFormData;
|
||||||
|
isInitiator: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Постобработка согласованной СТ — выгрузка пакета документов с ЭЦП в INCORE.
|
||||||
|
// Вынесено из бывшего useStep4Approval.ts отдельно от согласования (см. план миграции
|
||||||
|
// согласования на approval-route API — этот блок мехнизм согласования не трогает).
|
||||||
|
export function useIncore(args: UseIncoreArgs) {
|
||||||
|
const { service, formData, isInitiator } = args;
|
||||||
|
const { ct } = formData;
|
||||||
|
const incoreCardName = ct ? `Карточка_CT_${ct.id}.pdf` : '';
|
||||||
|
|
||||||
|
const [isSendingToIncore, setIsSendingToIncore] = useState(false);
|
||||||
|
const [incoreStep, setIncoreStep] = useState<IncoreStep | null>(null);
|
||||||
|
const [incoreError, setIncoreError] = useState<string | null>(null);
|
||||||
|
const [cardAttachment, setCardAttachment] = useState<{ name: string; url: string } | null>(null);
|
||||||
|
const [isLoadingCard, setIsLoadingCard] = useState(false);
|
||||||
|
|
||||||
|
// Согласованная СТ — финал: если карточка уже во вложениях, показываем файл вместо кнопки.
|
||||||
|
// Грузим только для инициатора и только в статусе «Согласована».
|
||||||
|
useEffect(() => {
|
||||||
|
if (ct?.status !== 'approved' || !isInitiator) {
|
||||||
|
setCardAttachment(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setIsLoadingCard(true);
|
||||||
|
service
|
||||||
|
.findCtCardAttachment(ct.id, `Карточка_CT_${ct.id}.pdf`)
|
||||||
|
.then((found) => {
|
||||||
|
if (!cancelled) setCardAttachment(found);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setCardAttachment(null);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setIsLoadingCard(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [ct?.status, ct?.id, isInitiator, service]);
|
||||||
|
|
||||||
|
const handleSendToIncore = async (): Promise<void> => {
|
||||||
|
if (!ct) return;
|
||||||
|
setIsSendingToIncore(true);
|
||||||
|
setIncoreError(null);
|
||||||
|
try {
|
||||||
|
// 1. Отчёт СТ в PDF — карточка SIGEX визуализирует именно PDF.
|
||||||
|
setIncoreStep('report');
|
||||||
|
const reportBlob = await service.getCtReportBlob(ct.id, 'pdf');
|
||||||
|
const title = `CT_${ct.id}.pdf`;
|
||||||
|
|
||||||
|
// 2. Подпись NCALayer → регистрация → фиксация → карточка (DDC).
|
||||||
|
const cardBlob = await sigexSign.signAndBuildCard(reportBlob, title, setIncoreStep);
|
||||||
|
|
||||||
|
// 3. Архивируем карточку во вложения элемента СТ и переключаем UI на показ файла.
|
||||||
|
const saved = await service.saveCtCardAttachment(ct.id, cardBlob, incoreCardName);
|
||||||
|
setCardAttachment(saved);
|
||||||
|
|
||||||
|
// 4. Пакет для INCORE: карточка с ЭЦП + файлы КП — скачиваем пользователю.
|
||||||
|
service.downloadBlob(cardBlob, incoreCardName);
|
||||||
|
|
||||||
|
const kpFiles = formData.uploadedKpFiles ?? [];
|
||||||
|
for (const f of kpFiles) {
|
||||||
|
await service.downloadKpFile(f.name);
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 300));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Пользователь закрыл окно NCALayer, не подписав — это не ошибка.
|
||||||
|
if ((err as { canceledByUser?: boolean }).canceledByUser) return;
|
||||||
|
setIncoreError((err as Error).message ?? 'Ошибка подписания и выгрузки пакета');
|
||||||
|
} finally {
|
||||||
|
setIncoreStep(null);
|
||||||
|
setIsSendingToIncore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSendingToIncore,
|
||||||
|
incoreStep,
|
||||||
|
incoreError,
|
||||||
|
dismissIncoreError: (): void => setIncoreError(null),
|
||||||
|
cardAttachment,
|
||||||
|
isLoadingCard,
|
||||||
|
handleSendToIncore,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
||||||
import { ApprovalMember, ApprovalMemberStatus, FormStep } from '../types/domain';
|
|
||||||
import { CtService } from '../../services/CtService';
|
|
||||||
import { SigexSignService, SigexSignStep } from '../../services/SigexSignService';
|
|
||||||
import { CTFormData } from '../hooks/useCtForm';
|
|
||||||
|
|
||||||
// Этапы выгрузки пакета в INCORE: формирование отчёта + этапы подписания SIGEX.
|
|
||||||
export type IncoreStep = 'report' | SigexSignStep;
|
|
||||||
|
|
||||||
// Подпись через SIGEX без состояния — один экземпляр на модуль.
|
|
||||||
const sigexSign = new SigexSignService();
|
|
||||||
|
|
||||||
export type ApprovalAction = 'approve' | 'reject';
|
|
||||||
|
|
||||||
export interface ActionDialogState {
|
|
||||||
memberId: string;
|
|
||||||
memberUserId: string;
|
|
||||||
memberName: string;
|
|
||||||
action: ApprovalAction;
|
|
||||||
comment: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UserSuggestion = { spId: number; displayName: string; email: string };
|
|
||||||
|
|
||||||
export interface ReplaceDialogState {
|
|
||||||
memberId: string;
|
|
||||||
spApprovalId?: number;
|
|
||||||
query: string;
|
|
||||||
selected: { spId: number; displayName: string } | null;
|
|
||||||
suggestions: UserSuggestion[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseStep4ApprovalArgs {
|
|
||||||
service: CtService;
|
|
||||||
formData: CTFormData;
|
|
||||||
userDisplayName: string;
|
|
||||||
onSave: (partial: Partial<CTFormData>) => void;
|
|
||||||
onStepComplete: (step: FormStep) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Логика Шага 4 «Согласование» (цепочка): манипуляции участниками (порядок/добавление/удаление/замена),
|
|
||||||
// голосование (approve/reject + нотификации), отправка на согласование, выгрузка в INCORE, перезапуск.
|
|
||||||
// Перенесено 1:1 из Step4Approval.tsx (§стайл-гайд: логика вне вёрстки).
|
|
||||||
export function useStep4Approval(args: UseStep4ApprovalArgs) {
|
|
||||||
const { service, formData, userDisplayName, onSave, onStepComplete } = args;
|
|
||||||
const { ct } = formData;
|
|
||||||
|
|
||||||
const [chain, setChain] = useState<ApprovalMember[]>(formData.approvalChain);
|
|
||||||
const [actionDialog, setActionDialog] = useState<ActionDialogState | null>(null);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
||||||
const [isSendingToIncore, setIsSendingToIncore] = useState(false);
|
|
||||||
const [incoreStep, setIncoreStep] = useState<IncoreStep | null>(null);
|
|
||||||
const [incoreError, setIncoreError] = useState<string | null>(null);
|
|
||||||
// Карточка INCORE во вложениях элемента СТ: если она уже есть — показываем файл, а не кнопку.
|
|
||||||
const [cardAttachment, setCardAttachment] = useState<{ name: string; url: string } | null>(null);
|
|
||||||
const [isLoadingCard, setIsLoadingCard] = useState(false);
|
|
||||||
const incoreCardName = ct ? `Карточка_CT_${ct.id}.pdf` : '';
|
|
||||||
|
|
||||||
// ─── Add member ───
|
|
||||||
const [showAddRow, setShowAddRow] = useState(false);
|
|
||||||
const [addQuery, setAddQuery] = useState('');
|
|
||||||
const [addRole, setAddRole] = useState('');
|
|
||||||
const [addSuggestions, setAddSuggestions] = useState<UserSuggestion[]>([]);
|
|
||||||
const [addSelected, setAddSelected] = useState<{ spId: number; displayName: string } | null>(null);
|
|
||||||
const [isAddingMember, setIsAddingMember] = useState(false);
|
|
||||||
const addTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
// ─── Replace member ───
|
|
||||||
const [replaceDialog, setReplaceDialog] = useState<ReplaceDialogState | null>(null);
|
|
||||||
const [isReplacing, setIsReplacing] = useState(false);
|
|
||||||
const replaceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const moveMember = useCallback((id: string, direction: 'up' | 'down'): void => {
|
|
||||||
const idx = chain.findIndex((m) => m.id === id);
|
|
||||||
if (idx === -1) return;
|
|
||||||
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
|
||||||
if (swapIdx < 0 || swapIdx >= chain.length) return;
|
|
||||||
const next = [...chain];
|
|
||||||
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
|
|
||||||
const reordered = next.map((m, i) => ({ ...m, order: i + 1 }));
|
|
||||||
setChain(reordered);
|
|
||||||
// Пишем порядок в formData сразу, иначе переупорядочивание теряется при переходе на другой шаг
|
|
||||||
// (chain реинициализируется из formData.approvalChain при возврате). В SP порядок фиксируется
|
|
||||||
// на отправке на согласование (activateApprovalChain).
|
|
||||||
onSave({ approvalChain: reordered });
|
|
||||||
}, [chain, onSave]);
|
|
||||||
|
|
||||||
const openActionDialog = useCallback((member: ApprovalMember, action: ApprovalAction): void => {
|
|
||||||
setActionDialog({
|
|
||||||
memberId: member.id,
|
|
||||||
memberUserId: member.userId,
|
|
||||||
memberName: member.userName,
|
|
||||||
action,
|
|
||||||
comment: '',
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeActionDialog = useCallback((): void => setActionDialog(null), []);
|
|
||||||
const setActionComment = useCallback((val: string): void => {
|
|
||||||
setActionDialog((prev) => (prev ? { ...prev, comment: val } : null));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAddQueryChange = (val: string): void => {
|
|
||||||
const q = val;
|
|
||||||
setAddQuery(q);
|
|
||||||
setAddSelected(null);
|
|
||||||
setAddSuggestions([]);
|
|
||||||
if (addTimerRef.current) clearTimeout(addTimerRef.current);
|
|
||||||
if (q.length < 2) return;
|
|
||||||
addTimerRef.current = setTimeout(async () => {
|
|
||||||
const results = await service.searchUsers(q);
|
|
||||||
setAddSuggestions(results);
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectAddSuggestion = (s: UserSuggestion): void => {
|
|
||||||
setAddSelected({ spId: s.spId, displayName: s.displayName });
|
|
||||||
setAddQuery(s.displayName);
|
|
||||||
setAddSuggestions([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelAddRow = (): void => {
|
|
||||||
setShowAddRow(false);
|
|
||||||
setAddQuery('');
|
|
||||||
setAddRole('');
|
|
||||||
setAddSelected(null);
|
|
||||||
setAddSuggestions([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddMember = async (): Promise<void> => {
|
|
||||||
if (!addSelected || !ct) return;
|
|
||||||
setIsAddingMember(true);
|
|
||||||
setSubmitError(null);
|
|
||||||
try {
|
|
||||||
const newOrder = chain.length + 1;
|
|
||||||
const newMember = await service.addApprovalMemberDraft(
|
|
||||||
ct.id, newOrder, addSelected.spId, addSelected.displayName, addRole.trim() || undefined,
|
|
||||||
);
|
|
||||||
const newChain = [...chain, newMember];
|
|
||||||
setChain(newChain);
|
|
||||||
onSave({ approvalChain: newChain });
|
|
||||||
cancelAddRow();
|
|
||||||
} catch (err) {
|
|
||||||
setSubmitError((err as Error).message ?? 'Ошибка добавления');
|
|
||||||
} finally {
|
|
||||||
setIsAddingMember(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteMember = async (member: ApprovalMember): Promise<void> => {
|
|
||||||
setSubmitError(null);
|
|
||||||
try {
|
|
||||||
if (member.spApprovalId) {
|
|
||||||
await service.deleteApprovalMembers([member.spApprovalId]);
|
|
||||||
}
|
|
||||||
const updated = chain
|
|
||||||
.filter((m) => m.id !== member.id)
|
|
||||||
.map((m, i) => ({ ...m, order: i + 1 }));
|
|
||||||
setChain(updated);
|
|
||||||
onSave({ approvalChain: updated });
|
|
||||||
} catch (err) {
|
|
||||||
setSubmitError((err as Error).message ?? 'Ошибка удаления');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openReplace = (member: ApprovalMember): void => {
|
|
||||||
setReplaceDialog({
|
|
||||||
memberId: member.id,
|
|
||||||
spApprovalId: member.spApprovalId,
|
|
||||||
query: '',
|
|
||||||
selected: null,
|
|
||||||
suggestions: [],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeReplace = (): void => setReplaceDialog(null);
|
|
||||||
|
|
||||||
const handleReplaceQueryChange = (val: string): void => {
|
|
||||||
const q = val;
|
|
||||||
setReplaceDialog((prev) => (prev ? { ...prev, query: q, selected: null, suggestions: [] } : null));
|
|
||||||
if (replaceTimerRef.current) clearTimeout(replaceTimerRef.current);
|
|
||||||
if (q.length < 2) return;
|
|
||||||
replaceTimerRef.current = setTimeout(async () => {
|
|
||||||
const results = await service.searchUsers(q);
|
|
||||||
setReplaceDialog((prev) => (prev ? { ...prev, suggestions: results } : null));
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectReplaceSuggestion = (s: UserSuggestion): void => {
|
|
||||||
setReplaceDialog((prev) =>
|
|
||||||
prev ? { ...prev, selected: { spId: s.spId, displayName: s.displayName }, query: s.displayName, suggestions: [] } : null,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReplaceConfirm = async (): Promise<void> => {
|
|
||||||
if (!replaceDialog?.selected) return;
|
|
||||||
setIsReplacing(true);
|
|
||||||
setSubmitError(null);
|
|
||||||
try {
|
|
||||||
const { memberId, spApprovalId, selected } = replaceDialog;
|
|
||||||
if (spApprovalId) {
|
|
||||||
await service.replaceApprovalMember(spApprovalId, selected.spId);
|
|
||||||
}
|
|
||||||
const updated = chain.map((m) =>
|
|
||||||
m.id === memberId ? { ...m, userId: String(selected.spId), userName: selected.displayName } : m,
|
|
||||||
);
|
|
||||||
setChain(updated);
|
|
||||||
onSave({ approvalChain: updated });
|
|
||||||
setReplaceDialog(null);
|
|
||||||
} catch (err) {
|
|
||||||
setSubmitError((err as Error).message ?? 'Ошибка замены');
|
|
||||||
} finally {
|
|
||||||
setIsReplacing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleActionConfirm = async (): Promise<void> => {
|
|
||||||
if (!actionDialog) return;
|
|
||||||
const { memberId, action, comment } = actionDialog;
|
|
||||||
|
|
||||||
if (action === 'reject' && !comment.trim()) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setSubmitError(null);
|
|
||||||
|
|
||||||
const apiStatus: 'approved' | 'rejected' = action === 'approve' ? 'approved' : 'rejected';
|
|
||||||
const currentMember = chain.find((m) => m.id === memberId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!currentMember?.spApprovalId) throw new Error('Не найден ID записи согласования');
|
|
||||||
await service.submitApprovalStep(currentMember.spApprovalId, apiStatus, comment.trim());
|
|
||||||
|
|
||||||
const notify = async (spId: number): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await service.triggerApprovalNotification(spId);
|
|
||||||
} catch (e) {
|
|
||||||
// Оповещение — best-effort: поток может ответить с таймаутом (504), хотя сообщение
|
|
||||||
// уже ушло. Не пугаем пользователя ложной плашкой, только логируем для отладки.
|
|
||||||
console.error('[Step4] triggerApprovalNotification failed for spId', spId, e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (action === 'approve') {
|
|
||||||
const updatedChain = chain.map((m) =>
|
|
||||||
m.id === memberId
|
|
||||||
? { ...m, status: 'approved' as ApprovalMemberStatus, comment: comment.trim(), actionDate: new Date().toISOString() }
|
|
||||||
: m,
|
|
||||||
);
|
|
||||||
setChain(updatedChain);
|
|
||||||
onSave({ approvalChain: updatedChain });
|
|
||||||
|
|
||||||
const allApproved = updatedChain.every(
|
|
||||||
(m) => m.status === 'approved' || m.status === 'skipped' || m.status === 'recommends',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Финальное согласование: сначала переводим СТ в «Согласовано», и только ПОТОМ
|
|
||||||
// шлём оповещение. Иначе поток прочитает статус ещё «На согласовании» и отправит
|
|
||||||
// не то письмо. Порядок как в ветке отклонения (сначала статус, затем notify).
|
|
||||||
if (allApproved) {
|
|
||||||
await service.updateCTStatus(ct!.id, 'approved');
|
|
||||||
onSave({ ct: { ...ct!, status: 'approved' } });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentMember?.spApprovalId) {
|
|
||||||
await notify(currentMember.spApprovalId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextPending = updatedChain.find((m) => m.status === 'pending');
|
|
||||||
if (nextPending?.spApprovalId) {
|
|
||||||
await notify(nextPending.spApprovalId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allApproved) {
|
|
||||||
onStepComplete(4);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const othersToDelete = chain.filter(
|
|
||||||
(m) => m.id !== memberId && m.status === 'pending' && m.spApprovalId,
|
|
||||||
);
|
|
||||||
if (othersToDelete.length > 0) {
|
|
||||||
await service.deleteApprovalMembers(othersToDelete.map((m) => m.spApprovalId!));
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalChain = chain
|
|
||||||
.filter((m) => m.status !== 'pending' || m.id === memberId)
|
|
||||||
.map((m) =>
|
|
||||||
m.id === memberId
|
|
||||||
? { ...m, status: 'rejected' as ApprovalMemberStatus, comment: comment.trim(), actionDate: new Date().toISOString() }
|
|
||||||
: m,
|
|
||||||
);
|
|
||||||
setChain(finalChain);
|
|
||||||
await service.updateCTStatus(ct!.id, 'rejected');
|
|
||||||
onSave({ approvalChain: finalChain, ct: { ...ct!, status: 'rejected' } });
|
|
||||||
const initiator = finalChain.find((m) => m.role === 'Инициатор');
|
|
||||||
if (initiator?.spApprovalId) await notify(initiator.spApprovalId);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeActionDialog();
|
|
||||||
} catch (err) {
|
|
||||||
setSubmitError((err as Error).message ?? 'Ошибка выполнения действия');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendForApproval = async (): Promise<void> => {
|
|
||||||
if (!ct || chain.length === 0) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setSubmitError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await service.activateApprovalChain(chain);
|
|
||||||
|
|
||||||
const winSupplier = formData.suppliers.find((s) => s.id === formData.recommendedSupplierId);
|
|
||||||
service.updateCTStatus(ct.id, 'on_approval', winSupplier?.spId).catch(() => undefined);
|
|
||||||
|
|
||||||
const activatedChain = chain.map((m) => ({
|
|
||||||
...m,
|
|
||||||
status: (m.role === 'Инициатор' ? 'recommends' : 'pending') as ApprovalMemberStatus,
|
|
||||||
}));
|
|
||||||
setChain(activatedChain);
|
|
||||||
onSave({ approvalChain: activatedChain, ct: { ...ct, status: 'on_approval' }, approvalSent: true });
|
|
||||||
|
|
||||||
const firstApprover = chain.find((m) => m.role !== 'Инициатор' && m.spApprovalId);
|
|
||||||
if (firstApprover?.spApprovalId) {
|
|
||||||
try {
|
|
||||||
await service.triggerApprovalNotification(firstApprover.spApprovalId);
|
|
||||||
} catch (e) {
|
|
||||||
// best-effort (см. notify выше) — ложную плашку не показываем.
|
|
||||||
console.error('[Step4] triggerApprovalNotification (send) failed for spId', firstApprover.spApprovalId, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onStepComplete(4);
|
|
||||||
} catch (err) {
|
|
||||||
setSubmitError((err as Error).message ?? 'Ошибка отправки на согласование');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendToIncore = async (): Promise<void> => {
|
|
||||||
if (!ct) return;
|
|
||||||
setIsSendingToIncore(true);
|
|
||||||
setIncoreError(null);
|
|
||||||
try {
|
|
||||||
// 1. Отчёт СТ в PDF — карточка SIGEX визуализирует именно PDF.
|
|
||||||
setIncoreStep('report');
|
|
||||||
const reportBlob = await service.getCtReportBlob(ct.id, 'pdf');
|
|
||||||
const title = `CT_${ct.id}.pdf`;
|
|
||||||
|
|
||||||
// 2. Подпись NCALayer → регистрация → фиксация → карточка (DDC).
|
|
||||||
const cardBlob = await sigexSign.signAndBuildCard(
|
|
||||||
reportBlob,
|
|
||||||
title,
|
|
||||||
setIncoreStep,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Архивируем карточку во вложения элемента СТ и переключаем UI на показ файла.
|
|
||||||
const saved = await service.saveCtCardAttachment(ct.id, cardBlob, incoreCardName);
|
|
||||||
setCardAttachment(saved);
|
|
||||||
|
|
||||||
// 4. Пакет для INCORE: карточка с ЭЦП + файлы КП — скачиваем пользователю.
|
|
||||||
service.downloadBlob(cardBlob, incoreCardName);
|
|
||||||
|
|
||||||
const kpFiles = formData.uploadedKpFiles ?? [];
|
|
||||||
for (const f of kpFiles) {
|
|
||||||
await service.downloadKpFile(f.name);
|
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 300));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Пользователь закрыл окно NCALayer, не подписав — это не ошибка.
|
|
||||||
if ((err as { canceledByUser?: boolean }).canceledByUser) return;
|
|
||||||
setIncoreError(
|
|
||||||
(err as Error).message ?? 'Ошибка подписания и выгрузки пакета',
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIncoreStep(null);
|
|
||||||
setIsSendingToIncore(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRestart = async (): Promise<void> => {
|
|
||||||
if (!ct) return;
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setSubmitError(null);
|
|
||||||
try {
|
|
||||||
const resetChain = chain.map((m) => ({
|
|
||||||
...m,
|
|
||||||
status: 'pending' as ApprovalMemberStatus,
|
|
||||||
comment: undefined,
|
|
||||||
actionDate: undefined,
|
|
||||||
}));
|
|
||||||
await service.updateCTStatus(ct.id, 'formed');
|
|
||||||
setChain(resetChain);
|
|
||||||
onSave({ approvalChain: resetChain, ct: { ...ct, status: 'formed' }, approvalSent: false });
|
|
||||||
} catch (err) {
|
|
||||||
setSubmitError((err as Error).message ?? 'Ошибка перезапуска согласования');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isOnApproval = ct?.status === 'on_approval' || ct?.status === 'approved';
|
|
||||||
const showStatuses =
|
|
||||||
formData.approvalSent ||
|
|
||||||
ct?.status === 'on_approval' ||
|
|
||||||
ct?.status === 'approved' ||
|
|
||||||
ct?.status === 'rejected';
|
|
||||||
const isInitiator = chain.some((m) => m.userName === userDisplayName && m.role === 'Инициатор');
|
|
||||||
const currentUserMember = chain.find(
|
|
||||||
(m) =>
|
|
||||||
m.userName === userDisplayName &&
|
|
||||||
m.status === 'pending' &&
|
|
||||||
m.role !== 'Инициатор' &&
|
|
||||||
chain.filter((prev) => prev.order < m.order).every((prev) => prev.status !== 'pending'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Согласованная СТ — финал: если карточка уже во вложениях, показываем файл вместо кнопки.
|
|
||||||
// Грузим только для инициатора и только в статусе «Согласована».
|
|
||||||
useEffect(() => {
|
|
||||||
if (ct?.status !== 'approved' || !isInitiator) {
|
|
||||||
setCardAttachment(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
setIsLoadingCard(true);
|
|
||||||
service
|
|
||||||
.findCtCardAttachment(ct.id, `Карточка_CT_${ct.id}.pdf`)
|
|
||||||
.then((found) => { if (!cancelled) setCardAttachment(found); })
|
|
||||||
.catch(() => { if (!cancelled) setCardAttachment(null); })
|
|
||||||
.finally(() => { if (!cancelled) setIsLoadingCard(false); });
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [ct?.status, ct?.id, isInitiator, service]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
ct,
|
|
||||||
chain,
|
|
||||||
submitError,
|
|
||||||
isSubmitting,
|
|
||||||
isSendingToIncore,
|
|
||||||
incoreStep,
|
|
||||||
incoreError,
|
|
||||||
dismissIncoreError: () => setIncoreError(null),
|
|
||||||
cardAttachment,
|
|
||||||
isLoadingCard,
|
|
||||||
isOnApproval,
|
|
||||||
showStatuses,
|
|
||||||
isInitiator,
|
|
||||||
currentUserMember,
|
|
||||||
// chain ops
|
|
||||||
moveMember,
|
|
||||||
handleDeleteMember,
|
|
||||||
openActionDialog,
|
|
||||||
openReplace,
|
|
||||||
// add member
|
|
||||||
showAddRow,
|
|
||||||
setShowAddRow,
|
|
||||||
addQuery,
|
|
||||||
addRole,
|
|
||||||
setAddRole,
|
|
||||||
addSuggestions,
|
|
||||||
addSelected,
|
|
||||||
isAddingMember,
|
|
||||||
handleAddQueryChange,
|
|
||||||
selectAddSuggestion,
|
|
||||||
cancelAddRow,
|
|
||||||
handleAddMember,
|
|
||||||
// action dialog
|
|
||||||
actionDialog,
|
|
||||||
setActionComment,
|
|
||||||
closeActionDialog,
|
|
||||||
handleActionConfirm,
|
|
||||||
// replace dialog
|
|
||||||
replaceDialog,
|
|
||||||
isReplacing,
|
|
||||||
handleReplaceQueryChange,
|
|
||||||
selectReplaceSuggestion,
|
|
||||||
closeReplace,
|
|
||||||
handleReplaceConfirm,
|
|
||||||
// footer
|
|
||||||
handleSendForApproval,
|
|
||||||
handleSendToIncore,
|
|
||||||
handleRestart,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { CT, FormStep, Position, Supplier, CurrencyRate, SupplierOffer, ApprovalMember } from '../types/domain';
|
import { CT, FormStep, Position, Supplier, CurrencyRate, SupplierOffer } from '../types/domain';
|
||||||
|
import { IMatrixRow } from '../../../../shared/approval';
|
||||||
|
|
||||||
export interface CTFormData {
|
export interface CTFormData {
|
||||||
ct: CT | undefined;
|
ct: CT | undefined;
|
||||||
@@ -9,7 +10,9 @@ export interface CTFormData {
|
|||||||
currencyRates: CurrencyRate[];
|
currencyRates: CurrencyRate[];
|
||||||
offers: SupplierOffer[];
|
offers: SupplierOffer[];
|
||||||
recommendedSupplierId: string | undefined;
|
recommendedSupplierId: string | undefined;
|
||||||
approvalChain: ApprovalMember[];
|
// Маршрут согласования — только для отображения (например, hidePrices в CtCreateForm.tsx);
|
||||||
|
// решения читают/пишут его напрямую через CtService (shared/approval), не через formData.
|
||||||
|
approvalMatrix: IMatrixRow[];
|
||||||
initiatorDepartment: string;
|
initiatorDepartment: string;
|
||||||
initiatorCompany: string;
|
initiatorCompany: string;
|
||||||
step3ExtraSuppliers: Array<{ id: string; name: string; currency: string; spId?: number; status?: string }>;
|
step3ExtraSuppliers: Array<{ id: string; name: string; currency: string; spId?: number; status?: string }>;
|
||||||
@@ -30,7 +33,7 @@ const INITIAL_FORM_DATA: CTFormData = {
|
|||||||
currencyRates: [],
|
currencyRates: [],
|
||||||
offers: [],
|
offers: [],
|
||||||
recommendedSupplierId: undefined,
|
recommendedSupplierId: undefined,
|
||||||
approvalChain: [],
|
approvalMatrix: [],
|
||||||
initiatorDepartment: '',
|
initiatorDepartment: '',
|
||||||
initiatorCompany: '',
|
initiatorCompany: '',
|
||||||
step3ExtraSuppliers: [],
|
step3ExtraSuppliers: [],
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ export interface CT {
|
|||||||
id: string;
|
id: string;
|
||||||
number: string; // sequential number within project
|
number: string; // sequential number within project
|
||||||
projectName: string; // 'project' SP field
|
projectName: string; // 'project' SP field
|
||||||
status?: CTStatus; // not in SP list, tracked in memory only
|
status?: CTStatus;
|
||||||
|
// Email автора записи (SP Author/Created By) — определяет инициатора для показа
|
||||||
|
// блока «Выгрузка в INCORE» и т.п.: в новом механизме согласования (approval-route API)
|
||||||
|
// инициатор в саму матрицу согласования не входит, в отличие от прежней цепочки.
|
||||||
|
authorEmail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CTStatus =
|
export type CTStatus =
|
||||||
@@ -149,26 +153,6 @@ export interface SupplierCriteria {
|
|||||||
comment?: string;
|
comment?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApprovalMember {
|
|
||||||
id: string;
|
|
||||||
order: number;
|
|
||||||
userId: string;
|
|
||||||
userName: string;
|
|
||||||
role?: string;
|
|
||||||
status: ApprovalMemberStatus;
|
|
||||||
comment?: string;
|
|
||||||
actionDate?: string;
|
|
||||||
spApprovalId?: number;
|
|
||||||
canAddApprover?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ApprovalMemberStatus =
|
|
||||||
| "pending"
|
|
||||||
| "recommends"
|
|
||||||
| "approved"
|
|
||||||
| "rejected"
|
|
||||||
| "skipped";
|
|
||||||
|
|
||||||
export type FormStep = 1 | 2 | 3 | 4;
|
export type FormStep = 1 | 2 | 3 | 4;
|
||||||
|
|
||||||
export const FORM_STEPS: Array<{ step: FormStep; label: string }> = [
|
export const FORM_STEPS: Array<{ step: FormStep; label: string }> = [
|
||||||
|
|||||||
@@ -9,12 +9,21 @@ import {
|
|||||||
Supplier,
|
Supplier,
|
||||||
SupplierOffer,
|
SupplierOffer,
|
||||||
SupplierCriteria,
|
SupplierCriteria,
|
||||||
ApprovalMember,
|
|
||||||
ApprovalMemberStatus,
|
|
||||||
SyntexFields,
|
SyntexFields,
|
||||||
FlowResult,
|
FlowResult,
|
||||||
AdataCompanyInfo,
|
AdataCompanyInfo,
|
||||||
} from "../components/types/domain";
|
} from "../components/types/domain";
|
||||||
|
import {
|
||||||
|
ApprovalListService,
|
||||||
|
ApproverService,
|
||||||
|
IApprovalListFields,
|
||||||
|
IApproverToWrite,
|
||||||
|
IMatrixRow,
|
||||||
|
REQUEST_STATUS,
|
||||||
|
getPlannedMatrix,
|
||||||
|
getRequestTypeIdByPageUrl,
|
||||||
|
writeRoute,
|
||||||
|
} from "../../../shared/approval";
|
||||||
|
|
||||||
// Round monetary values to 2 decimals before persisting to SharePoint (drops float tails).
|
// Round monetary values to 2 decimals before persisting to SharePoint (drops float tails).
|
||||||
function round2(value: number): number {
|
function round2(value: number): number {
|
||||||
@@ -52,6 +61,39 @@ type ApiPosition = Record<string, unknown>;
|
|||||||
|
|
||||||
const AAD_RESOURCE = "9232d9e2-9807-4753-808f-07250e5474b0";
|
const AAD_RESOURCE = "9232d9e2-9807-4753-808f-07250e5474b0";
|
||||||
|
|
||||||
|
// pageUrl типа заявки «СТ» в админке approval-route — заводит пользователь (см. план миграции
|
||||||
|
// согласования). Поля списка «Согласование СТ» (CT/aproover/status/aproovDate/comment/order0 +
|
||||||
|
// уже существующий canAddApprover, новый delegatedBy) переиспользуются как есть — см. APPROVAL_FIELDS ниже.
|
||||||
|
const CT_REQUEST_TYPE_PAGE_URL = "ct";
|
||||||
|
|
||||||
|
const APPROVAL_FIELDS: IApprovalListFields = {
|
||||||
|
ticket: "CT",
|
||||||
|
approver: "aproover",
|
||||||
|
status: "status",
|
||||||
|
date: "aproovDate",
|
||||||
|
comment: "comment",
|
||||||
|
order: "order0",
|
||||||
|
allowReassign: "canAddApprover", // существующая колонка — семантика совпадает с AllowReassignToOtherApprovers
|
||||||
|
delegatedBy: "delegatedBy",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Статус заявки (ApproverService.decide) → Choice «Статус» шапки СТ.
|
||||||
|
const APPROVAL_STATUS_TO_CT_STATUS: Record<"onApproval" | "approved" | "rejected", CTStatus> = {
|
||||||
|
onApproval: "on_approval",
|
||||||
|
approved: "approved",
|
||||||
|
rejected: "rejected",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обратный маппинг для чтения статуса шапки СТ (loadCTById) — раньше status не читался вовсе,
|
||||||
|
// а восстанавливался деривацией из цепочки согласования (см. CtCreateForm.tsx/deriveCtStatus,
|
||||||
|
// убрана вместе с переносом статуса в этот сервис, единый источник истины — сама SP-запись).
|
||||||
|
const CHOICE_TO_CT_STATUS: Record<string, CTStatus> = {
|
||||||
|
Черновик: "formed",
|
||||||
|
"На согласовании": "on_approval",
|
||||||
|
Согласовано: "approved",
|
||||||
|
Отклонено: "rejected",
|
||||||
|
};
|
||||||
|
|
||||||
export class CtService {
|
export class CtService {
|
||||||
private readonly baseUrl: string;
|
private readonly baseUrl: string;
|
||||||
private readonly http: SPHttpClient;
|
private readonly http: SPHttpClient;
|
||||||
@@ -175,6 +217,9 @@ export class CtService {
|
|||||||
id: String(data.Id),
|
id: String(data.Id),
|
||||||
number: ctNumber,
|
number: ctNumber,
|
||||||
projectName,
|
projectName,
|
||||||
|
// Автор новой записи — текущий пользователь; loadCTById читает то же самое из
|
||||||
|
// SP Author/EMail при повторных загрузках (см. isInitiator в Step4Approval/InlineApprovalChain).
|
||||||
|
authorEmail: this.currentUserEmail || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,83 +565,58 @@ export class CtService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Согласование СТ ───────────────────────────────────────────────────────
|
// ─── Согласование СТ (через shared/approval — approval-route API + список
|
||||||
|
// «Согласование СТ», см. план миграции согласования на approval-route API) ───
|
||||||
|
|
||||||
async deleteApprovalChainByCt(ctId: string): Promise<void> {
|
private _approval: ApprovalListService | undefined;
|
||||||
const filter = encodeURIComponent(`CTId eq ${parseInt(ctId, 10)}`);
|
|
||||||
const res = await this.http.get(
|
private get approval(): ApprovalListService {
|
||||||
`${this._li(LISTS.APPROVAL_CHAIN)}?$select=Id&$filter=${filter}`,
|
if (!this._approval) {
|
||||||
SPHttpClient.configurations.v1,
|
this._approval = new ApprovalListService(this.context, LISTS.APPROVAL_CHAIN, this.baseUrl, APPROVAL_FIELDS);
|
||||||
);
|
|
||||||
if (!res.ok) return;
|
|
||||||
const data = await res.json();
|
|
||||||
const items: Array<{ Id: number }> = data.value ?? [];
|
|
||||||
for (const item of items) {
|
|
||||||
await this.http.fetch(
|
|
||||||
this._liById(LISTS.APPROVAL_CHAIN, item.Id),
|
|
||||||
SPHttpClient.configurations.v1,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { "IF-MATCH": "*" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return this._approval;
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveApprovalChain(
|
get currentUserEmail(): string {
|
||||||
chain: ApprovalMember[],
|
return this.context.pageContext.user.email || "";
|
||||||
ctId: string,
|
|
||||||
): Promise<ApprovalMember[]> {
|
|
||||||
const saved: ApprovalMember[] = [];
|
|
||||||
for (const m of chain) {
|
|
||||||
const res = await this.http.post(
|
|
||||||
this._li(LISTS.APPROVAL_CHAIN),
|
|
||||||
SPHttpClient.configurations.v1,
|
|
||||||
{
|
|
||||||
headers: { "Content-Type": "application/json;odata=nometadata" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
Title: String(m.order),
|
|
||||||
order0: m.order,
|
|
||||||
aprooverId: parseInt(m.userId, 10),
|
|
||||||
status: "Черновик",
|
|
||||||
comment: "",
|
|
||||||
CTId: parseInt(ctId, 10),
|
|
||||||
canAddApprover: m.canAddApprover ?? false,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!res.ok)
|
|
||||||
throw new Error(`Ошибка сохранения согласующего: ${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
saved.push({ ...m, spApprovalId: Number(data.Id) });
|
|
||||||
}
|
|
||||||
return saved;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async activateApprovalChain(chain: ApprovalMember[]): Promise<void> {
|
resolveRequestTypeId(): Promise<number> {
|
||||||
for (const m of chain) {
|
return getRequestTypeIdByPageUrl(this.context, CT_REQUEST_TYPE_PAGE_URL);
|
||||||
if (!m.spApprovalId) continue;
|
}
|
||||||
const status = m.role === "Инициатор" ? "Рекомендую" : "На согласовании";
|
|
||||||
// Пишем order0 = позиция участника у инициатора. Удаление/добавление согласующих
|
// department/company — фильтры маршрута, как в прежней «Матрица согласования СТ» — ОБА условием
|
||||||
// пересчитывает порядок только локально; здесь фиксируем его в SP, иначе согласующий
|
// видимости (fieldValues), НЕ через параметр departmentId: тот в approval-route — GUID из
|
||||||
// при свежей загрузке (сортировка по order0) увидит другой порядок и не ту очередь.
|
// справочника отделов (см. ошибку 400 "$.departmentId: JSON value could not be converted" —
|
||||||
const res = await this.http.fetch(
|
// бэкенд ждёт GUID, а не текст должности/отдела из профиля SharePoint). Имена полей
|
||||||
this._liById(LISTS.APPROVAL_CHAIN, m.spApprovalId),
|
// 'company'/'department' должны совпадать с тем, что настроено в админке маршрутов для типа «ct».
|
||||||
SPHttpClient.configurations.v1,
|
async getPlannedApprovalMatrix(userEmail: string, department: string, company: string): Promise<IMatrixRow[]> {
|
||||||
{
|
const requestTypeId = await this.resolveRequestTypeId();
|
||||||
method: "POST",
|
const fieldValues: Array<{ fieldName: string; value: string }> = [];
|
||||||
headers: {
|
if (company) fieldValues.push({ fieldName: "company", value: company });
|
||||||
"Content-Type": "application/json;odata=nometadata",
|
if (department) fieldValues.push({ fieldName: "department", value: department });
|
||||||
"IF-MATCH": "*",
|
return getPlannedMatrix(this.context, requestTypeId, userEmail, this.approval, null, fieldValues, null);
|
||||||
"X-HTTP-Method": "MERGE",
|
}
|
||||||
},
|
|
||||||
body: JSON.stringify({ status, order0: m.order, Title: String(m.order) }),
|
getApprovalMatrixForRequest(ctId: string): Promise<IMatrixRow[]> {
|
||||||
},
|
return this.approval.getMatrixForRequest(parseInt(ctId, 10));
|
||||||
);
|
}
|
||||||
if (!res.ok)
|
|
||||||
throw new Error(
|
submitApprovalRoute(ctId: string, approvers: IApproverToWrite[]): Promise<void> {
|
||||||
`Ошибка активации согласующего ${m.order}: ${res.status}`,
|
return writeRoute(this.approval, parseInt(ctId, 10), approvers);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
getApproverService(ctId: string): ApproverService {
|
||||||
|
return new ApproverService(this.approval, async (status) => {
|
||||||
|
await this.updateCTStatus(ctId, APPROVAL_STATUS_TO_CT_STATUS[status]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyActiveApprover(ctId: string): Promise<void> {
|
||||||
|
const rows = await this.approval.getForRequest(parseInt(ctId, 10));
|
||||||
|
const active = rows.filter((r) => r.status === REQUEST_STATUS.onApproval)[0];
|
||||||
|
if (active) {
|
||||||
|
await this.triggerApprovalNotification(active.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,106 +646,8 @@ export class CtService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitApprovalStep(
|
|
||||||
spItemId: number,
|
|
||||||
status: "approved" | "rejected",
|
|
||||||
comment: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const spStatus = status === "approved" ? "Согласовано" : "Отклонено";
|
|
||||||
const res = await this.http.fetch(
|
|
||||||
this._liById(LISTS.APPROVAL_CHAIN, spItemId),
|
|
||||||
SPHttpClient.configurations.v1,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json;odata=nometadata",
|
|
||||||
"IF-MATCH": "*",
|
|
||||||
"X-HTTP-Method": "MERGE",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
status: spStatus,
|
|
||||||
comment,
|
|
||||||
aproovDate: new Date().toISOString(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!res.ok)
|
|
||||||
throw new Error(`Ошибка обновления шага согласования: ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteApprovalMembers(spIds: number[]): Promise<void> {
|
|
||||||
for (const id of spIds) {
|
|
||||||
await this.http.fetch(
|
|
||||||
this._liById(LISTS.APPROVAL_CHAIN, id),
|
|
||||||
SPHttpClient.configurations.v1,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { "IF-MATCH": "*" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async addApprovalMemberDraft(
|
|
||||||
ctId: string,
|
|
||||||
order: number,
|
|
||||||
userId: number,
|
|
||||||
userName: string,
|
|
||||||
role?: string,
|
|
||||||
): Promise<ApprovalMember> {
|
|
||||||
const res = await this.http.post(
|
|
||||||
this._li(LISTS.APPROVAL_CHAIN),
|
|
||||||
SPHttpClient.configurations.v1,
|
|
||||||
{
|
|
||||||
headers: { "Content-Type": "application/json;odata=nometadata" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
Title: String(order),
|
|
||||||
order0: order,
|
|
||||||
aprooverId: userId,
|
|
||||||
status: "Черновик",
|
|
||||||
comment: "",
|
|
||||||
CTId: parseInt(ctId, 10),
|
|
||||||
canAddApprover: false,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!res.ok)
|
|
||||||
throw new Error(`Ошибка добавления согласующего: ${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
return {
|
|
||||||
id: `chain_${data.Id}`,
|
|
||||||
order,
|
|
||||||
userId: String(userId),
|
|
||||||
userName,
|
|
||||||
role: role || undefined,
|
|
||||||
status: "pending",
|
|
||||||
spApprovalId: Number(data.Id),
|
|
||||||
canAddApprover: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async replaceApprovalMember(
|
|
||||||
spApprovalId: number,
|
|
||||||
newUserId: number,
|
|
||||||
): Promise<void> {
|
|
||||||
const res = await this.http.fetch(
|
|
||||||
this._liById(LISTS.APPROVAL_CHAIN, spApprovalId),
|
|
||||||
SPHttpClient.configurations.v1,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json;odata=nometadata",
|
|
||||||
"IF-MATCH": "*",
|
|
||||||
"X-HTTP-Method": "MERGE",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ aprooverId: newUserId }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!res.ok) throw new Error(`Ошибка замены согласующего: ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadCTById(ctId: string): Promise<CT> {
|
async loadCTById(ctId: string): Promise<CT> {
|
||||||
const url = `${this._liById(LISTS.CT, ctId)}?$select=Id,Title,number,project`;
|
const url = `${this._liById(LISTS.CT, ctId)}?$select=Id,Title,number,project,status,Author/EMail&$expand=Author`;
|
||||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||||
if (!res.ok) throw new Error(`Ошибка загрузки СТ: ${res.status}`);
|
if (!res.ok) throw new Error(`Ошибка загрузки СТ: ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -733,6 +655,8 @@ export class CtService {
|
|||||||
id: String(data.Id),
|
id: String(data.Id),
|
||||||
number: String(data.number ?? data.Title ?? ""),
|
number: String(data.number ?? data.Title ?? ""),
|
||||||
projectName: String(data.project ?? ""),
|
projectName: String(data.project ?? ""),
|
||||||
|
status: CHOICE_TO_CT_STATUS[String(data.status ?? "")] ?? "formed",
|
||||||
|
authorEmail: String(data.Author?.EMail ?? "") || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -894,150 +818,14 @@ export class CtService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadApprovalChain(ctId: string): Promise<ApprovalMember[]> {
|
// Поиск пользователей сайта — используется пикером согласующего/делегата в ApprovalMatrix/DecisionPanel.
|
||||||
const url =
|
searchUsers(query: string): Promise<Array<{ id: number; name: string; email: string; loginName: string }>> {
|
||||||
`${this._li(LISTS.APPROVAL_CHAIN)}` +
|
return this.approval.searchUsers(query);
|
||||||
`?$select=Id,order0,status,comment,aproovDate,canAddApprover,aproover/Id,aproover/Title` +
|
|
||||||
`&$expand=aproover` +
|
|
||||||
`&$filter=CTId eq ${parseInt(ctId, 10)}` +
|
|
||||||
`&$orderby=order0 asc`;
|
|
||||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
|
||||||
if (!res.ok)
|
|
||||||
throw new Error(`Ошибка загрузки цепочки согласования: ${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
return (data.value ?? []).map(
|
|
||||||
(item: Record<string, unknown>): ApprovalMember => {
|
|
||||||
const approver = item["aproover"] as
|
|
||||||
| Record<string, unknown>
|
|
||||||
| undefined;
|
|
||||||
const spStatus = String(item["status"] ?? "");
|
|
||||||
const status = this._mapSpApprovalStatus(spStatus);
|
|
||||||
return {
|
|
||||||
id: `chain_${item["Id"]}`,
|
|
||||||
order: Number(item["order0"] ?? 1),
|
|
||||||
userId: String(approver?.["Id"] ?? ""),
|
|
||||||
userName: String(approver?.["Title"] ?? ""),
|
|
||||||
role:
|
|
||||||
Number(item["order0"]) === 1 || status === "recommends"
|
|
||||||
? "Инициатор"
|
|
||||||
: undefined,
|
|
||||||
status,
|
|
||||||
comment: String(item["comment"] ?? "") || undefined,
|
|
||||||
actionDate: item["aproovDate"]
|
|
||||||
? String(item["aproovDate"])
|
|
||||||
: undefined,
|
|
||||||
spApprovalId: Number(item["Id"]),
|
|
||||||
canAddApprover: Boolean(item["canAddApprover"]),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async addApprovalDelegate(
|
// Должность добавляемого согласующего из профиля SharePoint — для useMatrixEditor.addApprover.
|
||||||
ctId: string,
|
getJobTitle(loginName: string): Promise<string> {
|
||||||
insertAtOrder: number,
|
return this.approval.getJobTitle(loginName);
|
||||||
delegateUserId: number,
|
|
||||||
delegateName: string,
|
|
||||||
): Promise<ApprovalMember> {
|
|
||||||
// Load items with order >= insertAtOrder to shift them down
|
|
||||||
const filter = encodeURIComponent(
|
|
||||||
`CTId eq ${parseInt(ctId, 10)} and order0 ge ${insertAtOrder}`,
|
|
||||||
);
|
|
||||||
const findRes = await this.http.get(
|
|
||||||
`${this._li(LISTS.APPROVAL_CHAIN)}?$select=Id,order0&$filter=${filter}&$orderby=order0 desc`,
|
|
||||||
SPHttpClient.configurations.v1,
|
|
||||||
);
|
|
||||||
if (!findRes.ok)
|
|
||||||
throw new Error(`Ошибка загрузки цепочки: ${findRes.status}`);
|
|
||||||
const findData = await findRes.json();
|
|
||||||
const itemsToShift: Array<{ Id: number; order0: number }> =
|
|
||||||
findData.value ?? [];
|
|
||||||
|
|
||||||
// Shift orders +1 (descending order to avoid unique constraint conflicts)
|
|
||||||
for (const item of itemsToShift) {
|
|
||||||
const res = await this.http.fetch(
|
|
||||||
this._liById(LISTS.APPROVAL_CHAIN, item.Id),
|
|
||||||
SPHttpClient.configurations.v1,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json;odata=nometadata",
|
|
||||||
"IF-MATCH": "*",
|
|
||||||
"X-HTTP-Method": "MERGE",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
order0: item.order0 + 1,
|
|
||||||
Title: String(item.order0 + 1),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!res.ok) throw new Error(`Ошибка обновления порядка: ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new delegate item at insertAtOrder
|
|
||||||
const createRes = await this.http.post(
|
|
||||||
this._li(LISTS.APPROVAL_CHAIN),
|
|
||||||
SPHttpClient.configurations.v1,
|
|
||||||
{
|
|
||||||
headers: { "Content-Type": "application/json;odata=nometadata" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
Title: String(insertAtOrder),
|
|
||||||
order0: insertAtOrder,
|
|
||||||
aprooverId: delegateUserId,
|
|
||||||
status: "На согласовании",
|
|
||||||
comment: "",
|
|
||||||
CTId: parseInt(ctId, 10),
|
|
||||||
canAddApprover: false,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!createRes.ok)
|
|
||||||
throw new Error(`Ошибка создания делегата: ${createRes.status}`);
|
|
||||||
const created = await createRes.json();
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `chain_${created.Id}`,
|
|
||||||
order: insertAtOrder,
|
|
||||||
userId: String(delegateUserId),
|
|
||||||
userName: delegateName,
|
|
||||||
status: "pending",
|
|
||||||
spApprovalId: Number(created.Id),
|
|
||||||
canAddApprover: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async searchUsers(
|
|
||||||
query: string,
|
|
||||||
): Promise<Array<{ spId: number; displayName: string; email: string }>> {
|
|
||||||
if (!query || query.length < 2) return [];
|
|
||||||
const escaped = query.replace(/'/g, "''");
|
|
||||||
const url =
|
|
||||||
`${this.baseUrl}/_api/web/siteusers` +
|
|
||||||
`?$filter=substringof('${escaped}',Title) and PrincipalType eq 1` +
|
|
||||||
`&$select=Id,Title,Email&$top=10`;
|
|
||||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = await res.json();
|
|
||||||
return (data.value ?? []).map((u: Record<string, unknown>) => ({
|
|
||||||
spId: Number(u["Id"]),
|
|
||||||
displayName: String(u["Title"] ?? ""),
|
|
||||||
email: String(u["Email"] ?? ""),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
private _mapSpApprovalStatus(spStatus: string): ApprovalMemberStatus {
|
|
||||||
switch (spStatus) {
|
|
||||||
case "Согласовано":
|
|
||||||
return "approved";
|
|
||||||
case "Отклонено":
|
|
||||||
case "На исполнении":
|
|
||||||
return "rejected";
|
|
||||||
case "Рекомендую":
|
|
||||||
return "recommends";
|
|
||||||
default:
|
|
||||||
return "pending";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Коммерческие предложения (библиотека) ────────────────────────────────
|
// ─── Коммерческие предложения (библиотека) ────────────────────────────────
|
||||||
@@ -1390,54 +1178,6 @@ export class CtService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadApprovalMatrix(
|
|
||||||
company: string,
|
|
||||||
department: string,
|
|
||||||
): Promise<
|
|
||||||
Array<{
|
|
||||||
order: number;
|
|
||||||
approverSpId: number;
|
|
||||||
approverName: string;
|
|
||||||
post: string;
|
|
||||||
canAddApprover: boolean;
|
|
||||||
}>
|
|
||||||
> {
|
|
||||||
const filterParts: string[] = [];
|
|
||||||
if (department) {
|
|
||||||
const deptEsc = department.replace(/'/g, "''");
|
|
||||||
filterParts.push(`department eq '${deptEsc}'`);
|
|
||||||
} else {
|
|
||||||
filterParts.push(`(department eq null or department eq '')`);
|
|
||||||
}
|
|
||||||
if (company) {
|
|
||||||
const companyEsc = company.replace(/'/g, "''");
|
|
||||||
filterParts.push(`company eq '${companyEsc}'`);
|
|
||||||
}
|
|
||||||
const filter = encodeURIComponent(filterParts.join(" and "));
|
|
||||||
const url =
|
|
||||||
`${this._li("Матрица согласования СТ")}` +
|
|
||||||
`?$select=ID,post,canAddAproover,approover/Id,approover/Title` +
|
|
||||||
`&$expand=approover` +
|
|
||||||
`&$filter=${filter}` +
|
|
||||||
`&$orderby=ID asc`;
|
|
||||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = await res.json();
|
|
||||||
return (data.value ?? []).map(
|
|
||||||
(item: Record<string, unknown>, idx: number) => ({
|
|
||||||
order: idx + 1,
|
|
||||||
approverSpId: Number(
|
|
||||||
(item["approover"] as Record<string, unknown>)?.Id ?? 0,
|
|
||||||
),
|
|
||||||
approverName: String(
|
|
||||||
(item["approover"] as Record<string, unknown>)?.Title ?? "",
|
|
||||||
),
|
|
||||||
post: String(item["post"] ?? ""),
|
|
||||||
canAddApprover: Boolean(item["canAddAproover"]),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async triggerAiComment(payload: {
|
async triggerAiComment(payload: {
|
||||||
ctId: string;
|
ctId: string;
|
||||||
ctNumber: string;
|
ctNumber: string;
|
||||||
|
|||||||
@@ -105,28 +105,19 @@ function renderStep(step: FormStep, stepProps: StepProps): JSX.Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Деривация статуса сущности из цепочки согласования (§12), по образцу CT (deriveCtStatus в
|
// Статус сущности — как есть из formData.status. Раньше был подстрахован деривацией из цепочки
|
||||||
// CtCreateForm.tsx). До появления цепочки (Шаги 1–3) статус берём как есть из formData.status —
|
// согласования (formData.approvers), пока цепочка жила в самом formData; теперь маршрут — отдельная
|
||||||
// его пишут явно (Step1 → 'draft', Step4 → 'on_approval'/'approved'/'rejected'/'formed').
|
// сущность (список «Согласование СТ КПС» через shared/approval), а статус СТ пишется напрямую
|
||||||
// Когда в цепочке есть участники, статус выводим из их решений — это подстраховка на случай,
|
// (KpsCtFormService.updateCtStatus) сразу при отправке и при каждом решении согласующего
|
||||||
// если formData.status не успел обновиться (напр. цепочка подгружена из SP при повторном входе).
|
// (ApproverService.decide → onStatusComputed), так что formData.status уже авторитетен.
|
||||||
function deriveCtStatus(formData: CtFormData): CtStatus {
|
function deriveCtStatus(formData: CtFormData): CtStatus {
|
||||||
const chain = formData.approvers;
|
|
||||||
if (chain.length === 0) return formData.status;
|
|
||||||
if (chain.some((m) => m.status === 'rejected')) return 'rejected';
|
|
||||||
if (chain.every((m) => m.status === 'approved' || m.status === 'recommends' || m.status === 'skipped')) return 'approved';
|
|
||||||
// 'recommends' у инициатора появляется только при активации цепочки (отправке на согласование,
|
|
||||||
// handleSendForApproval → useStep4Approval); просто добавленные в черновик участники — 'pending',
|
|
||||||
// это ещё не «на согласовании».
|
|
||||||
if (chain.some((m) => m.status === 'recommends')) return 'on_approval';
|
|
||||||
return formData.status;
|
return formData.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Самый дальний «заполненный» шаг существующей СТ — для приземления при входе по URL (режим редактирования).
|
// Самый дальний «заполненный» шаг существующей СТ — для приземления при входе по URL (режим редактирования).
|
||||||
// По фактическим данным реконструкции: цепочка/на согласовании → 4, есть офферы → 3, есть поставщики → 2,
|
// Статус «на согласовании»/«согласована»/«отклонена» → 4, есть офферы → 3, есть поставщики → 2, иначе → 1.
|
||||||
// иначе (есть позиции) → 1.
|
|
||||||
function furthestFilledStep(data: CtFormData): FormStep {
|
function furthestFilledStep(data: CtFormData): FormStep {
|
||||||
if (data.approvers.length > 0 || data.status === 'on_approval' || data.status === 'approved' || data.status === 'rejected') {
|
if (data.status === 'on_approval' || data.status === 'approved' || data.status === 'rejected') {
|
||||||
return 4;
|
return 4;
|
||||||
}
|
}
|
||||||
if (data.offers.length > 0) {
|
if (data.offers.length > 0) {
|
||||||
|
|||||||
+42
-74
@@ -1,10 +1,8 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Button, Dialog, Alert, TextArea, Icon } from '@tnsu/ui-kit';
|
import { useMemo, useState } from 'react';
|
||||||
import { Pill } from '../common/Pill';
|
import { CtFormData, CtStatus, FormStep } from '../types/domain';
|
||||||
import { APPROVER_STATUS_META, CtFormData, FormStep } from '../types/domain';
|
|
||||||
import { formatDateTime } from '../utils/format';
|
|
||||||
import { KpsCtFormService } from '../../services/KpsCtFormService';
|
import { KpsCtFormService } from '../../services/KpsCtFormService';
|
||||||
import { useStep4Approval } from './useStep4Approval';
|
import { ApprovalMatrix, DecisionPanel, IMatrixRow, useMatrixEditor } from '../../../../shared/approval';
|
||||||
import styles from './Step4Approval.module.scss';
|
import styles from './Step4Approval.module.scss';
|
||||||
|
|
||||||
interface InlineApprovalChainProps {
|
interface InlineApprovalChainProps {
|
||||||
@@ -15,79 +13,49 @@ interface InlineApprovalChainProps {
|
|||||||
userDisplayName: string;
|
userDisplayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инлайн-цепочка согласования на Шаге 3 — для согласующего, открывшего СТ по ссылке-уведомлению (read-only,
|
const RU_STATUS_TO_CT_STATUS: Record<string, CtStatus> = {
|
||||||
// как в эталоне). Переиспользует useStep4Approval (сам грузит цепочку из SP): только просмотр статусов +
|
Отклонено: 'rejected',
|
||||||
// «Согласовать/Отклонить» своей строки + диалог. Правка цепочки/отправка — это Шаг 4, здесь их нет.
|
Согласовано: 'approved',
|
||||||
|
'На согласовании': 'on_approval'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Инлайн-цепочка согласования на Шаге 3 — для согласующего, открывшего СТ по ссылке-уведомлению
|
||||||
|
// (read-only маршрут + DecisionPanel своей строки). Правка состава/отправка — это Шаг 4, здесь их нет.
|
||||||
export function InlineApprovalChain(props: InlineApprovalChainProps): JSX.Element | null {
|
export function InlineApprovalChain(props: InlineApprovalChainProps): JSX.Element | null {
|
||||||
// Инлайн-блок только читает цепочку из SP и даёт проголосовать — сид новой цепочки здесь запрещён.
|
const { service, formData, onSave } = props;
|
||||||
const a = useStep4Approval({ ...props, canSeed: false });
|
const ctId = formData.ctId;
|
||||||
if (a.chain.length === 0) {
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
return null; // цепочка ещё грузится из SP или её нет — блок не показываем
|
|
||||||
|
const loadMatrix = useMemo(
|
||||||
|
() => (): Promise<IMatrixRow[]> => (ctId ? service.getApprovalMatrixForRequest(ctId) : Promise.resolve([])),
|
||||||
|
[service, ctId]
|
||||||
|
);
|
||||||
|
const editor = useMatrixEditor(loadMatrix, `${ctId ?? ''}-${decisionSeq}`, false, !!ctId, (loginName) =>
|
||||||
|
service.getJobTitle(loginName)
|
||||||
|
);
|
||||||
|
const approverService = useMemo(() => (ctId ? service.getApproverService(ctId) : undefined), [service, ctId]);
|
||||||
|
|
||||||
|
if (!ctId || editor.loading || editor.rows.length === 0) {
|
||||||
|
return null; // маршрут ещё грузится из SP или его нет — блок не показываем
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDecided(newStatus: string): Promise<void> {
|
||||||
|
onSave({ status: RU_STATUS_TO_CT_STATUS[newStatus] || formData.status });
|
||||||
|
setDecisionSeq((n) => n + 1);
|
||||||
|
await service.notifyActiveApprover(ctId as number).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.section}>
|
<section className={styles.body}>
|
||||||
<h3 className={styles.title}>Цепочка согласования</h3>
|
<ApprovalMatrix editor={editor} search={(q) => service.searchUsers(q)} />
|
||||||
<ol className={styles.list}>
|
{approverService ? (
|
||||||
{a.chain.map((member) => {
|
<DecisionPanel
|
||||||
const meta = APPROVER_STATUS_META[member.status];
|
service={approverService}
|
||||||
return (
|
requestId={ctId}
|
||||||
<li key={member.rowId} className={styles.row}>
|
onDecided={handleDecided}
|
||||||
<span className={styles.num}>{member.order}</span>
|
search={(q) => service.searchUsers(q)}
|
||||||
<span className={styles.name}>{member.displayName}</span>
|
onDelegated={() => setDecisionSeq((n) => n + 1)}
|
||||||
{member.isInitiator ? <Pill kind="neutral">Инициатор</Pill> : undefined}
|
/>
|
||||||
<span className={styles.spacer} />
|
|
||||||
{a.showStatuses ? <Pill kind={meta.kind}>{meta.label}</Pill> : undefined}
|
|
||||||
{member.comment ? <span className={styles.comment}>«{member.comment}»</span> : undefined}
|
|
||||||
{member.decidedAt ? <span className={styles.date}>{formatDateTime(member.decidedAt)}</span> : undefined}
|
|
||||||
|
|
||||||
{a.isOnApproval && a.currentUserRowId === member.rowId ? (
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<Button variant="primary" size="sm" onClick={() => a.openAction(member, 'approve')}>
|
|
||||||
<Icon name="check" size="sm" />
|
|
||||||
Согласовать
|
|
||||||
</Button>
|
|
||||||
<Button variant="danger" size="sm" onClick={() => a.openAction(member, 'reject')}>
|
|
||||||
<Icon name="close" size="sm" />
|
|
||||||
Отклонить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
{a.error ? <Alert variant="err">{a.error}</Alert> : undefined}
|
|
||||||
{a.notifyWarning ? <Alert variant="warn">{a.notifyWarning}</Alert> : undefined}
|
|
||||||
|
|
||||||
{a.action ? (
|
|
||||||
<Dialog
|
|
||||||
open
|
|
||||||
title={a.action.kind === 'approve' ? 'Согласование' : 'Отклонение'}
|
|
||||||
subText={a.action.kind === 'approve' ? 'Подтвердите согласование СТ. Комментарий необязателен.' : 'Укажите причину отклонения (обязательно).'}
|
|
||||||
onDismiss={a.closeAction}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={a.closeAction} disabled={a.sending}>Отмена</Button>
|
|
||||||
<Button
|
|
||||||
variant={a.action.kind === 'approve' ? 'primary' : 'danger'}
|
|
||||||
onClick={a.submitAction}
|
|
||||||
disabled={a.sending || (a.action.kind === 'reject' && !a.action.comment.trim())}
|
|
||||||
>
|
|
||||||
{a.action.kind === 'approve' ? 'Согласовать' : 'Отклонить'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TextArea
|
|
||||||
value={a.action.comment}
|
|
||||||
onChange={a.setActionComment}
|
|
||||||
rows={3}
|
|
||||||
ariaLabel="Комментарий"
|
|
||||||
placeholder={a.action.kind === 'reject' ? 'Причина отклонения…' : 'Комментарий (необязательно)…'}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
-135
@@ -6,141 +6,6 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
margin: 0;
|
|
||||||
color: $muted;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
max-width: 860px; // цепочка не на всю ширину — как в эталоне (.chainList)
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid $border;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.num {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: $head-bg;
|
|
||||||
color: $muted;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: $text;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spacer {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment {
|
|
||||||
color: $muted;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date {
|
|
||||||
color: $muted;
|
|
||||||
font-size: 11px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rejectBtn {
|
|
||||||
color: $danger !important;
|
|
||||||
border-color: $danger !important;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $danger-bg !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Поиск согласующего ---
|
|
||||||
|
|
||||||
.searchBox {
|
|
||||||
position: relative;
|
|
||||||
max-width: 420px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestions {
|
|
||||||
list-style: none;
|
|
||||||
margin: 2px 0 0;
|
|
||||||
padding: 4px 0;
|
|
||||||
position: absolute;
|
|
||||||
z-index: 5;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background: $surface;
|
|
||||||
border: 1px solid $border-strong;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: $sh-2;
|
|
||||||
max-height: 240px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestion {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 6px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $hover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestionName {
|
|
||||||
font-size: 13px;
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestionEmail {
|
|
||||||
font-size: 11px;
|
|
||||||
color: $muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Футер ---
|
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,18 +1,91 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Button, Dialog, IconButton, Alert, Spinner, TextInput, TextArea, Icon } from '@tnsu/ui-kit';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Alert, Button, Spinner } from '@tnsu/ui-kit';
|
||||||
import { StepShell } from '../common/StepShell';
|
import { StepShell } from '../common/StepShell';
|
||||||
import { Pill } from '../common/Pill';
|
import { CtStatus, StepProps } from '../types/domain';
|
||||||
import { APPROVER_STATUS_META, StepProps } from '../types/domain';
|
import {
|
||||||
import { formatDateTime } from '../utils/format';
|
ApprovalMatrix,
|
||||||
import { useStep4Approval } from './useStep4Approval';
|
DecisionPanel,
|
||||||
|
IApproverToWrite,
|
||||||
|
IMatrixRow,
|
||||||
|
useMatrixEditor,
|
||||||
|
findUnassignedStep
|
||||||
|
} from '../../../../shared/approval';
|
||||||
import styles from './Step4Approval.module.scss';
|
import styles from './Step4Approval.module.scss';
|
||||||
|
|
||||||
// Шаг 4 «Согласование» (§12): цепочка согласования. Логика — в useStep4Approval.
|
const SUBMITTED_STATUSES: ReadonlySet<CtStatus> = new Set<CtStatus>(['on_approval', 'approved', 'rejected']);
|
||||||
// Оповещение согласующих в Teams (4c) — через notify; замена участника — через диалог openReplace.
|
|
||||||
|
const RU_STATUS_TO_CT_STATUS: Record<string, CtStatus> = {
|
||||||
|
Отклонено: 'rejected',
|
||||||
|
Согласовано: 'approved',
|
||||||
|
'На согласовании': 'on_approval'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Шаг 4 «Согласование» (см. план миграции согласования на approval-route API): маршрут — планируемый
|
||||||
|
// (до отправки, resolve по email инициатора) или уже записанный (после отправки), редактирование
|
||||||
|
// состава/решения — общие компоненты shared/approval (ApprovalMatrix/DecisionPanel/useMatrixEditor).
|
||||||
export function Step4Approval(props: StepProps): JSX.Element {
|
export function Step4Approval(props: StepProps): JSX.Element {
|
||||||
const { service, formData, onSave, onPrev, onStepComplete, userDisplayName } = props;
|
const { service, formData, onPrev, onSave, onStepComplete } = props;
|
||||||
const a = useStep4Approval({ service, formData, onSave, onStepComplete, userDisplayName });
|
const ctId = formData.ctId;
|
||||||
const status = formData.status;
|
const status = formData.status;
|
||||||
|
const isSubmitted = SUBMITTED_STATUSES.has(status);
|
||||||
|
|
||||||
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
|
const [sending, setSending] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const loadMatrix = useMemo(
|
||||||
|
() => (): Promise<IMatrixRow[]> =>
|
||||||
|
isSubmitted && ctId ? service.getApprovalMatrixForRequest(ctId) : service.getPlannedApprovalMatrix(service.currentUserEmail),
|
||||||
|
[service, ctId, isSubmitted]
|
||||||
|
);
|
||||||
|
|
||||||
|
const editor = useMatrixEditor(loadMatrix, `${ctId ?? 'draft'}-${decisionSeq}`, !isSubmitted, true, (loginName) =>
|
||||||
|
service.getJobTitle(loginName)
|
||||||
|
);
|
||||||
|
|
||||||
|
const approverService = useMemo(() => (ctId ? service.getApproverService(ctId) : undefined), [service, ctId]);
|
||||||
|
|
||||||
|
async function handleSubmit(): Promise<void> {
|
||||||
|
if (!ctId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editor.rows.length === 0) {
|
||||||
|
setError('Маршрут согласования пуст.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const unassigned = findUnassignedStep(editor.rows);
|
||||||
|
if (unassigned) {
|
||||||
|
setError(unassigned.warning || `Шаг «${unassigned.position || unassigned.order}» без назначенного согласующего — обратитесь к администратору маршрутов.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSending(true);
|
||||||
|
setError(undefined);
|
||||||
|
const approvers: IApproverToWrite[] = editor.rows.map((r) => ({
|
||||||
|
email: r.email,
|
||||||
|
position: r.position,
|
||||||
|
allowReassign: r.allowReassign
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
await service.submitApprovalRoute(ctId, approvers);
|
||||||
|
await service.updateCtStatus(ctId, 'on_approval');
|
||||||
|
onSave({ status: 'on_approval' });
|
||||||
|
onStepComplete(4);
|
||||||
|
await service.notifyActiveApprover(ctId).catch(() => undefined);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDecided(newStatus: string): Promise<void> {
|
||||||
|
onSave({ status: RU_STATUS_TO_CT_STATUS[newStatus] || status });
|
||||||
|
setDecisionSeq((n) => n + 1);
|
||||||
|
if (ctId) {
|
||||||
|
await service.notifyActiveApprover(ctId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StepShell title="Согласование" onPrev={onPrev}>
|
<StepShell title="Согласование" onPrev={onPrev}>
|
||||||
@@ -21,169 +94,26 @@ export function Step4Approval(props: StepProps): JSX.Element {
|
|||||||
{status === 'rejected' ? <Alert variant="err">СТ отклонена. Требуется корректировка и повторная отправка.</Alert> : undefined}
|
{status === 'rejected' ? <Alert variant="err">СТ отклонена. Требуется корректировка и повторная отправка.</Alert> : undefined}
|
||||||
{status === 'on_approval' ? <Alert variant="info">СТ находится на согласовании.</Alert> : undefined}
|
{status === 'on_approval' ? <Alert variant="info">СТ находится на согласовании.</Alert> : undefined}
|
||||||
|
|
||||||
<section className={styles.section}>
|
<ApprovalMatrix editor={editor} search={(q) => service.searchUsers(q)} />
|
||||||
<h3 className={styles.title}>Цепочка согласования</h3>
|
|
||||||
|
|
||||||
{a.chain.length === 0 ? (
|
{error ? <Alert variant="err">{error}</Alert> : undefined}
|
||||||
<p className={styles.empty}>Добавьте участников для согласования.</p>
|
|
||||||
) : (
|
|
||||||
<ol className={styles.list}>
|
|
||||||
{a.chain.map((member, index) => {
|
|
||||||
const meta = APPROVER_STATUS_META[member.status];
|
|
||||||
// Заменить можно не-инициатора со статусом pending, кроме того, чья очередь идёт сейчас
|
|
||||||
// (до отправки и во время согласования) — как в эталоне.
|
|
||||||
const canReplace =
|
|
||||||
!member.isInitiator &&
|
|
||||||
member.status === 'pending' &&
|
|
||||||
!(a.isOnApproval && a.currentUserRowId === member.rowId);
|
|
||||||
return (
|
|
||||||
<li key={member.rowId} className={styles.row}>
|
|
||||||
<span className={styles.num}>{member.order}</span>
|
|
||||||
<span className={styles.name}>{member.displayName}</span>
|
|
||||||
{member.isInitiator ? <Pill kind="neutral">Инициатор</Pill> : undefined}
|
|
||||||
<span className={styles.spacer} />
|
|
||||||
{a.showStatuses ? <Pill kind={meta.kind}>{meta.label}</Pill> : undefined}
|
|
||||||
{member.comment ? <span className={styles.comment}>«{member.comment}»</span> : undefined}
|
|
||||||
{member.decidedAt ? <span className={styles.date}>{formatDateTime(member.decidedAt)}</span> : undefined}
|
|
||||||
|
|
||||||
{a.canEdit && !member.isInitiator ? (
|
{!isSubmitted ? (
|
||||||
<div className={styles.actions}>
|
<div className={styles.footer}>
|
||||||
<IconButton icon="arrow-up" size="sm" title="Выше" disabled={index === 0} onClick={() => a.moveApprover(member.rowId, 'up')} />
|
<Button variant="primary" onClick={handleSubmit} disabled={sending || editor.rows.length === 0}>
|
||||||
<IconButton icon="arrow-down" size="sm" title="Ниже" disabled={index === a.chain.length - 1} onClick={() => a.moveApprover(member.rowId, 'down')} />
|
{sending ? <Spinner size="small" /> : 'Отправить на согласование'}
|
||||||
<IconButton icon="trash" size="sm" danger title="Удалить" onClick={() => a.removeApprover(member.rowId)} />
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
|
|
||||||
{a.isOnApproval && a.currentUserRowId === member.rowId ? (
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<Button variant="primary" size="sm" onClick={() => a.openAction(member, 'approve')}>
|
|
||||||
<Icon name="check" size="sm" />
|
|
||||||
Согласовать
|
|
||||||
</Button>
|
|
||||||
<Button variant="danger" size="sm" onClick={() => a.openAction(member, 'reject')}>
|
|
||||||
<Icon name="close" size="sm" />
|
|
||||||
Отклонить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
|
|
||||||
{canReplace ? (
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<IconButton icon="swap" size="sm" title="Заменить согласующего" onClick={() => a.openReplace(member)} />
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{a.canEdit ? (
|
|
||||||
<div className={styles.searchBox}>
|
|
||||||
<TextInput
|
|
||||||
placeholder="Поиск согласующего (имя)…"
|
|
||||||
ariaLabel="Поиск согласующего"
|
|
||||||
value={a.query}
|
|
||||||
onChange={a.setQuery}
|
|
||||||
/>
|
|
||||||
{a.suggestions.length > 0 ? (
|
|
||||||
<ul className={styles.suggestions}>
|
|
||||||
{a.suggestions.map((s) => (
|
|
||||||
<li key={s.spId} className={styles.suggestion} onClick={() => a.addApprover(s)}>
|
|
||||||
<span className={styles.suggestionName}>{s.displayName}</span>
|
|
||||||
<span className={styles.suggestionEmail}>{s.email}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : undefined}
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className={styles.footer}>
|
|
||||||
{a.error ? <Alert variant="err">{a.error}</Alert> : undefined}
|
|
||||||
{a.notifyWarning ? <Alert variant="warn">{a.notifyWarning}</Alert> : undefined}
|
|
||||||
{a.canEdit ? (
|
|
||||||
<Button variant="primary" onClick={a.sendForApproval} disabled={a.chain.length === 0 || a.sending}>
|
|
||||||
{a.sending ? (
|
|
||||||
<Spinner size="small" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="send" size="sm" />
|
|
||||||
Отправить на согласование
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
) : undefined}
|
</div>
|
||||||
{(status === 'on_approval' || status === 'rejected') && a.currentUserIsInitiator ? (
|
|
||||||
<Button variant="secondary" onClick={a.restart} disabled={a.sending}>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Перезапустить согласование
|
|
||||||
</Button>
|
|
||||||
) : undefined}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{a.action ? (
|
|
||||||
<Dialog
|
|
||||||
open
|
|
||||||
title={a.action.kind === 'approve' ? 'Согласование' : 'Отклонение'}
|
|
||||||
subText={a.action.kind === 'approve' ? 'Подтвердите согласование СТ. Комментарий необязателен.' : 'Укажите причину отклонения (обязательно).'}
|
|
||||||
onDismiss={a.closeAction}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={a.closeAction} disabled={a.sending}>Отмена</Button>
|
|
||||||
<Button
|
|
||||||
variant={a.action.kind === 'approve' ? 'primary' : 'danger'}
|
|
||||||
onClick={a.submitAction}
|
|
||||||
disabled={a.sending || (a.action.kind === 'reject' && !a.action.comment.trim())}
|
|
||||||
>
|
|
||||||
{a.action.kind === 'approve' ? 'Согласовать' : 'Отклонить'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TextArea
|
|
||||||
value={a.action.comment}
|
|
||||||
onChange={a.setActionComment}
|
|
||||||
rows={3}
|
|
||||||
ariaLabel="Комментарий"
|
|
||||||
placeholder={a.action.kind === 'reject' ? 'Причина отклонения…' : 'Комментарий (необязательно)…'}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|
||||||
{a.replace ? (
|
{isSubmitted && ctId && approverService ? (
|
||||||
<Dialog
|
<DecisionPanel
|
||||||
open
|
service={approverService}
|
||||||
title="Заменить согласующего"
|
requestId={ctId}
|
||||||
subText={`Текущий: ${a.replace.name}. Найдите нового участника.`}
|
onDecided={handleDecided}
|
||||||
onDismiss={a.closeReplace}
|
search={(q) => service.searchUsers(q)}
|
||||||
footer={
|
onDelegated={() => setDecisionSeq((n) => n + 1)}
|
||||||
<>
|
/>
|
||||||
<Button variant="secondary" onClick={a.closeReplace} disabled={a.sending}>Отмена</Button>
|
|
||||||
<Button variant="primary" onClick={a.submitReplace} disabled={!a.replace.selected || a.sending}>Заменить</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={styles.searchBox}>
|
|
||||||
<TextInput
|
|
||||||
placeholder="Поиск участника (имя)…"
|
|
||||||
ariaLabel="Поиск участника"
|
|
||||||
value={a.replace.query}
|
|
||||||
onChange={a.setReplaceQuery}
|
|
||||||
/>
|
|
||||||
{a.replace.suggestions.length > 0 ? (
|
|
||||||
<ul className={styles.suggestions}>
|
|
||||||
{a.replace.suggestions.map((s) => (
|
|
||||||
<li key={s.spId} className={styles.suggestion} onClick={() => a.selectReplace(s)}>
|
|
||||||
<span className={styles.suggestionName}>{s.displayName}</span>
|
|
||||||
<span className={styles.suggestionEmail}>{s.email}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : undefined}
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
</StepShell>
|
</StepShell>
|
||||||
|
|||||||
@@ -1,396 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { Approver, CtFormData, FormStep } from '../types/domain';
|
|
||||||
import { KpsCtFormService } from '../../services/KpsCtFormService';
|
|
||||||
|
|
||||||
// Логика Шага 4 (цепочка согласования) — отдельно от вёрстки. Оповещение согласующих — через notify (4c).
|
|
||||||
|
|
||||||
// Уникальный ключ СТРОКИ цепочки (счётчик на время жизни страницы) — один человек может быть в цепочке
|
|
||||||
// несколько раз. Загруженные из SP строки получают `sp_<id>` в сервисе; локальные — `row_<n>` отсюда.
|
|
||||||
let rowIdCounter = 0;
|
|
||||||
function makeRowId(): string {
|
|
||||||
rowIdCounter += 1;
|
|
||||||
return `row_${rowIdCounter}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserSuggestion {
|
|
||||||
spId: number;
|
|
||||||
displayName: string;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActionDialog {
|
|
||||||
rowId: string; // строка цепочки, по которой голосуем (не userId — человек может быть в цепочке дважды)
|
|
||||||
name: string;
|
|
||||||
kind: 'approve' | 'reject';
|
|
||||||
comment: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReplaceDialog {
|
|
||||||
rowId: string; // строка цепочки, которую меняем
|
|
||||||
spApprovalId?: number; // запись в SP (есть в фазе согласования; до отправки — нет)
|
|
||||||
name: string; // текущее имя — для заголовка диалога
|
|
||||||
query: string;
|
|
||||||
suggestions: UserSuggestion[];
|
|
||||||
selected?: UserSuggestion;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseStep4ApprovalArgs {
|
|
||||||
service: KpsCtFormService;
|
|
||||||
formData: CtFormData;
|
|
||||||
onSave: (partial: Partial<CtFormData>) => void;
|
|
||||||
onStepComplete: (step: FormStep) => void;
|
|
||||||
userDisplayName: string;
|
|
||||||
// Разрешено ли засевать новую цепочку (инициатор + матрица) при пустой цепочке. Инлайн-блок на Шаге 3
|
|
||||||
// (согласующий по ссылке) сид НЕ запускает — только читает из SP. По умолчанию — да (Шаг 4).
|
|
||||||
canSeed?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Step4Approval {
|
|
||||||
chain: Approver[];
|
|
||||||
canEdit: boolean; // до отправки — цепочку можно менять
|
|
||||||
isOnApproval: boolean; // активная фаза голосования
|
|
||||||
showStatuses: boolean;
|
|
||||||
currentUserIsInitiator: boolean;
|
|
||||||
currentUserRowId?: string; // rowId строки, чья очередь = текущий пользователь
|
|
||||||
query: string;
|
|
||||||
setQuery: (q: string) => void;
|
|
||||||
suggestions: UserSuggestion[];
|
|
||||||
addApprover: (u: UserSuggestion) => void;
|
|
||||||
removeApprover: (rowId: string) => void;
|
|
||||||
moveApprover: (rowId: string, dir: 'up' | 'down') => void;
|
|
||||||
sending: boolean;
|
|
||||||
error?: string;
|
|
||||||
notifyWarning?: string; // голос/отправка прошли, но флоу-оповещение не удалось
|
|
||||||
sendForApproval: () => void;
|
|
||||||
action?: ActionDialog;
|
|
||||||
openAction: (approver: Approver, kind: 'approve' | 'reject') => void;
|
|
||||||
closeAction: () => void;
|
|
||||||
setActionComment: (c: string) => void;
|
|
||||||
submitAction: () => void;
|
|
||||||
replace?: ReplaceDialog;
|
|
||||||
openReplace: (approver: Approver) => void;
|
|
||||||
closeReplace: () => void;
|
|
||||||
setReplaceQuery: (q: string) => void;
|
|
||||||
selectReplace: (u: UserSuggestion) => void;
|
|
||||||
submitReplace: () => void;
|
|
||||||
restart: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useStep4Approval(args: UseStep4ApprovalArgs): Step4Approval {
|
|
||||||
const { service, formData, onSave, onStepComplete, userDisplayName, canSeed = true } = args;
|
|
||||||
const chain = formData.approvers;
|
|
||||||
const status = formData.status;
|
|
||||||
const isOnApprovalPhase = status === 'on_approval' || status === 'approved' || status === 'rejected';
|
|
||||||
const canEdit = !isOnApprovalPhase;
|
|
||||||
const isOnApproval = status === 'on_approval';
|
|
||||||
|
|
||||||
const [query, setQueryState] = useState('');
|
|
||||||
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
|
||||||
const [action, setAction] = useState<ActionDialog | undefined>(undefined);
|
|
||||||
const [replace, setReplace] = useState<ReplaceDialog | undefined>(undefined);
|
|
||||||
const [notifyWarning, setNotifyWarning] = useState<string | undefined>(undefined);
|
|
||||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
||||||
const replaceTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
||||||
|
|
||||||
// Некритичное оповещение согласующего: сбой флоу НЕ должен валить голос/отправку (§7).
|
|
||||||
const notify = async (spApprovalId: number): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await service.triggerApprovalNotification(spApprovalId);
|
|
||||||
} catch (e) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error('[Step4] triggerApprovalNotification failed for spId', spApprovalId, e);
|
|
||||||
setNotifyWarning('Действие выполнено, но уведомление в Teams не удалось отправить. Проверьте Power Automate.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Восстановление/инициализация цепочки один раз при монтировании.
|
|
||||||
useEffect(() => {
|
|
||||||
if (chain.length > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isOnApprovalPhase && formData.ctId) {
|
|
||||||
service
|
|
||||||
.loadApprovalChain(formData.ctId)
|
|
||||||
.then((loaded) => {
|
|
||||||
if (loaded.length > 0) {
|
|
||||||
onSave({ approvers: loaded });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
} else if (canSeed && canEdit && service.currentUserSpId > 0) {
|
|
||||||
const me = service.currentUserSpId;
|
|
||||||
const initiator: Approver = { rowId: makeRowId(), userId: me, displayName: userDisplayName, order: 1, status: 'pending', isInitiator: true };
|
|
||||||
// Автозаполнение из «Матрица согласования КПС» (инициатор + участники по порядку); при ошибке — только инициатор.
|
|
||||||
service
|
|
||||||
.loadApprovalMatrix()
|
|
||||||
.then((matrix) => {
|
|
||||||
const rest: Approver[] = matrix
|
|
||||||
.filter((m) => m.userId !== me)
|
|
||||||
.map((m, i): Approver => ({ rowId: makeRowId(), userId: m.userId, displayName: m.displayName, order: i + 2, status: 'pending' }));
|
|
||||||
onSave({ approvers: [initiator, ...rest] });
|
|
||||||
})
|
|
||||||
.catch(() => onSave({ approvers: [initiator] }));
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setQuery = (q: string): void => {
|
|
||||||
setQueryState(q);
|
|
||||||
setSuggestions([]);
|
|
||||||
if (searchTimer.current) {
|
|
||||||
clearTimeout(searchTimer.current);
|
|
||||||
}
|
|
||||||
if (q.trim().length < 2) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
searchTimer.current = setTimeout(() => {
|
|
||||||
// Показываем всех найденных — фильтра «уже в цепочке» нет: один человек может быть добавлен
|
|
||||||
// в цепочку несколько раз (строки различаются по rowId).
|
|
||||||
service
|
|
||||||
.searchUsers(q.trim())
|
|
||||||
.then((res) => setSuggestions(res))
|
|
||||||
.catch(() => undefined);
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
const reindex = (list: Approver[]): Approver[] => list.map((m, i) => ({ ...m, order: i + 1 }));
|
|
||||||
|
|
||||||
const addApprover = (u: UserSuggestion): void => {
|
|
||||||
onSave({ approvers: reindex([...chain, { rowId: makeRowId(), userId: u.spId, displayName: u.displayName, email: u.email, order: chain.length + 1, status: 'pending' }]) });
|
|
||||||
setQueryState('');
|
|
||||||
setSuggestions([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeApprover = (rowId: string): void => {
|
|
||||||
onSave({ approvers: reindex(chain.filter((m) => m.rowId !== rowId || m.isInitiator)) });
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveApprover = (rowId: string, dir: 'up' | 'down'): void => {
|
|
||||||
const idx = chain.findIndex((m) => m.rowId === rowId);
|
|
||||||
const swap = dir === 'up' ? idx - 1 : idx + 1;
|
|
||||||
if (idx === -1 || swap < 0 || swap >= chain.length || chain[swap].isInitiator) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const next = [...chain];
|
|
||||||
[next[idx], next[swap]] = [next[swap], next[idx]];
|
|
||||||
onSave({ approvers: reindex(next) });
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendForApproval = (): void => {
|
|
||||||
if (!formData.ctId || chain.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSending(true);
|
|
||||||
setError(undefined);
|
|
||||||
setNotifyWarning(undefined);
|
|
||||||
const ctId = formData.ctId;
|
|
||||||
const activated: Approver[] = chain.map((m) => ({ ...m, status: m.isInitiator ? 'recommends' : 'pending' }));
|
|
||||||
const winSpId = formData.suppliers.find((s) => s.id === formData.recommendedSupplierId)?.spId;
|
|
||||||
(async () => {
|
|
||||||
const saved = await service.saveApprovalChain(ctId, activated);
|
|
||||||
await service.updateCtStatus(ctId, 'on_approval', winSpId);
|
|
||||||
onSave({ approvers: saved, status: 'on_approval' });
|
|
||||||
const firstApprover = saved.find((m) => !m.isInitiator && m.spApprovalId);
|
|
||||||
if (firstApprover?.spApprovalId) {
|
|
||||||
await notify(firstApprover.spApprovalId);
|
|
||||||
}
|
|
||||||
onStepComplete(4);
|
|
||||||
})()
|
|
||||||
.then(() => setSending(false))
|
|
||||||
.catch((e: Error) => {
|
|
||||||
setError(e.message);
|
|
||||||
setSending(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const openAction = (approver: Approver, kind: 'approve' | 'reject'): void =>
|
|
||||||
setAction({ rowId: approver.rowId, name: approver.displayName, kind, comment: '' });
|
|
||||||
const closeAction = (): void => setAction(undefined);
|
|
||||||
const setActionComment = (c: string): void => setAction((prev) => (prev ? { ...prev, comment: c } : prev));
|
|
||||||
|
|
||||||
const submitAction = (): void => {
|
|
||||||
if (!action || !formData.ctId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const member = chain.find((m) => m.rowId === action.rowId);
|
|
||||||
if (!member?.spApprovalId) {
|
|
||||||
setError('Не найден ID записи согласования');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action.kind === 'reject' && !action.comment.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSending(true);
|
|
||||||
setError(undefined);
|
|
||||||
setNotifyWarning(undefined);
|
|
||||||
const ctId = formData.ctId;
|
|
||||||
const decision = action.kind === 'approve' ? 'approved' : 'rejected';
|
|
||||||
const comment = action.comment.trim();
|
|
||||||
const spId = member.spApprovalId;
|
|
||||||
(async () => {
|
|
||||||
await service.submitApprovalStep(spId, decision, comment);
|
|
||||||
const updated: Approver[] = chain.map((m) =>
|
|
||||||
m.rowId === action.rowId ? { ...m, status: decision, comment, decidedAt: new Date().toISOString() } : m
|
|
||||||
);
|
|
||||||
if (decision === 'approved') {
|
|
||||||
// Уведомить проголосовавшего (поток видит «Согласовано») и следующего по очереди.
|
|
||||||
await notify(spId);
|
|
||||||
const nextPending = updated.find((m) => m.status === 'pending' && !m.isInitiator);
|
|
||||||
if (nextPending?.spApprovalId) {
|
|
||||||
await notify(nextPending.spApprovalId);
|
|
||||||
}
|
|
||||||
const allApproved = updated.every((m) => m.status === 'approved' || m.status === 'recommends');
|
|
||||||
if (allApproved) {
|
|
||||||
await service.updateCtStatus(ctId, 'approved');
|
|
||||||
onSave({ approvers: updated, status: 'approved' });
|
|
||||||
} else {
|
|
||||||
onSave({ approvers: updated });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Отклонение — процесс остановлен: удалить оставшихся pending из SP и цепочки, уведомить инициатора.
|
|
||||||
// Уведомляем через spId ОТКЛОНИВШЕГО (его статус уже "Отклонено" — поток определяет ветку по этой строке),
|
|
||||||
// а не через строку инициатора: там статус всегда "Рекомендую" и ни под одно условие потока не подходит.
|
|
||||||
const others = updated.filter((m) => m.status === 'pending' && !m.isInitiator && m.spApprovalId);
|
|
||||||
if (others.length > 0) {
|
|
||||||
await service.deleteApprovalMembers(others.map((m) => m.spApprovalId as number));
|
|
||||||
}
|
|
||||||
const finalChain = updated.filter((m) => m.status !== 'pending' || m.isInitiator);
|
|
||||||
await service.updateCtStatus(ctId, 'rejected');
|
|
||||||
onSave({ approvers: finalChain, status: 'rejected' });
|
|
||||||
await notify(spId);
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
.then(() => {
|
|
||||||
setSending(false);
|
|
||||||
setAction(undefined);
|
|
||||||
})
|
|
||||||
.catch((e: Error) => {
|
|
||||||
setError(e.message);
|
|
||||||
setSending(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const openReplace = (approver: Approver): void =>
|
|
||||||
setReplace({
|
|
||||||
rowId: approver.rowId,
|
|
||||||
spApprovalId: approver.spApprovalId,
|
|
||||||
name: approver.displayName,
|
|
||||||
query: '',
|
|
||||||
suggestions: [],
|
|
||||||
selected: undefined
|
|
||||||
});
|
|
||||||
const closeReplace = (): void => setReplace(undefined);
|
|
||||||
|
|
||||||
const setReplaceQuery = (q: string): void => {
|
|
||||||
setReplace((prev) => (prev ? { ...prev, query: q, suggestions: [], selected: undefined } : prev));
|
|
||||||
if (replaceTimer.current) {
|
|
||||||
clearTimeout(replaceTimer.current);
|
|
||||||
}
|
|
||||||
if (q.trim().length < 2) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
replaceTimer.current = setTimeout(() => {
|
|
||||||
service
|
|
||||||
.searchUsers(q.trim())
|
|
||||||
.then((res) => {
|
|
||||||
// Без фильтра «уже в цепочке» — заменить можно на кого угодно (в т.ч. уже присутствующего).
|
|
||||||
setReplace((prev) => (prev ? { ...prev, suggestions: res } : prev));
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectReplace = (u: UserSuggestion): void =>
|
|
||||||
setReplace((prev) => (prev ? { ...prev, selected: u, query: u.displayName, suggestions: [] } : prev));
|
|
||||||
|
|
||||||
// Замена персоны на записи: до отправки (нет spApprovalId) — только локально, при отправке цепочка пишется
|
|
||||||
// заново; в фазе согласования — MERGE aprooverId на записи «Согласование КПС».
|
|
||||||
const submitReplace = (): void => {
|
|
||||||
if (!replace?.selected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const targetRowId = replace.rowId;
|
|
||||||
const spApprovalId = replace.spApprovalId;
|
|
||||||
const picked = replace.selected;
|
|
||||||
setSending(true);
|
|
||||||
setError(undefined);
|
|
||||||
(async () => {
|
|
||||||
if (spApprovalId) {
|
|
||||||
await service.replaceApprovalMember(spApprovalId, picked.spId);
|
|
||||||
}
|
|
||||||
const updated: Approver[] = chain.map((m) =>
|
|
||||||
m.rowId === targetRowId ? { ...m, userId: picked.spId, displayName: picked.displayName, email: picked.email } : m
|
|
||||||
);
|
|
||||||
onSave({ approvers: updated });
|
|
||||||
})()
|
|
||||||
.then(() => {
|
|
||||||
setSending(false);
|
|
||||||
setReplace(undefined);
|
|
||||||
})
|
|
||||||
.catch((e: Error) => {
|
|
||||||
setError(e.message);
|
|
||||||
setSending(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const restart = (): void => {
|
|
||||||
if (!formData.ctId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSending(true);
|
|
||||||
setError(undefined);
|
|
||||||
const ctId = formData.ctId;
|
|
||||||
const reset: Approver[] = chain.map((m) => ({ ...m, status: 'pending', comment: undefined, decidedAt: undefined }));
|
|
||||||
(async () => {
|
|
||||||
await service.updateCtStatus(ctId, 'formed');
|
|
||||||
onSave({ approvers: reset, status: 'formed' });
|
|
||||||
})()
|
|
||||||
.then(() => setSending(false))
|
|
||||||
.catch((e: Error) => {
|
|
||||||
setError(e.message);
|
|
||||||
setSending(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentUserIsInitiator = chain.some((m) => m.displayName === userDisplayName && m.isInitiator);
|
|
||||||
const currentUserMember = chain.find(
|
|
||||||
(m) =>
|
|
||||||
m.displayName === userDisplayName &&
|
|
||||||
m.status === 'pending' &&
|
|
||||||
!m.isInitiator &&
|
|
||||||
chain.filter((p) => p.order < m.order).every((p) => p.status !== 'pending')
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
chain,
|
|
||||||
canEdit,
|
|
||||||
isOnApproval,
|
|
||||||
showStatuses: isOnApprovalPhase,
|
|
||||||
currentUserIsInitiator,
|
|
||||||
currentUserRowId: currentUserMember?.rowId,
|
|
||||||
query,
|
|
||||||
setQuery,
|
|
||||||
suggestions,
|
|
||||||
addApprover,
|
|
||||||
removeApprover,
|
|
||||||
moveApprover,
|
|
||||||
sending,
|
|
||||||
error,
|
|
||||||
notifyWarning,
|
|
||||||
sendForApproval,
|
|
||||||
action,
|
|
||||||
openAction,
|
|
||||||
closeAction,
|
|
||||||
setActionComment,
|
|
||||||
submitAction,
|
|
||||||
replace,
|
|
||||||
openReplace,
|
|
||||||
closeReplace,
|
|
||||||
setReplaceQuery,
|
|
||||||
selectReplace,
|
|
||||||
submitReplace,
|
|
||||||
restart
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
@import './theme';
|
|
||||||
|
|
||||||
.chain {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
margin: 0;
|
|
||||||
color: $muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid $border;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.num {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: $hover;
|
|
||||||
color: $muted;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spacer {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment {
|
|
||||||
color: $muted;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date {
|
|
||||||
color: $muted;
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import styles from './ApprovalChain.module.scss';
|
|
||||||
import { Pill } from './Pill';
|
|
||||||
import { Approver, APPROVER_STATUS_META } from '../types/domain';
|
|
||||||
import { formatDateTime } from '../utils/format';
|
|
||||||
|
|
||||||
interface ApprovalChainProps {
|
|
||||||
approvers: Approver[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Цепочка согласования (эталон): номер, имя, роль, статус-пилюля, комментарий, дата.
|
|
||||||
// Общая для Шага 3 (внизу таблицы) и Шага 4.
|
|
||||||
export function ApprovalChain({ approvers }: ApprovalChainProps): JSX.Element {
|
|
||||||
return (
|
|
||||||
<div className={styles.chain}>
|
|
||||||
<h3 className={styles.title}>Цепочка согласования</h3>
|
|
||||||
{approvers.length === 0 ? (
|
|
||||||
<p className={styles.empty}>Цепочка согласования ещё не сформирована.</p>
|
|
||||||
) : (
|
|
||||||
<ol className={styles.list}>
|
|
||||||
{approvers.map((approver, index) => {
|
|
||||||
const meta = APPROVER_STATUS_META[approver.status];
|
|
||||||
return (
|
|
||||||
<li key={approver.rowId} className={styles.row}>
|
|
||||||
<span className={styles.num}>{approver.order || index + 1}</span>
|
|
||||||
<span className={styles.name}>{approver.displayName}</span>
|
|
||||||
{approver.isInitiator ? <Pill kind="neutral">Инициатор</Pill> : undefined}
|
|
||||||
<span className={styles.spacer} />
|
|
||||||
<Pill kind={meta.kind}>{meta.label}</Pill>
|
|
||||||
{approver.comment ? <span className={styles.comment}>«{approver.comment}»</span> : undefined}
|
|
||||||
<span className={styles.date}>{formatDateTime(approver.decidedAt)}</span>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -33,23 +33,6 @@ export const CT_STATUS_META: Record<CtStatus, { label: string; kind: PillKind }>
|
|||||||
rejected: { label: 'Отклонена', kind: 'danger' }
|
rejected: { label: 'Отклонена', kind: 'danger' }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Статус участника цепочки согласования.
|
|
||||||
export type ApproverStatus =
|
|
||||||
| 'pending' // ждёт своей очереди/решения
|
|
||||||
| 'recommends' // инициатор поставил «Рекомендую»
|
|
||||||
| 'approved' // согласовал
|
|
||||||
| 'rejected' // отклонил
|
|
||||||
| 'skipped'; // пропущен
|
|
||||||
|
|
||||||
// Метка + цвет статуса согласующего для цепочки (Шаги 3–4).
|
|
||||||
export const APPROVER_STATUS_META: Record<ApproverStatus, { label: string; kind: PillKind }> = {
|
|
||||||
pending: { label: 'Ожидает', kind: 'neutral' },
|
|
||||||
recommends: { label: 'Рекомендую', kind: 'ok' },
|
|
||||||
approved: { label: 'Согласовано', kind: 'ok' },
|
|
||||||
rejected: { label: 'Отклонено', kind: 'danger' },
|
|
||||||
skipped: { label: 'Пропущен', kind: 'neutral' }
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Шаги формы ---
|
// --- Шаги формы ---
|
||||||
|
|
||||||
// Номер шага. На него опираются Breadcrumbs и хук формы.
|
// Номер шага. На него опираются Breadcrumbs и хук формы.
|
||||||
@@ -130,22 +113,6 @@ export interface PurchaseRequestDetail {
|
|||||||
canCancel: boolean;
|
canCancel: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Участник цепочки согласования (строка «Согласование СТ»).
|
|
||||||
export interface Approver {
|
|
||||||
rowId: string; // уникальный ключ СТРОКИ цепочки (не человека) — один человек может быть в цепочке
|
|
||||||
// несколько раз; по нему идут ключи React и операции выше/ниже/удалить/заменить/голосовать.
|
|
||||||
// Загруженные из SP: `sp_<spApprovalId>`; локально добавленные: `row_<n>`.
|
|
||||||
spApprovalId?: number; // Id строки в «Согласование СТ» (после сохранения)
|
|
||||||
userId: number; // ID пользователя SharePoint (aproover)
|
|
||||||
displayName: string;
|
|
||||||
email?: string;
|
|
||||||
order: number; // порядок в цепочке (order0)
|
|
||||||
status: ApproverStatus;
|
|
||||||
comment?: string;
|
|
||||||
decidedAt?: string; // aproovDate — когда принято решение (ISO)
|
|
||||||
isInitiator?: boolean; // инициатор маршрута (первый в цепочке)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Позиция, выбранная на Шаге 1 = строка заявки + контекст родителя и проекта.
|
// Позиция, выбранная на Шаге 1 = строка заявки + контекст родителя и проекта.
|
||||||
// Снимок нужных полей, чтобы Шаги 2–4 (КП, СТ, согласование) не тянули деталь заново.
|
// Снимок нужных полей, чтобы Шаги 2–4 (КП, СТ, согласование) не тянули деталь заново.
|
||||||
export interface SelectedPosition {
|
export interface SelectedPosition {
|
||||||
@@ -279,8 +246,6 @@ export interface CtFormData {
|
|||||||
recommendedSupplierId?: string; // выбранный поставщик (локальный id)
|
recommendedSupplierId?: string; // выбранный поставщик (локальный id)
|
||||||
// TODO: step3PositionQtyOverrides
|
// TODO: step3PositionQtyOverrides
|
||||||
|
|
||||||
// Шаг 4 — согласование
|
|
||||||
approvers: Approver[];
|
|
||||||
winSupplierId?: number; // выбранный поставщик (lookup в SP)
|
winSupplierId?: number; // выбранный поставщик (lookup в SP)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,8 +257,7 @@ export const EMPTY_FORM_DATA: CtFormData = {
|
|||||||
currencyRates: [],
|
currencyRates: [],
|
||||||
offers: [],
|
offers: [],
|
||||||
step3ExtraPositions: [],
|
step3ExtraPositions: [],
|
||||||
criteria: [],
|
criteria: []
|
||||||
approvers: []
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Единый контракт props для всех шагов (§5.2) ---
|
// --- Единый контракт props для всех шагов (§5.2) ---
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { SPHttpClient, SPHttpClientResponse, ISPHttpClientOptions, AadHttpClient } from '@microsoft/sp-http';
|
import { SPHttpClient, SPHttpClientResponse, ISPHttpClientOptions, AadHttpClient } from '@microsoft/sp-http';
|
||||||
|
import {
|
||||||
|
ApprovalListService,
|
||||||
|
ApproverService,
|
||||||
|
REQUEST_STATUS,
|
||||||
|
getRequestTypeIdByPageUrl,
|
||||||
|
getPlannedMatrix,
|
||||||
|
writeRoute,
|
||||||
|
IApproverToWrite,
|
||||||
|
IMatrixRow
|
||||||
|
} from '../../../shared/approval';
|
||||||
import type {
|
import type {
|
||||||
Approver,
|
|
||||||
ApproverStatus,
|
|
||||||
CtFormData,
|
CtFormData,
|
||||||
CtStatus,
|
CtStatus,
|
||||||
CurrencyRate,
|
CurrencyRate,
|
||||||
@@ -148,57 +156,24 @@ const CRITERIA_FIELDS = {
|
|||||||
comment: 'comment' // Примечание (Text)
|
comment: 'comment' // Примечание (Text)
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Список «Согласование СТ» (цепочка согласования Шага 4).
|
// pageUrl типа заявки «КПС» в админке approval-route — заводит пользователь (см. план миграции
|
||||||
const APPROVAL_FIELDS = {
|
// согласования на approval-route API). Имена полей списка «Согласование СТ КПС» (CT/aproover/status/
|
||||||
title: 'Title',
|
// aproovDate/comment/order0 + новые allowReassign/delegatedBy) совпадают с DEFAULT_APPROVAL_FIELDS
|
||||||
ct: 'CT', // Lookup → «Сравнительная таблица» (body CTId)
|
// в shared/approval/approvalList.service.ts — отдельный маппинг здесь не нужен.
|
||||||
order: 'order0', // Number — порядок
|
const KPS_REQUEST_TYPE_PAGE_URL = 'kps';
|
||||||
aproover: 'aproover', // Person — согласующий (body aprooverId)
|
|
||||||
status: 'status', // Choice
|
|
||||||
aproovDate: 'aproovDate', // DateTime — дата решения
|
|
||||||
comment: 'comment' // Text
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Статус согласующего → Choice «Статус» в «Согласование СТ».
|
// Статус заявки (ApproverService.decide) → Choice «Статус» шапки СТ.
|
||||||
const APPROVER_STATUS_TO_CHOICE: Record<ApproverStatus, string> = {
|
const APPROVAL_STATUS_TO_CT_STATUS: Record<'onApproval' | 'approved' | 'rejected', CtStatus> = {
|
||||||
pending: 'На согласовании',
|
onApproval: 'on_approval',
|
||||||
recommends: 'Рекомендую',
|
approved: 'approved',
|
||||||
approved: 'Согласовано',
|
rejected: 'rejected'
|
||||||
rejected: 'Отклонено',
|
|
||||||
skipped: 'Черновик'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Обратный маппинг (чтение цепочки).
|
|
||||||
const CHOICE_TO_APPROVER_STATUS: Record<string, ApproverStatus> = {
|
|
||||||
Черновик: 'pending',
|
|
||||||
'На согласовании': 'pending',
|
|
||||||
Рекомендую: 'recommends',
|
|
||||||
Согласовано: 'approved',
|
|
||||||
Отклонено: 'rejected',
|
|
||||||
'На исполнении': 'rejected'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Список «Матрица согласования КПС» (шаблон цепочки). Читаем только для автозаполнения.
|
|
||||||
const MATRIX_FIELDS = {
|
|
||||||
title: 'Title',
|
|
||||||
company: 'company', // Компания (Choice)
|
|
||||||
department: 'department', // Отдел (Text)
|
|
||||||
approover: 'approover', // Согласующий (Person)
|
|
||||||
order: 'order', // Порядок (Number)
|
|
||||||
post: 'post' // Должность (Text)
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Округление денег до 2 знаков (срезаем хвост float перед записью в SP).
|
// Округление денег до 2 знаков (срезаем хвост float перед записью в SP).
|
||||||
function round2(value: number): number {
|
function round2(value: number): number {
|
||||||
return Math.round(value * 100) / 100;
|
return Math.round(value * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL HTTP-триггеров Power Automate — только в константах (§7).
|
|
||||||
// TODO: подставить реальные URL флоу.
|
|
||||||
const FLOWS = {
|
|
||||||
saveStatus: '' // запись статуса сущности
|
|
||||||
};
|
|
||||||
|
|
||||||
// Поток обработки КП ct-processing-kps (Шаг 2). Триггер «manual» с «Кто может активировать поток? =
|
// Поток обработки КП ct-processing-kps (Шаг 2). Триггер «manual» с «Кто может активировать поток? =
|
||||||
// Любой пользователь в моём клиенте» (Entra) — зовём через AadHttpClient (токен на Flow Service).
|
// Любой пользователь в моём клиенте» (Entra) — зовём через AadHttpClient (токен на Flow Service).
|
||||||
// ⚠️ 2026-07-15: стойкий 404 был из-за СЛОМАННОГО БЛОКА в самом потоке (мешал публикации/резолву);
|
// ⚠️ 2026-07-15: стойкий 404 был из-за СЛОМАННОГО БЛОКА в самом потоке (мешал публикации/резолву);
|
||||||
@@ -224,6 +199,8 @@ const TNSU_API_RESOURCE_ID = '9232d9e2-9807-4753-808f-07250e5474b0';
|
|||||||
export class KpsCtFormService {
|
export class KpsCtFormService {
|
||||||
private readonly baseUrl: string;
|
private readonly baseUrl: string;
|
||||||
private readonly spHttpClient: SPHttpClient;
|
private readonly spHttpClient: SPHttpClient;
|
||||||
|
// Ленивая инициализация — конструктору нужен this.baseUrl, который вычисляется в его же теле.
|
||||||
|
private _approval: ApprovalListService | undefined;
|
||||||
|
|
||||||
public constructor(private readonly context: WebPartContext, siteUrl?: string) {
|
public constructor(private readonly context: WebPartContext, siteUrl?: string) {
|
||||||
// siteUrl задаём, когда форма встроена в портал на ДРУГОМ сайте (списки KPS
|
// siteUrl задаём, когда форма встроена в портал на ДРУГОМ сайте (списки KPS
|
||||||
@@ -232,12 +209,6 @@ export class KpsCtFormService {
|
|||||||
this.spHttpClient = context.spHttpClient;
|
this.spHttpClient = context.spHttpClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SP Id текущего пользователя — для инициатора цепочки согласования.
|
|
||||||
public get currentUserSpId(): number {
|
|
||||||
const legacy = this.context.pageContext.legacyPageContext as Record<string, unknown>;
|
|
||||||
return Number(legacy?.userId ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Хелперы URL списков (§5.3) ---
|
// --- Хелперы URL списков (§5.3) ---
|
||||||
|
|
||||||
private _li(listName: string): string {
|
private _li(listName: string): string {
|
||||||
@@ -371,23 +342,6 @@ export class KpsCtFormService {
|
|||||||
return this.tnsuFetch<PurchaseRequestDetail>(`/inventory/api/purchase-requests/${id}`);
|
return this.tnsuFetch<PurchaseRequestDetail>(`/inventory/api/purchase-requests/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Power Automate (§7) ---
|
|
||||||
|
|
||||||
// POST на HTTP-триггер флоу. Если URL не задан (заглушка) — тихо пропускаем.
|
|
||||||
private async callFlow(url: string, body: Record<string, unknown>): Promise<void> {
|
|
||||||
if (!url) {
|
|
||||||
return; // TODO: URL флоу ещё не задан
|
|
||||||
}
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Flow call failed: ${res.status} ${res.statusText}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Шаг 2: библиотека КП + поток обработки (Syntex) ---
|
// --- Шаг 2: библиотека КП + поток обработки (Syntex) ---
|
||||||
|
|
||||||
private _readFile(file: File): Promise<ArrayBuffer> {
|
private _readFile(file: File): Promise<ArrayBuffer> {
|
||||||
@@ -677,8 +631,9 @@ export class KpsCtFormService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Цепочка согласования + курсы для пересчёта в ₸ + критерии (все толерантны к сбою).
|
// Курсы для пересчёта в ₸ + критерии (толерантны к сбою). Маршрут согласования сюда больше не
|
||||||
const approvers = await this.loadApprovalChain(ctId).catch(() => [] as Approver[]);
|
// входит — Step4Approval/InlineApprovalChain читают его отдельно через ApprovalListService
|
||||||
|
// (getApprovalMatrixForRequest), он не часть единого formData.
|
||||||
const currencyRates = await this.getCurrencyRates().catch(() => [] as CurrencyRate[]);
|
const currencyRates = await this.getCurrencyRates().catch(() => [] as CurrencyRate[]);
|
||||||
const criteria = await this.getCriteria(ctId).catch(() => [] as SupplierCriteria[]);
|
const criteria = await this.getCriteria(ctId).catch(() => [] as SupplierCriteria[]);
|
||||||
|
|
||||||
@@ -700,7 +655,6 @@ export class KpsCtFormService {
|
|||||||
suppliers,
|
suppliers,
|
||||||
offers,
|
offers,
|
||||||
currencyRates,
|
currencyRates,
|
||||||
approvers,
|
|
||||||
criteria,
|
criteria,
|
||||||
uploadedKpFiles
|
uploadedKpFiles
|
||||||
};
|
};
|
||||||
@@ -1115,21 +1069,25 @@ export class KpsCtFormService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Шаг 4: согласование ---
|
// --- Шаг 4: согласование (через shared/approval — approval-route API + список
|
||||||
|
// «Согласование СТ КПС», см. план миграции согласования на approval-route API) ---
|
||||||
|
|
||||||
// Поиск пользователей сайта (для добавления согласующих).
|
// Один экземпляр на форму — сервис без состояния конкретной заявки, безопасно переиспользовать.
|
||||||
public async searchUsers(query: string): Promise<Array<{ spId: number; displayName: string; email: string }>> {
|
private get approval(): ApprovalListService {
|
||||||
if (!query || query.length < 2) {
|
if (!this._approval) {
|
||||||
return [];
|
this._approval = new ApprovalListService(this.context, LISTS.approvers, this.baseUrl);
|
||||||
}
|
}
|
||||||
const escaped = query.replace(/'/g, "''");
|
return this._approval;
|
||||||
const url = `${this.baseUrl}/_api/web/siteusers?$filter=substringof('${escaped}',Title) and PrincipalType eq 1&$select=Id,Title,Email&$top=10`;
|
}
|
||||||
const res = await this.spHttpClient.get(url, SPHttpClient.configurations.v1);
|
|
||||||
if (!res.ok) {
|
// Поиск пользователей сайта — используется пикером согласующего/делегата в ApprovalMatrix/DecisionPanel.
|
||||||
return [];
|
public searchUsers(query: string): Promise<Array<{ id: number; name: string; email: string; loginName: string }>> {
|
||||||
}
|
return this.approval.searchUsers(query);
|
||||||
const json: { value: Array<{ Id: number; Title?: string; Email?: string }> } = await res.json();
|
}
|
||||||
return json.value.map((u) => ({ spId: Number(u.Id), displayName: String(u.Title ?? ''), email: String(u.Email ?? '') }));
|
|
||||||
|
// Должность добавляемого согласующего из профиля SharePoint — для useMatrixEditor.addApprover.
|
||||||
|
public getJobTitle(loginName: string): Promise<string> {
|
||||||
|
return this.approval.getJobTitle(loginName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновление статуса шапки СТ (+ опц. поставщик-победитель) — MERGE.
|
// Обновление статуса шапки СТ (+ опц. поставщик-победитель) — MERGE.
|
||||||
@@ -1141,126 +1099,55 @@ export class KpsCtFormService {
|
|||||||
await this.updateListItem(LISTS.ct, ctId, body);
|
await this.updateListItem(LISTS.ct, ctId, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Идемпотентная запись цепочки (модель эталона: переиспользуем записи, не пересоздаём вслепую).
|
// Email текущего пользователя — инициатора, по которому резолвится планируемый маршрут.
|
||||||
// По каждому согласующему: если запись в SP уже есть — MERGE (порядок/статус/комментарий), иначе POST.
|
public get currentUserEmail(): string {
|
||||||
// Лишние записи (дубли переиспользованных + удалённые из цепочки) — удаляем. Так recycle+re-POST на
|
return this.context.pageContext.user.email || '';
|
||||||
// «защищённом» списке больше не плодит дубли, а уже задвоенные списки лечатся при первой же записи.
|
|
||||||
public async saveApprovalChain(ctId: number, approvers: Approver[]): Promise<Approver[]> {
|
|
||||||
const existing = await this.getListItems<{ Id: number }>(
|
|
||||||
LISTS.approvers,
|
|
||||||
`$select=Id&$filter=${APPROVAL_FIELDS.ct}Id eq ${ctId}&$top=500`
|
|
||||||
);
|
|
||||||
const existingIds = new Set(existing.map((e) => e.Id));
|
|
||||||
const kept = new Set<number>();
|
|
||||||
const saved: Approver[] = [];
|
|
||||||
for (const a of approvers) {
|
|
||||||
const fields: Record<string, unknown> = {
|
|
||||||
[APPROVAL_FIELDS.title]: String(a.order),
|
|
||||||
[APPROVAL_FIELDS.order]: a.order,
|
|
||||||
[APPROVAL_FIELDS.status]: APPROVER_STATUS_TO_CHOICE[a.status],
|
|
||||||
[APPROVAL_FIELDS.comment]: a.comment ?? ''
|
|
||||||
};
|
|
||||||
// Идентификация — по ЗАПИСИ (spApprovalId), а не по человеку: один человек может быть в цепочке
|
|
||||||
// несколько раз, каждая строка = своя запись. Есть запись — MERGE (в т.ч. смена персоны после
|
|
||||||
// «Заменить»); нет — POST новой.
|
|
||||||
if (a.spApprovalId !== undefined && existingIds.has(a.spApprovalId) && !kept.has(a.spApprovalId)) {
|
|
||||||
kept.add(a.spApprovalId);
|
|
||||||
await this.updateListItem(LISTS.approvers, a.spApprovalId, { ...fields, [`${APPROVAL_FIELDS.aproover}Id`]: a.userId });
|
|
||||||
saved.push({ ...a });
|
|
||||||
} else {
|
|
||||||
const created = await this.createListItem<{ Id: number }>(LISTS.approvers, {
|
|
||||||
...fields,
|
|
||||||
[`${APPROVAL_FIELDS.aproover}Id`]: a.userId,
|
|
||||||
[`${APPROVAL_FIELDS.ct}Id`]: ctId
|
|
||||||
});
|
|
||||||
saved.push({ ...a, rowId: `sp_${created.Id}`, spApprovalId: created.Id });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Записи, которые не переиспользовали (удалённые из цепочки) — убираем best-effort.
|
|
||||||
for (const e of existing) {
|
|
||||||
if (!kept.has(e.Id)) {
|
|
||||||
await this._removeItemBestEffort(LISTS.approvers, e.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return saved;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Решение согласующего — MERGE статус + комментарий + дата.
|
// Числовой RequestTypeId «КПС» в approval-route API — резолвится один раз по стабильному pageUrl
|
||||||
public async submitApprovalStep(spApprovalId: number, decision: 'approved' | 'rejected', comment: string): Promise<void> {
|
// (сам id назначается в админке маршрутов при регистрации типа и заранее непредсказуем).
|
||||||
await this.updateListItem(LISTS.approvers, spApprovalId, {
|
public resolveRequestTypeId(): Promise<number> {
|
||||||
[APPROVAL_FIELDS.status]: decision === 'approved' ? APPROVER_STATUS_TO_CHOICE.approved : APPROVER_STATUS_TO_CHOICE.rejected,
|
return getRequestTypeIdByPageUrl(this.context, KPS_REQUEST_TYPE_PAGE_URL);
|
||||||
[APPROVAL_FIELDS.comment]: comment,
|
}
|
||||||
[APPROVAL_FIELDS.aproovDate]: new Date().toISOString()
|
|
||||||
|
// Планируемый маршрут для НОВОЙ СТ (черновик, до отправки) — резолв по email инициатора.
|
||||||
|
public async getPlannedApprovalMatrix(userEmail: string): Promise<IMatrixRow[]> {
|
||||||
|
const requestTypeId = await this.resolveRequestTypeId();
|
||||||
|
return getPlannedMatrix(this.context, requestTypeId, userEmail, this.approval);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Маршрут уже отправленной СТ — как записан в списке согласования.
|
||||||
|
public getApprovalMatrixForRequest(ctId: number): Promise<IMatrixRow[]> {
|
||||||
|
return this.approval.getMatrixForRequest(ctId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка на согласование: пишет маршрут (удаляет прежний, если это повторная отправка) и
|
||||||
|
// активирует первый шаг. Статус шапки СТ выставляет вызывающая сторона (Step4Approval).
|
||||||
|
public submitApprovalRoute(ctId: number, approvers: IApproverToWrite[]): Promise<void> {
|
||||||
|
return writeRoute(this.approval, ctId, approvers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сервис решений согласующего для конкретной СТ: onStatusComputed пишет статус НАЗАД в шапку СТ
|
||||||
|
// (Choice-поле `status`, свой набор значений — ApproverService домена статусов не знает).
|
||||||
|
public getApproverService(ctId: number): ApproverService {
|
||||||
|
return new ApproverService(this.approval, async (status) => {
|
||||||
|
await this.updateCtStatus(ctId, APPROVAL_STATUS_TO_CT_STATUS[status]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Замена согласующего: MERGE персоны на существующей записи. Порядок/статус не трогаем (как эталон).
|
// Уведомление активного согласующего — некритичный побочный эффект (вызывать с .catch()), общая
|
||||||
public async replaceApprovalMember(spApprovalId: number, newUserId: number): Promise<void> {
|
// точка для «после отправки» и «после каждого решения» (см. Step4Approval/InlineApprovalChain).
|
||||||
await this.updateListItem(LISTS.approvers, spApprovalId, {
|
public async notifyActiveApprover(ctId: number): Promise<void> {
|
||||||
[`${APPROVAL_FIELDS.aproover}Id`]: newUserId
|
const rows = await this.approval.getForRequest(ctId);
|
||||||
});
|
const active = rows.filter((r) => r.status === REQUEST_STATUS.onApproval)[0];
|
||||||
}
|
if (active) {
|
||||||
|
await this.triggerApprovalNotification(active.id);
|
||||||
// Удаление записей согласования (при отклонении — оставшиеся pending уже не проголосуют).
|
|
||||||
// Best-effort DELETE (fallback recycle) — сбой не валит поток, но и не глотается молча.
|
|
||||||
public async deleteApprovalMembers(spIds: number[]): Promise<void> {
|
|
||||||
for (const id of spIds) {
|
|
||||||
await this._removeItemBestEffort(LISTS.approvers, id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка цепочки из «Согласование СТ» по СТ (для восстановления при перезагрузке).
|
// Оповещение согласующего в Teams. Entra-триггер — зовём через AadHttpClient (токен Flow Service,
|
||||||
public async loadApprovalChain(ctId: number): Promise<Approver[]> {
|
// как triggerKpFlow/triggerAiComment; обычный fetch → 401). Тело { RowID } = spId записи
|
||||||
const rows = await this.getListItems<{
|
// «Согласование СТ КПС», по которой поток находит согласующего/статус/родительскую СТ.
|
||||||
Id: number;
|
|
||||||
order0?: number;
|
|
||||||
status?: string;
|
|
||||||
comment?: string;
|
|
||||||
aproovDate?: string;
|
|
||||||
aproover?: { Id: number; Title: string };
|
|
||||||
}>(
|
|
||||||
LISTS.approvers,
|
|
||||||
`$select=Id,${APPROVAL_FIELDS.order},${APPROVAL_FIELDS.status},${APPROVAL_FIELDS.comment},${APPROVAL_FIELDS.aproovDate},` +
|
|
||||||
`${APPROVAL_FIELDS.aproover}/Id,${APPROVAL_FIELDS.aproover}/Title&$expand=${APPROVAL_FIELDS.aproover}` +
|
|
||||||
`&$filter=${APPROVAL_FIELDS.ct}Id eq ${ctId}&$orderby=${APPROVAL_FIELDS.order} asc`
|
|
||||||
);
|
|
||||||
// Возвращаем ВСЕ строки (без дедупа по человеку) — один человек может быть в цепочке несколько раз;
|
|
||||||
// строку опознаём по rowId (`sp_<spApprovalId>`), не по userId. rows уже отсортированы по order.
|
|
||||||
return rows.map((r, i) => ({
|
|
||||||
rowId: `sp_${r.Id}`,
|
|
||||||
spApprovalId: r.Id,
|
|
||||||
userId: Number(r.aproover?.Id ?? 0),
|
|
||||||
displayName: String(r.aproover?.Title ?? ''),
|
|
||||||
order: Number(r.order0 ?? i + 1),
|
|
||||||
status: (r.status && CHOICE_TO_APPROVER_STATUS[r.status]) || 'pending',
|
|
||||||
comment: r.comment || undefined,
|
|
||||||
decidedAt: r.aproovDate || undefined,
|
|
||||||
isInitiator: Number(r.order0 ?? i + 1) === 1
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Шаблон цепочки из «Матрица согласования КПС». Порядок — по `Id` (создания), как в эталоне.
|
|
||||||
// $select под-полей Person + $expand ОБЯЗАТЕЛЬНО вместе (иначе Person не разворачивается).
|
|
||||||
// Поле `order` в $select/$orderby НЕ кладём — на нём OData SharePoint падает (FormatException).
|
|
||||||
public async loadApprovalMatrix(): Promise<Array<{ userId: number; displayName: string }>> {
|
|
||||||
const rows = await this.getListItems<{ Id: number; approover?: { Id: number; Title: string } }>(
|
|
||||||
LISTS.approvalMatrix,
|
|
||||||
`$select=Id,${MATRIX_FIELDS.approover}/Id,${MATRIX_FIELDS.approover}/Title&$expand=${MATRIX_FIELDS.approover}&$orderby=Id asc&$top=500`
|
|
||||||
);
|
|
||||||
return rows
|
|
||||||
.filter((r) => r.approover?.Id)
|
|
||||||
.map((r) => ({ userId: Number(r.approover?.Id), displayName: String(r.approover?.Title ?? '') }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Запись статуса сущности. Non-blocking побочный эффект (§7) — вызывать с .catch(() => undefined).
|
|
||||||
// TODO: PATCH списка ct или флоу saveStatus.
|
|
||||||
public async saveCtStatus(ctId: number, status: CtStatus): Promise<void> {
|
|
||||||
await this.callFlow(FLOWS.saveStatus, { ctId, status });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Оповещение согласующего в Teams (Шаг 4). Entra-триггер — зовём через AadHttpClient (токен Flow Service,
|
|
||||||
// как triggerKpFlow/triggerAiComment; обычный fetch → 401). Тело { RowID } = spId записи «Согласование КПС»,
|
|
||||||
// по которой поток находит согласующего/статус/родительскую СТ. Ошибка не критична — вызывать некритично.
|
|
||||||
public async triggerApprovalNotification(spApprovalId: number): Promise<void> {
|
public async triggerApprovalNotification(spApprovalId: number): Promise<void> {
|
||||||
const client = await this.context.aadHttpClientFactory.getClient(FLOW_RESOURCE);
|
const client = await this.context.aadHttpClientFactory.getClient(FLOW_RESOURCE);
|
||||||
const res = await client.post(FLOW_APPROVAL_NOTIFICATION_URL, AadHttpClient.configurations.v1, {
|
const res = await client.post(FLOW_APPROVAL_NOTIFICATION_URL, AadHttpClient.configurations.v1, {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import type { FormStep } from "./types/domain";
|
|||||||
const OidCreateForm: React.FC<IOidCreateFormProps> = ({
|
const OidCreateForm: React.FC<IOidCreateFormProps> = ({
|
||||||
context,
|
context,
|
||||||
isDarkTheme,
|
isDarkTheme,
|
||||||
userDisplayName,
|
|
||||||
}) => {
|
}) => {
|
||||||
const service = useMemo(() => new OidService(context), [context]);
|
const service = useMemo(() => new OidService(context), [context]);
|
||||||
|
|
||||||
@@ -88,7 +87,6 @@ const OidCreateForm: React.FC<IOidCreateFormProps> = ({
|
|||||||
<Step2CommercialOffers
|
<Step2CommercialOffers
|
||||||
service={service}
|
service={service}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
userDisplayName={userDisplayName}
|
|
||||||
isReadOnly={formData.approvalSent}
|
isReadOnly={formData.approvalSent}
|
||||||
onSave={updateFormData}
|
onSave={updateFormData}
|
||||||
onPrev={goPrev}
|
onPrev={goPrev}
|
||||||
@@ -111,7 +109,6 @@ const OidCreateForm: React.FC<IOidCreateFormProps> = ({
|
|||||||
<Step4Approval
|
<Step4Approval
|
||||||
service={service}
|
service={service}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
userDisplayName={userDisplayName}
|
|
||||||
onSave={updateFormData}
|
onSave={updateFormData}
|
||||||
onPrev={goPrev}
|
onPrev={goPrev}
|
||||||
onStepComplete={markStepComplete}
|
onStepComplete={markStepComplete}
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ export function useStep1WorksInput(args: UseStep1WorksInputArgs) {
|
|||||||
project,
|
project,
|
||||||
workName: workName.trim(),
|
workName: workName.trim(),
|
||||||
status: "draft" as const,
|
status: "draft" as const,
|
||||||
|
authorEmail: service.currentUserEmail || undefined,
|
||||||
};
|
};
|
||||||
onSave({ oid, workName: workName.trim() });
|
onSave({ oid, workName: workName.trim() });
|
||||||
// Persist positions typed before the OID existed (edits then had no oidId)
|
// Persist positions typed before the OID existed (edits then had no oidId)
|
||||||
@@ -455,6 +456,7 @@ export function useStep1WorksInput(args: UseStep1WorksInputArgs) {
|
|||||||
project,
|
project,
|
||||||
workName: workName.trim(),
|
workName: workName.trim(),
|
||||||
status: "formed" as const,
|
status: "formed" as const,
|
||||||
|
authorEmail: service.currentUserEmail || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
onSave({ oid, workName: workName.trim(), positionGroups: updatedGroups });
|
onSave({ oid, workName: workName.trim(), positionGroups: updatedGroups });
|
||||||
|
|||||||
-3
@@ -14,7 +14,6 @@ import styles from "./Step2CommercialOffers.module.scss";
|
|||||||
interface Props {
|
interface Props {
|
||||||
service: OidService;
|
service: OidService;
|
||||||
formData: OIDFormData;
|
formData: OIDFormData;
|
||||||
userDisplayName: string;
|
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
onSave: (partial: Partial<OIDFormData>) => void;
|
onSave: (partial: Partial<OIDFormData>) => void;
|
||||||
onPrev: () => void;
|
onPrev: () => void;
|
||||||
@@ -26,7 +25,6 @@ interface Props {
|
|||||||
export const Step2CommercialOffers: React.FC<Props> = ({
|
export const Step2CommercialOffers: React.FC<Props> = ({
|
||||||
service,
|
service,
|
||||||
formData,
|
formData,
|
||||||
userDisplayName,
|
|
||||||
isReadOnly = false,
|
isReadOnly = false,
|
||||||
onSave,
|
onSave,
|
||||||
onPrev,
|
onPrev,
|
||||||
@@ -47,7 +45,6 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
|||||||
const patch = await persistStep2({
|
const patch = await persistStep2({
|
||||||
service,
|
service,
|
||||||
formData,
|
formData,
|
||||||
userDisplayName,
|
|
||||||
suppliers: kp.suppliers,
|
suppliers: kp.suppliers,
|
||||||
kpFiles: kp.kpFiles,
|
kpFiles: kp.kpFiles,
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-34
@@ -1,54 +1,25 @@
|
|||||||
import type { OIDFormData, Supplier, ApprovalMember, ApprovalMemberStatus } from "../types/domain";
|
import type { OIDFormData, Supplier } from "../types/domain";
|
||||||
import { OidService } from "../../services/OidService";
|
import { OidService } from "../../services/OidService";
|
||||||
import { UploadedKpFile } from "./useKpProcessing";
|
import { UploadedKpFile } from "./useKpProcessing";
|
||||||
|
|
||||||
export interface PersistStep2Args {
|
export interface PersistStep2Args {
|
||||||
service: OidService;
|
service: OidService;
|
||||||
formData: OIDFormData;
|
formData: OIDFormData;
|
||||||
userDisplayName: string;
|
|
||||||
suppliers: Supplier[];
|
suppliers: Supplier[];
|
||||||
kpFiles: UploadedKpFile[];
|
kpFiles: UploadedKpFile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сохранение Шага 2: поставщики + построение цепочки согласования из матрицы (как у CT Шаг 2).
|
// Сохранение Шага 2: поставщики. Цепочка согласования БОЛЬШЕ не сеется черновиком здесь: в новом
|
||||||
// Перенесено 1:1 из Step2CommercialOffers.handleSave (§стайл-гайд: логика вне вёрстки).
|
// механизме (approval-route API, см. план миграции согласования) маршрут — это планируемый resolve,
|
||||||
|
// который Step4Approval резолвит «на лету» при открытии шага (OidService.getPlannedApprovalMatrix).
|
||||||
export async function persistStep2(args: PersistStep2Args): Promise<Partial<OIDFormData>> {
|
export async function persistStep2(args: PersistStep2Args): Promise<Partial<OIDFormData>> {
|
||||||
const { service, formData, userDisplayName, suppliers, kpFiles } = args;
|
const { service, formData, suppliers, kpFiles } = args;
|
||||||
if (!formData.oid) throw new Error("СТ не создана");
|
if (!formData.oid) throw new Error("СТ не создана");
|
||||||
|
|
||||||
const savedSuppliers = await service.saveSuppliers(suppliers, formData.oid.id);
|
const savedSuppliers = await service.saveSuppliers(suppliers, formData.oid.id);
|
||||||
|
|
||||||
// Build approval chain from matrix (same flow as CT Step 2)
|
|
||||||
let approvalChain: ApprovalMember[] = [];
|
|
||||||
await service.deleteApprovalChainByOid(formData.oid.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) {
|
|
||||||
const chainToSave: ApprovalMember[] = [
|
|
||||||
{
|
|
||||||
id: "chain_1",
|
|
||||||
order: 1,
|
|
||||||
userId: service.currentUserSpId,
|
|
||||||
userName: userDisplayName,
|
|
||||||
role: "Инициатор",
|
|
||||||
status: "pending" as ApprovalMemberStatus,
|
|
||||||
},
|
|
||||||
...matrix.map((m, idx) => ({
|
|
||||||
...m,
|
|
||||||
id: `chain_${idx + 2}`,
|
|
||||||
order: idx + 2,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
approvalChain = await service.saveApprovalChain(chainToSave, formData.oid.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
suppliers: savedSuppliers,
|
suppliers: savedSuppliers,
|
||||||
uploadedKpFiles: kpFiles,
|
uploadedKpFiles: kpFiles,
|
||||||
approvalChain,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-188
@@ -1,200 +1,65 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Button, Alert, Spinner, Dialog, TextArea, TextInput } from "@tnsu/ui-kit";
|
import { useMemo, useState } from "react";
|
||||||
import type { ApprovalMemberStatus } from "../types/domain";
|
import type { OIDFormData, OIDStatus } from "../types/domain";
|
||||||
import { useStep3Approval } from "./useStep3Approval";
|
import { OidService } from "../../services/OidService";
|
||||||
import styles from "./Step3ComparisonTable.module.scss";
|
import { ApprovalMatrix, DecisionPanel, IMatrixRow, useMatrixEditor } from "../../../../shared/approval";
|
||||||
|
|
||||||
const APPROVAL_STATUS_LABELS: Record<ApprovalMemberStatus, string> = {
|
|
||||||
pending: "На согласовании",
|
|
||||||
recommends: "Рекомендую",
|
|
||||||
approved: "Согласовано",
|
|
||||||
rejected: "Отклонено",
|
|
||||||
skipped: "Пропущено",
|
|
||||||
};
|
|
||||||
|
|
||||||
const APPROVAL_STATUS_STYLE: Record<
|
|
||||||
ApprovalMemberStatus,
|
|
||||||
{ bg: string; color: string }
|
|
||||||
> = {
|
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
approval: ReturnType<typeof useStep3Approval>;
|
service: OidService;
|
||||||
|
formData: OIDFormData;
|
||||||
|
onSave: (partial: Partial<OIDFormData>) => void;
|
||||||
isNotificationLink: boolean;
|
isNotificationLink: boolean;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инлайн-цепочка согласования Шага 3 + диалоги (решение/делегат). Показывается только по
|
const RU_STATUS_TO_OID_STATUS: Record<string, OIDStatus> = {
|
||||||
// Teams-ссылке в read-only. Логика — в useStep3Approval. Перенесено 1:1 из Step3ComparisonTable.
|
Отклонено: "rejected",
|
||||||
export const InlineApprovalChain: React.FC<Props> = ({ approval: a, isNotificationLink, isReadOnly }) => {
|
Согласовано: "approved",
|
||||||
|
"На согласовании": "on_approval",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Инлайн-цепочка согласования Шага 3 — для согласующего, открывшего СТ ОИД по Teams-ссылке,
|
||||||
|
// read-only маршрут + DecisionPanel своей строки. Правка состава/отправка — это Шаг 4, здесь их нет.
|
||||||
|
export const InlineApprovalChain: React.FC<Props> = ({ service, formData, onSave, isNotificationLink, isReadOnly }) => {
|
||||||
|
const { oid } = formData;
|
||||||
|
const oidId = oid?.id;
|
||||||
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
|
|
||||||
|
const loadMatrix = useMemo(
|
||||||
|
() => (): Promise<IMatrixRow[]> => (oidId ? service.getApprovalMatrixForRequest(oidId) : Promise.resolve([])),
|
||||||
|
[service, oidId]
|
||||||
|
);
|
||||||
|
const editor = useMatrixEditor(
|
||||||
|
loadMatrix,
|
||||||
|
`${oidId ?? ""}-${decisionSeq}`,
|
||||||
|
false,
|
||||||
|
!!oidId && isNotificationLink && isReadOnly,
|
||||||
|
(loginName) => service.getJobTitle(loginName)
|
||||||
|
);
|
||||||
|
const approverService = useMemo(() => (oidId ? service.getApproverService(oidId) : undefined), [service, oidId]);
|
||||||
|
|
||||||
|
if (!isNotificationLink || !isReadOnly || !oidId || editor.loading || editor.rows.length === 0) {
|
||||||
|
return null; // маршрут ещё грузится из SP или его нет — блок не показываем
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDecided(newStatus: string): Promise<void> {
|
||||||
|
if (!oid) return;
|
||||||
|
onSave({ oid: { ...oid, status: RU_STATUS_TO_OID_STATUS[newStatus] || oid.status } });
|
||||||
|
setDecisionSeq((n) => n + 1);
|
||||||
|
await service.notifyActiveApprover(oidId as number).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isNotificationLink && isReadOnly && a.approvalChain.length > 0 && (
|
<ApprovalMatrix editor={editor} search={(q) => service.searchUsers(q)} />
|
||||||
<section className={styles.section}>
|
{approverService && (
|
||||||
<h3 className={styles.sectionTitle}>Цепочка согласования</h3>
|
<DecisionPanel
|
||||||
{a.notifyWarning && <Alert variant="warn">{a.notifyWarning}</Alert>}
|
service={approverService}
|
||||||
<ol className={styles.chainList}>
|
requestId={oidId}
|
||||||
{a.approvalChain.map((member) => {
|
onDecided={handleDecided}
|
||||||
const st = APPROVAL_STATUS_STYLE[member.status];
|
search={(q) => service.searchUsers(q)}
|
||||||
return (
|
onDelegated={() => setDecisionSeq((n) => n + 1)}
|
||||||
<li key={member.id} className={styles.chainItem}>
|
/>
|
||||||
<span className={styles.orderBadge}>{member.order}</span>
|
|
||||||
<span className={styles.memberName}>{member.userName}</span>
|
|
||||||
{member.role && (
|
|
||||||
<span className={styles.memberRole}>{member.role}</span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={styles.approvalStatusBadge}
|
|
||||||
style={{ background: st.bg, color: st.color }}
|
|
||||||
>
|
|
||||||
{APPROVAL_STATUS_LABELS[member.status]}
|
|
||||||
</span>
|
|
||||||
{member.comment && (
|
|
||||||
<span className={styles.approvalComment}>
|
|
||||||
«{member.comment}»
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{member.actionDate && (
|
|
||||||
<span className={styles.approvalDate}>
|
|
||||||
{formatApprovalDate(member.actionDate)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{a.isOnApproval && a.currentUserMember?.id === member.id && (
|
|
||||||
<div className={styles.chainActions}>
|
|
||||||
{member.canAddApprover && (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() =>
|
|
||||||
a.openDelegate(member.id, member.order, member.spApprovalId!)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Добавить делегата
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="primary" onClick={() => a.openApprove(member.id)}>
|
|
||||||
Согласовать
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
onClick={() => a.openReject(member.id)}
|
|
||||||
className={styles.approvalRejectBtn}
|
|
||||||
>
|
|
||||||
Отклонить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{a.delegateDialog && (
|
|
||||||
<Dialog
|
|
||||||
open
|
|
||||||
title="Добавить делегата"
|
|
||||||
subText="Выбранный пользователь будет вставлен перед вами в цепочку согласования."
|
|
||||||
onDismiss={a.closeDelegate}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={a.closeDelegate} disabled={a.isDelegateSubmitting}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={a.handleAddDelegate}
|
|
||||||
disabled={a.isDelegateSubmitting || !a.delegateDialog.selected}
|
|
||||||
>
|
|
||||||
{a.isDelegateSubmitting ? <Spinner size="small" /> : "Добавить"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={styles.searchBox}>
|
|
||||||
<TextInput
|
|
||||||
placeholder="Поиск делегата (имя)…"
|
|
||||||
ariaLabel="Поиск делегата"
|
|
||||||
value={a.delegateDialog.query}
|
|
||||||
onChange={a.setDelegateQuery}
|
|
||||||
/>
|
|
||||||
{a.delegateDialog.suggestions.length > 0 && (
|
|
||||||
<ul className={styles.suggestions}>
|
|
||||||
{a.delegateDialog.suggestions.map((s) => (
|
|
||||||
<li key={s.spId} className={styles.suggestionItem} onClick={() => a.selectDelegate(s)}>
|
|
||||||
<span className={styles.suggestionName}>{s.displayName}</span>
|
|
||||||
<span className={styles.suggestionEmail}>{s.email}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{a.delegateError && <Alert variant="err">{a.delegateError}</Alert>}
|
|
||||||
</Dialog>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{a.actionDialog && (
|
|
||||||
<Dialog
|
|
||||||
open
|
|
||||||
title={a.actionDialog.action === "approve" ? "Согласование" : "Отклонение"}
|
|
||||||
subText={
|
|
||||||
a.actionDialog.action === "approve"
|
|
||||||
? "Вы подтверждаете согласование? Комментарий необязателен."
|
|
||||||
: "Укажите причину отклонения (обязательно)."
|
|
||||||
}
|
|
||||||
onDismiss={a.closeAction}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={a.closeAction} disabled={a.isApprovalSubmitting}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={a.actionDialog.action === "approve" ? "primary" : "danger"}
|
|
||||||
onClick={a.handleApprovalConfirm}
|
|
||||||
disabled={
|
|
||||||
a.isApprovalSubmitting ||
|
|
||||||
(a.actionDialog.action === "reject" && !a.actionDialog.comment.trim())
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{a.isApprovalSubmitting ? (
|
|
||||||
<Spinner size="small" />
|
|
||||||
) : a.actionDialog.action === "approve" ? (
|
|
||||||
"Согласовать"
|
|
||||||
) : (
|
|
||||||
"Отклонить"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TextArea
|
|
||||||
value={a.actionDialog.comment}
|
|
||||||
onChange={a.setActionComment}
|
|
||||||
rows={3}
|
|
||||||
ariaLabel="Комментарий"
|
|
||||||
placeholder={
|
|
||||||
a.actionDialog.action === "reject"
|
|
||||||
? "Укажите причину..."
|
|
||||||
: "Комментарий (необязательно)..."
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{a.approvalError && <Alert variant="err">{a.approvalError}</Alert>}
|
|
||||||
</Dialog>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+7
-3
@@ -3,7 +3,6 @@ import { Button, TextInput, Alert, Spinner, Dialog, Icon } from "@tnsu/ui-kit";
|
|||||||
import type { OIDFormData, FormStep } from "../types/domain";
|
import type { OIDFormData, FormStep } from "../types/domain";
|
||||||
import { OidService } from "../../services/OidService";
|
import { OidService } from "../../services/OidService";
|
||||||
import { useStep3Matrix } from "./useStep3Matrix";
|
import { useStep3Matrix } from "./useStep3Matrix";
|
||||||
import { useStep3Approval } from "./useStep3Approval";
|
|
||||||
import { InlineApprovalChain } from "./InlineApprovalChain";
|
import { InlineApprovalChain } from "./InlineApprovalChain";
|
||||||
import { useAiComment } from "./useAiComment";
|
import { useAiComment } from "./useAiComment";
|
||||||
import { AiCommentBlock } from "./AiCommentBlock";
|
import { AiCommentBlock } from "./AiCommentBlock";
|
||||||
@@ -32,7 +31,6 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
onStepComplete,
|
onStepComplete,
|
||||||
}) => {
|
}) => {
|
||||||
const matrix = useStep3Matrix({ service, formData, onSave, onStepComplete });
|
const matrix = useStep3Matrix({ service, formData, onSave, onStepComplete });
|
||||||
const approval = useStep3Approval({ service, formData, onSave, onStepComplete });
|
|
||||||
const ai = useAiComment({ service, formData, onReportError: matrix.setSaveError });
|
const ai = useAiComment({ service, formData, onReportError: matrix.setSaveError });
|
||||||
|
|
||||||
const { isReadOnly, newSupplierName, setNewSupplierName, handleAddSupplier, isSaving, saveError, handleSave } = matrix;
|
const { isReadOnly, newSupplierName, setNewSupplierName, handleAddSupplier, isSaving, saveError, handleSave } = matrix;
|
||||||
@@ -62,7 +60,13 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
|
|
||||||
<AiCommentBlock comment={ai.aiComment} isLoading={ai.isAiLoading} error={ai.aiError} />
|
<AiCommentBlock comment={ai.aiComment} isLoading={ai.isAiLoading} error={ai.aiError} />
|
||||||
|
|
||||||
<InlineApprovalChain approval={approval} isNotificationLink={isNotificationLink} isReadOnly={isReadOnly} />
|
<InlineApprovalChain
|
||||||
|
service={service}
|
||||||
|
formData={formData}
|
||||||
|
onSave={onSave}
|
||||||
|
isNotificationLink={isNotificationLink}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
/>
|
||||||
|
|
||||||
{saveError && <Alert variant="err">{saveError}</Alert>}
|
{saveError && <Alert variant="err">{saveError}</Alert>}
|
||||||
|
|
||||||
|
|||||||
-258
@@ -1,258 +0,0 @@
|
|||||||
import { useRef, useState } from "react";
|
|
||||||
import type { OIDFormData, ApprovalMemberStatus, FormStep } from "../types/domain";
|
|
||||||
import { OidService } from "../../services/OidService";
|
|
||||||
|
|
||||||
export type ApprovalAction = "approve" | "reject";
|
|
||||||
|
|
||||||
export interface UserSuggestion {
|
|
||||||
spId: number;
|
|
||||||
displayName: string;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActionDialogState {
|
|
||||||
memberId: string;
|
|
||||||
action: ApprovalAction;
|
|
||||||
comment: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DelegateDialogState {
|
|
||||||
memberId: string;
|
|
||||||
memberOrder: number;
|
|
||||||
spApprovalId: number;
|
|
||||||
query: string;
|
|
||||||
suggestions: UserSuggestion[];
|
|
||||||
selected?: UserSuggestion;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseStep3ApprovalArgs {
|
|
||||||
service: OidService;
|
|
||||||
formData: OIDFormData;
|
|
||||||
onSave: (partial: Partial<OIDFormData>) => void;
|
|
||||||
onStepComplete: (step: FormStep) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Логика инлайн-цепочки согласования на Шаге 3 (для согласующего, открывшего СТ ОИД по Teams-ссылке):
|
|
||||||
// голосование approve/reject + нотификации, добавление делегата. Перенесено 1:1 из
|
|
||||||
// Step3ComparisonTable (§стайл-гайд: логика вне вёрстки).
|
|
||||||
export function useStep3Approval(args: UseStep3ApprovalArgs) {
|
|
||||||
const { service, formData, onSave, onStepComplete } = args;
|
|
||||||
|
|
||||||
const approvalChain = formData.approvalChain ?? [];
|
|
||||||
const isOnApproval =
|
|
||||||
formData.oid?.status === "on_approval" || formData.oid?.status === "approved";
|
|
||||||
const currentUserMember = approvalChain.find(
|
|
||||||
(m) =>
|
|
||||||
m.userId === service.currentUserSpId &&
|
|
||||||
m.status === "pending" &&
|
|
||||||
m.role !== "Инициатор" &&
|
|
||||||
approvalChain
|
|
||||||
.filter((prev) => prev.order < m.order)
|
|
||||||
.every((prev) => prev.status !== "pending"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const [actionDialog, setActionDialog] = useState<ActionDialogState | null>(null);
|
|
||||||
const [isApprovalSubmitting, setIsApprovalSubmitting] = useState(false);
|
|
||||||
const [approvalError, setApprovalError] = useState<string | null>(null);
|
|
||||||
const [notifyWarning, setNotifyWarning] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [delegateDialog, setDelegateDialog] = useState<DelegateDialogState | null>(null);
|
|
||||||
const [isDelegateSubmitting, setIsDelegateSubmitting] = useState(false);
|
|
||||||
const [delegateError, setDelegateError] = useState<string | null>(null);
|
|
||||||
const delegateSearchTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
||||||
|
|
||||||
const handleApprovalConfirm = async (): Promise<void> => {
|
|
||||||
if (!actionDialog || !formData.oid) return;
|
|
||||||
const oid = formData.oid;
|
|
||||||
const { memberId, action, comment } = actionDialog;
|
|
||||||
if (action === "reject" && !comment.trim()) return;
|
|
||||||
|
|
||||||
setIsApprovalSubmitting(true);
|
|
||||||
setApprovalError(null);
|
|
||||||
const apiStatus = action === "approve" ? "approved" : "rejected";
|
|
||||||
const currentMember = approvalChain.find((m) => m.id === memberId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!currentMember?.spApprovalId)
|
|
||||||
throw new Error("Не найден ID записи согласования");
|
|
||||||
await service.submitApprovalStep(
|
|
||||||
currentMember.spApprovalId,
|
|
||||||
apiStatus,
|
|
||||||
comment.trim(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const notify = async (spId: number): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await service.triggerApprovalNotification(spId);
|
|
||||||
} catch {
|
|
||||||
setNotifyWarning(
|
|
||||||
"Голос записан, но уведомление в Teams не удалось отправить.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (action === "approve") {
|
|
||||||
const updatedChain = approvalChain.map((m) =>
|
|
||||||
m.id === memberId
|
|
||||||
? {
|
|
||||||
...m,
|
|
||||||
status: "approved" as ApprovalMemberStatus,
|
|
||||||
comment: comment.trim(),
|
|
||||||
actionDate: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
: m,
|
|
||||||
);
|
|
||||||
onSave({ approvalChain: updatedChain });
|
|
||||||
const allDone = updatedChain.every(
|
|
||||||
(m) =>
|
|
||||||
m.status === "approved" ||
|
|
||||||
m.status === "skipped" ||
|
|
||||||
m.status === "recommends",
|
|
||||||
);
|
|
||||||
if (allDone) {
|
|
||||||
// Финал: статус «Согласовано» ДО оповещения, и шлём по currentMember (у него
|
|
||||||
// spApprovalId точно есть) — иначе на финале «Согласована» не уходит, а поток
|
|
||||||
// читает ещё «На согласовании». Порядок как в ветке отклонения / в Step4.
|
|
||||||
await service.updateOIDStatus(oid.id, "approved");
|
|
||||||
onSave({
|
|
||||||
approvalChain: updatedChain,
|
|
||||||
oid: { ...oid, status: "approved" },
|
|
||||||
});
|
|
||||||
if (currentMember.spApprovalId) await notify(currentMember.spApprovalId);
|
|
||||||
onStepComplete(4);
|
|
||||||
} else {
|
|
||||||
const nextPending = updatedChain.find((m) => m.status === "pending");
|
|
||||||
if (nextPending?.spApprovalId) await notify(nextPending.spApprovalId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const othersToDelete = approvalChain.filter(
|
|
||||||
(m) => m.id !== memberId && m.status === "pending" && m.spApprovalId,
|
|
||||||
);
|
|
||||||
if (othersToDelete.length > 0) {
|
|
||||||
await service.deleteApprovalMembers(
|
|
||||||
othersToDelete.map((m) => m.spApprovalId!),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const finalChain = approvalChain
|
|
||||||
.filter((m) => m.status !== "pending" || m.id === memberId)
|
|
||||||
.map((m) =>
|
|
||||||
m.id === memberId
|
|
||||||
? {
|
|
||||||
...m,
|
|
||||||
status: "rejected" as ApprovalMemberStatus,
|
|
||||||
comment: comment.trim(),
|
|
||||||
actionDate: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
: m,
|
|
||||||
);
|
|
||||||
await service.updateOIDStatus(oid.id, "rejected");
|
|
||||||
onSave({
|
|
||||||
approvalChain: finalChain,
|
|
||||||
oid: { ...oid, status: "rejected" },
|
|
||||||
});
|
|
||||||
// Шлём по отклонившему (currentMember) — у него spApprovalId гарантированно есть
|
|
||||||
// (проверен в начале). У инициатора он мог отсутствовать после манипуляций с цепочкой,
|
|
||||||
// из-за чего reject по Teams-ссылке вообще не дёргал поток.
|
|
||||||
await notify(currentMember.spApprovalId);
|
|
||||||
}
|
|
||||||
setActionDialog(null);
|
|
||||||
} catch (err) {
|
|
||||||
setApprovalError((err as Error).message ?? "Ошибка выполнения действия");
|
|
||||||
} finally {
|
|
||||||
setIsApprovalSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setDelegateQuery = (q: string): void => {
|
|
||||||
setDelegateDialog((prev) => (prev ? { ...prev, query: q, suggestions: [], selected: undefined } : prev));
|
|
||||||
if (delegateSearchTimer.current) clearTimeout(delegateSearchTimer.current);
|
|
||||||
if (q.trim().length < 2) return;
|
|
||||||
delegateSearchTimer.current = setTimeout(() => {
|
|
||||||
service
|
|
||||||
.searchUsers(q.trim())
|
|
||||||
.then((res) => setDelegateDialog((prev) => (prev ? { ...prev, suggestions: res } : prev)))
|
|
||||||
.catch(() => undefined);
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectDelegate = (u: UserSuggestion): void =>
|
|
||||||
setDelegateDialog((prev) => (prev ? { ...prev, selected: u, query: u.displayName, suggestions: [] } : prev));
|
|
||||||
|
|
||||||
const handleAddDelegate = async (): Promise<void> => {
|
|
||||||
if (!delegateDialog || !formData.oid || !delegateDialog.selected) return;
|
|
||||||
const delegateUserId = delegateDialog.selected.spId;
|
|
||||||
const delegateName = delegateDialog.selected.displayName;
|
|
||||||
|
|
||||||
setIsDelegateSubmitting(true);
|
|
||||||
setDelegateError(null);
|
|
||||||
try {
|
|
||||||
const newMember = await service.addApprovalDelegate(
|
|
||||||
formData.oid.id,
|
|
||||||
delegateDialog.memberOrder,
|
|
||||||
delegateUserId,
|
|
||||||
delegateName,
|
|
||||||
);
|
|
||||||
const updatedChain = approvalChain
|
|
||||||
.map((m) =>
|
|
||||||
m.order >= delegateDialog.memberOrder
|
|
||||||
? { ...m, order: m.order + 1 }
|
|
||||||
: m,
|
|
||||||
)
|
|
||||||
.concat(newMember)
|
|
||||||
.sort((a, b) => a.order - b.order);
|
|
||||||
onSave({ approvalChain: updatedChain });
|
|
||||||
if (newMember.spApprovalId) {
|
|
||||||
try {
|
|
||||||
await service.triggerApprovalNotification(newMember.spApprovalId);
|
|
||||||
} catch {
|
|
||||||
setNotifyWarning(
|
|
||||||
"Делегат добавлен, но уведомление в Teams не удалось отправить.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setDelegateDialog(null);
|
|
||||||
} catch (err) {
|
|
||||||
setDelegateError((err as Error).message ?? "Ошибка добавления делегата");
|
|
||||||
} finally {
|
|
||||||
setIsDelegateSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Удобные открыватели диалогов (view не дёргает сырые сеттеры).
|
|
||||||
const openApprove = (memberId: string): void =>
|
|
||||||
setActionDialog({ memberId, action: "approve", comment: "" });
|
|
||||||
const openReject = (memberId: string): void =>
|
|
||||||
setActionDialog({ memberId, action: "reject", comment: "" });
|
|
||||||
const setActionComment = (val: string): void =>
|
|
||||||
setActionDialog((prev) => (prev ? { ...prev, comment: val } : null));
|
|
||||||
const openDelegate = (memberId: string, memberOrder: number, spApprovalId: number): void => {
|
|
||||||
setDelegateDialog({ memberId, memberOrder, spApprovalId, query: "", suggestions: [] });
|
|
||||||
setDelegateError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
approvalChain,
|
|
||||||
isOnApproval,
|
|
||||||
currentUserMember,
|
|
||||||
notifyWarning,
|
|
||||||
setNotifyWarning,
|
|
||||||
// action dialog
|
|
||||||
actionDialog,
|
|
||||||
isApprovalSubmitting,
|
|
||||||
approvalError,
|
|
||||||
openApprove,
|
|
||||||
openReject,
|
|
||||||
setActionComment,
|
|
||||||
closeAction: () => setActionDialog(null),
|
|
||||||
handleApprovalConfirm,
|
|
||||||
// delegate dialog
|
|
||||||
delegateDialog,
|
|
||||||
setDelegateQuery,
|
|
||||||
selectDelegate,
|
|
||||||
isDelegateSubmitting,
|
|
||||||
delegateError,
|
|
||||||
openDelegate,
|
|
||||||
closeDelegate: () => setDelegateDialog(null),
|
|
||||||
handleAddDelegate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1078,7 +1078,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
Partial<SupplierCriteria>
|
Partial<SupplierCriteria>
|
||||||
>,
|
>,
|
||||||
step3CustomCriteria: customCriteria,
|
step3CustomCriteria: customCriteria,
|
||||||
approvalChain: formData.approvalChain ?? [],
|
approvalMatrix: formData.approvalMatrix ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
setExtraPositions([]);
|
setExtraPositions([]);
|
||||||
|
|||||||
+1
-237
@@ -1,4 +1,5 @@
|
|||||||
@import '../common/theme';
|
@import '../common/theme';
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -6,239 +7,6 @@
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $text;
|
|
||||||
margin: 0;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
border-bottom: 1px solid $border;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyChain {
|
|
||||||
font-size: 13px;
|
|
||||||
color: $muted-2;
|
|
||||||
font-style: italic;
|
|
||||||
margin: 0;
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chainList {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
max-width: 860px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chainItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: $surface;
|
|
||||||
border: 1px solid $border;
|
|
||||||
border-radius: 4px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $head-bg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.orderBadge {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: $border;
|
|
||||||
color: $muted;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memberInfo {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memberName {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memberRole {
|
|
||||||
font-size: 12px;
|
|
||||||
color: $muted;
|
|
||||||
background: $hover;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusBadge {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 2px 9px;
|
|
||||||
border-radius: 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusPending {
|
|
||||||
background: $hover;
|
|
||||||
color: $muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusApproved {
|
|
||||||
background: $ok-bg;
|
|
||||||
color: $ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusRejected {
|
|
||||||
background: $danger-bg;
|
|
||||||
color: $danger;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusSkipped {
|
|
||||||
background: $hover;
|
|
||||||
color: $muted-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memberComment {
|
|
||||||
font-size: 12px;
|
|
||||||
color: $muted;
|
|
||||||
font-style: italic;
|
|
||||||
max-width: 200px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionDate {
|
|
||||||
font-size: 11px;
|
|
||||||
color: $muted-2;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memberActions {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.approvalActions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rejectBtn {
|
|
||||||
color: $danger;
|
|
||||||
border-color: $danger;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $danger-bg;
|
|
||||||
color: $danger;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Add member ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.addMemberRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memberNameInput {
|
|
||||||
flex: 2;
|
|
||||||
min-width: 180px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memberRoleInput {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 140px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.addRowBtn {
|
|
||||||
align-self: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Suggestions ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.suggestions {
|
|
||||||
list-style: none;
|
|
||||||
margin: 4px 0 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid $border;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: $surface;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
||||||
position: absolute;
|
|
||||||
z-index: 10;
|
|
||||||
width: 100%;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Модификатор для списка внутри диалога (замена согласующего): absolute-подсказки
|
|
||||||
// обрезались телом диалога (overflow-y: auto). В потоке список раздвигает диалог,
|
|
||||||
// а при длинном списке скроллится внутри него.
|
|
||||||
.suggestionsInline {
|
|
||||||
position: static;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestionItem {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $hover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestionName {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestionEmail {
|
|
||||||
font-size: 11px;
|
|
||||||
color: $muted-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.replaceBtn {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deleteBtn {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: $danger;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Footer ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -255,7 +23,3 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialogError {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,245 +1,160 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Button, Alert, Spinner, IconButton, Dialog, TextInput, TextArea, Icon, FormField } from "@tnsu/ui-kit";
|
import { useMemo, useState } from "react";
|
||||||
import type { ApprovalMemberStatus, OIDFormData, FormStep } from "../types/domain";
|
import { Button, Alert, Spinner, Icon } from "@tnsu/ui-kit";
|
||||||
|
import type { OIDFormData, OIDStatus, FormStep } from "../types/domain";
|
||||||
import { OidService } from "../../services/OidService";
|
import { OidService } from "../../services/OidService";
|
||||||
import { useStep4Approval } from "./useStep4Approval";
|
import {
|
||||||
|
ApprovalMatrix,
|
||||||
|
DecisionPanel,
|
||||||
|
IApproverToWrite,
|
||||||
|
IMatrixRow,
|
||||||
|
useMatrixEditor,
|
||||||
|
findUnassignedStep,
|
||||||
|
} from "../../../../shared/approval";
|
||||||
import styles from "./Step4Approval.module.scss";
|
import styles from "./Step4Approval.module.scss";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
service: OidService;
|
service: OidService;
|
||||||
formData: OIDFormData;
|
formData: OIDFormData;
|
||||||
userDisplayName: string;
|
|
||||||
onSave: (partial: Partial<OIDFormData>) => void;
|
onSave: (partial: Partial<OIDFormData>) => void;
|
||||||
onPrev: () => void;
|
onPrev: () => void;
|
||||||
onStepComplete: (step: FormStep) => void;
|
onStepComplete: (step: FormStep) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<ApprovalMemberStatus, string> = {
|
const SUBMITTED_STATUSES: ReadonlySet<OIDStatus> = new Set<OIDStatus>(["on_approval", "approved", "rejected"]);
|
||||||
pending: "На согласовании",
|
|
||||||
recommends: "Рекомендую",
|
const RU_STATUS_TO_OID_STATUS: Record<string, OIDStatus> = {
|
||||||
approved: "Согласовано",
|
Отклонено: "rejected",
|
||||||
rejected: "Отклонено",
|
Согласовано: "approved",
|
||||||
skipped: "Пропущено",
|
"На согласовании": "on_approval",
|
||||||
};
|
};
|
||||||
|
|
||||||
function getStatusClass(status: ApprovalMemberStatus): string {
|
// Шаг 4 «Согласование» (см. план миграции согласования на approval-route API): маршрут — планируемый
|
||||||
const map: Record<ApprovalMemberStatus, string> = {
|
// (до отправки, resolve по email/отделу/компании инициатора) или уже записанный (после отправки).
|
||||||
pending: styles.statusPending,
|
// Редактирование состава/решения — общие компоненты shared/approval. Экспорт отчёта для INCORE MANAGEMENT
|
||||||
recommends: styles.statusApproved,
|
// (без ЭЦП-подписи — в отличие от CT) остаётся отдельным действием инициатора.
|
||||||
approved: styles.statusApproved,
|
export const Step4Approval: React.FC<Props> = ({ service, formData, onPrev, onSave, onStepComplete }) => {
|
||||||
rejected: styles.statusRejected,
|
const { oid } = formData;
|
||||||
skipped: styles.statusSkipped,
|
const oidId = oid?.id;
|
||||||
};
|
const status: OIDStatus = oid?.status ?? "formed";
|
||||||
return map[status];
|
const isSubmitted = SUBMITTED_STATUSES.has(status);
|
||||||
}
|
const isInitiator = !!oid?.authorEmail && oid.authorEmail === service.currentUserEmail;
|
||||||
|
|
||||||
function formatDate(iso?: string): string {
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
if (!iso) return "";
|
const [sending, setSending] = useState<boolean>(false);
|
||||||
return new Date(iso).toLocaleString("ru-RU", {
|
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||||
day: "2-digit",
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
month: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Шаг 4 «Согласование» (цепочка). Логика — в useStep4Approval; здесь только разметка.
|
const loadMatrix = useMemo(
|
||||||
export const Step4Approval: React.FC<Props> = (props) => {
|
() => (): Promise<IMatrixRow[]> =>
|
||||||
const { onPrev } = props;
|
isSubmitted && oidId
|
||||||
const a = useStep4Approval(props);
|
? service.getApprovalMatrixForRequest(oidId)
|
||||||
|
: service.getPlannedApprovalMatrix(service.currentUserEmail, formData.initiatorDepartment, formData.initiatorCompany),
|
||||||
|
[service, oidId, isSubmitted, formData.initiatorDepartment, formData.initiatorCompany]
|
||||||
|
);
|
||||||
|
|
||||||
|
const editor = useMatrixEditor(loadMatrix, `${oidId ?? "draft"}-${decisionSeq}`, !isSubmitted, true, (loginName) =>
|
||||||
|
service.getJobTitle(loginName)
|
||||||
|
);
|
||||||
|
|
||||||
|
const approverService = useMemo(() => (oidId ? service.getApproverService(oidId) : undefined), [service, oidId]);
|
||||||
|
|
||||||
|
async function handleSubmit(): Promise<void> {
|
||||||
|
if (!oidId || !oid) return;
|
||||||
|
if (editor.rows.length === 0) {
|
||||||
|
setError("Маршрут согласования пуст.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const unassigned = findUnassignedStep(editor.rows);
|
||||||
|
if (unassigned) {
|
||||||
|
setError(
|
||||||
|
unassigned.warning ||
|
||||||
|
`Шаг «${unassigned.position || unassigned.order}» без назначенного согласующего — обратитесь к администратору маршрутов.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSending(true);
|
||||||
|
setError(undefined);
|
||||||
|
const approvers: IApproverToWrite[] = editor.rows.map((r) => ({
|
||||||
|
email: r.email,
|
||||||
|
position: r.position,
|
||||||
|
allowReassign: r.allowReassign,
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
await service.submitApprovalRoute(oidId, approvers);
|
||||||
|
await service.updateOIDStatus(oidId, "on_approval");
|
||||||
|
onSave({ oid: { ...oid, status: "on_approval" }, approvalSent: true });
|
||||||
|
onStepComplete(4);
|
||||||
|
await service.notifyActiveApprover(oidId).catch(() => undefined);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDecided(newStatus: string): Promise<void> {
|
||||||
|
if (!oid) return;
|
||||||
|
onSave({ oid: { ...oid, status: RU_STATUS_TO_OID_STATUS[newStatus] || status } });
|
||||||
|
setDecisionSeq((n) => n + 1);
|
||||||
|
if (oidId) {
|
||||||
|
await service.notifyActiveApprover(oidId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestart(): Promise<void> {
|
||||||
|
if (!oidId || !oid) return;
|
||||||
|
setSending(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
await service.updateOIDStatus(oidId, "formed");
|
||||||
|
onSave({ oid: { ...oid, status: "formed" }, approvalSent: false });
|
||||||
|
setDecisionSeq((n) => n + 1);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport(): Promise<void> {
|
||||||
|
if (!oid) return;
|
||||||
|
setIsExporting(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
await service.generateOidReport(oid.id);
|
||||||
|
const kpFiles = formData.uploadedKpFiles ?? [];
|
||||||
|
for (const f of kpFiles) {
|
||||||
|
await service.downloadKpFile(f.name);
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 300));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message ?? "Ошибка скачивания файлов");
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
{a.oid?.status === "approved" && <Alert variant="ok">СТ ОИД согласована всеми участниками.</Alert>}
|
{status === "approved" && <Alert variant="ok">СТ ОИД согласована всеми участниками.</Alert>}
|
||||||
{a.oid?.status === "rejected" && (
|
{status === "rejected" && <Alert variant="err">СТ ОИД отклонена. Требуется корректировка и повторная отправка.</Alert>}
|
||||||
<Alert variant="err">СТ ОИД отклонена. Требуется корректировка и повторная отправка.</Alert>
|
{status === "on_approval" && <Alert variant="info">СТ ОИД находится на согласовании.</Alert>}
|
||||||
)}
|
|
||||||
{a.oid?.status === "on_approval" && <Alert variant="info">СТ ОИД находится на согласовании.</Alert>}
|
|
||||||
|
|
||||||
{a.notifyWarning && <Alert variant="warn">{a.notifyWarning}</Alert>}
|
<ApprovalMatrix editor={editor} search={(q) => service.searchUsers(q)} />
|
||||||
|
|
||||||
<section className={styles.section}>
|
{error && <Alert variant="err">{error}</Alert>}
|
||||||
<h3 className={styles.sectionTitle}>Цепочка согласования</h3>
|
|
||||||
|
|
||||||
{a.chain.length === 0 && (
|
|
||||||
<p className={styles.emptyChain}>
|
|
||||||
Добавьте участников для согласования
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{a.chain.length > 0 && (
|
|
||||||
<ol className={styles.chainList}>
|
|
||||||
{a.chain.map((member, index) => (
|
|
||||||
<li key={member.id} className={styles.chainItem}>
|
|
||||||
<span className={styles.orderBadge}>{member.order}</span>
|
|
||||||
|
|
||||||
<div className={styles.memberInfo}>
|
|
||||||
<span className={styles.memberName}>{member.userName}</span>
|
|
||||||
{member.role && (
|
|
||||||
<span className={styles.memberRole}>{member.role}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{a.showStatuses && (
|
|
||||||
<span
|
|
||||||
className={[
|
|
||||||
styles.statusBadge,
|
|
||||||
getStatusClass(member.status),
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{STATUS_LABELS[member.status]}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{member.comment && (
|
|
||||||
<span className={styles.memberComment} title={member.comment}>
|
|
||||||
«{member.comment}»
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{member.actionDate && (
|
|
||||||
<span className={styles.actionDate}>
|
|
||||||
{formatDate(member.actionDate)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!a.isOnApproval &&
|
|
||||||
member.status === "pending" &&
|
|
||||||
member.role !== "Инициатор" && (
|
|
||||||
<div className={styles.memberActions}>
|
|
||||||
<IconButton
|
|
||||||
icon="arrow-up"
|
|
||||||
disabled={index === 0}
|
|
||||||
onClick={() => a.moveMember(member.id, "up")}
|
|
||||||
title="Переместить вверх"
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon="arrow-down"
|
|
||||||
disabled={index === a.chain.length - 1}
|
|
||||||
onClick={() => a.moveMember(member.id, "down")}
|
|
||||||
title="Переместить вниз"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{member.role !== "Инициатор" &&
|
|
||||||
member.status === "pending" &&
|
|
||||||
!(a.isOnApproval && a.currentUserMember?.id === member.id) && (
|
|
||||||
<Button variant="secondary" className={styles.replaceBtn} onClick={() => a.openReplace(member)}>
|
|
||||||
<Icon name="swap" size="sm" />
|
|
||||||
Заменить
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!a.isOnApproval && member.role !== "Инициатор" && (
|
|
||||||
<IconButton
|
|
||||||
icon="trash"
|
|
||||||
danger
|
|
||||||
className={styles.deleteBtn}
|
|
||||||
onClick={() => a.handleDeleteMember(member)}
|
|
||||||
title="Удалить согласующего"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{a.isOnApproval && a.currentUserMember?.id === member.id && (
|
|
||||||
<div className={styles.approvalActions}>
|
|
||||||
<Button variant="primary" onClick={() => a.openActionDialog(member, "approve")}>
|
|
||||||
<Icon name="check" size="sm" />
|
|
||||||
Согласовать
|
|
||||||
</Button>
|
|
||||||
<Button variant="danger" className={styles.rejectBtn} onClick={() => a.openActionDialog(member, "reject")}>
|
|
||||||
<Icon name="close" size="sm" />
|
|
||||||
Отклонить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!a.isOnApproval && (
|
|
||||||
<div>
|
|
||||||
{a.showAddRow ? (
|
|
||||||
<div className={styles.addMemberRow}>
|
|
||||||
<div className={styles.memberNameInput}>
|
|
||||||
<FormField label="Поиск участника">
|
|
||||||
<TextInput
|
|
||||||
value={a.addQuery}
|
|
||||||
onChange={a.handleAddQueryChange}
|
|
||||||
placeholder="Введите имя для поиска..."
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
{a.addSuggestions.length > 0 && !a.addSelected && (
|
|
||||||
<ul className={styles.suggestions}>
|
|
||||||
{a.addSuggestions.map((s) => (
|
|
||||||
<li
|
|
||||||
key={s.spId}
|
|
||||||
className={styles.suggestionItem}
|
|
||||||
onClick={() => a.selectAddSuggestion(s)}
|
|
||||||
>
|
|
||||||
<span className={styles.suggestionName}>{s.displayName}</span>
|
|
||||||
<span className={styles.suggestionEmail}>{s.email}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.memberRoleInput}>
|
|
||||||
<FormField label="Роль (необязательно)">
|
|
||||||
<TextInput
|
|
||||||
value={a.addRole}
|
|
||||||
onChange={a.setAddRole}
|
|
||||||
placeholder="Напр.: Руководитель"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
className={styles.addRowBtn}
|
|
||||||
disabled={!a.addSelected || a.isAddingMember}
|
|
||||||
onClick={a.handleAddMember}
|
|
||||||
>
|
|
||||||
{a.isAddingMember ? (
|
|
||||||
<Spinner size="small" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="plus" size="sm" />
|
|
||||||
Добавить
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" className={styles.addRowBtn} onClick={a.cancelAddRow}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button variant="secondary" onClick={() => a.setShowAddRow(true)}>
|
|
||||||
<Icon name="plus" size="sm" />
|
|
||||||
Добавить согласующего
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
<Button variant="secondary" onClick={onPrev} disabled={a.isOnApproval}>
|
<Button variant="secondary" onClick={onPrev} disabled={isSubmitted}>
|
||||||
<Icon name="arrow-left" size="sm" />
|
<Icon name="arrow-left" size="sm" />
|
||||||
Назад
|
Назад
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className={styles.footerRight}>
|
<div className={styles.footerRight}>
|
||||||
{a.submitError && <Alert variant="err">{a.submitError}</Alert>}
|
{!isSubmitted && (
|
||||||
|
<Button variant="primary" onClick={handleSubmit} disabled={sending || editor.rows.length === 0}>
|
||||||
{!a.isOnApproval && (
|
{sending ? (
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={a.handleSendForApproval}
|
|
||||||
disabled={a.chain.length === 0 || a.isSubmitting || !a.oid}
|
|
||||||
>
|
|
||||||
{a.isSubmitting ? (
|
|
||||||
<Spinner size="small" />
|
<Spinner size="small" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -250,10 +165,10 @@ export const Step4Approval: React.FC<Props> = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{a.isOnApproval && a.isInitiator && a.oid?.status !== "approved" && (
|
{isSubmitted && isInitiator && status !== "approved" && (
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={a.handleExport} disabled={a.isExporting}>
|
<Button variant="secondary" onClick={handleExport} disabled={isExporting}>
|
||||||
{a.isExporting ? (
|
{isExporting ? (
|
||||||
<Spinner size="small" />
|
<Spinner size="small" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -262,7 +177,7 @@ export const Step4Approval: React.FC<Props> = (props) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={a.handleRestart} disabled={a.isSubmitting}>
|
<Button variant="secondary" onClick={handleRestart} disabled={sending}>
|
||||||
<Icon name="refresh" size="sm" />
|
<Icon name="refresh" size="sm" />
|
||||||
Перезапустить согласование
|
Перезапустить согласование
|
||||||
</Button>
|
</Button>
|
||||||
@@ -271,104 +186,14 @@ export const Step4Approval: React.FC<Props> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{a.replaceDialog && (
|
{oidId && approverService && (
|
||||||
<Dialog
|
<DecisionPanel
|
||||||
open
|
service={approverService}
|
||||||
title="Заменить согласующего"
|
requestId={oidId}
|
||||||
subText="Найдите нового участника для замены."
|
onDecided={handleDecided}
|
||||||
onDismiss={a.closeReplace}
|
search={(q) => service.searchUsers(q)}
|
||||||
footer={
|
onDelegated={() => setDecisionSeq((n) => n + 1)}
|
||||||
<>
|
/>
|
||||||
<Button variant="secondary" onClick={a.closeReplace}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={a.handleReplaceConfirm}
|
|
||||||
disabled={!a.replaceDialog.selected || a.isReplacing}
|
|
||||||
>
|
|
||||||
{a.isReplacing ? <Spinner size="small" /> : "Заменить"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={styles.memberNameInput}>
|
|
||||||
<FormField label="Поиск нового участника">
|
|
||||||
<TextInput
|
|
||||||
value={a.replaceDialog.query}
|
|
||||||
onChange={a.handleReplaceQueryChange}
|
|
||||||
placeholder="Введите имя для поиска..."
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
{a.replaceDialog.suggestions.length > 0 && !a.replaceDialog.selected && (
|
|
||||||
<ul className={`${styles.suggestions} ${styles.suggestionsInline}`}>
|
|
||||||
{a.replaceDialog.suggestions.map((s) => (
|
|
||||||
<li
|
|
||||||
key={s.spId}
|
|
||||||
className={styles.suggestionItem}
|
|
||||||
onClick={() => a.selectReplaceSuggestion(s)}
|
|
||||||
>
|
|
||||||
<span className={styles.suggestionName}>{s.displayName}</span>
|
|
||||||
<span className={styles.suggestionEmail}>{s.email}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{a.actionDialog && (
|
|
||||||
<Dialog
|
|
||||||
open
|
|
||||||
title={a.actionDialog.action === "approve" ? "Согласование" : "Отклонение"}
|
|
||||||
subText={
|
|
||||||
a.actionDialog.action === "approve"
|
|
||||||
? "Вы подтверждаете согласование СТ ОИД? Комментарий необязателен."
|
|
||||||
: "Укажите причину отклонения (обязательно)."
|
|
||||||
}
|
|
||||||
onDismiss={a.closeActionDialog}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={a.closeActionDialog} disabled={a.isSubmitting}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={a.actionDialog.action === "approve" ? "primary" : "danger"}
|
|
||||||
onClick={a.handleActionConfirm}
|
|
||||||
disabled={
|
|
||||||
a.isSubmitting ||
|
|
||||||
(a.actionDialog.action === "reject" && !a.actionDialog.comment.trim())
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{a.isSubmitting ? (
|
|
||||||
<Spinner size="small" />
|
|
||||||
) : a.actionDialog.action === "approve" ? (
|
|
||||||
"Согласовать"
|
|
||||||
) : (
|
|
||||||
"Отклонить"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TextArea
|
|
||||||
value={a.actionDialog.comment}
|
|
||||||
onChange={a.setActionComment}
|
|
||||||
rows={3}
|
|
||||||
ariaLabel="Комментарий"
|
|
||||||
placeholder={
|
|
||||||
a.actionDialog.action === "reject"
|
|
||||||
? "Укажите причину отклонения..."
|
|
||||||
: "Комментарий (необязательно)..."
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{a.submitError && (
|
|
||||||
<div className={styles.dialogError}>
|
|
||||||
<Alert variant="err">{a.submitError}</Alert>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,488 +0,0 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
|
||||||
import type { ApprovalMember, ApprovalMemberStatus, OIDFormData, FormStep } from "../types/domain";
|
|
||||||
import { OidService } from "../../services/OidService";
|
|
||||||
|
|
||||||
export type ApprovalAction = "approve" | "reject";
|
|
||||||
|
|
||||||
export type UserSuggestion = { spId: number; displayName: string; email: string };
|
|
||||||
|
|
||||||
export interface ActionDialogState {
|
|
||||||
memberId: string;
|
|
||||||
memberUserId: number;
|
|
||||||
memberName: string;
|
|
||||||
action: ApprovalAction;
|
|
||||||
comment: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReplaceDialogState {
|
|
||||||
memberId: string;
|
|
||||||
spApprovalId?: number;
|
|
||||||
query: string;
|
|
||||||
selected: { spId: number; displayName: string } | null;
|
|
||||||
suggestions: UserSuggestion[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseStep4ApprovalArgs {
|
|
||||||
service: OidService;
|
|
||||||
formData: OIDFormData;
|
|
||||||
userDisplayName: string;
|
|
||||||
onSave: (partial: Partial<OIDFormData>) => void;
|
|
||||||
onStepComplete: (step: FormStep) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Логика Шага 4 «Согласование» (цепочка): манипуляции участниками (порядок/добавление/удаление/замена),
|
|
||||||
// голосование (approve/reject + нотификации), отправка на согласование, выгрузка в INCORE, перезапуск.
|
|
||||||
// Перенесено 1:1 из Step4Approval.tsx (§стайл-гайд: логика вне вёрстки).
|
|
||||||
export function useStep4Approval(args: UseStep4ApprovalArgs) {
|
|
||||||
const { service, formData, userDisplayName, onSave, onStepComplete } = args;
|
|
||||||
const { oid, approvalChain } = formData;
|
|
||||||
|
|
||||||
const [chain, setChain] = useState<ApprovalMember[]>(approvalChain);
|
|
||||||
const [actionDialog, setActionDialog] = useState<ActionDialogState | null>(null);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
||||||
const [notifyWarning, setNotifyWarning] = useState<string | null>(null);
|
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
|
||||||
|
|
||||||
// ─── Add member ──────────────────────────────────────────────────────────
|
|
||||||
const [showAddRow, setShowAddRow] = useState(false);
|
|
||||||
const [addQuery, setAddQuery] = useState("");
|
|
||||||
const [addRole, setAddRole] = useState("");
|
|
||||||
const [addSuggestions, setAddSuggestions] = useState<UserSuggestion[]>([]);
|
|
||||||
const [addSelected, setAddSelected] = useState<{ spId: number; displayName: string } | null>(null);
|
|
||||||
const [isAddingMember, setIsAddingMember] = useState(false);
|
|
||||||
const addTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
// ─── Replace member ───────────────────────────────────────────────────────
|
|
||||||
const [replaceDialog, setReplaceDialog] = useState<ReplaceDialogState | null>(null);
|
|
||||||
const [isReplacing, setIsReplacing] = useState(false);
|
|
||||||
const replaceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const moveMember = useCallback((id: string, direction: "up" | "down"): void => {
|
|
||||||
setChain((prev) => {
|
|
||||||
const idx = prev.findIndex((m) => m.id === id);
|
|
||||||
if (idx === -1) return prev;
|
|
||||||
const next = [...prev];
|
|
||||||
const swapIdx = direction === "up" ? idx - 1 : idx + 1;
|
|
||||||
if (swapIdx < 0 || swapIdx >= next.length) return prev;
|
|
||||||
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
|
|
||||||
return next.map((m, i) => ({ ...m, order: i + 1 }));
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const openActionDialog = useCallback(
|
|
||||||
(member: ApprovalMember, action: ApprovalAction): void => {
|
|
||||||
setActionDialog({
|
|
||||||
memberId: member.id,
|
|
||||||
memberUserId: member.userId,
|
|
||||||
memberName: member.userName,
|
|
||||||
action,
|
|
||||||
comment: "",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const closeActionDialog = useCallback((): void => setActionDialog(null), []);
|
|
||||||
const setActionComment = useCallback((val: string): void => {
|
|
||||||
setActionDialog((prev) => (prev ? { ...prev, comment: val } : null));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAddQueryChange = (val: string): void => {
|
|
||||||
const q = val;
|
|
||||||
setAddQuery(q);
|
|
||||||
setAddSelected(null);
|
|
||||||
setAddSuggestions([]);
|
|
||||||
if (addTimerRef.current) clearTimeout(addTimerRef.current);
|
|
||||||
if (q.length < 2) return;
|
|
||||||
addTimerRef.current = setTimeout(async () => {
|
|
||||||
const results = await service.searchUsers(q);
|
|
||||||
setAddSuggestions(results);
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectAddSuggestion = (s: UserSuggestion): void => {
|
|
||||||
setAddSelected({ spId: s.spId, displayName: s.displayName });
|
|
||||||
setAddQuery(s.displayName);
|
|
||||||
setAddSuggestions([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelAddRow = (): void => {
|
|
||||||
setShowAddRow(false);
|
|
||||||
setAddQuery("");
|
|
||||||
setAddRole("");
|
|
||||||
setAddSelected(null);
|
|
||||||
setAddSuggestions([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddMember = async (): Promise<void> => {
|
|
||||||
if (!addSelected || !formData.oid) return;
|
|
||||||
setIsAddingMember(true);
|
|
||||||
setSubmitError(null);
|
|
||||||
try {
|
|
||||||
const newOrder = chain.length + 1;
|
|
||||||
const newMember = await service.addApprovalMemberDraft(
|
|
||||||
formData.oid.id, newOrder, addSelected.spId, addSelected.displayName,
|
|
||||||
);
|
|
||||||
const newChain = [...chain, newMember];
|
|
||||||
setChain(newChain);
|
|
||||||
onSave({ approvalChain: newChain });
|
|
||||||
cancelAddRow();
|
|
||||||
} catch (err) {
|
|
||||||
setSubmitError((err as Error).message ?? "Ошибка добавления");
|
|
||||||
} finally {
|
|
||||||
setIsAddingMember(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteMember = async (member: ApprovalMember): Promise<void> => {
|
|
||||||
setSubmitError(null);
|
|
||||||
try {
|
|
||||||
if (member.spApprovalId) {
|
|
||||||
await service.deleteApprovalMembers([member.spApprovalId]);
|
|
||||||
}
|
|
||||||
const updated = chain
|
|
||||||
.filter((m) => m.id !== member.id)
|
|
||||||
.map((m, i) => ({ ...m, order: i + 1 }));
|
|
||||||
setChain(updated);
|
|
||||||
onSave({ approvalChain: updated });
|
|
||||||
} catch (err) {
|
|
||||||
setSubmitError((err as Error).message ?? "Ошибка удаления");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openReplace = (member: ApprovalMember): void => {
|
|
||||||
setReplaceDialog({
|
|
||||||
memberId: member.id,
|
|
||||||
spApprovalId: member.spApprovalId,
|
|
||||||
query: "",
|
|
||||||
selected: null,
|
|
||||||
suggestions: [],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeReplace = (): void => setReplaceDialog(null);
|
|
||||||
|
|
||||||
const handleReplaceQueryChange = (val: string): void => {
|
|
||||||
const q = val;
|
|
||||||
setReplaceDialog((prev) => prev ? { ...prev, query: q, selected: null, suggestions: [] } : null);
|
|
||||||
if (replaceTimerRef.current) clearTimeout(replaceTimerRef.current);
|
|
||||||
if (q.length < 2) return;
|
|
||||||
replaceTimerRef.current = setTimeout(async () => {
|
|
||||||
const results = await service.searchUsers(q);
|
|
||||||
setReplaceDialog((prev) => prev ? { ...prev, suggestions: results } : null);
|
|
||||||
}, 300);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectReplaceSuggestion = (s: UserSuggestion): void =>
|
|
||||||
setReplaceDialog((prev) =>
|
|
||||||
prev ? { ...prev, selected: { spId: s.spId, displayName: s.displayName }, query: s.displayName, suggestions: [] } : null
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleReplaceConfirm = async (): Promise<void> => {
|
|
||||||
if (!replaceDialog?.selected) return;
|
|
||||||
setIsReplacing(true);
|
|
||||||
setSubmitError(null);
|
|
||||||
try {
|
|
||||||
const { memberId, spApprovalId, selected } = replaceDialog;
|
|
||||||
if (spApprovalId) {
|
|
||||||
await service.replaceApprovalMember(spApprovalId, selected.spId, selected.displayName);
|
|
||||||
}
|
|
||||||
const updated = chain.map((m) =>
|
|
||||||
m.id === memberId ? { ...m, userId: selected.spId, userName: selected.displayName } : m,
|
|
||||||
);
|
|
||||||
setChain(updated);
|
|
||||||
onSave({ approvalChain: updated });
|
|
||||||
setReplaceDialog(null);
|
|
||||||
} catch (err) {
|
|
||||||
setSubmitError((err as Error).message ?? "Ошибка замены");
|
|
||||||
} finally {
|
|
||||||
setIsReplacing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleActionConfirm = async (): Promise<void> => {
|
|
||||||
if (!actionDialog) return;
|
|
||||||
const { memberId, action, comment } = actionDialog;
|
|
||||||
if (action === "reject" && !comment.trim()) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setSubmitError(null);
|
|
||||||
|
|
||||||
const apiStatus: "approved" | "rejected" =
|
|
||||||
action === "approve" ? "approved" : "rejected";
|
|
||||||
const currentMember = chain.find((m) => m.id === memberId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!currentMember?.spApprovalId)
|
|
||||||
throw new Error("Не найден ID записи согласования");
|
|
||||||
|
|
||||||
await service.submitApprovalStep(
|
|
||||||
currentMember.spApprovalId,
|
|
||||||
apiStatus,
|
|
||||||
comment.trim()
|
|
||||||
);
|
|
||||||
|
|
||||||
const notify = async (spId: number): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await service.triggerApprovalNotification(spId);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[Step4] notify failed for spId", spId, e);
|
|
||||||
setNotifyWarning(
|
|
||||||
"Голос записан, но уведомление в Teams не удалось отправить. Проверьте Power Automate."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (action === "approve") {
|
|
||||||
const updatedChain = chain.map((m) =>
|
|
||||||
m.id === memberId
|
|
||||||
? {
|
|
||||||
...m,
|
|
||||||
status: "approved" as ApprovalMemberStatus,
|
|
||||||
comment: comment.trim(),
|
|
||||||
actionDate: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
: m
|
|
||||||
);
|
|
||||||
setChain(updatedChain);
|
|
||||||
onSave({ approvalChain: updatedChain });
|
|
||||||
|
|
||||||
const allDone = updatedChain.every(
|
|
||||||
(m) =>
|
|
||||||
m.status === "approved" ||
|
|
||||||
m.status === "skipped" ||
|
|
||||||
m.status === "recommends"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (allDone) {
|
|
||||||
// Финал: сначала статус «Согласовано», потом оповещение — иначе поток прочитает
|
|
||||||
// «На согласовании» и не отправит «Согласована» (а раньше на финале вообще ничего
|
|
||||||
// не слал, т.к. nextPending уже нет). Порядок как в ветке отклонения.
|
|
||||||
await service.updateOIDStatus(oid!.id, "approved");
|
|
||||||
onSave({ oid: { ...oid!, status: "approved" }, approvalChain: updatedChain });
|
|
||||||
if (currentMember.spApprovalId) {
|
|
||||||
await notify(currentMember.spApprovalId);
|
|
||||||
}
|
|
||||||
onStepComplete(4);
|
|
||||||
} else {
|
|
||||||
const nextPending = updatedChain.find((m) => m.status === "pending");
|
|
||||||
if (nextPending) {
|
|
||||||
if (currentMember.spApprovalId) {
|
|
||||||
await notify(currentMember.spApprovalId);
|
|
||||||
}
|
|
||||||
if (nextPending.spApprovalId) {
|
|
||||||
await notify(nextPending.spApprovalId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const othersToDelete = chain.filter(
|
|
||||||
(m) => m.id !== memberId && m.status === "pending" && m.spApprovalId
|
|
||||||
);
|
|
||||||
if (othersToDelete.length > 0) {
|
|
||||||
await service.deleteApprovalMembers(
|
|
||||||
othersToDelete.map((m) => m.spApprovalId!)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalChain = chain
|
|
||||||
.filter((m) => m.status !== "pending" || m.id === memberId)
|
|
||||||
.map((m) =>
|
|
||||||
m.id === memberId
|
|
||||||
? {
|
|
||||||
...m,
|
|
||||||
status: "rejected" as ApprovalMemberStatus,
|
|
||||||
comment: comment.trim(),
|
|
||||||
actionDate: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
: m
|
|
||||||
);
|
|
||||||
setChain(finalChain);
|
|
||||||
await service.updateOIDStatus(oid!.id, "rejected");
|
|
||||||
onSave({
|
|
||||||
approvalChain: finalChain,
|
|
||||||
oid: { ...oid!, status: "rejected" },
|
|
||||||
});
|
|
||||||
// Уведомляем по spId ОТКЛОНИВШЕГО (currentMember) — он гарантированно есть (проверен в
|
|
||||||
// начале обработчика). У инициатора spApprovalId мог отсутствовать после манипуляций с
|
|
||||||
// цепочкой, из-за чего reject вообще не дёргал поток. Ветку письма определяет статус СТ
|
|
||||||
// («Отклонено»), получателя — автор СТ, поэтому чей элемент согласования передан — не важно.
|
|
||||||
await notify(currentMember.spApprovalId);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeActionDialog();
|
|
||||||
} catch (err) {
|
|
||||||
setSubmitError((err as Error).message ?? "Ошибка выполнения действия");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendForApproval = async (): Promise<void> => {
|
|
||||||
if (!oid || chain.length === 0) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setSubmitError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await service.activateApprovalChain(chain);
|
|
||||||
|
|
||||||
const winSupplier = formData.suppliers.find(
|
|
||||||
(s) => s.id === formData.recommendedSupplierId
|
|
||||||
);
|
|
||||||
service
|
|
||||||
.updateOIDStatus(oid.id, "on_approval", winSupplier?.spId)
|
|
||||||
.catch(() => undefined);
|
|
||||||
|
|
||||||
const activatedChain = chain.map((m) => ({
|
|
||||||
...m,
|
|
||||||
status: (m.role === "Инициатор"
|
|
||||||
? "recommends"
|
|
||||||
: "pending") as ApprovalMemberStatus,
|
|
||||||
}));
|
|
||||||
setChain(activatedChain);
|
|
||||||
onSave({
|
|
||||||
approvalChain: activatedChain,
|
|
||||||
oid: { ...oid, status: "on_approval" },
|
|
||||||
approvalSent: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstApprover = chain.find(
|
|
||||||
(m) => m.role !== "Инициатор" && m.spApprovalId
|
|
||||||
);
|
|
||||||
if (firstApprover?.spApprovalId) {
|
|
||||||
try {
|
|
||||||
await service.triggerApprovalNotification(firstApprover.spApprovalId);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[Step4] notify (send) failed", e);
|
|
||||||
setNotifyWarning(
|
|
||||||
"СТ отправлена на согласование, но уведомление в Teams не удалось отправить. Проверьте Power Automate."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onStepComplete(4);
|
|
||||||
} catch (err) {
|
|
||||||
setSubmitError(
|
|
||||||
(err as Error).message ?? "Ошибка отправки на согласование"
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = async (): Promise<void> => {
|
|
||||||
if (!oid) return;
|
|
||||||
setIsExporting(true);
|
|
||||||
setSubmitError(null);
|
|
||||||
try {
|
|
||||||
await service.generateOidReport(oid.id);
|
|
||||||
|
|
||||||
const kpFiles = formData.uploadedKpFiles ?? [];
|
|
||||||
for (const f of kpFiles) {
|
|
||||||
await service.downloadKpFile(f.name);
|
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 300));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setSubmitError((err as Error).message ?? "Ошибка скачивания файлов");
|
|
||||||
} finally {
|
|
||||||
setIsExporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRestart = async (): Promise<void> => {
|
|
||||||
if (!oid) return;
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setSubmitError(null);
|
|
||||||
try {
|
|
||||||
await service.resetApprovalChainToDraft(chain);
|
|
||||||
await service.updateOIDStatus(oid.id, "formed");
|
|
||||||
const resetChain = chain.map((m) => ({
|
|
||||||
...m,
|
|
||||||
status: "pending" as ApprovalMemberStatus,
|
|
||||||
comment: undefined,
|
|
||||||
actionDate: undefined,
|
|
||||||
}));
|
|
||||||
setChain(resetChain);
|
|
||||||
onSave({
|
|
||||||
approvalChain: resetChain,
|
|
||||||
oid: { ...oid, status: "formed" },
|
|
||||||
approvalSent: false,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
setSubmitError(
|
|
||||||
(err as Error).message ?? "Ошибка перезапуска согласования"
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isOnApproval = oid?.status === "on_approval" || oid?.status === "approved";
|
|
||||||
const showStatuses =
|
|
||||||
formData.approvalSent ||
|
|
||||||
oid?.status === "on_approval" ||
|
|
||||||
oid?.status === "approved" ||
|
|
||||||
oid?.status === "rejected";
|
|
||||||
const isInitiator = chain.some(
|
|
||||||
(m) => m.userName === userDisplayName && m.role === "Инициатор"
|
|
||||||
);
|
|
||||||
const currentUserMember = chain.find(
|
|
||||||
(m) =>
|
|
||||||
m.userName === userDisplayName &&
|
|
||||||
m.status === "pending" &&
|
|
||||||
m.role !== "Инициатор" &&
|
|
||||||
chain
|
|
||||||
.filter((prev) => prev.order < m.order)
|
|
||||||
.every((prev) => prev.status !== "pending")
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
oid,
|
|
||||||
chain,
|
|
||||||
submitError,
|
|
||||||
notifyWarning,
|
|
||||||
dismissNotifyWarning: () => setNotifyWarning(null),
|
|
||||||
isSubmitting,
|
|
||||||
isExporting,
|
|
||||||
isOnApproval,
|
|
||||||
showStatuses,
|
|
||||||
isInitiator,
|
|
||||||
currentUserMember,
|
|
||||||
// chain ops
|
|
||||||
moveMember,
|
|
||||||
handleDeleteMember,
|
|
||||||
openActionDialog,
|
|
||||||
openReplace,
|
|
||||||
// add member
|
|
||||||
showAddRow,
|
|
||||||
setShowAddRow,
|
|
||||||
addQuery,
|
|
||||||
addRole,
|
|
||||||
setAddRole,
|
|
||||||
addSuggestions,
|
|
||||||
addSelected,
|
|
||||||
isAddingMember,
|
|
||||||
handleAddQueryChange,
|
|
||||||
selectAddSuggestion,
|
|
||||||
cancelAddRow,
|
|
||||||
handleAddMember,
|
|
||||||
// action dialog
|
|
||||||
actionDialog,
|
|
||||||
setActionComment,
|
|
||||||
closeActionDialog,
|
|
||||||
handleActionConfirm,
|
|
||||||
// replace dialog
|
|
||||||
replaceDialog,
|
|
||||||
isReplacing,
|
|
||||||
handleReplaceQueryChange,
|
|
||||||
selectReplaceSuggestion,
|
|
||||||
closeReplace,
|
|
||||||
handleReplaceConfirm,
|
|
||||||
// footer
|
|
||||||
handleSendForApproval,
|
|
||||||
handleExport,
|
|
||||||
handleRestart,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ const INITIAL_FORM_DATA: OIDFormData = {
|
|||||||
positionGroups: [],
|
positionGroups: [],
|
||||||
suppliers: [],
|
suppliers: [],
|
||||||
offers: [],
|
offers: [],
|
||||||
approvalChain: [],
|
approvalMatrix: [],
|
||||||
initiatorDepartment: "",
|
initiatorDepartment: "",
|
||||||
initiatorCompany: "",
|
initiatorCompany: "",
|
||||||
uploadedKpFiles: [],
|
uploadedKpFiles: [],
|
||||||
@@ -126,7 +126,7 @@ export function useOidForm(context: WebPartContext): UseOidFormResult {
|
|||||||
setFormData((prev) => ({ ...prev, ...cached.formData }));
|
setFormData((prev) => ({ ...prev, ...cached.formData }));
|
||||||
const fd = cached.formData;
|
const fd = cached.formData;
|
||||||
const derivedHighest: FormStep =
|
const derivedHighest: FormStep =
|
||||||
fd.approvalChain?.length ? 4 :
|
fd.approvalMatrix?.length ? 4 :
|
||||||
fd.suppliers?.length ? 3 :
|
fd.suppliers?.length ? 3 :
|
||||||
fd.positionGroups?.length ? 2 : 1;
|
fd.positionGroups?.length ? 2 : 1;
|
||||||
setCurrentStep(cached.currentStep ?? derivedHighest);
|
setCurrentStep(cached.currentStep ?? derivedHighest);
|
||||||
@@ -171,10 +171,10 @@ export function useOidForm(context: WebPartContext): UseOidFormResult {
|
|||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
service.loadOIDPositions(oidId),
|
service.loadOIDPositions(oidId),
|
||||||
service.loadApprovalChain(oidId),
|
service.getApprovalMatrixForRequest(oidId),
|
||||||
service.loadOID(oidId),
|
service.loadOID(oidId),
|
||||||
])
|
])
|
||||||
.then(async ([positions, approvalChain, oidInfo]) => {
|
.then(async ([positions, approvalMatrix, oidInfo]) => {
|
||||||
const positionGroups = groupPositions(positions);
|
const positionGroups = groupPositions(positions);
|
||||||
const oid = {
|
const oid = {
|
||||||
id: oidId,
|
id: oidId,
|
||||||
@@ -182,6 +182,7 @@ export function useOidForm(context: WebPartContext): UseOidFormResult {
|
|||||||
project: oidInfo?.project ?? "",
|
project: oidInfo?.project ?? "",
|
||||||
workName: oidInfo?.workName ?? "",
|
workName: oidInfo?.workName ?? "",
|
||||||
status: oidInfo?.status,
|
status: oidInfo?.status,
|
||||||
|
authorEmail: oidInfo?.authorEmail,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isNotificationLink) {
|
if (isNotificationLink) {
|
||||||
@@ -200,7 +201,7 @@ export function useOidForm(context: WebPartContext): UseOidFormResult {
|
|||||||
oid,
|
oid,
|
||||||
workName: oid.workName,
|
workName: oid.workName,
|
||||||
positionGroups,
|
positionGroups,
|
||||||
approvalChain,
|
approvalMatrix,
|
||||||
offers,
|
offers,
|
||||||
suppliers,
|
suppliers,
|
||||||
approvalSent: true,
|
approvalSent: true,
|
||||||
@@ -214,9 +215,9 @@ export function useOidForm(context: WebPartContext): UseOidFormResult {
|
|||||||
oid,
|
oid,
|
||||||
workName: oid.workName,
|
workName: oid.workName,
|
||||||
positionGroups,
|
positionGroups,
|
||||||
approvalChain,
|
approvalMatrix,
|
||||||
}));
|
}));
|
||||||
if (approvalChain.length > 0) {
|
if (approvalMatrix.length > 0) {
|
||||||
setHighestStep(4);
|
setHighestStep(4);
|
||||||
setCompletedSteps([1, 2, 3]);
|
setCompletedSteps([1, 2, 3]);
|
||||||
} else if (positions.length > 0) {
|
} else if (positions.length > 0) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { IMatrixRow } from '../../../../shared/approval';
|
||||||
|
|
||||||
export interface OID {
|
export interface OID {
|
||||||
id: number;
|
id: number;
|
||||||
number: number;
|
number: number;
|
||||||
@@ -6,6 +8,10 @@ export interface OID {
|
|||||||
status?: OIDStatus;
|
status?: OIDStatus;
|
||||||
winSupplierSpId?: number;
|
winSupplierSpId?: number;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
// Email автора записи (SP Author/Created By) — определяет инициатора (кнопки экспорта/перезапуска
|
||||||
|
// в Step4Approval): в новом механизме согласования (approval-route API) инициатор в саму матрицу
|
||||||
|
// согласования не входит, в отличие от прежней цепочки.
|
||||||
|
authorEmail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OIDStatus =
|
export type OIDStatus =
|
||||||
@@ -80,26 +86,6 @@ export interface SupplierCriteria {
|
|||||||
comment?: string;
|
comment?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApprovalMember {
|
|
||||||
id: string;
|
|
||||||
order: number;
|
|
||||||
userId: number;
|
|
||||||
userName: string;
|
|
||||||
role?: string;
|
|
||||||
status: ApprovalMemberStatus;
|
|
||||||
comment?: string;
|
|
||||||
actionDate?: string;
|
|
||||||
spApprovalId?: number;
|
|
||||||
canAddApprover?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ApprovalMemberStatus =
|
|
||||||
| "pending"
|
|
||||||
| "recommends"
|
|
||||||
| "approved"
|
|
||||||
| "rejected"
|
|
||||||
| "skipped";
|
|
||||||
|
|
||||||
export interface SyntexFields {
|
export interface SyntexFields {
|
||||||
supplierName?: string;
|
supplierName?: string;
|
||||||
fileContent?: string;
|
fileContent?: string;
|
||||||
@@ -140,7 +126,9 @@ export interface OIDFormData {
|
|||||||
positionGroups: PositionGroup[];
|
positionGroups: PositionGroup[];
|
||||||
suppliers: Supplier[];
|
suppliers: Supplier[];
|
||||||
offers: SupplierOffer[];
|
offers: SupplierOffer[];
|
||||||
approvalChain: ApprovalMember[];
|
// Маршрут согласования — только для отображения; решения читают/пишут его напрямую через
|
||||||
|
// OidService (shared/approval), не через formData.
|
||||||
|
approvalMatrix: IMatrixRow[];
|
||||||
initiatorDepartment: string;
|
initiatorDepartment: string;
|
||||||
initiatorCompany: string;
|
initiatorCompany: string;
|
||||||
uploadedKpFiles: Array<{ name: string; spId: number; supplierName: string }>;
|
uploadedKpFiles: Array<{ name: string; spId: number; supplierName: string }>;
|
||||||
|
|||||||
@@ -11,11 +11,20 @@ import type {
|
|||||||
Supplier,
|
Supplier,
|
||||||
SupplierOffer,
|
SupplierOffer,
|
||||||
SupplierCriteria,
|
SupplierCriteria,
|
||||||
ApprovalMember,
|
|
||||||
ApprovalMemberStatus,
|
|
||||||
SyntexFields,
|
SyntexFields,
|
||||||
FlowResult,
|
FlowResult,
|
||||||
} from "../components/types/domain";
|
} from "../components/types/domain";
|
||||||
|
import {
|
||||||
|
ApprovalListService,
|
||||||
|
ApproverService,
|
||||||
|
IApprovalListFields,
|
||||||
|
IApproverToWrite,
|
||||||
|
IMatrixRow,
|
||||||
|
REQUEST_STATUS,
|
||||||
|
getPlannedMatrix,
|
||||||
|
getRequestTypeIdByPageUrl,
|
||||||
|
writeRoute,
|
||||||
|
} from "../../../shared/approval";
|
||||||
|
|
||||||
const SITE_URL = "https://tnsukz.sharepoint.com/sites/sed";
|
const SITE_URL = "https://tnsukz.sharepoint.com/sites/sed";
|
||||||
|
|
||||||
@@ -41,6 +50,29 @@ const LISTS = {
|
|||||||
KP_LIBRARY_TITLE: "Коммерческие предложения ОИД",
|
KP_LIBRARY_TITLE: "Коммерческие предложения ОИД",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// pageUrl типа заявки «ОИД» в админке approval-route (см. план миграции согласования). Поля списка
|
||||||
|
// «Согласование СТ ОИД» переиспользуются как есть: ticket=CT, allowReassign=canAddAproover (существующая
|
||||||
|
// колонка, семантика совпадает с AllowReassignToOtherApprovers), delegatedBy — новая колонка.
|
||||||
|
const OID_REQUEST_TYPE_PAGE_URL = "oid";
|
||||||
|
|
||||||
|
const APPROVAL_FIELDS: IApprovalListFields = {
|
||||||
|
ticket: "CT",
|
||||||
|
approver: "aproover",
|
||||||
|
status: "status",
|
||||||
|
date: "aproovDate",
|
||||||
|
comment: "comment",
|
||||||
|
order: "order0",
|
||||||
|
allowReassign: "canAddAproover",
|
||||||
|
delegatedBy: "delegatedBy",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Статус заявки (ApproverService.decide) → Choice «Статус» шапки СТ ОИД.
|
||||||
|
const APPROVAL_STATUS_TO_OID_STATUS: Record<"onApproval" | "approved" | "rejected", OIDStatus> = {
|
||||||
|
onApproval: "on_approval",
|
||||||
|
approved: "approved",
|
||||||
|
rejected: "rejected",
|
||||||
|
};
|
||||||
|
|
||||||
const EXT_API_RESOURCE = "9232d9e2-9807-4753-808f-07250e5474b0";
|
const EXT_API_RESOURCE = "9232d9e2-9807-4753-808f-07250e5474b0";
|
||||||
const EXTERNAL_API = "https://api.tnsu.kz";
|
const EXTERNAL_API = "https://api.tnsu.kz";
|
||||||
|
|
||||||
@@ -153,12 +185,8 @@ export class OidService {
|
|||||||
return tokenProvider.getToken(EXT_API_RESOURCE);
|
return tokenProvider.getToken(EXT_API_RESOURCE);
|
||||||
}
|
}
|
||||||
|
|
||||||
get currentUserSpId(): number {
|
get currentUserEmail(): string {
|
||||||
return Number(
|
return this.context.pageContext.user.email || "";
|
||||||
(this.context.pageContext.legacyPageContext as Record<string, unknown>)[
|
|
||||||
"userId"
|
|
||||||
] ?? 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private get sp(): SPHttpClient {
|
private get sp(): SPHttpClient {
|
||||||
@@ -264,16 +292,17 @@ export class OidService {
|
|||||||
// survive in a fresh tab / for approvers, where sessionStorage is empty.
|
// survive in a fresh tab / for approvers, where sessionStorage is empty.
|
||||||
async loadOID(
|
async loadOID(
|
||||||
oidId: number,
|
oidId: number,
|
||||||
): Promise<{ number: number; workName: string; project: string; status: OIDStatus } | null> {
|
): Promise<{ number: number; workName: string; project: string; status: OIDStatus; authorEmail?: string } | null> {
|
||||||
const url = `${listUrl(LISTS.OID)}/items(${oidId})?$select=number,workName,project,status`;
|
const url = `${listUrl(LISTS.OID)}/items(${oidId})?$select=number,workName,project,status,Author/EMail&$expand=Author`;
|
||||||
const res = await this.sp.get(url, SPHttpClient.configurations.v1);
|
const res = await this.sp.get(url, SPHttpClient.configurations.v1);
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const data = (await res.json()) as Record<string, unknown>;
|
const data = (await res.json()) as Record<string, unknown> & { Author?: { EMail?: string } };
|
||||||
return {
|
return {
|
||||||
number: Number(data.number) || oidId,
|
number: Number(data.number) || oidId,
|
||||||
workName: (data.workName as string) || "",
|
workName: (data.workName as string) || "",
|
||||||
project: (data.project as string) || "",
|
project: (data.project as string) || "",
|
||||||
status: OidService.reverseOidStatus((data.status as string) || ""),
|
status: OidService.reverseOidStatus((data.status as string) || ""),
|
||||||
|
authorEmail: data.Author?.EMail || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -965,289 +994,55 @@ export class OidService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Approval chain (aproover = User field) ──────────────────────────────────
|
// ─── Согласование СТ ОИД (через shared/approval — approval-route API,
|
||||||
|
// см. план миграции согласования на approval-route API) ───────────────────
|
||||||
|
|
||||||
async saveApprovalChain(
|
private _approval: ApprovalListService | undefined;
|
||||||
chain: ApprovalMember[],
|
|
||||||
oidId: number,
|
private get approval(): ApprovalListService {
|
||||||
): Promise<ApprovalMember[]> {
|
if (!this._approval) {
|
||||||
const saved: ApprovalMember[] = [];
|
this._approval = new ApprovalListService(this.context, LISTS.APPROVAL, SITE_URL, APPROVAL_FIELDS);
|
||||||
for (const member of chain) {
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
Title: String(member.order),
|
|
||||||
CTId: oidId,
|
|
||||||
aprooverId: member.userId,
|
|
||||||
order0: member.order,
|
|
||||||
canAddAproover: member.canAddApprover ?? false,
|
|
||||||
status: "Черновик",
|
|
||||||
comment: member.comment || "",
|
|
||||||
};
|
|
||||||
const res = await this.sp.post(
|
|
||||||
`${listUrl(LISTS.APPROVAL)}/items`,
|
|
||||||
SPHttpClient.configurations.v1,
|
|
||||||
{ headers: SP_JSON, body: JSON.stringify(body) },
|
|
||||||
);
|
|
||||||
if (!res.ok)
|
|
||||||
throw new Error(`Ошибка сохранения согласующего: ${res.status}`);
|
|
||||||
const created = await res.json();
|
|
||||||
saved.push({ ...member, spApprovalId: created.Id as number });
|
|
||||||
}
|
}
|
||||||
return saved;
|
return this._approval;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteApprovalChainByOid(oidId: number): Promise<void> {
|
resolveRequestTypeId(): Promise<number> {
|
||||||
const url = `${listUrl(LISTS.APPROVAL)}/items?$select=Id&$filter=CTId eq ${oidId}`;
|
return getRequestTypeIdByPageUrl(this.context, OID_REQUEST_TYPE_PAGE_URL);
|
||||||
const res = await this.sp.get(url, SPHttpClient.configurations.v1);
|
|
||||||
if (!res.ok) return;
|
|
||||||
const data = await res.json();
|
|
||||||
const items: Array<{ Id: number }> =
|
|
||||||
(data.value as Array<{ Id: number }>) ?? [];
|
|
||||||
for (const item of items) {
|
|
||||||
await this.sp.post(
|
|
||||||
`${listUrl(LISTS.APPROVAL)}/items(${item.Id})`,
|
|
||||||
SPHttpClient.configurations.v1,
|
|
||||||
{ headers: SP_DELETE },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetApprovalChainToDraft(chain: ApprovalMember[]): Promise<void> {
|
// department/company — фильтры маршрута, как в прежней «Матрица согласования СТ» (общей с CT) —
|
||||||
for (const member of chain) {
|
// ОБА условием видимости (fieldValues), НЕ через параметр departmentId: тот в approval-route —
|
||||||
if (!member.spApprovalId) continue;
|
// GUID из справочника отделов, а не текст должности/отдела из профиля SharePoint (см. ошибку 400
|
||||||
await this.sp.post(
|
// "$.departmentId: JSON value could not be converted"). Имена полей 'company'/'department' должны
|
||||||
`${listUrl(LISTS.APPROVAL)}/items(${member.spApprovalId})`,
|
// совпадать с тем, что настроено в админке маршрутов для типа «oid».
|
||||||
SPHttpClient.configurations.v1,
|
async getPlannedApprovalMatrix(userEmail: string, department: string, company: string): Promise<IMatrixRow[]> {
|
||||||
{
|
const requestTypeId = await this.resolveRequestTypeId();
|
||||||
headers: SP_UPDATE,
|
const fieldValues: Array<{ fieldName: string; value: string }> = [];
|
||||||
body: JSON.stringify({ status: "Черновик", comment: "", aproovDate: null }),
|
if (company) fieldValues.push({ fieldName: "company", value: company });
|
||||||
},
|
if (department) fieldValues.push({ fieldName: "department", value: department });
|
||||||
);
|
return getPlannedMatrix(this.context, requestTypeId, userEmail, this.approval, null, fieldValues, null);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async activateApprovalChain(chain: ApprovalMember[]): Promise<void> {
|
getApprovalMatrixForRequest(oidId: number): Promise<IMatrixRow[]> {
|
||||||
for (const member of chain) {
|
return this.approval.getMatrixForRequest(oidId);
|
||||||
if (!member.spApprovalId) continue;
|
|
||||||
const status = member.role === "Инициатор" ? "Рекомендую" : "На согласовании";
|
|
||||||
await this.sp.post(
|
|
||||||
`${listUrl(LISTS.APPROVAL)}/items(${member.spApprovalId})`,
|
|
||||||
SPHttpClient.configurations.v1,
|
|
||||||
{ headers: SP_UPDATE, body: JSON.stringify({ status }) },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitApprovalStep(
|
submitApprovalRoute(oidId: number, approvers: IApproverToWrite[]): Promise<void> {
|
||||||
spItemId: number,
|
return writeRoute(this.approval, oidId, approvers);
|
||||||
status: string,
|
|
||||||
comment: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const spStatus = status === "approved" ? "Согласовано" : "Отклонено";
|
|
||||||
const body = { status: spStatus, comment, aproovDate: new Date().toISOString() };
|
|
||||||
await this.sp.post(
|
|
||||||
`${listUrl(LISTS.APPROVAL)}/items(${spItemId})`,
|
|
||||||
SPHttpClient.configurations.v1,
|
|
||||||
{ headers: SP_UPDATE, body: JSON.stringify(body) },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async addApprovalMemberDraft(
|
getApproverService(oidId: number): ApproverService {
|
||||||
oidId: number,
|
return new ApproverService(this.approval, async (status) => {
|
||||||
insertAtOrder: number,
|
await this.updateOIDStatus(oidId, APPROVAL_STATUS_TO_OID_STATUS[status]);
|
||||||
userId: number,
|
});
|
||||||
name: string,
|
|
||||||
): Promise<ApprovalMember> {
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
Title: `${oidId}-${userId}-delegate`,
|
|
||||||
CTId: oidId,
|
|
||||||
aprooverId: userId,
|
|
||||||
order0: insertAtOrder,
|
|
||||||
status: "Черновик",
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await this.sp.post(
|
|
||||||
`${listUrl(LISTS.APPROVAL)}/items`,
|
|
||||||
SPHttpClient.configurations.v1,
|
|
||||||
{ headers: SP_JSON, body: JSON.stringify(body) },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Ошибка добавления делегата: ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = await res.json();
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: String(created.Id),
|
|
||||||
spApprovalId: created.Id as number,
|
|
||||||
order: insertAtOrder,
|
|
||||||
userId,
|
|
||||||
userName: name,
|
|
||||||
status: "pending",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async addApprovalDelegate(
|
async notifyActiveApprover(oidId: number): Promise<void> {
|
||||||
oidId: number,
|
const rows = await this.approval.getForRequest(oidId);
|
||||||
insertAtOrder: number,
|
const active = rows.filter((r) => r.status === REQUEST_STATUS.onApproval)[0];
|
||||||
userId: number,
|
if (active) {
|
||||||
name: string,
|
await this.triggerApprovalNotification(active.id);
|
||||||
): Promise<ApprovalMember> {
|
|
||||||
const url =
|
|
||||||
`${listUrl(LISTS.APPROVAL)}/items` +
|
|
||||||
`?$filter=CTId eq ${oidId} and order0 ge ${insertAtOrder}` +
|
|
||||||
`&$select=Id,order0` +
|
|
||||||
`&$orderby=order0 desc`;
|
|
||||||
|
|
||||||
const res = await this.sp.get(url, SPHttpClient.configurations.v1);
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
const itemsToShift = (data.value as Array<Record<string, unknown>>) || [];
|
|
||||||
|
|
||||||
for (const item of itemsToShift) {
|
|
||||||
const body = { order0: (item.order0 as number) + 1 };
|
|
||||||
await this.sp.post(
|
|
||||||
`${listUrl(LISTS.APPROVAL)}/items(${item.Id})`,
|
|
||||||
SPHttpClient.configurations.v1,
|
|
||||||
{ headers: SP_UPDATE, body: JSON.stringify(body) },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newBody: Record<string, unknown> = {
|
|
||||||
Title: name,
|
|
||||||
CTId: oidId,
|
|
||||||
order0: insertAtOrder,
|
|
||||||
aprooverId: userId,
|
|
||||||
status: "На согласовании",
|
|
||||||
};
|
|
||||||
|
|
||||||
const createRes = await this.sp.post(
|
|
||||||
`${listUrl(LISTS.APPROVAL)}/items`,
|
|
||||||
SPHttpClient.configurations.v1,
|
|
||||||
{ headers: SP_JSON, body: JSON.stringify(newBody) },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!createRes.ok) {
|
|
||||||
throw new Error(`Ошибка добавления делегата: ${createRes.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = await createRes.json();
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: String(created.Id),
|
|
||||||
order: insertAtOrder,
|
|
||||||
userId,
|
|
||||||
userName: name,
|
|
||||||
status: "pending" as ApprovalMemberStatus,
|
|
||||||
spApprovalId: created.Id as number,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async replaceApprovalMember(
|
|
||||||
spApprovalId: number,
|
|
||||||
userId: number,
|
|
||||||
name: string,
|
|
||||||
): Promise<ApprovalMember> {
|
|
||||||
const body = { aprooverId: userId, Title: name };
|
|
||||||
await this.sp.post(
|
|
||||||
`${listUrl(LISTS.APPROVAL)}/items(${spApprovalId})`,
|
|
||||||
SPHttpClient.configurations.v1,
|
|
||||||
{ headers: SP_UPDATE, body: JSON.stringify(body) },
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
id: String(spApprovalId),
|
|
||||||
spApprovalId,
|
|
||||||
order: 0,
|
|
||||||
userId,
|
|
||||||
userName: name,
|
|
||||||
status: "pending",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapSpStatus(spStatus: string): ApprovalMemberStatus {
|
|
||||||
switch (spStatus) {
|
|
||||||
case "Согласовано": return "approved";
|
|
||||||
case "Отклонено": return "rejected";
|
|
||||||
case "Рекомендую": return "recommends";
|
|
||||||
case "На согласовании":
|
|
||||||
case "Черновик":
|
|
||||||
default: return "pending";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadApprovalChain(oidId: number): Promise<ApprovalMember[]> {
|
|
||||||
const url =
|
|
||||||
`${listUrl(LISTS.APPROVAL)}/items` +
|
|
||||||
`?$filter=CTId eq ${oidId}` +
|
|
||||||
`&$expand=aproover` +
|
|
||||||
`&$select=Id,order0,canAddAproover,status,comment,aproovDate,aproover/Id,aproover/Title` +
|
|
||||||
`&$orderby=order0 asc&$top=100`;
|
|
||||||
const res = await this.sp.get(url, SPHttpClient.configurations.v1);
|
|
||||||
const data = await res.json();
|
|
||||||
return ((data.value as Array<Record<string, unknown>>) || []).map(
|
|
||||||
(item) => {
|
|
||||||
const aproover = item.aproover as
|
|
||||||
| { Id: number; Title: string }
|
|
||||||
| undefined;
|
|
||||||
return {
|
|
||||||
id: String(item.Id),
|
|
||||||
spApprovalId: item.Id as number,
|
|
||||||
order: (item.order0 as number) || 0,
|
|
||||||
userId: aproover?.Id ?? 0,
|
|
||||||
userName: aproover?.Title ?? "",
|
|
||||||
canAddApprover: (item.canAddAproover as boolean) || false,
|
|
||||||
status: this.mapSpStatus((item.status as string) || "Черновик"),
|
|
||||||
comment: (item.comment as string) || undefined,
|
|
||||||
actionDate: (item.aproovDate as string) || undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteApprovalMembers(spIds: number[]): Promise<void> {
|
|
||||||
for (const id of spIds) {
|
|
||||||
await this.sp.post(
|
|
||||||
`${listUrl(LISTS.APPROVAL)}/items(${id})`,
|
|
||||||
SPHttpClient.configurations.v1,
|
|
||||||
{ headers: SP_DELETE },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadApprovalMatrix(
|
|
||||||
company: string,
|
|
||||||
department: string,
|
|
||||||
): Promise<ApprovalMember[]> {
|
|
||||||
const filterParts: string[] = [];
|
|
||||||
if (department) {
|
|
||||||
filterParts.push(`department eq '${department.replace(/'/g, "''")}'`);
|
|
||||||
} else {
|
|
||||||
filterParts.push(`(department eq null or department eq '')`);
|
|
||||||
}
|
|
||||||
if (company) {
|
|
||||||
filterParts.push(`company eq '${company.replace(/'/g, "''")}'`);
|
|
||||||
}
|
|
||||||
const filter = encodeURIComponent(filterParts.join(" and "));
|
|
||||||
const url =
|
|
||||||
`${SITE_URL}/_api/web/lists/getbytitle('${encodeURIComponent("Матрица согласования СТ")}')/items` +
|
|
||||||
`?$select=ID,post,canAddAproover,approover/Id,approover/Title` +
|
|
||||||
`&$expand=approover&$filter=${filter}&$orderby=ID asc`;
|
|
||||||
const res = await this.sp.get(url, SPHttpClient.configurations.v1);
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = await res.json();
|
|
||||||
return ((data.value as Array<Record<string, unknown>>) ?? []).map(
|
|
||||||
(item, idx) => ({
|
|
||||||
id: String(item["ID"] ?? idx),
|
|
||||||
order: idx + 1,
|
|
||||||
userId: Number((item["approover"] as Record<string, unknown>)?.Id ?? 0),
|
|
||||||
userName: String(
|
|
||||||
(item["approover"] as Record<string, unknown>)?.Title ?? "",
|
|
||||||
),
|
|
||||||
role: String(item["post"] ?? ""),
|
|
||||||
canAddApprover: Boolean(item["canAddAproover"]),
|
|
||||||
status: "pending" as ApprovalMemberStatus,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async triggerApprovalNotification(spItemId: number): Promise<void> {
|
async triggerApprovalNotification(spItemId: number): Promise<void> {
|
||||||
@@ -1562,22 +1357,13 @@ export class OidService {
|
|||||||
return { department: get("Department"), company: get("Company") };
|
return { department: get("Department"), company: get("Company") };
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchUsers(
|
// Поиск пользователей сайта — используется пикером согласующего/делегата в ApprovalMatrix/DecisionPanel.
|
||||||
query: string,
|
searchUsers(query: string): Promise<Array<{ id: number; name: string; email: string; loginName: string }>> {
|
||||||
): Promise<Array<{ spId: number; displayName: string; email: string }>> {
|
return this.approval.searchUsers(query);
|
||||||
if (!query || query.length < 2) return [];
|
}
|
||||||
const escaped = query.replace(/'/g, "''");
|
|
||||||
const url =
|
// Должность добавляемого согласующего из профиля SharePoint — для useMatrixEditor.addApprover.
|
||||||
`${SITE_URL}/_api/web/siteusers` +
|
getJobTitle(loginName: string): Promise<string> {
|
||||||
`?$filter=substringof('${escaped}',Title) and PrincipalType eq 1` +
|
return this.approval.getJobTitle(loginName);
|
||||||
`&$select=Id,Title,Email&$top=10`;
|
|
||||||
const res = await this.sp.get(url, SPHttpClient.configurations.v1);
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = await res.json();
|
|
||||||
return (data.value ?? []).map((u: Record<string, unknown>) => ({
|
|
||||||
spId: Number(u["Id"]),
|
|
||||||
displayName: String(u["Title"] ?? ""),
|
|
||||||
email: String(u["Email"] ?? ""),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { SPHttpClient, SPHttpClientResponse, ISPHttpClientOptions } from '@microsoft/sp-http';
|
||||||
|
import { IApprovalRow } from './models/IApprovalRow';
|
||||||
|
import { IMatrixRow } from './models/IMatrixRow';
|
||||||
|
import { ISiteUser } from './models/ISiteUser';
|
||||||
|
import { REQUEST_STATUS } from './models/statuses';
|
||||||
|
|
||||||
|
// Внутренние имена колонок списка «Согласование …» — конвенция форм ct-forms-portal
|
||||||
|
// (см. KpsCtFormService.APPROVAL_FIELDS): order0 уже служит «порядком очереди»,
|
||||||
|
// aproover/status/aproovDate/comment — существующие колонки, allowReassign и
|
||||||
|
// delegatedBy — новые (см. provisioning-манифест формы).
|
||||||
|
export interface IApprovalListFields {
|
||||||
|
ticket: string; // Lookup → список данных
|
||||||
|
approver: string; // Person — согласующий
|
||||||
|
status: string; // Choice — статус строки
|
||||||
|
date: string; // DateTime — дата решения
|
||||||
|
comment: string; // Text
|
||||||
|
order: string; // Number — порядок очереди
|
||||||
|
allowReassign: string; // Boolean
|
||||||
|
delegatedBy: string; // Person
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_APPROVAL_FIELDS: IApprovalListFields = {
|
||||||
|
ticket: 'CT',
|
||||||
|
approver: 'aproover',
|
||||||
|
status: 'status',
|
||||||
|
date: 'aproovDate',
|
||||||
|
comment: 'comment',
|
||||||
|
order: 'order0',
|
||||||
|
allowReassign: 'allowReassign',
|
||||||
|
delegatedBy: 'delegatedBy'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Согласующий для записи в список.
|
||||||
|
export interface IApproverToWrite {
|
||||||
|
email: string;
|
||||||
|
position: string;
|
||||||
|
allowReassign: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Все запросы к списку «Согласование …» одной формы (REST-клиент SharePoint —
|
||||||
|
// в проекте нет PnPjs, весь остальной код форм тоже работает через SPHttpClient).
|
||||||
|
export class ApprovalListService {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly spHttpClient: SPHttpClient;
|
||||||
|
private readonly fields: IApprovalListFields;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly context: WebPartContext,
|
||||||
|
private readonly listTitle: string,
|
||||||
|
siteUrl?: string,
|
||||||
|
fields: IApprovalListFields = DEFAULT_APPROVAL_FIELDS
|
||||||
|
) {
|
||||||
|
this.baseUrl = siteUrl ?? context.pageContext.web.absoluteUrl;
|
||||||
|
this.spHttpClient = context.spHttpClient;
|
||||||
|
this.fields = fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _li(listName: string): string {
|
||||||
|
return `${this.baseUrl}/_api/web/lists/getbytitle('${listName}')/items`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _liById(listName: string, id: number): string {
|
||||||
|
return `${this.baseUrl}/_api/web/lists/getbytitle('${listName}')/items(${id})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getListItems<T>(odataQuery?: string): Promise<T[]> {
|
||||||
|
const url = odataQuery ? `${this._li(this.listTitle)}?${odataQuery}` : this._li(this.listTitle);
|
||||||
|
const res: SPHttpClientResponse = await this.spHttpClient.get(url, SPHttpClient.configurations.v1);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`GET ${this.listTitle} failed: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
const json: { value: T[] } = await res.json();
|
||||||
|
return json.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createListItem<T>(body: Record<string, unknown>): Promise<T> {
|
||||||
|
const options: ISPHttpClientOptions = {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json;odata=nometadata',
|
||||||
|
'Content-Type': 'application/json;odata=nometadata',
|
||||||
|
'odata-version': ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
};
|
||||||
|
const res = await this.spHttpClient.post(this._li(this.listTitle), SPHttpClient.configurations.v1, options);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`POST ${this.listTitle} failed: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateListItem(id: number, body: Record<string, unknown>): Promise<void> {
|
||||||
|
const options: ISPHttpClientOptions = {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json;odata=nometadata',
|
||||||
|
'Content-Type': 'application/json;odata=nometadata',
|
||||||
|
'odata-version': '',
|
||||||
|
'IF-MATCH': '*',
|
||||||
|
'X-HTTP-Method': 'MERGE'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
};
|
||||||
|
const res = await this.spHttpClient.post(this._liById(this.listTitle, id), SPHttpClient.configurations.v1, options);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`MERGE ${this.listTitle}(${id}) failed: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recycleListItem(id: number): Promise<void> {
|
||||||
|
const options: ISPHttpClientOptions = {
|
||||||
|
headers: { Accept: 'application/json;odata=nometadata', 'odata-version': '' }
|
||||||
|
};
|
||||||
|
const res = await this.spHttpClient.post(
|
||||||
|
`${this._liById(this.listTitle, id)}/recycle()`,
|
||||||
|
SPHttpClient.configurations.v1,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`recycle ${this.listTitle}(${id}) failed: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пишет по строке на каждого участника маршрута. Порядок (order) — позиция в
|
||||||
|
// массиве, начиная с orderStart. Статус пишется одинаковым для всей партии —
|
||||||
|
// кто сейчас «на очереди», решает отдельно activateNextQueued.
|
||||||
|
public async createSteps(requestId: number, approvers: IApproverToWrite[], status: string, orderStart = 1): Promise<void> {
|
||||||
|
const missing = approvers.filter((a) => !a.email);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`[Согласование] createSteps: пропущены шаги без назначенного человека — заявка №${requestId}`);
|
||||||
|
}
|
||||||
|
const toWrite = approvers.filter((a) => !!a.email);
|
||||||
|
for (let i = 0; i < toWrite.length; i += 1) {
|
||||||
|
const a = toWrite[i];
|
||||||
|
const approverId = await this.resolveUserId(a.email);
|
||||||
|
await this.createListItem({
|
||||||
|
Title: a.position || a.email,
|
||||||
|
[`${this.fields.approver}Id`]: approverId,
|
||||||
|
[this.fields.status]: status,
|
||||||
|
[`${this.fields.ticket}Id`]: requestId,
|
||||||
|
[this.fields.order]: orderStart + i,
|
||||||
|
[this.fields.allowReassign]: a.allowReassign
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Продвигает очередь: среди строк со статусом «Ждёт очереди» активирует ту, что
|
||||||
|
// делит наименьший order (обычно одну). Если маршрут уже остановлен решением
|
||||||
|
// (Отклонено) — ничего не активирует.
|
||||||
|
public async activateNextQueued(requestId: number): Promise<void> {
|
||||||
|
const rows = await this.getForRequest(requestId);
|
||||||
|
if (rows.some((r) => r.status === REQUEST_STATUS.rejected)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rows.some((r) => r.status === REQUEST_STATUS.onApproval)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queuedRows = rows.filter((r) => r.status === REQUEST_STATUS.queued);
|
||||||
|
if (queuedRows.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const minOrder = Math.min(...queuedRows.map((r) => r.order));
|
||||||
|
for (const row of queuedRows.filter((r) => r.order === minOrder)) {
|
||||||
|
await this.setActive(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setStatus(rowId: number, status: string): Promise<void> {
|
||||||
|
await this.updateListItem(rowId, { [this.fields.status]: status });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Активация строки: статус + отметка времени, когда строка стала текущей.
|
||||||
|
private async setActive(rowId: number): Promise<void> {
|
||||||
|
await this.updateListItem(rowId, {
|
||||||
|
[this.fields.status]: REQUEST_STATUS.onApproval,
|
||||||
|
[this.fields.date]: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляет одну строку согласования с заданным order (для делегата).
|
||||||
|
public async addApproverRow(
|
||||||
|
requestId: number,
|
||||||
|
approver: IApproverToWrite,
|
||||||
|
order: number,
|
||||||
|
status: string,
|
||||||
|
delegatedById?: number
|
||||||
|
): Promise<void> {
|
||||||
|
const approverId = await this.resolveUserId(approver.email);
|
||||||
|
await this.createListItem({
|
||||||
|
Title: approver.position || approver.email,
|
||||||
|
[`${this.fields.approver}Id`]: approverId,
|
||||||
|
[this.fields.status]: status,
|
||||||
|
[`${this.fields.ticket}Id`]: requestId,
|
||||||
|
[this.fields.order]: order,
|
||||||
|
[this.fields.allowReassign]: approver.allowReassign,
|
||||||
|
...(delegatedById ? { [`${this.fields.delegatedBy}Id`]: delegatedById } : {})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строки согласования по заявке, в порядке очереди.
|
||||||
|
public async getForRequest(requestId: number): Promise<IApprovalRow[]> {
|
||||||
|
const rows = await this.getListItems<Record<string, unknown>>(
|
||||||
|
`$select=Id,Title,${this.fields.status},${this.fields.order},${this.fields.allowReassign},` +
|
||||||
|
`${this.fields.approver}/Id,${this.fields.delegatedBy}/Id` +
|
||||||
|
`&$expand=${this.fields.approver},${this.fields.delegatedBy}` +
|
||||||
|
`&$filter=${this.fields.ticket}Id eq ${requestId}&$orderby=${this.fields.order} asc&$top=200`
|
||||||
|
);
|
||||||
|
return rows.map((r) => {
|
||||||
|
const approver = r[this.fields.approver] as { Id?: number } | undefined;
|
||||||
|
const delegatedBy = r[this.fields.delegatedBy] as { Id?: number } | undefined;
|
||||||
|
return {
|
||||||
|
id: r.Id as number,
|
||||||
|
status: (r[this.fields.status] as string) || '',
|
||||||
|
approverId: approver?.Id ?? 0,
|
||||||
|
order: (r[this.fields.order] as number) ?? 0,
|
||||||
|
allowReassign: (r[this.fields.allowReassign] as boolean) ?? false,
|
||||||
|
position: (r.Title as string) || '',
|
||||||
|
delegatedById: delegatedBy?.Id ?? 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строки согласования для отображения матрицы (ФИО, должность, статус, дата, комментарий).
|
||||||
|
public async getMatrixForRequest(requestId: number): Promise<IMatrixRow[]> {
|
||||||
|
const rows = await this.getListItems<Record<string, unknown>>(
|
||||||
|
`$select=Id,Title,${this.fields.status},${this.fields.date},${this.fields.comment},` +
|
||||||
|
`${this.fields.order},${this.fields.allowReassign},${this.fields.approver}/Title,` +
|
||||||
|
`${this.fields.approver}/EMail,${this.fields.delegatedBy}/Title` +
|
||||||
|
`&$expand=${this.fields.approver},${this.fields.delegatedBy}` +
|
||||||
|
`&$filter=${this.fields.ticket}Id eq ${requestId}&$orderby=${this.fields.order} asc&$top=200`
|
||||||
|
);
|
||||||
|
return rows.map((r, i) => {
|
||||||
|
const approver = r[this.fields.approver] as { Title?: string; EMail?: string } | undefined;
|
||||||
|
const delegatedBy = r[this.fields.delegatedBy] as { Title?: string } | undefined;
|
||||||
|
return {
|
||||||
|
order: (r[this.fields.order] as number) ?? i + 1,
|
||||||
|
who: approver?.Title || '',
|
||||||
|
email: approver?.EMail || '',
|
||||||
|
position: (r.Title as string) || '',
|
||||||
|
status: (r[this.fields.status] as string) || '',
|
||||||
|
date: this.formatDate((r[this.fields.date] as string) || ''),
|
||||||
|
comment: (r[this.fields.comment] as string) || '',
|
||||||
|
allowReassign: (r[this.fields.allowReassign] as boolean) ?? false,
|
||||||
|
addedBy: delegatedBy?.Title || ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск пользователей сайта по части имени — для пикера согласующего.
|
||||||
|
public async searchUsers(query: string): Promise<ISiteUser[]> {
|
||||||
|
const q = query.trim();
|
||||||
|
if (q.length < 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const escaped = q.replace(/'/g, "''");
|
||||||
|
const url =
|
||||||
|
`${this.baseUrl}/_api/web/siteusers?$filter=substringof('${escaped}',Title) and PrincipalType eq 1` +
|
||||||
|
`&$select=Id,Title,Email,LoginName&$top=10`;
|
||||||
|
const res = await this.spHttpClient.get(url, SPHttpClient.configurations.v1);
|
||||||
|
if (!res.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const json: { value: Array<Record<string, unknown>> } = await res.json();
|
||||||
|
return json.value.map((u) => ({
|
||||||
|
id: u.Id as number,
|
||||||
|
name: (u.Title as string) || '',
|
||||||
|
email: (u.Email as string) || '',
|
||||||
|
loginName: (u.LoginName as string) || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Должность пользователя из профиля SharePoint (SPS-JobTitle). '' если недоступно.
|
||||||
|
public async getJobTitle(loginName: string): Promise<string> {
|
||||||
|
if (!loginName) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const account = loginName.indexOf('|') >= 0 ? loginName : `i:0#.f|membership|${loginName}`;
|
||||||
|
try {
|
||||||
|
const url = `${this.baseUrl}/_api/SP.UserProfiles.PeopleManager/GetPropertiesFor(accountName=@v)?@v='${encodeURIComponent(account)}'`;
|
||||||
|
const res = await this.spHttpClient.get(url, SPHttpClient.configurations.v1, {
|
||||||
|
headers: { Accept: 'application/json;odata=verbose' }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const json: {
|
||||||
|
d?: { UserProfileProperties?: { results?: Array<{ Key: string; Value: string }> } };
|
||||||
|
} = await res.json();
|
||||||
|
const props = json.d?.UserProfileProperties?.results ?? [];
|
||||||
|
return props.filter((p) => p.Key === 'SPS-JobTitle')[0]?.Value || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Решение согласующего по строке: статус + дата + комментарий.
|
||||||
|
public async setDecision(rowId: number, status: string, comment: string): Promise<void> {
|
||||||
|
await this.updateListItem(rowId, {
|
||||||
|
[this.fields.status]: status,
|
||||||
|
[this.fields.date]: new Date().toISOString(),
|
||||||
|
[this.fields.comment]: comment
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляет все строки согласования по заявке (при повторной отправке).
|
||||||
|
public async deleteForRequest(requestId: number): Promise<void> {
|
||||||
|
const rows = await this.getForRequest(requestId);
|
||||||
|
for (const row of rows) {
|
||||||
|
await this.recycleListItem(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Id текущего пользователя на сайте (для сравнения с полем «Согласующий»).
|
||||||
|
public async getCurrentUserId(): Promise<number> {
|
||||||
|
const url = `${this.baseUrl}/_api/web/currentuser?$select=Id`;
|
||||||
|
const res = await this.spHttpClient.get(url, SPHttpClient.configurations.v1);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`currentuser failed: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
const user: { Id: number } = await res.json();
|
||||||
|
return user.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// email → числовой Id пользователя сайта (для Person-поля «Согласующий»).
|
||||||
|
private async resolveUserId(email: string): Promise<number> {
|
||||||
|
const user = await this.ensure(email);
|
||||||
|
return user.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// email → отображаемое имя (ФИО). Фолбэк на email, если имя недоступно.
|
||||||
|
public async resolveUserName(email: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const user = await this.ensure(email);
|
||||||
|
return user.Title || email;
|
||||||
|
} catch {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensure(email: string): Promise<{ Id: number; Title: string }> {
|
||||||
|
const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`;
|
||||||
|
const options: ISPHttpClientOptions = {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json;odata=nometadata',
|
||||||
|
'Content-Type': 'application/json;odata=nometadata',
|
||||||
|
'odata-version': ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ logonName: login })
|
||||||
|
};
|
||||||
|
const res = await this.spHttpClient.post(`${this.baseUrl}/_api/web/ensureuser`, SPHttpClient.configurations.v1, options);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`ensureuser(${email}) failed: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формат «дд.мм.гггг чч:мм» в казахстанском поясе (UTC+5) — жёстко, а не по
|
||||||
|
// поясу браузера: SharePoint хранит дату в UTC, Казахстан без перехода на летнее время.
|
||||||
|
private formatDate(iso: string): string {
|
||||||
|
if (!iso) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const KZ_OFFSET_MS = 5 * 60 * 60 * 1000;
|
||||||
|
const d = new Date(new Date(iso).getTime() + KZ_OFFSET_MS);
|
||||||
|
const p = (n: number): string => (n < 10 ? `0${n}` : `${n}`);
|
||||||
|
const date = `${p(d.getUTCDate())}.${p(d.getUTCMonth() + 1)}.${d.getUTCFullYear()}`;
|
||||||
|
return `${date} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { APPROVAL_API_CLIENT_ID, APPROVAL_API_BASE_URL } from '../config';
|
||||||
|
import { IApiResponse, IRequestType, IResolvedRoute, IResolveFieldValue } from './models/IApprovalRouteApi';
|
||||||
|
|
||||||
|
// Все обращения к внешнему approval-route API — только здесь. Токен — через AAD
|
||||||
|
// (тот же приём, что и KpsCtFormService.tnsuFetch для /inventory и /Document:
|
||||||
|
// aadTokenProviderFactory + обычный fetch с заголовком Authorization, БЕЗ
|
||||||
|
// AadHttpClient — этот resourceId в тенанте уже одобрен и работает так).
|
||||||
|
async function getToken(context: WebPartContext): Promise<string> {
|
||||||
|
const tokenProvider = await context.aadTokenProviderFactory.getTokenProvider();
|
||||||
|
return tokenProvider.getToken(APPROVAL_API_CLIENT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/request-types — список типов заявок (id + pageUrl).
|
||||||
|
export async function getRequestTypes(context: WebPartContext): Promise<IRequestType[]> {
|
||||||
|
const token = await getToken(context);
|
||||||
|
const res = await fetch(`${APPROVAL_API_BASE_URL}/api/request-types`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`request-types: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
const body: IApiResponse<IRequestType[]> = await res.json();
|
||||||
|
return body.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кеш на уровне модуля — типы заявок админ меняет редко, каждая форма не должна
|
||||||
|
// дёргать /api/request-types заново. Ошибку не кешируем — следующий вызов пробует ещё раз.
|
||||||
|
let requestTypesPromise: Promise<IRequestType[]> | undefined;
|
||||||
|
|
||||||
|
export async function getRequestTypesCached(context: WebPartContext): Promise<IRequestType[]> {
|
||||||
|
if (!requestTypesPromise) {
|
||||||
|
requestTypesPromise = getRequestTypes(context).catch((e) => {
|
||||||
|
requestTypesPromise = undefined;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return requestTypesPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Числовой RequestTypeId по pageUrl формы — id непредсказуем (назначается в
|
||||||
|
// админке маршрутов при регистрации типа), pageUrl задаём мы сами и он стабилен.
|
||||||
|
export async function getRequestTypeIdByPageUrl(context: WebPartContext, pageUrl: string): Promise<number> {
|
||||||
|
const types = await getRequestTypesCached(context);
|
||||||
|
const found = types.filter((t) => t.pageUrl === pageUrl)[0];
|
||||||
|
if (!found) {
|
||||||
|
throw new Error(
|
||||||
|
`Тип заявки с pageUrl «${pageUrl}» не найден в approval-route API — зарегистрируйте его в админке маршрутов (Типы заявок).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return found.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/template-routes/resolve — планируемый маршрут согласования для типа
|
||||||
|
// заявки и пользователя. Бэкенд сам подставляет согласующих по ролям исходя из
|
||||||
|
// userEmail (и, если задано, отдела/проекта).
|
||||||
|
export async function resolveRoute(
|
||||||
|
context: WebPartContext,
|
||||||
|
requestTypeId: number,
|
||||||
|
userEmail: string,
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- null отправляется в теле запроса как «не задано»
|
||||||
|
departmentId: string | null = null,
|
||||||
|
fieldValues: IResolveFieldValue[] = [],
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- см. departmentId
|
||||||
|
projectId: string | null = null
|
||||||
|
): Promise<IResolvedRoute> {
|
||||||
|
const token = await getToken(context);
|
||||||
|
const res = await fetch(`${APPROVAL_API_BASE_URL}/api/template-routes/resolve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
requestTypeId,
|
||||||
|
userEmail,
|
||||||
|
projectId: projectId || null,
|
||||||
|
status: 'Approval',
|
||||||
|
fieldValues,
|
||||||
|
departmentId: departmentId || null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`resolve: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
const body: IApiResponse<IResolvedRoute> = await res.json();
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { resolveRoute } from './approvalRoute.service';
|
||||||
|
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
|
||||||
|
import { IMatrixRow } from './models/IMatrixRow';
|
||||||
|
import { IResolveFieldValue } from './models/IApprovalRouteApi';
|
||||||
|
import { REQUEST_STATUS } from './models/statuses';
|
||||||
|
|
||||||
|
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
|
||||||
|
// departmentId/fieldValues/projectId — условия маршрута для форм, где согласующий зависит от
|
||||||
|
// отдела/компании/проекта инициатора (напр. CT/OID — см. CtService.getPlannedApprovalMatrix);
|
||||||
|
// формы без таких условий (KPS) просто их не передают.
|
||||||
|
export async function getPlannedMatrix(
|
||||||
|
context: WebPartContext,
|
||||||
|
requestTypeId: number,
|
||||||
|
userEmail: string,
|
||||||
|
approval: ApprovalListService,
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- транзитом в resolveRoute(), см. её departmentId
|
||||||
|
departmentId: string | null = null,
|
||||||
|
fieldValues: IResolveFieldValue[] = [],
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- см. departmentId
|
||||||
|
projectId: string | null = null
|
||||||
|
): Promise<IMatrixRow[]> {
|
||||||
|
if (!userEmail) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const route = await resolveRoute(context, requestTypeId, userEmail, departmentId, fieldValues, projectId);
|
||||||
|
const ordered = route.steps.slice().sort((a, b) => a.orderNo - b.orderNo);
|
||||||
|
return Promise.all(
|
||||||
|
ordered.map(async (s) => ({
|
||||||
|
order: s.orderNo,
|
||||||
|
who: s.executorEmail ? await approval.resolveUserName(s.executorEmail) : '',
|
||||||
|
email: s.executorEmail || '',
|
||||||
|
position: s.position || '',
|
||||||
|
status: '—',
|
||||||
|
date: '',
|
||||||
|
comment: '',
|
||||||
|
allowReassign: s.allowReassignToOtherApprovers,
|
||||||
|
warning: s.warning
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Записать маршрут согласования заявки: удаляет прежние строки (повторная
|
||||||
|
// отправка/пересохранение), пишет новые как «Ждёт очереди» и активирует первую.
|
||||||
|
export async function writeRoute(approval: ApprovalListService, requestId: number, approvers: IApproverToWrite[]): Promise<void> {
|
||||||
|
await approval.deleteForRequest(requestId);
|
||||||
|
await approval.createSteps(requestId, approvers, REQUEST_STATUS.queued, 1);
|
||||||
|
await approval.activateNextQueued(requestId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { ApprovalListService } from './approvalList.service';
|
||||||
|
import { REQUEST_STATUS } from './models/statuses';
|
||||||
|
import { IApprovalRow } from './models/IApprovalRow';
|
||||||
|
import { ISiteUser } from './models/ISiteUser';
|
||||||
|
|
||||||
|
// approve — согласовать; reject — отклонить (нет фазы исполнения и статуса
|
||||||
|
// «возврат на доработку» — как в прежнем KPS-механизме).
|
||||||
|
export type ApproverDecision = 'approve' | 'reject';
|
||||||
|
|
||||||
|
// Активная строка текущего пользователя: либо он сам назначен на неё
|
||||||
|
// (isDelegator=false), либо это его делегат недоступен и он, как делегировавший,
|
||||||
|
// вправе переназначить шаг ещё раз (isDelegator=true).
|
||||||
|
export interface IPendingRow extends IApprovalRow {
|
||||||
|
isDelegator: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW_STATUS: Record<ApproverDecision, string> = {
|
||||||
|
approve: REQUEST_STATUS.approved,
|
||||||
|
reject: REQUEST_STATUS.rejected
|
||||||
|
};
|
||||||
|
|
||||||
|
// Действия согласующего: определение его строки, применение решения,
|
||||||
|
// делегирование и пересчёт статуса заявки в списке данных.
|
||||||
|
export class ApproverService {
|
||||||
|
/**
|
||||||
|
* onStatusComputed пишет статус в СВОЁ поле списка данных формы (у каждой
|
||||||
|
* формы своё имя колонки и свой набор значений Choice — see
|
||||||
|
* KpsCtFormService.updateCtStatus) — этот сервис домена не знает, только
|
||||||
|
* вычисляет статус из строк маршрута и отдаёт его вызывающей стороне.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private readonly approval: ApprovalListService,
|
||||||
|
private readonly onStatusComputed: (status: 'onApproval' | 'approved' | 'rejected') => Promise<void>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Ожидающая строка текущего пользователя, если сейчас его очередь; иначе undefined.
|
||||||
|
public async getPendingRowForCurrentUser(requestId: number): Promise<IPendingRow | undefined> {
|
||||||
|
const rows = await this.approval.getForRequest(requestId);
|
||||||
|
const userId = await this.approval.getCurrentUserId();
|
||||||
|
const active = rows.filter((r) => r.status === REQUEST_STATUS.onApproval);
|
||||||
|
const own = active.filter((r) => r.approverId === userId)[0];
|
||||||
|
if (own) {
|
||||||
|
return { ...own, isDelegator: false };
|
||||||
|
}
|
||||||
|
const delegated = active.filter((r) => r.delegatedById === userId)[0];
|
||||||
|
return delegated ? { ...delegated, isDelegator: true } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Делегирование: согласующий добавляет одного или нескольких делегантов ПЕРЕД
|
||||||
|
// собой. Order — из равных долей промежутка между предыдущей строкой и своей.
|
||||||
|
public async delegate(requestId: number, currentRowId: number, users: ISiteUser[]): Promise<void> {
|
||||||
|
if (users.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = await this.approval.getForRequest(requestId);
|
||||||
|
const current = rows.filter((r) => r.id === currentRowId)[0];
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const before = rows.filter((r) => r.order < current.order);
|
||||||
|
const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1;
|
||||||
|
const step = (current.order - prevOrder) / (users.length + 1);
|
||||||
|
const delegatedById = await this.approval.getCurrentUserId();
|
||||||
|
|
||||||
|
for (let i = 0; i < users.length; i += 1) {
|
||||||
|
const user = users[i];
|
||||||
|
const position = await this.approval.getJobTitle(user.loginName).catch(() => '');
|
||||||
|
await this.approval.addApproverRow(
|
||||||
|
requestId,
|
||||||
|
{ email: user.email, position, allowReassign: false },
|
||||||
|
prevOrder + step * (i + 1),
|
||||||
|
REQUEST_STATUS.queued,
|
||||||
|
delegatedById
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.approval.setStatus(current.id, REQUEST_STATUS.queued);
|
||||||
|
await this.approval.activateNextQueued(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Применяет решение: обновляет строку, продвигает очередь (если согласовано) и
|
||||||
|
// пересчитывает статус заявки. Возвращает новый статус заявки (RU-текст).
|
||||||
|
public async decide(requestId: number, rowId: number, decision: ApproverDecision, comment: string): Promise<string> {
|
||||||
|
await this.approval.setDecision(rowId, ROW_STATUS[decision], comment);
|
||||||
|
if (decision === 'approve') {
|
||||||
|
await this.approval.activateNextQueued(requestId);
|
||||||
|
}
|
||||||
|
const rows = await this.approval.getForRequest(requestId);
|
||||||
|
const status = this.computeRequestStatus(rows);
|
||||||
|
await this.onStatusComputed(status.key);
|
||||||
|
return status.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статус заявки из строк маршрута: отклонение любой строки → «Отклонено»;
|
||||||
|
// все согласовали → «Согласовано»; иначе ещё «На согласовании».
|
||||||
|
private computeRequestStatus(rows: IApprovalRow[]): { key: 'onApproval' | 'approved' | 'rejected'; label: string } {
|
||||||
|
if (rows.some((r) => r.status === REQUEST_STATUS.rejected)) {
|
||||||
|
return { key: 'rejected', label: REQUEST_STATUS.rejected };
|
||||||
|
}
|
||||||
|
if (rows.length > 0 && rows.every((r) => r.status === REQUEST_STATUS.approved)) {
|
||||||
|
return { key: 'approved', label: REQUEST_STATUS.approved };
|
||||||
|
}
|
||||||
|
return { key: 'onApproval', label: REQUEST_STATUS.onApproval };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Единая точка импорта механизма согласования (порт из request-portal,
|
||||||
|
// см. [[request-portal-approval]]; адаптирован под REST/SPHttpClient и без
|
||||||
|
// фазы исполнения — ct-forms-portal не использует PnPjs).
|
||||||
|
export { REQUEST_STATUS, APPROVAL_ROW_STATUS_CHOICES } from './models/statuses';
|
||||||
|
export type { ISiteUser } from './models/ISiteUser';
|
||||||
|
export type { IMatrixRow } from './models/IMatrixRow';
|
||||||
|
export type { IApprovalRow } from './models/IApprovalRow';
|
||||||
|
export type { IRequestType, IResolvedRoute, IResolvedRouteStep, IResolveFieldValue } from './models/IApprovalRouteApi';
|
||||||
|
|
||||||
|
export { getRequestTypes, getRequestTypesCached, getRequestTypeIdByPageUrl, resolveRoute } from './approvalRoute.service';
|
||||||
|
export { ApprovalListService, DEFAULT_APPROVAL_FIELDS } from './approvalList.service';
|
||||||
|
export type { IApproverToWrite, IApprovalListFields } from './approvalList.service';
|
||||||
|
export { ApproverService } from './approverDecision.service';
|
||||||
|
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
|
||||||
|
export { getPlannedMatrix, writeRoute } from './approvalSubmission.service';
|
||||||
|
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
||||||
|
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||||
|
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||||
|
export { UserPicker } from './ui/UserPicker';
|
||||||
|
export { DecisionPanel } from './ui/DecisionPanel';
|
||||||
|
export { StatusBadge } from './ui/StatusBadge';
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Типы внешнего approval-route API (api.tnsu.kz/approval-route) — тот же бэкенд,
|
||||||
|
// что использует request-portal (см. [[request-portal-approval]]).
|
||||||
|
|
||||||
|
export type RouteType = 'None' | 'Parallel' | 'Sequential';
|
||||||
|
|
||||||
|
// Обёртка ответа API (все ответы приходят в поле data).
|
||||||
|
export interface IApiResponse<T> {
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/request-types → { data: IRequestType[] }
|
||||||
|
export interface IRequestType {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
pageUrl: string;
|
||||||
|
section: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Условие видимости шага маршрута (настраивается в админке маршрутов). ТОЛЬКО
|
||||||
|
// строка — бэкенд не принимает массив в value.
|
||||||
|
export interface IResolveFieldValue {
|
||||||
|
fieldName: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/template-routes/resolve → data
|
||||||
|
export interface IResolvedRoute {
|
||||||
|
routeId: number;
|
||||||
|
routeType: RouteType;
|
||||||
|
requiresControl: boolean;
|
||||||
|
steps: IResolvedRouteStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IResolvedRouteStep {
|
||||||
|
id: number;
|
||||||
|
orderNo: number;
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
|
||||||
|
executorEmail: string | null;
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- см. executorEmail
|
||||||
|
position: string | null;
|
||||||
|
allowReassignToOtherApprovers: boolean;
|
||||||
|
// Предупреждение маршрута по этому шагу (например, роль не разрешилась в
|
||||||
|
// конкретного человека) — приходит от API, показываем автору при желании.
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- см. executorEmail
|
||||||
|
warning: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Строка согласования из списка «Согласование …».
|
||||||
|
export interface IApprovalRow {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
approverId: number; // Id пользователя сайта в поле «Согласующий»
|
||||||
|
order: number; // порядок согласования (колонка order0)
|
||||||
|
allowReassign: boolean; // может ли этот согласующий добавить делеганта
|
||||||
|
/** Должность/роль шага — колонка Title строки. */
|
||||||
|
position: string;
|
||||||
|
/** Id пользователя сайта, который делегировал ЭТУ строку (DelegatedBy). 0 — строка
|
||||||
|
* не делегирована. Позволяет делегировавшему переназначить шаг ещё раз, если
|
||||||
|
* делегат недоступен — см. ApproverService.getPendingRowForCurrentUser. */
|
||||||
|
delegatedById: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Строка матрицы согласования для отображения и редактирования на форме.
|
||||||
|
export interface IMatrixRow {
|
||||||
|
order: number; // порядок согласования
|
||||||
|
who: string; // ФИО согласующего
|
||||||
|
email: string; // email (для записи новых строк; '' для загруженных из списка)
|
||||||
|
position: string; // должность/роль
|
||||||
|
status: string; // статус согласования или «—» для планируемой
|
||||||
|
date: string; // дата решения (дд.мм.гггг) или ''
|
||||||
|
comment: string;
|
||||||
|
allowReassign: boolean; // может ли этот согласующий добавить делеганта
|
||||||
|
// ФИО согласующего, который добавил ЭТУ строку перед собой (делегирование). ''
|
||||||
|
// для обычных строк маршрута.
|
||||||
|
addedBy?: string;
|
||||||
|
/**
|
||||||
|
* Причина, по которой approval-route не назначил на этот шаг конкретного
|
||||||
|
* человека (роль не разрешилась и т.п.). Только у ПЛАНИРУЕМОГО маршрута.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
|
||||||
|
warning?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Пользователь сайта (результат поиска для пикера согласующего).
|
||||||
|
export interface ISiteUser {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
loginName: string; // claims-логин (для чтения должности из профиля)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Статусы СТРОКИ списка «Согласование …» (маршрут), отдельно от статуса самой
|
||||||
|
// сущности (список данных: «Черновик»/«На согласовании»/«Согласовано»/«Отклонено» —
|
||||||
|
// у KPS это CtStatus, см. forms/kps/components/types/domain.ts).
|
||||||
|
//
|
||||||
|
// Активация — только через ApprovalListService.activateNextQueued (вызывается при
|
||||||
|
// отправке заявки и после каждого положительного решения): среди строк со статусом
|
||||||
|
// queued находит наименьший StepOrder и переводит её в onApproval. Так «чья сейчас
|
||||||
|
// очередь» — просто значение колонки статуса, без пересчёта порядка на клиенте.
|
||||||
|
export const REQUEST_STATUS = {
|
||||||
|
draft: 'Черновик',
|
||||||
|
queued: 'Ждёт очереди',
|
||||||
|
onApproval: 'На согласовании',
|
||||||
|
approved: 'Согласовано',
|
||||||
|
rejected: 'Отклонено',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Значения Choice-колонки статуса строки маршрута при провижининге.
|
||||||
|
export const APPROVAL_ROW_STATUS_CHOICES: string[] = [
|
||||||
|
REQUEST_STATUS.draft,
|
||||||
|
REQUEST_STATUS.queued,
|
||||||
|
REQUEST_STATUS.onApproval,
|
||||||
|
REQUEST_STATUS.approved,
|
||||||
|
REQUEST_STATUS.rejected,
|
||||||
|
];
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Icon } from '@tnsu/ui-kit';
|
||||||
|
import styles from './approval.module.scss';
|
||||||
|
import { IUseMatrixEditor } from '../useMatrixEditor';
|
||||||
|
import { ISiteUser } from '../models/ISiteUser';
|
||||||
|
import { StatusBadge } from './StatusBadge';
|
||||||
|
import { UserPicker } from './UserPicker';
|
||||||
|
|
||||||
|
export interface IApprovalMatrixProps {
|
||||||
|
editor: IUseMatrixEditor;
|
||||||
|
search: (query: string) => Promise<ISiteUser[]>;
|
||||||
|
title?: string;
|
||||||
|
roleLabel?: string;
|
||||||
|
emptyText?: string;
|
||||||
|
pickerPlaceholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Таблица маршрута согласования + (если editable) правка состава. */
|
||||||
|
export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||||
|
editor,
|
||||||
|
search,
|
||||||
|
title = 'Маршрут согласования',
|
||||||
|
roleLabel = 'Согласующий',
|
||||||
|
emptyText = 'Согласующие не заданы.',
|
||||||
|
pickerPlaceholder = 'Добавить согласующего: начните вводить ФИО…'
|
||||||
|
}) => {
|
||||||
|
const { rows, loading, error, editable, retry } = editor;
|
||||||
|
|
||||||
|
const hasStatuses = rows.some((r) => r.status && r.status !== '—');
|
||||||
|
const hasDates = rows.some((r) => !!r.date);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.matrixWrap}>
|
||||||
|
<div className={styles.matrixTitle}>{title}</div>
|
||||||
|
{loading && <div className={styles.matrixNote}>Загрузка маршрута…</div>}
|
||||||
|
{!loading && error && (
|
||||||
|
<div className={styles.matrixNote}>
|
||||||
|
Маршрут недоступен: {error}{' '}
|
||||||
|
<button type="button" className={styles.retryBtn} onClick={retry}>
|
||||||
|
Повторить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && rows.length === 0 && <div className={styles.matrixNote}>{emptyText}</div>}
|
||||||
|
{!loading && !error && rows.length > 0 && (
|
||||||
|
<div className={styles.matrixScroll}>
|
||||||
|
<table className={styles.matrix}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={styles.matrixStepCol}>Шаг</th>
|
||||||
|
<th>{roleLabel}</th>
|
||||||
|
<th>Должность</th>
|
||||||
|
{hasStatuses && <th>Статус</th>}
|
||||||
|
{hasDates && <th>Дата</th>}
|
||||||
|
{editable && <th />}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<tr key={r.email || r.who || `row-${i}`}>
|
||||||
|
<td className={styles.matrixStepCol}>
|
||||||
|
<span className={styles.stepNo}>{r.order}</span>
|
||||||
|
</td>
|
||||||
|
<td data-label={roleLabel}>
|
||||||
|
{r.who}
|
||||||
|
{!r.who && r.warning && <span className={styles.warningInline}>{r.warning}</span>}
|
||||||
|
{r.addedBy && (
|
||||||
|
<span className={styles.addedByInline}>
|
||||||
|
<span className={styles.addedByLabel}>Доп. согласующий, добавил:</span> {r.addedBy}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{r.comment && (
|
||||||
|
<span className={styles.commentInline}>
|
||||||
|
<span className={styles.commentLabel}>Комментарий:</span> {r.comment}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td data-label="Должность">{r.position}</td>
|
||||||
|
{hasStatuses && (
|
||||||
|
<td data-label="Статус">{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}</td>
|
||||||
|
)}
|
||||||
|
{hasDates && <td data-label="Дата">{r.date}</td>}
|
||||||
|
{editable && (
|
||||||
|
<td className={styles.matrixActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.iconBtn}
|
||||||
|
disabled={i === 0}
|
||||||
|
onClick={() => editor.moveUp(i)}
|
||||||
|
title="Выше"
|
||||||
|
>
|
||||||
|
<Icon name="arrow-up" size="sm" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.iconBtn}
|
||||||
|
disabled={i === rows.length - 1}
|
||||||
|
onClick={() => editor.moveDown(i)}
|
||||||
|
title="Ниже"
|
||||||
|
>
|
||||||
|
<Icon name="arrow-down" size="sm" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
||||||
|
onClick={() => editor.remove(i)}
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<Icon name="trash" size="sm" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{editable && !loading && (
|
||||||
|
<div className={styles.matrixPicker}>
|
||||||
|
<UserPicker search={search} onPick={editor.addApprover} placeholder={pickerPlaceholder} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delegate {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid $gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickedList {
|
||||||
|
list-style: none;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickedItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: $gray-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickedOrder {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $gold-500;
|
||||||
|
color: $navy-950;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickedName {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickedEmail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: $gray-500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickedRemove {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-500;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: $white;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.delegateHint {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: $gray-500;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button, Alert, FormField, TextArea, Icon } from '@tnsu/ui-kit';
|
||||||
|
import styles from './DecisionPanel.module.scss';
|
||||||
|
import { ApproverService, ApproverDecision, IPendingRow } from '../approverDecision.service';
|
||||||
|
import { ISiteUser } from '../models/ISiteUser';
|
||||||
|
import { UserPicker } from './UserPicker';
|
||||||
|
|
||||||
|
interface IDecisionPanelProps {
|
||||||
|
service: ApproverService;
|
||||||
|
requestId: number;
|
||||||
|
/** Уведомление после сохранения решения (например, обновить статус на экране). */
|
||||||
|
onDecided?: (status: string) => void | Promise<void>;
|
||||||
|
/** Поиск пользователей — нужен для переназначения (делегирования). Без него
|
||||||
|
* кнопка «Переназначить» не показывается. */
|
||||||
|
search?: (query: string) => Promise<ISiteUser[]>;
|
||||||
|
/** Вызывается после успешного переназначения (обновить экран/матрицу). */
|
||||||
|
onDelegated?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Панель решения текущего шага: согласующий (Согласовать/Отклонить). Если у шага
|
||||||
|
// разрешено переназначение (allowReassign) — можно передать шаг другому сотруднику.
|
||||||
|
export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestId, onDecided, search, onDelegated }) => {
|
||||||
|
const [checking, setChecking] = useState<boolean>(true);
|
||||||
|
const [pendingRow, setPendingRow] = useState<IPendingRow | undefined>(undefined);
|
||||||
|
const [comment, setComment] = useState<string>('');
|
||||||
|
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||||
|
const [resultStatus, setResultStatus] = useState<string | undefined>(undefined);
|
||||||
|
const [delegated, setDelegated] = useState<number>(0);
|
||||||
|
const [showDelegate, setShowDelegate] = useState<boolean>(false);
|
||||||
|
const [picked, setPicked] = useState<ISiteUser[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setChecking(true);
|
||||||
|
service
|
||||||
|
.getPendingRowForCurrentUser(requestId)
|
||||||
|
.then((row) => {
|
||||||
|
if (alive) {
|
||||||
|
setPendingRow(row);
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) {
|
||||||
|
setError(`Не удалось проверить очередь: ${(e as Error).message}`);
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [requestId, service]);
|
||||||
|
|
||||||
|
async function decide(decision: ApproverDecision): Promise<void> {
|
||||||
|
if (!pendingRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (decision === 'reject' && comment.trim().length === 0) {
|
||||||
|
setError('Для отклонения укажите комментарий.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const status = await service.decide(requestId, pendingRow.id, decision, comment.trim());
|
||||||
|
if (onDecided) {
|
||||||
|
try {
|
||||||
|
await onDecided(status);
|
||||||
|
} catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Побочное действие после решения не выполнилось', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setResultStatus(status);
|
||||||
|
setSubmitting(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Не удалось сохранить решение: ${(e as Error).message}`);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickDelegate(user: ISiteUser): void {
|
||||||
|
setPicked((prev) => (prev.some((p) => p.email === user.email) ? prev : [...prev, user]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function unpickDelegate(index: number): void {
|
||||||
|
setPicked((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDelegate(): void {
|
||||||
|
setPicked([]);
|
||||||
|
setShowDelegate(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelegate(): Promise<void> {
|
||||||
|
if (!pendingRow || picked.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await service.delegate(requestId, pendingRow.id, picked);
|
||||||
|
setDelegated(picked.length);
|
||||||
|
setSubmitting(false);
|
||||||
|
if (onDelegated) {
|
||||||
|
onDelegated();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Не удалось переназначить: ${(e as Error).message}`);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canDelegate = !!search && !!pendingRow && (pendingRow.isDelegator || pendingRow.allowReassign);
|
||||||
|
const isDelegator = !!pendingRow?.isDelegator;
|
||||||
|
|
||||||
|
if (checking || !pendingRow) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delegated > 0) {
|
||||||
|
return (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<Alert variant="ok">
|
||||||
|
{delegated === 1
|
||||||
|
? 'Согласующий добавлен перед вами — он рассмотрит заявку раньше вас. Ваш шаг остаётся в маршруте.'
|
||||||
|
: `Согласующих добавлено: ${delegated}. Они рассмотрят заявку раньше вас, в порядке добавления. Ваш шаг остаётся в маршруте.`}
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resultStatus) {
|
||||||
|
return (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<Alert variant="ok">Решение сохранено. Статус заявки: «{resultStatus}».</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delegateBlock = canDelegate ? (
|
||||||
|
<div className={styles.delegate}>
|
||||||
|
{showDelegate ? (
|
||||||
|
<>
|
||||||
|
<FormField label="Кого добавить перед собой">
|
||||||
|
<UserPicker search={search as (query: string) => Promise<ISiteUser[]>} onPick={pickDelegate} placeholder="Начните вводить ФИО сотрудника…" />
|
||||||
|
</FormField>
|
||||||
|
{picked.length > 0 && (
|
||||||
|
<ul className={styles.pickedList}>
|
||||||
|
{picked.map((u, i) => (
|
||||||
|
<li className={styles.pickedItem} key={u.email || u.id}>
|
||||||
|
<span className={styles.pickedOrder}>{i + 1}</span>
|
||||||
|
<span className={styles.pickedName}>
|
||||||
|
{u.name}
|
||||||
|
{u.email && <span className={styles.pickedEmail}>{u.email}</span>}
|
||||||
|
</span>
|
||||||
|
<button type="button" className={styles.pickedRemove} disabled={submitting} aria-label={`Убрать ${u.name}`} onClick={() => unpickDelegate(i)}>
|
||||||
|
<Icon name="close" size="sm" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className={styles.delegateHint}>
|
||||||
|
{picked.length === 0
|
||||||
|
? 'Выберите одного или нескольких сотрудников. Они согласуют заявку до вас, в порядке добавления; ваш шаг остаётся в маршруте.'
|
||||||
|
: 'Согласуют до вас в этом порядке. Ваш шаг остаётся в маршруте.'}
|
||||||
|
</div>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button variant="accent" disabled={submitting || picked.length === 0} onClick={handleDelegate}>
|
||||||
|
<Icon name="people" size="sm" />
|
||||||
|
{picked.length > 1 ? `Добавить (${picked.length})` : 'Добавить'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" disabled={submitting} onClick={cancelDelegate}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button variant="secondary" disabled={submitting} onClick={() => setShowDelegate(true)}>
|
||||||
|
<Icon name="people" size="sm" />
|
||||||
|
Переназначить
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (isDelegator) {
|
||||||
|
return (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<div className={styles.title}>Ваше решение</div>
|
||||||
|
<Alert variant="warn">
|
||||||
|
Заявку сейчас рассматривает сотрудник, которого вы добавили перед собой. Ваш шаг никуда не делся — он
|
||||||
|
вернётся к вам, когда очередь дойдёт. Если добавленный сотрудник недоступен, можно добавить ещё.
|
||||||
|
</Alert>
|
||||||
|
{error && <Alert variant="err">{error}</Alert>}
|
||||||
|
{delegateBlock}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<div className={styles.title}>Ваше решение</div>
|
||||||
|
{error && <Alert variant="err">{error}</Alert>}
|
||||||
|
<FormField label="Комментарий" htmlFor="decisionComment">
|
||||||
|
<TextArea id="decisionComment" value={comment} onChange={setComment} placeholder="Комментарий (обязателен при отклонении)" />
|
||||||
|
</FormField>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button variant="accent" disabled={submitting} onClick={() => decide('approve')}>
|
||||||
|
<Icon name="check" size="sm" />
|
||||||
|
Согласовать
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" disabled={submitting} onClick={() => decide('reject')}>
|
||||||
|
<Icon name="close" size="sm" />
|
||||||
|
Отклонить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{delegateBlock}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Badge, BadgeVariant } from '@tnsu/ui-kit';
|
||||||
|
import { REQUEST_STATUS } from '../models/statuses';
|
||||||
|
|
||||||
|
// Единственное место, где статус строки маршрута превращается в цвет.
|
||||||
|
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||||
|
[REQUEST_STATUS.draft]: 'neutral',
|
||||||
|
[REQUEST_STATUS.queued]: 'muted',
|
||||||
|
[REQUEST_STATUS.onApproval]: 'info',
|
||||||
|
[REQUEST_STATUS.approved]: 'success',
|
||||||
|
[REQUEST_STATUS.rejected]: 'danger'
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IStatusBadgeProps {
|
||||||
|
status: string;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusBadge: React.FC<IStatusBadgeProps> = ({ status, size = 'md' }) => (
|
||||||
|
<Badge variant={STATUS_VARIANT[status] || 'neutral'} size={size}>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './approval.module.scss';
|
||||||
|
import { ISiteUser } from '../models/ISiteUser';
|
||||||
|
|
||||||
|
export interface IUserPickerProps {
|
||||||
|
search: (query: string) => Promise<ISiteUser[]>;
|
||||||
|
onPick: (user: ISiteUser) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Лёгкий поиск пользователя сайта: ввод → выпадающий список → выбор. */
|
||||||
|
export const UserPicker: React.FC<IUserPickerProps> = ({ search, onPick, placeholder }) => {
|
||||||
|
const [query, setQuery] = React.useState<string>('');
|
||||||
|
const [results, setResults] = React.useState<ISiteUser[]>([]);
|
||||||
|
const [busy, setBusy] = React.useState<boolean>(false);
|
||||||
|
const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
|
const handleChange = (value: string): void => {
|
||||||
|
setQuery(value);
|
||||||
|
if (timer.current) {
|
||||||
|
clearTimeout(timer.current);
|
||||||
|
}
|
||||||
|
if (value.trim().length < 2) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer.current = setTimeout(() => {
|
||||||
|
setBusy(true);
|
||||||
|
search(value)
|
||||||
|
.then((r) => {
|
||||||
|
setResults(r);
|
||||||
|
setBusy(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setResults([]);
|
||||||
|
setBusy(false);
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePick = (user: ISiteUser): void => {
|
||||||
|
onPick(user);
|
||||||
|
setQuery('');
|
||||||
|
setResults([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.picker}>
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
value={query}
|
||||||
|
placeholder={placeholder || 'Начните вводить ФИО…'}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
{busy && <div className={styles.pickerNote}>Поиск…</div>}
|
||||||
|
{!busy && results.length > 0 && (
|
||||||
|
<div className={styles.pickerList}>
|
||||||
|
{results.map((u) => (
|
||||||
|
<button key={u.id} type="button" className={styles.pickerItem} onClick={() => handlePick(u)}>
|
||||||
|
{u.name}
|
||||||
|
{u.email && <span className={styles.pickerEmail}>{u.email}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// ---- Общий инпут (пикер) ----
|
||||||
|
.input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: $white;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: $gray-900;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Пикер пользователя ----
|
||||||
|
.picker {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickerNote {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickerList {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickerItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $gray-900;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $navy-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickerEmail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Матрица согласования ----
|
||||||
|
// 35% ширины — по месту это узкая справочная панель, а не главный контент шага;
|
||||||
|
// на планшете/телефоне (см. медиазапрос ниже) сузилась бы до нечитаемого — там на 100%.
|
||||||
|
.matrixWrap {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 35%;
|
||||||
|
min-width: 420px; // не уже самой таблицы (см. .matrix min-width)
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: $gray-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.matrixWrap {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrixTitle {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-800;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrixNote {
|
||||||
|
font-size: 13px;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retryBtn {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
color: $navy-700;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $gold-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrixScroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 420px;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid $gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid $gray-200;
|
||||||
|
color: $gray-900;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentInline {
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: $gray-500;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentLabel {
|
||||||
|
color: $gray-400;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addedByInline {
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: $gold-600;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addedByLabel {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warningInline {
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: $red-500;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrixActions {
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
> * + * {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: $white;
|
||||||
|
color: $navy-700;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $gold-400;
|
||||||
|
color: $gold-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtnDanger {
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $red-500;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrixStepCol {
|
||||||
|
width: 40px;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepNo {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrixPicker {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.matrix {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix tr {
|
||||||
|
display: block;
|
||||||
|
border-bottom: 1px solid $gray-200;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix td {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 0 !important;
|
||||||
|
padding: 3px 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix td[data-label]::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
flex: 0 0 44%;
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentInline,
|
||||||
|
.addedByInline {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrixActions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-top: 8px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { IMatrixRow } from './models/IMatrixRow';
|
||||||
|
import { ISiteUser } from './models/ISiteUser';
|
||||||
|
|
||||||
|
export interface IUseMatrixEditor {
|
||||||
|
rows: IMatrixRow[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | undefined;
|
||||||
|
editable: boolean;
|
||||||
|
/** Перезапросить маршрут ещё раз (после сетевой ошибки) без смены reloadKey. */
|
||||||
|
retry: () => void;
|
||||||
|
addApprover: (user: ISiteUser) => void;
|
||||||
|
moveUp: (index: number) => void;
|
||||||
|
moveDown: (index: number) => void;
|
||||||
|
remove: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статус для только что добавленной строки: если все имеющиеся строки в одном
|
||||||
|
// статусе — новая получает тот же, иначе честно «—» (в какое состояние попадёт,
|
||||||
|
// ещё не решено).
|
||||||
|
function statusForNewRow(rows: IMatrixRow[]): string {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const first = rows[0].status || '—';
|
||||||
|
return rows.every((r) => (r.status || '—') === first) ? first : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function withOrders(rows: IMatrixRow[]): IMatrixRow[] {
|
||||||
|
return rows.map((r, i) => ({ ...r, order: i + 1 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Первый шаг без назначенного человека (email пуст) — писать такую строку в
|
||||||
|
// SharePoint нельзя (Person-поле требует значение), нужно поймать это перед отправкой.
|
||||||
|
export function findUnassignedStep(rows: IMatrixRow[]): IMatrixRow | undefined {
|
||||||
|
return rows.filter((r) => !r.email).sort((a, b) => a.order - b.order)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unassignedStepMessage(rows: IMatrixRow[], fallback: (row: IMatrixRow) => string): string | undefined {
|
||||||
|
const row = findUnassignedStep(rows);
|
||||||
|
if (!row) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return row.warning || fallback(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает матрицу согласования и (если editable) позволяет её редактировать.
|
||||||
|
* @param load загрузчик строк (планируемый маршрут из resolve или строки заявки).
|
||||||
|
* @param reloadKey при изменении — перезагрузка (например, email инициатора).
|
||||||
|
* @param editable доступны ли правки (порядок/добавление/удаление).
|
||||||
|
* @param ready можно ли грузить (false — ждём готовности данных).
|
||||||
|
* @param getJobTitle должность добавляемого согласующего из профиля.
|
||||||
|
*/
|
||||||
|
export function useMatrixEditor(
|
||||||
|
load: () => Promise<IMatrixRow[]>,
|
||||||
|
reloadKey: string,
|
||||||
|
editable: boolean,
|
||||||
|
ready: boolean,
|
||||||
|
getJobTitle: (loginName: string) => Promise<string>
|
||||||
|
): IUseMatrixEditor {
|
||||||
|
const [rows, setRows] = useState<IMatrixRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
const [retrySeq, setRetrySeq] = useState<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let active = true;
|
||||||
|
setLoading(true);
|
||||||
|
load()
|
||||||
|
.then((r) => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRows(r);
|
||||||
|
setError(undefined);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(err.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
// load в зависимостях сознательно нет — когда перечитывать, решает reloadKey.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [reloadKey, ready, retrySeq]);
|
||||||
|
|
||||||
|
const retry = (): void => setRetrySeq((n) => n + 1);
|
||||||
|
|
||||||
|
const appendUser = (user: ISiteUser, position: string): void => {
|
||||||
|
setRows((prev) => {
|
||||||
|
if (user.email && prev.some((r) => r.email === user.email)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const row: IMatrixRow = {
|
||||||
|
order: 0,
|
||||||
|
who: user.name,
|
||||||
|
email: user.email,
|
||||||
|
position,
|
||||||
|
status: statusForNewRow(prev),
|
||||||
|
date: '',
|
||||||
|
comment: '',
|
||||||
|
allowReassign: false
|
||||||
|
};
|
||||||
|
return withOrders([...prev, row]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addApprover = (user: ISiteUser): void => {
|
||||||
|
getJobTitle(user.loginName)
|
||||||
|
.then((position) => appendUser(user, position))
|
||||||
|
.catch(() => appendUser(user, ''));
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveUp = (i: number): void =>
|
||||||
|
setRows((prev) => {
|
||||||
|
if (i <= 0) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = [...prev];
|
||||||
|
[next[i - 1], next[i]] = [next[i], next[i - 1]];
|
||||||
|
return withOrders(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveDown = (i: number): void =>
|
||||||
|
setRows((prev) => {
|
||||||
|
if (i >= prev.length - 1) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = [...prev];
|
||||||
|
[next[i], next[i + 1]] = [next[i + 1], next[i]];
|
||||||
|
return withOrders(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = (i: number): void => setRows((prev) => withOrders(prev.filter((_, idx) => idx !== i)));
|
||||||
|
|
||||||
|
return { rows, loading, error, editable, retry, addApprover, moveUp, moveDown, remove };
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Конфигурация внешнего approval-route API (тот же бэкенд и то же AAD-приложение,
|
||||||
|
// что уже используется в KpsCtFormService.tnsuFetch для /inventory и /Document —
|
||||||
|
// TNSU_API_RESOURCE_ID там равен этому же clientId). Отдельный webApiPermissionRequests
|
||||||
|
// в package-solution.json не нужен: этот resourceId уже одобрен в тенанте.
|
||||||
|
export const APPROVAL_API_CLIENT_ID = '9232d9e2-9807-4753-808f-07250e5474b0';
|
||||||
|
export const APPROVAL_API_BASE_URL = 'https://api.tnsu.kz/approval-route';
|
||||||
Reference in New Issue
Block a user