fix: перенес матрицу согласования для всех таблиц со списков на бэк

This commit is contained in:
Тимур Городецкий
2026-09-11 16:55:21 +05:00
parent a762c755fd
commit 37bc153b98
57 changed files with 2722 additions and 4908 deletions
+9 -2
View File
@@ -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"
} }
] ]
}, },
+10 -2
View File
@@ -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}
@@ -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();
@@ -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,
}; };
} }
@@ -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',
return ( 'На согласовании': 'on_approval',
<> };
{a.notifyWarning && <Alert variant="warn">{a.notifyWarning}</Alert>}
{/* Цепочка согласования — только по Teams-ссылке (ctid); обычный reload → согласование на Шаге 4 */} // Инлайн-цепочка согласования Шага 3 — для согласующего, открывшего СТ по Teams-ссылке (?ctid=),
{isNotificationLink && isReadOnly && a.approvalChain.length > 0 && ( // read-only маршрут + DecisionPanel своей строки. Правка состава/отправка — это Шаг 4, здесь их нет.
<div className={styles.approvalChainSection}> export const InlineApprovalChain: React.FC<Props> = ({ service, formData, onSave, isNotificationLink, isReadOnly }) => {
<h4 className={styles.approvalChainTitle}>Цепочка согласования</h4> const { ct } = formData;
<ol className={styles.approvalChainList}> const ctId = ct?.id;
{a.approvalChain.map((member) => { const [decisionSeq, setDecisionSeq] = useState<number>(0);
const st = APPROVAL_STATUS_STYLE[member.status];
return ( const loadMatrix = useMemo(
<li key={member.id} className={styles.approvalChainRow}> () => (): Promise<IMatrixRow[]> => (ctId ? service.getApprovalMatrixForRequest(ctId) : Promise.resolve([])),
<span className={styles.approvalChainOrder}>{member.order}</span> [service, ctId]
<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>
); );
})} const editor = useMatrixEditor(loadMatrix, `${ctId ?? ''}-${decisionSeq}`, false, !!ctId && isNotificationLink && isReadOnly, (loginName) =>
</ol> service.getJobTitle(loginName)
</div> );
)} const approverService = useMemo(() => (ctId ? service.getApproverService(ctId) : undefined), [service, ctId]);
{a.delegateDialog && ( if (!isNotificationLink || !isReadOnly || !ctId || editor.loading || editor.rows.length === 0) {
<Dialog return null; // цепочка ещё грузится из SP или её нет — блок не показываем
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 && ( async function handleDecided(newStatus: string): Promise<void> {
<Dialog if (!ct) return;
open onSave({ ct: { ...ct, status: RU_STATUS_TO_CT_STATUS[newStatus] || ct.status } });
title={a.actionDialog.action === 'approve' ? 'Согласование' : 'Отклонение'} setDecisionSeq((n) => n + 1);
subText={ await service.notifyActiveApprover(ctId as string).catch(() => undefined);
a.actionDialog.action === 'approve'
? 'Вы подтверждаете согласование СТ? Комментарий необязателен.'
: 'Укажите причину отклонения (обязательно).'
} }
onDismiss={a.closeAction}
footer={ return (
<> <>
<Button variant="secondary" onClick={a.closeAction} disabled={a.isApprovalSubmitting}> <ApprovalMatrix editor={editor} search={(q) => service.searchUsers(q)} />
Отмена {approverService && (
</Button> <DecisionPanel
<Button service={approverService}
variant={a.actionDialog.action === 'approve' ? 'primary' : 'danger'} requestId={parseInt(ctId, 10)}
onClick={a.handleApprovalConfirm} onDecided={handleDecided}
disabled={a.isApprovalSubmitting || (a.actionDialog.action === 'reject' && !a.actionDialog.comment.trim())} search={(q) => service.searchUsers(q)}
> onDelegated={() => setDecisionSeq((n) => n + 1)}
{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>
)} )}
</> </>
); );
@@ -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}>
@@ -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,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) {
@@ -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 или её нет — блок не показываем
}
return ( const loadMatrix = useMemo(
<section className={styles.section}> () => (): Promise<IMatrixRow[]> => (ctId ? service.getApprovalMatrixForRequest(ctId) : Promise.resolve([])),
<h3 className={styles.title}>Цепочка согласования</h3> [service, ctId]
<ol className={styles.list}>
{a.chain.map((member) => {
const meta = APPROVER_STATUS_META[member.status];
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.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>
); );
})} const editor = useMatrixEditor(loadMatrix, `${ctId ?? ''}-${decisionSeq}`, false, !!ctId, (loginName) =>
</ol> service.getJobTitle(loginName)
);
const approverService = useMemo(() => (ctId ? service.getApproverService(ctId) : undefined), [service, ctId]);
{a.error ? <Alert variant="err">{a.error}</Alert> : undefined} if (!ctId || editor.loading || editor.rows.length === 0) {
{a.notifyWarning ? <Alert variant="warn">{a.notifyWarning}</Alert> : undefined} return null; // маршрут ещё грузится из SP или его нет — блок не показываем
{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 async function handleDecided(newStatus: string): Promise<void> {
value={a.action.comment} onSave({ status: RU_STATUS_TO_CT_STATUS[newStatus] || formData.status });
onChange={a.setActionComment} setDecisionSeq((n) => n + 1);
rows={3} await service.notifyActiveApprover(ctId as number).catch(() => undefined);
ariaLabel="Комментарий" }
placeholder={a.action.kind === 'reject' ? 'Причина отклонения…' : 'Комментарий (необязательно)…'}
return (
<section className={styles.body}>
<ApprovalMatrix editor={editor} search={(q) => service.searchUsers(q)} />
{approverService ? (
<DecisionPanel
service={approverService}
requestId={ctId}
onDecided={handleDecided}
search={(q) => service.searchUsers(q)}
onDelegated={() => setDecisionSeq((n) => n + 1)}
/> />
</Dialog>
) : undefined} ) : undefined}
</section> </section>
); );
@@ -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 ? (
<div className={styles.actions}>
<IconButton icon="arrow-up" size="sm" title="Выше" disabled={index === 0} onClick={() => a.moveApprover(member.rowId, 'up')} />
<IconButton icon="arrow-down" size="sm" title="Ниже" disabled={index === a.chain.length - 1} onClick={() => a.moveApprover(member.rowId, 'down')} />
<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>
{!isSubmitted ? (
<div className={styles.footer}> <div className={styles.footer}>
{a.error ? <Alert variant="err">{a.error}</Alert> : undefined} <Button variant="primary" onClick={handleSubmit} disabled={sending || editor.rows.length === 0}>
{a.notifyWarning ? <Alert variant="warn">{a.notifyWarning}</Alert> : undefined} {sending ? <Spinner size="small" /> : 'Отправить на согласование'}
{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}
{(status === 'on_approval' || status === 'rejected') && a.currentUserIsInitiator ? (
<Button variant="secondary" onClick={a.restart} disabled={a.sending}>
<Icon name="refresh" size="sm" />
Перезапустить согласование
</Button>
) : undefined}
</div> </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) {
return [];
} }
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 ?? '') })); // Поиск пользователей сайта — используется пикером согласующего/делегата в ApprovalMatrix/DecisionPanel.
public searchUsers(query: string): Promise<Array<{ id: number; name: string; email: string; loginName: string }>> {
return this.approval.searchUsers(query);
}
// Должность добавляемого согласующего из профиля 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 });
@@ -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,
}); });
@@ -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,
}; };
} }
@@ -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",
return ( "На согласовании": "on_approval",
<> };
{isNotificationLink && isReadOnly && a.approvalChain.length > 0 && (
<section className={styles.section}> // Инлайн-цепочка согласования Шага 3 — для согласующего, открывшего СТ ОИД по Teams-ссылке,
<h3 className={styles.sectionTitle}>Цепочка согласования</h3> // read-only маршрут + DecisionPanel своей строки. Правка состава/отправка — это Шаг 4, здесь их нет.
{a.notifyWarning && <Alert variant="warn">{a.notifyWarning}</Alert>} export const InlineApprovalChain: React.FC<Props> = ({ service, formData, onSave, isNotificationLink, isReadOnly }) => {
<ol className={styles.chainList}> const { oid } = formData;
{a.approvalChain.map((member) => { const oidId = oid?.id;
const st = APPROVAL_STATUS_STYLE[member.status]; const [decisionSeq, setDecisionSeq] = useState<number>(0);
return (
<li key={member.id} className={styles.chainItem}> const loadMatrix = useMemo(
<span className={styles.orderBadge}>{member.order}</span> () => (): Promise<IMatrixRow[]> => (oidId ? service.getApprovalMatrixForRequest(oidId) : Promise.resolve([])),
<span className={styles.memberName}>{member.userName}</span> [service, oidId]
{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>
); );
})} const editor = useMatrixEditor(
</ol> loadMatrix,
</section> `${oidId ?? ""}-${decisionSeq}`,
)} false,
!!oidId && isNotificationLink && isReadOnly,
(loginName) => service.getJobTitle(loginName)
);
const approverService = useMemo(() => (oidId ? service.getApproverService(oidId) : undefined), [service, oidId]);
{a.delegateDialog && ( if (!isNotificationLink || !isReadOnly || !oidId || editor.loading || editor.rows.length === 0) {
<Dialog return null; // маршрут ещё грузится из SP или его нет — блок не показываем
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 && ( async function handleDecided(newStatus: string): Promise<void> {
<Dialog if (!oid) return;
open onSave({ oid: { ...oid, status: RU_STATUS_TO_OID_STATUS[newStatus] || oid.status } });
title={a.actionDialog.action === "approve" ? "Согласование" : "Отклонение"} setDecisionSeq((n) => n + 1);
subText={ await service.notifyActiveApprover(oidId as number).catch(() => undefined);
a.actionDialog.action === "approve"
? "Вы подтверждаете согласование? Комментарий необязателен."
: "Укажите причину отклонения (обязательно)."
} }
onDismiss={a.closeAction}
footer={ return (
<> <>
<Button variant="secondary" onClick={a.closeAction} disabled={a.isApprovalSubmitting}> <ApprovalMatrix editor={editor} search={(q) => service.searchUsers(q)} />
Отмена {approverService && (
</Button> <DecisionPanel
<Button service={approverService}
variant={a.actionDialog.action === "approve" ? "primary" : "danger"} requestId={oidId}
onClick={a.handleApprovalConfirm} onDecided={handleDecided}
disabled={ search={(q) => service.searchUsers(q)}
a.isApprovalSubmitting || onDelegated={() => setDecisionSeq((n) => n + 1)}
(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>
)} )}
</> </>
); );
@@ -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>}
@@ -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,
};
}
@@ -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,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(); async notifyActiveApprover(oidId: number): Promise<void> {
const rows = await this.approval.getForRequest(oidId);
return { const active = rows.filter((r) => r.status === REQUEST_STATUS.onApproval)[0];
id: String(created.Id), if (active) {
spApprovalId: created.Id as number, await this.triggerApprovalNotification(active.id);
order: insertAtOrder,
userId,
userName: name,
status: "pending",
};
} }
async addApprovalDelegate(
oidId: number,
insertAtOrder: number,
userId: number,
name: string,
): 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';