fix: перенес матрицу согласования для всех таблиц со списков на бэк
This commit is contained in:
@@ -8,20 +8,9 @@ import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSe
|
||||
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
|
||||
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
|
||||
import { Step4Approval } from './Step4Approval/Step4Approval';
|
||||
import { ApprovalMember, CTStatus } from './types/domain';
|
||||
import styles from './CtCreateForm.module.scss';
|
||||
import '@tnsu/ui-kit/styles.css';
|
||||
|
||||
function deriveCtStatus(chain: ApprovalMember[]): CTStatus {
|
||||
if (chain.length === 0) return 'formed';
|
||||
if (chain.some(m => m.status === 'rejected')) return 'rejected';
|
||||
if (chain.every(m => m.status === 'approved' || m.status === 'recommends' || m.status === 'skipped')) return 'approved';
|
||||
// Chain is activated only when the initiator has 'recommends' (Рекомендую).
|
||||
// All-pending means chain was saved as draft but not yet activated via Step 4.
|
||||
if (chain.some(m => m.status === 'recommends')) return 'on_approval';
|
||||
return 'formed';
|
||||
}
|
||||
|
||||
const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName, isDarkTheme, siteUrl }) => {
|
||||
const service = useMemo(() => new CtService(context, siteUrl), [context, siteUrl]);
|
||||
const [isRestrictedView, setIsRestrictedView] = useState(false);
|
||||
@@ -46,7 +35,7 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
// astel-k (isRestrictedView) — независимое ограничение, складывается с этим: если его уберут,
|
||||
// правило по цепочке согласования останется. Сопоставление участника — по отображаемому имени
|
||||
// (пока тестируем; при необходимости переключим на ID).
|
||||
const userInApprovalChain = (formData.approvalChain ?? []).some(m => m.userName === userDisplayName);
|
||||
const userInApprovalChain = (formData.approvalMatrix ?? []).some(m => m.who === userDisplayName);
|
||||
const hidePrices = isRestrictedView || (formData.approvalSent === true && !userInApprovalChain);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -54,7 +43,11 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
.then(profile => updateFormData({ initiatorDepartment: profile.department }))
|
||||
.catch(() => undefined);
|
||||
service.getCurrentUserCompany()
|
||||
.then(company => setIsRestrictedView(company.toLowerCase() === 'astel-k'))
|
||||
.then(company => {
|
||||
setIsRestrictedView(company.toLowerCase() === 'astel-k');
|
||||
// Компания инициатора — условие маршрута согласования (см. CtService.getPlannedApprovalMatrix).
|
||||
updateFormData({ initiatorCompany: company });
|
||||
})
|
||||
.catch(() => undefined);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -65,9 +58,9 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
if (!initialCtId) return;
|
||||
Promise.all([
|
||||
service.loadCTById(initialCtId),
|
||||
service.loadApprovalChain(initialCtId),
|
||||
service.getApprovalMatrixForRequest(initialCtId),
|
||||
service.loadPositionsForCT(initialCtId),
|
||||
]).then(async ([ct, approvalChain, positions]) => {
|
||||
]).then(async ([ct, approvalMatrix, positions]) => {
|
||||
const positionSpIds = positions.map(p => p.spId).filter((id): id is number => id !== undefined);
|
||||
const rawOffers = await service.loadFullOffersForPositions(positionSpIds);
|
||||
|
||||
@@ -157,10 +150,9 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
}
|
||||
}
|
||||
|
||||
const derivedStatus = deriveCtStatus(approvalChain);
|
||||
updateFormData({
|
||||
ct: { ...ct, status: derivedStatus },
|
||||
approvalChain,
|
||||
ct,
|
||||
approvalMatrix,
|
||||
// Preserve session selectedPositions — overwriting changes pos.id (Запросы ID → Позиции СТ ID),
|
||||
// which breaks edits keys built from buildOfferKey(sup.id, pos.id) in Step 3.
|
||||
...(isNotificationLink || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}),
|
||||
@@ -179,10 +171,10 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
ensureHighestStep(4);
|
||||
markStepComplete(3);
|
||||
markStepComplete(4);
|
||||
} else if (approvalChain.length > 0) {
|
||||
} else if (approvalMatrix.length > 0) {
|
||||
// Regular reload — make Step 4 accessible; only mark complete if CT is fully resolved
|
||||
ensureHighestStep(4);
|
||||
if (derivedStatus === 'approved' || derivedStatus === 'rejected') {
|
||||
if (ct.status === 'approved' || ct.status === 'rejected') {
|
||||
markStepComplete(4);
|
||||
}
|
||||
}
|
||||
@@ -208,7 +200,6 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
<Step2CommercialOffers
|
||||
service={service}
|
||||
formData={formData}
|
||||
userDisplayName={userDisplayName}
|
||||
isNotificationLink={isNotificationLink}
|
||||
isReadOnly={formData.ct?.status === 'on_approval' || formData.ct?.status === 'approved' || formData.ct?.status === 'rejected'}
|
||||
isRestrictedView={hidePrices}
|
||||
@@ -223,7 +214,6 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
<Step3ComparisonTable
|
||||
service={service}
|
||||
formData={formData}
|
||||
userDisplayName={userDisplayName}
|
||||
isNotificationLink={isNotificationLink}
|
||||
isRestrictedView={hidePrices}
|
||||
onSave={updateFormData}
|
||||
|
||||
+1
-3
@@ -16,7 +16,6 @@ import styles from './Step2CommercialOffers.module.scss';
|
||||
interface Props {
|
||||
service: CtService;
|
||||
formData: CTFormData;
|
||||
userDisplayName: string;
|
||||
isNotificationLink?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
isRestrictedView?: boolean;
|
||||
@@ -31,7 +30,6 @@ interface Props {
|
||||
export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
service,
|
||||
formData,
|
||||
userDisplayName,
|
||||
isNotificationLink,
|
||||
onSave,
|
||||
onNext,
|
||||
@@ -78,7 +76,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
setIsSaving(true);
|
||||
setSaveError(undefined);
|
||||
try {
|
||||
const patch = await persistStep2({ service, formData, kpFiles: kp.kpFiles, currencyRates, noOfferSuppliers, userDisplayName });
|
||||
const patch = await persistStep2({ service, formData, kpFiles: kp.kpFiles, currencyRates, noOfferSuppliers });
|
||||
onSave(patch);
|
||||
onStepComplete(2);
|
||||
onNext();
|
||||
|
||||
+5
-36
@@ -1,4 +1,4 @@
|
||||
import { ApprovalMember, ApprovalMemberStatus, CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
|
||||
import { CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
|
||||
import { CtService } from '../../services/CtService';
|
||||
import { CTFormData } from '../hooks/useCtForm';
|
||||
import { KpFile, NoOfferEntry } from './kpTypes';
|
||||
@@ -9,7 +9,6 @@ export interface PersistStep2Args {
|
||||
kpFiles: KpFile[];
|
||||
currencyRates: CurrencyRate[];
|
||||
noOfferSuppliers: NoOfferEntry[];
|
||||
userDisplayName: string;
|
||||
}
|
||||
|
||||
// Строит поставщиков с КП и их офферы из результатов потока (Syntex). Вынесено из persistStep2,
|
||||
@@ -64,7 +63,7 @@ export function buildKpSuppliersAndOffers(
|
||||
// Сохранение Шага 2: курсы валют, поставщики без КП, поставщики с КП + офферы из результатов потока,
|
||||
// пересборка цепочки согласования из матрицы. Возвращает patch для onSave. Логика 1:1 из исходного handleSave.
|
||||
export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFormData>> {
|
||||
const { service, formData, kpFiles, currencyRates, noOfferSuppliers, userDisplayName } = args;
|
||||
const { service, formData, kpFiles, currencyRates, noOfferSuppliers } = args;
|
||||
|
||||
let savedRates = currencyRates;
|
||||
if (currencyRates.length > 0) {
|
||||
@@ -117,43 +116,13 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
|
||||
const preservedNoOfferSuppliers = (formData.suppliers ?? [])
|
||||
.filter(s => !s.hasOffer && !newNoOfferNames.has(s.name.trim()));
|
||||
|
||||
let approvalChain: ApprovalMember[] = [];
|
||||
if (formData.ct?.id) {
|
||||
await service.deleteApprovalChainByCt(formData.ct.id);
|
||||
let matrix = formData.initiatorDepartment
|
||||
? await service.loadApprovalMatrix(formData.initiatorCompany, formData.initiatorDepartment)
|
||||
: [];
|
||||
if (matrix.length === 0) {
|
||||
matrix = await service.loadApprovalMatrix(formData.initiatorCompany, '');
|
||||
}
|
||||
if (matrix.length > 0) {
|
||||
approvalChain = [
|
||||
{
|
||||
id: 'chain_1',
|
||||
order: 1,
|
||||
userId: String(service.currentUserSpId),
|
||||
userName: userDisplayName,
|
||||
role: 'Инициатор',
|
||||
status: 'pending',
|
||||
},
|
||||
...matrix.map((m, idx) => ({
|
||||
id: `chain_${idx + 2}`,
|
||||
order: idx + 2,
|
||||
userId: String(m.approverSpId),
|
||||
userName: m.approverName,
|
||||
role: m.post,
|
||||
status: 'pending' as ApprovalMemberStatus,
|
||||
canAddApprover: m.canAddApprover,
|
||||
})),
|
||||
];
|
||||
approvalChain = await service.saveApprovalChain(approvalChain, formData.ct.id);
|
||||
}
|
||||
}
|
||||
// Цепочка согласования БОЛЬШЕ не сеется черновиком на Шаге 2: в новом механизме (approval-route
|
||||
// API, см. план миграции согласования) маршрут — это планируемый resolve, который Step4Approval
|
||||
// резолвит «на лету» при открытии шага (CtService.getPlannedApprovalMatrix), писать в SP заранее не нужно.
|
||||
|
||||
return {
|
||||
currencyRates: savedRates,
|
||||
suppliers: [...mergedSuppliersWithOffer, ...preservedNoOfferSuppliers, ...suppliersWithoutOffer],
|
||||
offers: mergedOffers,
|
||||
approvalChain,
|
||||
};
|
||||
}
|
||||
|
||||
+50
-156
@@ -1,168 +1,62 @@
|
||||
import * as React from 'react';
|
||||
import { Button, Alert, Spinner, Dialog, TextArea, TextInput } from '@tnsu/ui-kit';
|
||||
import { ApprovalMemberStatus } from '../types/domain';
|
||||
import { useStep3Approval } from './useStep3Approval';
|
||||
import styles from './Step3ComparisonTable.module.scss';
|
||||
|
||||
const APPROVAL_STATUS_LABELS: Record<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',
|
||||
});
|
||||
}
|
||||
import { useMemo, useState } from 'react';
|
||||
import { CTStatus } from '../types/domain';
|
||||
import { CtService } from '../../services/CtService';
|
||||
import { CTFormData } from '../hooks/useCtForm';
|
||||
import { ApprovalMatrix, DecisionPanel, IMatrixRow, useMatrixEditor } from '../../../../shared/approval';
|
||||
|
||||
interface Props {
|
||||
approval: ReturnType<typeof useStep3Approval>;
|
||||
service: CtService;
|
||||
formData: CTFormData;
|
||||
onSave: (partial: Partial<CTFormData>) => void;
|
||||
isNotificationLink: boolean;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
// Инлайн-цепочка согласования Шага 3 + диалоги (решение/делегат) + баннер уведомлений.
|
||||
// Показывается только по Teams-ссылке (?ctid) в read-only. Логика — в useStep3Approval.
|
||||
export const InlineApprovalChain: React.FC<Props> = ({ approval: a, isNotificationLink, isReadOnly }) => {
|
||||
const RU_STATUS_TO_CT_STATUS: Record<string, CTStatus> = {
|
||||
Отклонено: 'rejected',
|
||||
Согласовано: 'approved',
|
||||
'На согласовании': 'on_approval',
|
||||
};
|
||||
|
||||
// Инлайн-цепочка согласования Шага 3 — для согласующего, открывшего СТ по Teams-ссылке (?ctid=),
|
||||
// read-only маршрут + DecisionPanel своей строки. Правка состава/отправка — это Шаг 4, здесь их нет.
|
||||
export const InlineApprovalChain: React.FC<Props> = ({ service, formData, onSave, isNotificationLink, isReadOnly }) => {
|
||||
const { ct } = formData;
|
||||
const ctId = ct?.id;
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
|
||||
const loadMatrix = useMemo(
|
||||
() => (): Promise<IMatrixRow[]> => (ctId ? service.getApprovalMatrixForRequest(ctId) : Promise.resolve([])),
|
||||
[service, ctId]
|
||||
);
|
||||
const editor = useMatrixEditor(loadMatrix, `${ctId ?? ''}-${decisionSeq}`, false, !!ctId && isNotificationLink && isReadOnly, (loginName) =>
|
||||
service.getJobTitle(loginName)
|
||||
);
|
||||
const approverService = useMemo(() => (ctId ? service.getApproverService(ctId) : undefined), [service, ctId]);
|
||||
|
||||
if (!isNotificationLink || !isReadOnly || !ctId || editor.loading || editor.rows.length === 0) {
|
||||
return null; // цепочка ещё грузится из SP или её нет — блок не показываем
|
||||
}
|
||||
|
||||
async function handleDecided(newStatus: string): Promise<void> {
|
||||
if (!ct) return;
|
||||
onSave({ ct: { ...ct, status: RU_STATUS_TO_CT_STATUS[newStatus] || ct.status } });
|
||||
setDecisionSeq((n) => n + 1);
|
||||
await service.notifyActiveApprover(ctId as string).catch(() => undefined);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{a.notifyWarning && <Alert variant="warn">{a.notifyWarning}</Alert>}
|
||||
|
||||
{/* Цепочка согласования — только по Teams-ссылке (ctid); обычный reload → согласование на Шаге 4 */}
|
||||
{isNotificationLink && isReadOnly && a.approvalChain.length > 0 && (
|
||||
<div className={styles.approvalChainSection}>
|
||||
<h4 className={styles.approvalChainTitle}>Цепочка согласования</h4>
|
||||
<ol className={styles.approvalChainList}>
|
||||
{a.approvalChain.map((member) => {
|
||||
const st = APPROVAL_STATUS_STYLE[member.status];
|
||||
return (
|
||||
<li key={member.id} className={styles.approvalChainRow}>
|
||||
<span className={styles.approvalChainOrder}>{member.order}</span>
|
||||
<span className={styles.approvalChainName}>{member.userName}</span>
|
||||
{member.role && <span className={styles.approvalChainRole}>{member.role}</span>}
|
||||
<span className={styles.approvalChainStatusBadge} style={{ background: st.bg, color: st.color }}>
|
||||
{APPROVAL_STATUS_LABELS[member.status]}
|
||||
</span>
|
||||
{member.comment && <span className={styles.approvalChainComment}>«{member.comment}»</span>}
|
||||
{member.actionDate && <span className={styles.approvalChainDate}>{formatApprovalDate(member.actionDate)}</span>}
|
||||
{a.isOnApproval && a.currentUserMember?.id === member.id && (
|
||||
<div className={styles.approvalChainActions}>
|
||||
{member.canAddApprover && (
|
||||
<Button variant="secondary" onClick={() => a.openDelegate(member)}>
|
||||
Добавить делегата
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" onClick={() => a.openApprove(member)}>
|
||||
Согласовать
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => a.openReject(member)}>
|
||||
Отклонить
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{a.delegateDialog && (
|
||||
<Dialog
|
||||
open
|
||||
title="Добавить делегата"
|
||||
subText="Выбранный пользователь будет вставлен перед вами в цепочку согласования."
|
||||
onDismiss={a.closeDelegate}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={a.closeDelegate} disabled={a.isDelegateSubmitting}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={a.handleAddDelegate}
|
||||
disabled={a.isDelegateSubmitting || !a.delegateDialog.selected}
|
||||
>
|
||||
{a.isDelegateSubmitting ? <Spinner size="small" /> : 'Добавить'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.searchBox}>
|
||||
<TextInput
|
||||
placeholder="Поиск делегата (имя)…"
|
||||
ariaLabel="Поиск делегата"
|
||||
value={a.delegateDialog.query}
|
||||
onChange={a.setDelegateQuery}
|
||||
/>
|
||||
{a.delegateDialog.suggestions.length > 0 && (
|
||||
<ul className={styles.suggestions}>
|
||||
{a.delegateDialog.suggestions.map((s) => (
|
||||
<li key={s.spId} className={styles.suggestion} onClick={() => a.selectDelegate(s)}>
|
||||
<span className={styles.suggestionName}>{s.displayName}</span>
|
||||
<span className={styles.suggestionEmail}>{s.email}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{a.delegateError && (
|
||||
<div className={styles.approvalDialogError}>
|
||||
<Alert variant="err">{a.delegateError}</Alert>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{a.actionDialog && (
|
||||
<Dialog
|
||||
open
|
||||
title={a.actionDialog.action === 'approve' ? 'Согласование' : 'Отклонение'}
|
||||
subText={
|
||||
a.actionDialog.action === 'approve'
|
||||
? 'Вы подтверждаете согласование СТ? Комментарий необязателен.'
|
||||
: 'Укажите причину отклонения (обязательно).'
|
||||
}
|
||||
onDismiss={a.closeAction}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={a.closeAction} disabled={a.isApprovalSubmitting}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant={a.actionDialog.action === 'approve' ? 'primary' : 'danger'}
|
||||
onClick={a.handleApprovalConfirm}
|
||||
disabled={a.isApprovalSubmitting || (a.actionDialog.action === 'reject' && !a.actionDialog.comment.trim())}
|
||||
>
|
||||
{a.isApprovalSubmitting ? <Spinner size="small" /> : a.actionDialog.action === 'approve' ? 'Согласовать' : 'Отклонить'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TextArea
|
||||
value={a.actionDialog.comment}
|
||||
onChange={a.setActionComment}
|
||||
rows={3}
|
||||
ariaLabel="Комментарий"
|
||||
placeholder={a.actionDialog.action === 'reject' ? 'Укажите причину отклонения...' : 'Комментарий (необязательно)...'}
|
||||
/>
|
||||
{a.approvalError && (
|
||||
<div className={styles.approvalDialogError}>
|
||||
<Alert variant="err">{a.approvalError}</Alert>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
<ApprovalMatrix editor={editor} search={(q) => service.searchUsers(q)} />
|
||||
{approverService && (
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
requestId={parseInt(ctId, 10)}
|
||||
onDecided={handleDecided}
|
||||
search={(q) => service.searchUsers(q)}
|
||||
onDelegated={() => setDecisionSeq((n) => n + 1)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+7
-7
@@ -4,7 +4,6 @@ import { FormStep } from '../types/domain';
|
||||
import { CtService } from '../../services/CtService';
|
||||
import { CTFormData } from '../hooks/useCtForm';
|
||||
import styles from './Step3ComparisonTable.module.scss';
|
||||
import { useStep3Approval } from './useStep3Approval';
|
||||
import { InlineApprovalChain } from './InlineApprovalChain';
|
||||
import { useAiComment } from './useAiComment';
|
||||
import { AiCommentBlock } from './AiCommentBlock';
|
||||
@@ -17,7 +16,6 @@ import { useRecommendedPick } from './useRecommendedPick';
|
||||
interface Props {
|
||||
service: CtService;
|
||||
formData: CTFormData;
|
||||
userDisplayName: string;
|
||||
isNotificationLink: boolean;
|
||||
isRestrictedView?: boolean;
|
||||
onSave: (partial: Partial<CTFormData>) => void;
|
||||
@@ -29,7 +27,6 @@ interface Props {
|
||||
export const Step3ComparisonTable: React.FC<Props> = ({
|
||||
service,
|
||||
formData,
|
||||
userDisplayName,
|
||||
isNotificationLink,
|
||||
isRestrictedView = false,
|
||||
onSave,
|
||||
@@ -55,9 +52,6 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
||||
// Выбор рекомендованного поставщика по формуле 80% цена / 20% условия оплаты вынесен в хук.
|
||||
const recPick = useRecommendedPick(service, matrix);
|
||||
|
||||
// Согласование (инлайн-цепочка Teams-ссылки) вынесено в хук.
|
||||
const approval = useStep3Approval({ service, formData, userDisplayName, onSave, onStepComplete });
|
||||
|
||||
// AI-комментарий + печать PDF вынесены в хук.
|
||||
const ai = useAiComment({
|
||||
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}>
|
||||
<Button variant="secondary" onClick={onPrev} disabled={isReadOnly}>
|
||||
|
||||
-221
@@ -1,221 +0,0 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { ApprovalMember, ApprovalMemberStatus, FormStep } from '../types/domain';
|
||||
import { CtService } from '../../services/CtService';
|
||||
import { CTFormData } from '../hooks/useCtForm';
|
||||
|
||||
export type ApprovalAction = 'approve' | 'reject';
|
||||
|
||||
export interface UserSuggestion {
|
||||
spId: number;
|
||||
displayName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ActionDialogState {
|
||||
memberId: string;
|
||||
memberUserId: string;
|
||||
memberName: string;
|
||||
action: ApprovalAction;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export interface DelegateDialogState {
|
||||
memberId: string;
|
||||
memberOrder: number;
|
||||
spApprovalId: number;
|
||||
query: string;
|
||||
suggestions: UserSuggestion[];
|
||||
selected?: UserSuggestion;
|
||||
}
|
||||
|
||||
export interface UseStep3ApprovalArgs {
|
||||
service: CtService;
|
||||
formData: CTFormData;
|
||||
userDisplayName: string;
|
||||
onSave: (partial: Partial<CTFormData>) => void;
|
||||
onStepComplete: (step: FormStep) => void;
|
||||
}
|
||||
|
||||
// Логика инлайн-цепочки согласования на Шаге 3 (для согласующего, открывшего СТ по Teams-ссылке ?ctid=):
|
||||
// голосование approve/reject + нотификации, добавление делегата. Работает поверх formData.approvalChain
|
||||
// через onSave, клубок рефов матрицы не трогает. Перенесено 1:1 из Step3ComparisonTable.
|
||||
export function useStep3Approval(args: UseStep3ApprovalArgs) {
|
||||
const { service, formData, userDisplayName, onSave, onStepComplete } = args;
|
||||
const { ct } = formData;
|
||||
|
||||
const [actionDialog, setActionDialog] = useState<ActionDialogState | null>(null);
|
||||
const [isApprovalSubmitting, setIsApprovalSubmitting] = useState(false);
|
||||
const [approvalError, setApprovalError] = useState<string | null>(null);
|
||||
const [notifyWarning, setNotifyWarning] = useState<string | null>(null);
|
||||
|
||||
const [delegateDialog, setDelegateDialog] = useState<DelegateDialogState | null>(null);
|
||||
const [isDelegateSubmitting, setIsDelegateSubmitting] = useState(false);
|
||||
const [delegateError, setDelegateError] = useState<string | null>(null);
|
||||
const delegateSearchTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
const approvalChain = formData.approvalChain ?? [];
|
||||
const isOnApproval = ct?.status === 'on_approval' || ct?.status === 'approved';
|
||||
const currentUserMember = approvalChain.find(
|
||||
(m) =>
|
||||
m.userName === userDisplayName &&
|
||||
m.status === 'pending' &&
|
||||
m.role !== 'Инициатор' &&
|
||||
approvalChain.filter((prev) => prev.order < m.order).every((prev) => prev.status !== 'pending'),
|
||||
);
|
||||
|
||||
const handleApprovalConfirm = async (): Promise<void> => {
|
||||
if (!actionDialog || !ct) return;
|
||||
const { memberId, action, comment } = actionDialog;
|
||||
if (action === 'reject' && !comment.trim()) return;
|
||||
|
||||
setIsApprovalSubmitting(true);
|
||||
setApprovalError(null);
|
||||
const apiStatus = action === 'approve' ? 'approved' : 'rejected';
|
||||
const currentMember = approvalChain.find((m) => m.id === memberId);
|
||||
|
||||
try {
|
||||
if (!currentMember?.spApprovalId) throw new Error('Не найден ID записи согласования');
|
||||
await service.submitApprovalStep(currentMember.spApprovalId, apiStatus, comment.trim());
|
||||
|
||||
const notify = async (spId: number): Promise<void> => {
|
||||
try {
|
||||
await service.triggerApprovalNotification(spId);
|
||||
} catch (e) {
|
||||
console.error('[Step3] triggerApprovalNotification failed for spId', spId, e);
|
||||
setNotifyWarning('Голос записан, но уведомление в Teams не удалось отправить. Проверьте Power Automate.');
|
||||
}
|
||||
};
|
||||
|
||||
if (action === 'approve') {
|
||||
const updatedChain = approvalChain.map((m) =>
|
||||
m.id === memberId
|
||||
? { ...m, status: 'approved' as ApprovalMemberStatus, comment: comment.trim(), actionDate: new Date().toISOString() }
|
||||
: m,
|
||||
);
|
||||
onSave({ approvalChain: updatedChain });
|
||||
const nextPending = updatedChain.find((m) => m.status === 'pending');
|
||||
if (nextPending?.spApprovalId) {
|
||||
await notify(nextPending.spApprovalId);
|
||||
}
|
||||
const allApproved = updatedChain.every((m) => m.status === 'approved' || m.status === 'skipped' || m.status === 'recommends');
|
||||
const initiator = updatedChain.find((m) => m.role === 'Инициатор');
|
||||
if (allApproved) {
|
||||
await service.updateCTStatus(ct.id, 'approved');
|
||||
onSave({ ct: { ...ct, status: 'approved' } });
|
||||
if (initiator?.spApprovalId) await notify(initiator.spApprovalId);
|
||||
onStepComplete(4);
|
||||
} else if (initiator?.spApprovalId) {
|
||||
await notify(initiator.spApprovalId);
|
||||
}
|
||||
} else {
|
||||
const othersToDelete = approvalChain.filter((m) => m.id !== memberId && m.status === 'pending' && m.spApprovalId);
|
||||
if (othersToDelete.length > 0) {
|
||||
await service.deleteApprovalMembers(othersToDelete.map((m) => m.spApprovalId!));
|
||||
}
|
||||
const finalChain = approvalChain
|
||||
.filter((m) => m.status !== 'pending' || m.id === memberId)
|
||||
.map((m) =>
|
||||
m.id === memberId
|
||||
? { ...m, status: 'rejected' as ApprovalMemberStatus, comment: comment.trim(), actionDate: new Date().toISOString() }
|
||||
: m,
|
||||
);
|
||||
await service.updateCTStatus(ct.id, 'rejected');
|
||||
onSave({ approvalChain: finalChain, ct: { ...ct, status: 'rejected' } });
|
||||
const initiator = finalChain.find((m) => m.role === 'Инициатор');
|
||||
if (initiator?.spApprovalId) await notify(initiator.spApprovalId);
|
||||
}
|
||||
setActionDialog(null);
|
||||
} catch (err) {
|
||||
setApprovalError((err as Error).message ?? 'Ошибка выполнения действия');
|
||||
} finally {
|
||||
setIsApprovalSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setDelegateQuery = (q: string): void => {
|
||||
setDelegateDialog((prev) => (prev ? { ...prev, query: q, suggestions: [], selected: undefined } : prev));
|
||||
if (delegateSearchTimer.current) {
|
||||
clearTimeout(delegateSearchTimer.current);
|
||||
}
|
||||
if (q.trim().length < 2) return;
|
||||
delegateSearchTimer.current = setTimeout(() => {
|
||||
service
|
||||
.searchUsers(q.trim())
|
||||
.then((res) => setDelegateDialog((prev) => (prev ? { ...prev, suggestions: res } : prev)))
|
||||
.catch(() => undefined);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const selectDelegate = (u: UserSuggestion): void =>
|
||||
setDelegateDialog((prev) => (prev ? { ...prev, selected: u, query: u.displayName, suggestions: [] } : prev));
|
||||
|
||||
const handleAddDelegate = async (): Promise<void> => {
|
||||
if (!delegateDialog || !ct || !delegateDialog.selected) return;
|
||||
const delegateUserId = delegateDialog.selected.spId;
|
||||
const delegateName = delegateDialog.selected.displayName;
|
||||
|
||||
setIsDelegateSubmitting(true);
|
||||
setDelegateError(null);
|
||||
try {
|
||||
const newMember = await service.addApprovalDelegate(ct.id, delegateDialog.memberOrder, delegateUserId, delegateName);
|
||||
|
||||
const updatedChain = approvalChain
|
||||
.map((m) => (m.order >= delegateDialog.memberOrder ? { ...m, order: m.order + 1 } : m))
|
||||
.concat(newMember)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
onSave({ approvalChain: updatedChain });
|
||||
|
||||
if (newMember.spApprovalId) {
|
||||
try {
|
||||
await service.triggerApprovalNotification(newMember.spApprovalId);
|
||||
} catch {
|
||||
setNotifyWarning('Делегат добавлен, но уведомление в Teams не удалось отправить. Проверьте Power Automate.');
|
||||
}
|
||||
}
|
||||
setDelegateDialog(null);
|
||||
} catch (err) {
|
||||
setDelegateError((err as Error).message ?? 'Ошибка добавления делегата');
|
||||
} finally {
|
||||
setIsDelegateSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Удобные открыватели диалогов (view не дёргает сырые сеттеры).
|
||||
const openApprove = (member: ApprovalMember): void =>
|
||||
setActionDialog({ memberId: member.id, memberUserId: member.userId, memberName: member.userName, action: 'approve', comment: '' });
|
||||
const openReject = (member: ApprovalMember): void =>
|
||||
setActionDialog({ memberId: member.id, memberUserId: member.userId, memberName: member.userName, action: 'reject', comment: '' });
|
||||
const setActionComment = (val: string): void =>
|
||||
setActionDialog((prev) => (prev ? { ...prev, comment: val } : null));
|
||||
const openDelegate = (member: ApprovalMember): void => {
|
||||
setDelegateDialog({ memberId: member.id, memberOrder: member.order, spApprovalId: member.spApprovalId!, query: '', suggestions: [] });
|
||||
setDelegateError(null);
|
||||
};
|
||||
|
||||
return {
|
||||
approvalChain,
|
||||
isOnApproval,
|
||||
currentUserMember,
|
||||
notifyWarning,
|
||||
setNotifyWarning,
|
||||
// action dialog
|
||||
actionDialog,
|
||||
isApprovalSubmitting,
|
||||
approvalError,
|
||||
openApprove,
|
||||
openReject,
|
||||
setActionComment,
|
||||
closeAction: () => setActionDialog(null),
|
||||
handleApprovalConfirm,
|
||||
// delegate dialog
|
||||
delegateDialog,
|
||||
setDelegateQuery,
|
||||
selectDelegate,
|
||||
isDelegateSubmitting,
|
||||
delegateError,
|
||||
openDelegate,
|
||||
closeDelegate: () => setDelegateDialog(null),
|
||||
handleAddDelegate,
|
||||
};
|
||||
}
|
||||
+1
-251
@@ -1,4 +1,5 @@
|
||||
@import '../common/theme';
|
||||
|
||||
.root {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
@@ -6,253 +7,6 @@
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -270,10 +24,6 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dialogError {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.incoreProgress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import * as React from "react";
|
||||
import { Button, Alert, Spinner, IconButton, Dialog, TextInput, TextArea, Icon, FormField } from "@tnsu/ui-kit";
|
||||
import { ApprovalMemberStatus, FormStep } from "../types/domain";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Alert, Spinner, Dialog, Icon } from "@tnsu/ui-kit";
|
||||
import { CTStatus, FormStep } from "../types/domain";
|
||||
import { CtService } from "../../services/CtService";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
@@ -15,15 +24,15 @@ interface Props {
|
||||
onStepComplete: (step: FormStep) => void;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<ApprovalMemberStatus, string> = {
|
||||
pending: "На согласовании",
|
||||
recommends: "Рекомендую",
|
||||
approved: "Согласовано",
|
||||
rejected: "Отклонено",
|
||||
skipped: "Пропущено",
|
||||
const SUBMITTED_STATUSES: ReadonlySet<CTStatus> = new Set<CTStatus>(["on_approval", "approved", "rejected"]);
|
||||
|
||||
const RU_STATUS_TO_CT_STATUS: Record<string, CTStatus> = {
|
||||
Отклонено: "rejected",
|
||||
Согласовано: "approved",
|
||||
"На согласовании": "on_approval",
|
||||
};
|
||||
|
||||
const INCORE_STEP_LABELS: Record<IncoreStep, string> = {
|
||||
const INCORE_STEP_LABELS: Record<string, string> = {
|
||||
report: "Формирование отчёта СТ…",
|
||||
connecting: "Подключение к NCALayer…",
|
||||
signing: "Ожидание подписи в NCALayer…",
|
||||
@@ -32,229 +41,114 @@ const INCORE_STEP_LABELS: Record<IncoreStep, string> = {
|
||||
building: "Формирование карточки с ЭЦП…",
|
||||
};
|
||||
|
||||
function getStatusClass(status: ApprovalMemberStatus): string {
|
||||
const map: Record<ApprovalMemberStatus, string> = {
|
||||
pending: styles.statusPending,
|
||||
recommends: styles.statusApproved,
|
||||
approved: styles.statusApproved,
|
||||
rejected: styles.statusRejected,
|
||||
skipped: styles.statusSkipped,
|
||||
};
|
||||
return map[status];
|
||||
}
|
||||
// Шаг 4 «Согласование» (см. план миграции согласования на approval-route API): маршрут — планируемый
|
||||
// (до отправки, resolve по email/отделу/компании инициатора) или уже записанный (после отправки).
|
||||
// Редактирование состава/решения — общие компоненты shared/approval. Постобработка ЭЦП/INCORE — see useIncore.
|
||||
export const Step4Approval: React.FC<Props> = ({ service, formData, onPrev, onSave, onStepComplete }) => {
|
||||
const { ct } = formData;
|
||||
const ctId = ct?.id;
|
||||
const status: CTStatus = ct?.status ?? "formed";
|
||||
const isSubmitted = SUBMITTED_STATUSES.has(status);
|
||||
const isInitiator = !!ct?.authorEmail && ct.authorEmail === service.currentUserEmail;
|
||||
|
||||
function formatDate(iso?: string): string {
|
||||
if (!iso) return "";
|
||||
return new Date(iso).toLocaleString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
const [sending, setSending] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
export const Step4Approval: React.FC<Props> = (props) => {
|
||||
const { onPrev } = props;
|
||||
const a = useStep4Approval(props);
|
||||
const incore = useIncore({ service, formData, isInitiator });
|
||||
|
||||
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 (
|
||||
<div className={styles.root}>
|
||||
{/* Current approval status banner */}
|
||||
{a.ct?.status === "approved" && <Alert variant="ok">СТ согласована всеми участниками.</Alert>}
|
||||
{a.ct?.status === "rejected" && (
|
||||
<Alert variant="err">СТ отклонена. Требуется корректировка и повторная отправка.</Alert>
|
||||
)}
|
||||
{a.ct?.status === "on_approval" && <Alert variant="info">СТ находится на согласовании.</Alert>}
|
||||
{status === "approved" && <Alert variant="ok">СТ согласована всеми участниками.</Alert>}
|
||||
{status === "rejected" && <Alert variant="err">СТ отклонена. Требуется корректировка и повторная отправка.</Alert>}
|
||||
{status === "on_approval" && <Alert variant="info">СТ находится на согласовании.</Alert>}
|
||||
|
||||
<ApprovalMatrix editor={editor} search={(q) => service.searchUsers(q)} />
|
||||
|
||||
{/* Approval chain */}
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Цепочка согласования</h3>
|
||||
{error && <Alert variant="err">{error}</Alert>}
|
||||
|
||||
{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}>
|
||||
<Button variant="secondary" onClick={onPrev} disabled={a.isOnApproval}>
|
||||
<Button variant="secondary" onClick={onPrev} disabled={isSubmitted}>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
Назад
|
||||
</Button>
|
||||
|
||||
<div className={styles.footerRight}>
|
||||
{a.submitError && <Alert variant="err">{a.submitError}</Alert>}
|
||||
|
||||
{!a.isOnApproval && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={a.handleSendForApproval}
|
||||
disabled={a.chain.length === 0 || a.isSubmitting || !a.ct}
|
||||
>
|
||||
{a.isSubmitting ? (
|
||||
{!isSubmitted && (
|
||||
<Button variant="primary" onClick={handleSubmit} disabled={sending || editor.rows.length === 0}>
|
||||
{sending ? (
|
||||
<Spinner size="small" />
|
||||
) : (
|
||||
<>
|
||||
@@ -265,23 +159,21 @@ export const Step4Approval: React.FC<Props> = (props) => {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* INCORE: согласованная СТ — финал. Есть карточка во вложении → показываем файл;
|
||||
нет → кнопка формирования. Только для инициатора. */}
|
||||
{a.isInitiator && a.ct?.status === "approved" && !a.isLoadingCard && (
|
||||
a.cardAttachment ? (
|
||||
{isInitiator && status === "approved" && !incore.isLoadingCard && (
|
||||
incore.cardAttachment ? (
|
||||
<a
|
||||
className={styles.cardLink}
|
||||
href={a.cardAttachment.url}
|
||||
href={incore.cardAttachment.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Открыть карточку электронного документа"
|
||||
>
|
||||
<Icon name="download" size="sm" />
|
||||
{a.cardAttachment.name}
|
||||
{incore.cardAttachment.name}
|
||||
</a>
|
||||
) : (
|
||||
<Button variant="secondary" onClick={a.handleSendToIncore} disabled={a.isSendingToIncore}>
|
||||
{a.isSendingToIncore ? (
|
||||
<Button variant="secondary" onClick={incore.handleSendToIncore} disabled={incore.isSendingToIncore}>
|
||||
{incore.isSendingToIncore ? (
|
||||
<Spinner size="small" />
|
||||
) : (
|
||||
<>
|
||||
@@ -293,9 +185,8 @@ export const Step4Approval: React.FC<Props> = (props) => {
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Перезапуск — пока СТ на согласовании (ещё не согласована) */}
|
||||
{a.isOnApproval && a.isInitiator && a.ct?.status !== "approved" && (
|
||||
<Button variant="secondary" onClick={a.handleRestart} disabled={a.isSubmitting}>
|
||||
{isSubmitted && isInitiator && status !== "approved" && (
|
||||
<Button variant="secondary" onClick={handleRestart} disabled={sending}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Перезапустить согласование
|
||||
</Button>
|
||||
@@ -303,141 +194,40 @@ export const Step4Approval: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Replace member dialog */}
|
||||
{a.replaceDialog && (
|
||||
<Dialog
|
||||
open
|
||||
title="Заменить согласующего"
|
||||
subText="Найдите нового участника для замены."
|
||||
onDismiss={a.closeReplace}
|
||||
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>
|
||||
{ctId && approverService && (
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
requestId={parseInt(ctId, 10)}
|
||||
onDecided={handleDecided}
|
||||
search={(q) => service.searchUsers(q)}
|
||||
onDelegated={() => setDecisionSeq((n) => n + 1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Approve / Reject 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>
|
||||
)}
|
||||
|
||||
{/* INCORE: прогресс подписания и сборки карточки */}
|
||||
{a.incoreStep && (
|
||||
<Dialog
|
||||
open
|
||||
title="Подписание и выгрузка в INCORE"
|
||||
onDismiss={() => undefined}
|
||||
>
|
||||
{incore.incoreStep && (
|
||||
<Dialog open title="Подписание и выгрузка в INCORE" onDismiss={() => undefined}>
|
||||
<div className={styles.incoreProgress}>
|
||||
<Spinner size="small" />
|
||||
<span>{INCORE_STEP_LABELS[a.incoreStep]}</span>
|
||||
<span>{INCORE_STEP_LABELS[incore.incoreStep]}</span>
|
||||
</div>
|
||||
{a.incoreStep === "signing" && (
|
||||
<p className={styles.incoreHint}>
|
||||
Выберите сертификат ЭЦП и введите PIN-код в открывшемся окне
|
||||
NCALayer.
|
||||
</p>
|
||||
{incore.incoreStep === "signing" && (
|
||||
<p className={styles.incoreHint}>Выберите сертификат ЭЦП и введите PIN-код в открывшемся окне NCALayer.</p>
|
||||
)}
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* INCORE: ошибка подписания */}
|
||||
{a.incoreError && (
|
||||
{incore.incoreError && (
|
||||
<Dialog
|
||||
open
|
||||
title="Ошибка выгрузки в INCORE"
|
||||
onDismiss={a.dismissIncoreError}
|
||||
onDismiss={incore.dismissIncoreError}
|
||||
footer={
|
||||
<Button variant="secondary" onClick={a.dismissIncoreError}>
|
||||
<Button variant="secondary" onClick={incore.dismissIncoreError}>
|
||||
Закрыть
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Alert variant="err">{a.incoreError}</Alert>
|
||||
<Alert variant="err">{incore.incoreError}</Alert>
|
||||
</Dialog>
|
||||
)}
|
||||
</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 { 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 {
|
||||
ct: CT | undefined;
|
||||
@@ -9,7 +10,9 @@ export interface CTFormData {
|
||||
currencyRates: CurrencyRate[];
|
||||
offers: SupplierOffer[];
|
||||
recommendedSupplierId: string | undefined;
|
||||
approvalChain: ApprovalMember[];
|
||||
// Маршрут согласования — только для отображения (например, hidePrices в CtCreateForm.tsx);
|
||||
// решения читают/пишут его напрямую через CtService (shared/approval), не через formData.
|
||||
approvalMatrix: IMatrixRow[];
|
||||
initiatorDepartment: string;
|
||||
initiatorCompany: string;
|
||||
step3ExtraSuppliers: Array<{ id: string; name: string; currency: string; spId?: number; status?: string }>;
|
||||
@@ -30,7 +33,7 @@ const INITIAL_FORM_DATA: CTFormData = {
|
||||
currencyRates: [],
|
||||
offers: [],
|
||||
recommendedSupplierId: undefined,
|
||||
approvalChain: [],
|
||||
approvalMatrix: [],
|
||||
initiatorDepartment: '',
|
||||
initiatorCompany: '',
|
||||
step3ExtraSuppliers: [],
|
||||
|
||||
@@ -37,7 +37,11 @@ export interface CT {
|
||||
id: string;
|
||||
number: string; // sequential number within project
|
||||
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 =
|
||||
@@ -149,26 +153,6 @@ export interface SupplierCriteria {
|
||||
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 const FORM_STEPS: Array<{ step: FormStep; label: string }> = [
|
||||
|
||||
@@ -9,12 +9,21 @@ import {
|
||||
Supplier,
|
||||
SupplierOffer,
|
||||
SupplierCriteria,
|
||||
ApprovalMember,
|
||||
ApprovalMemberStatus,
|
||||
SyntexFields,
|
||||
FlowResult,
|
||||
AdataCompanyInfo,
|
||||
} 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).
|
||||
function round2(value: number): number {
|
||||
@@ -52,6 +61,39 @@ type ApiPosition = Record<string, unknown>;
|
||||
|
||||
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 {
|
||||
private readonly baseUrl: string;
|
||||
private readonly http: SPHttpClient;
|
||||
@@ -175,6 +217,9 @@ export class CtService {
|
||||
id: String(data.Id),
|
||||
number: ctNumber,
|
||||
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> {
|
||||
const filter = encodeURIComponent(`CTId eq ${parseInt(ctId, 10)}`);
|
||||
const res = await this.http.get(
|
||||
`${this._li(LISTS.APPROVAL_CHAIN)}?$select=Id&$filter=${filter}`,
|
||||
SPHttpClient.configurations.v1,
|
||||
);
|
||||
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": "*" },
|
||||
},
|
||||
);
|
||||
private _approval: ApprovalListService | undefined;
|
||||
|
||||
private get approval(): ApprovalListService {
|
||||
if (!this._approval) {
|
||||
this._approval = new ApprovalListService(this.context, LISTS.APPROVAL_CHAIN, this.baseUrl, APPROVAL_FIELDS);
|
||||
}
|
||||
return this._approval;
|
||||
}
|
||||
|
||||
async saveApprovalChain(
|
||||
chain: ApprovalMember[],
|
||||
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;
|
||||
get currentUserEmail(): string {
|
||||
return this.context.pageContext.user.email || "";
|
||||
}
|
||||
|
||||
async activateApprovalChain(chain: ApprovalMember[]): Promise<void> {
|
||||
for (const m of chain) {
|
||||
if (!m.spApprovalId) continue;
|
||||
const status = m.role === "Инициатор" ? "Рекомендую" : "На согласовании";
|
||||
// Пишем order0 = позиция участника у инициатора. Удаление/добавление согласующих
|
||||
// пересчитывает порядок только локально; здесь фиксируем его в SP, иначе согласующий
|
||||
// при свежей загрузке (сортировка по order0) увидит другой порядок и не ту очередь.
|
||||
const res = await this.http.fetch(
|
||||
this._liById(LISTS.APPROVAL_CHAIN, m.spApprovalId),
|
||||
SPHttpClient.configurations.v1,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json;odata=nometadata",
|
||||
"IF-MATCH": "*",
|
||||
"X-HTTP-Method": "MERGE",
|
||||
},
|
||||
body: JSON.stringify({ status, order0: m.order, Title: String(m.order) }),
|
||||
},
|
||||
);
|
||||
if (!res.ok)
|
||||
throw new Error(
|
||||
`Ошибка активации согласующего ${m.order}: ${res.status}`,
|
||||
);
|
||||
resolveRequestTypeId(): Promise<number> {
|
||||
return getRequestTypeIdByPageUrl(this.context, CT_REQUEST_TYPE_PAGE_URL);
|
||||
}
|
||||
|
||||
// department/company — фильтры маршрута, как в прежней «Матрица согласования СТ» — ОБА условием
|
||||
// видимости (fieldValues), НЕ через параметр departmentId: тот в approval-route — GUID из
|
||||
// справочника отделов (см. ошибку 400 "$.departmentId: JSON value could not be converted" —
|
||||
// бэкенд ждёт GUID, а не текст должности/отдела из профиля SharePoint). Имена полей
|
||||
// 'company'/'department' должны совпадать с тем, что настроено в админке маршрутов для типа «ct».
|
||||
async getPlannedApprovalMatrix(userEmail: string, department: string, company: string): Promise<IMatrixRow[]> {
|
||||
const requestTypeId = await this.resolveRequestTypeId();
|
||||
const fieldValues: Array<{ fieldName: string; value: string }> = [];
|
||||
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);
|
||||
}
|
||||
|
||||
getApprovalMatrixForRequest(ctId: string): Promise<IMatrixRow[]> {
|
||||
return this.approval.getMatrixForRequest(parseInt(ctId, 10));
|
||||
}
|
||||
|
||||
submitApprovalRoute(ctId: string, approvers: IApproverToWrite[]): Promise<void> {
|
||||
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> {
|
||||
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);
|
||||
if (!res.ok) throw new Error(`Ошибка загрузки СТ: ${res.status}`);
|
||||
const data = await res.json();
|
||||
@@ -733,6 +655,8 @@ export class CtService {
|
||||
id: String(data.Id),
|
||||
number: String(data.number ?? data.Title ?? ""),
|
||||
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[]> {
|
||||
const url =
|
||||
`${this._li(LISTS.APPROVAL_CHAIN)}` +
|
||||
`?$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"]),
|
||||
};
|
||||
},
|
||||
);
|
||||
// Поиск пользователей сайта — используется пикером согласующего/делегата в ApprovalMatrix/DecisionPanel.
|
||||
searchUsers(query: string): Promise<Array<{ id: number; name: string; email: string; loginName: string }>> {
|
||||
return this.approval.searchUsers(query);
|
||||
}
|
||||
|
||||
async addApprovalDelegate(
|
||||
ctId: string,
|
||||
insertAtOrder: number,
|
||||
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";
|
||||
}
|
||||
// Должность добавляемого согласующего из профиля SharePoint — для useMatrixEditor.addApprover.
|
||||
getJobTitle(loginName: string): Promise<string> {
|
||||
return this.approval.getJobTitle(loginName);
|
||||
}
|
||||
|
||||
// ─── Коммерческие предложения (библиотека) ────────────────────────────────
|
||||
@@ -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: {
|
||||
ctId: string;
|
||||
ctNumber: string;
|
||||
|
||||
@@ -105,28 +105,19 @@ function renderStep(step: FormStep, stepProps: StepProps): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
// Деривация статуса сущности из цепочки согласования (§12), по образцу CT (deriveCtStatus в
|
||||
// CtCreateForm.tsx). До появления цепочки (Шаги 1–3) статус берём как есть из formData.status —
|
||||
// его пишут явно (Step1 → 'draft', Step4 → 'on_approval'/'approved'/'rejected'/'formed').
|
||||
// Когда в цепочке есть участники, статус выводим из их решений — это подстраховка на случай,
|
||||
// если formData.status не успел обновиться (напр. цепочка подгружена из SP при повторном входе).
|
||||
// Статус сущности — как есть из formData.status. Раньше был подстрахован деривацией из цепочки
|
||||
// согласования (formData.approvers), пока цепочка жила в самом formData; теперь маршрут — отдельная
|
||||
// сущность (список «Согласование СТ КПС» через shared/approval), а статус СТ пишется напрямую
|
||||
// (KpsCtFormService.updateCtStatus) сразу при отправке и при каждом решении согласующего
|
||||
// (ApproverService.decide → onStatusComputed), так что formData.status уже авторитетен.
|
||||
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;
|
||||
}
|
||||
|
||||
// Самый дальний «заполненный» шаг существующей СТ — для приземления при входе по URL (режим редактирования).
|
||||
// По фактическим данным реконструкции: цепочка/на согласовании → 4, есть офферы → 3, есть поставщики → 2,
|
||||
// иначе (есть позиции) → 1.
|
||||
// Статус «на согласовании»/«согласована»/«отклонена» → 4, есть офферы → 3, есть поставщики → 2, иначе → 1.
|
||||
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;
|
||||
}
|
||||
if (data.offers.length > 0) {
|
||||
|
||||
+42
-74
@@ -1,10 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { Button, Dialog, Alert, TextArea, Icon } from '@tnsu/ui-kit';
|
||||
import { Pill } from '../common/Pill';
|
||||
import { APPROVER_STATUS_META, CtFormData, FormStep } from '../types/domain';
|
||||
import { formatDateTime } from '../utils/format';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { CtFormData, CtStatus, FormStep } from '../types/domain';
|
||||
import { KpsCtFormService } from '../../services/KpsCtFormService';
|
||||
import { useStep4Approval } from './useStep4Approval';
|
||||
import { ApprovalMatrix, DecisionPanel, IMatrixRow, useMatrixEditor } from '../../../../shared/approval';
|
||||
import styles from './Step4Approval.module.scss';
|
||||
|
||||
interface InlineApprovalChainProps {
|
||||
@@ -15,79 +13,49 @@ interface InlineApprovalChainProps {
|
||||
userDisplayName: string;
|
||||
}
|
||||
|
||||
// Инлайн-цепочка согласования на Шаге 3 — для согласующего, открывшего СТ по ссылке-уведомлению (read-only,
|
||||
// как в эталоне). Переиспользует useStep4Approval (сам грузит цепочку из SP): только просмотр статусов +
|
||||
// «Согласовать/Отклонить» своей строки + диалог. Правка цепочки/отправка — это Шаг 4, здесь их нет.
|
||||
const RU_STATUS_TO_CT_STATUS: Record<string, CtStatus> = {
|
||||
Отклонено: 'rejected',
|
||||
Согласовано: 'approved',
|
||||
'На согласовании': 'on_approval'
|
||||
};
|
||||
|
||||
// Инлайн-цепочка согласования на Шаге 3 — для согласующего, открывшего СТ по ссылке-уведомлению
|
||||
// (read-only маршрут + DecisionPanel своей строки). Правка состава/отправка — это Шаг 4, здесь их нет.
|
||||
export function InlineApprovalChain(props: InlineApprovalChainProps): JSX.Element | null {
|
||||
// Инлайн-блок только читает цепочку из SP и даёт проголосовать — сид новой цепочки здесь запрещён.
|
||||
const a = useStep4Approval({ ...props, canSeed: false });
|
||||
if (a.chain.length === 0) {
|
||||
return null; // цепочка ещё грузится из SP или её нет — блок не показываем
|
||||
const { service, formData, onSave } = props;
|
||||
const ctId = formData.ctId;
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
|
||||
const loadMatrix = useMemo(
|
||||
() => (): Promise<IMatrixRow[]> => (ctId ? service.getApprovalMatrixForRequest(ctId) : Promise.resolve([])),
|
||||
[service, ctId]
|
||||
);
|
||||
const editor = useMatrixEditor(loadMatrix, `${ctId ?? ''}-${decisionSeq}`, false, !!ctId, (loginName) =>
|
||||
service.getJobTitle(loginName)
|
||||
);
|
||||
const approverService = useMemo(() => (ctId ? service.getApproverService(ctId) : undefined), [service, ctId]);
|
||||
|
||||
if (!ctId || editor.loading || editor.rows.length === 0) {
|
||||
return null; // маршрут ещё грузится из SP или его нет — блок не показываем
|
||||
}
|
||||
|
||||
async function handleDecided(newStatus: string): Promise<void> {
|
||||
onSave({ status: RU_STATUS_TO_CT_STATUS[newStatus] || formData.status });
|
||||
setDecisionSeq((n) => n + 1);
|
||||
await service.notifyActiveApprover(ctId as number).catch(() => undefined);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.title}>Цепочка согласования</h3>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{a.error ? <Alert variant="err">{a.error}</Alert> : undefined}
|
||||
{a.notifyWarning ? <Alert variant="warn">{a.notifyWarning}</Alert> : undefined}
|
||||
|
||||
{a.action ? (
|
||||
<Dialog
|
||||
open
|
||||
title={a.action.kind === 'approve' ? 'Согласование' : 'Отклонение'}
|
||||
subText={a.action.kind === 'approve' ? 'Подтвердите согласование СТ. Комментарий необязателен.' : 'Укажите причину отклонения (обязательно).'}
|
||||
onDismiss={a.closeAction}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={a.closeAction} disabled={a.sending}>Отмена</Button>
|
||||
<Button
|
||||
variant={a.action.kind === 'approve' ? 'primary' : 'danger'}
|
||||
onClick={a.submitAction}
|
||||
disabled={a.sending || (a.action.kind === 'reject' && !a.action.comment.trim())}
|
||||
>
|
||||
{a.action.kind === 'approve' ? 'Согласовать' : 'Отклонить'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TextArea
|
||||
value={a.action.comment}
|
||||
onChange={a.setActionComment}
|
||||
rows={3}
|
||||
ariaLabel="Комментарий"
|
||||
placeholder={a.action.kind === 'reject' ? 'Причина отклонения…' : 'Комментарий (необязательно)…'}
|
||||
/>
|
||||
</Dialog>
|
||||
<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)}
|
||||
/>
|
||||
) : undefined}
|
||||
</section>
|
||||
);
|
||||
|
||||
-135
@@ -6,141 +6,6 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,18 +1,91 @@
|
||||
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 { Pill } from '../common/Pill';
|
||||
import { APPROVER_STATUS_META, StepProps } from '../types/domain';
|
||||
import { formatDateTime } from '../utils/format';
|
||||
import { useStep4Approval } from './useStep4Approval';
|
||||
import { CtStatus, StepProps } from '../types/domain';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
DecisionPanel,
|
||||
IApproverToWrite,
|
||||
IMatrixRow,
|
||||
useMatrixEditor,
|
||||
findUnassignedStep
|
||||
} from '../../../../shared/approval';
|
||||
import styles from './Step4Approval.module.scss';
|
||||
|
||||
// Шаг 4 «Согласование» (§12): цепочка согласования. Логика — в useStep4Approval.
|
||||
// Оповещение согласующих в Teams (4c) — через notify; замена участника — через диалог openReplace.
|
||||
const SUBMITTED_STATUSES: ReadonlySet<CtStatus> = new Set<CtStatus>(['on_approval', 'approved', 'rejected']);
|
||||
|
||||
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 {
|
||||
const { service, formData, onSave, onPrev, onStepComplete, userDisplayName } = props;
|
||||
const a = useStep4Approval({ service, formData, onSave, onStepComplete, userDisplayName });
|
||||
const { service, formData, onPrev, onSave, onStepComplete } = props;
|
||||
const ctId = formData.ctId;
|
||||
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 (
|
||||
<StepShell title="Согласование" onPrev={onPrev}>
|
||||
@@ -21,169 +94,26 @@ export function Step4Approval(props: StepProps): JSX.Element {
|
||||
{status === 'rejected' ? <Alert variant="err">СТ отклонена. Требуется корректировка и повторная отправка.</Alert> : undefined}
|
||||
{status === 'on_approval' ? <Alert variant="info">СТ находится на согласовании.</Alert> : undefined}
|
||||
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.title}>Цепочка согласования</h3>
|
||||
<ApprovalMatrix editor={editor} search={(q) => service.searchUsers(q)} />
|
||||
|
||||
{a.chain.length === 0 ? (
|
||||
<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}
|
||||
{error ? <Alert variant="err">{error}</Alert> : 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>
|
||||
|
||||
<div className={styles.footer}>
|
||||
{a.error ? <Alert variant="err">{a.error}</Alert> : undefined}
|
||||
{a.notifyWarning ? <Alert variant="warn">{a.notifyWarning}</Alert> : undefined}
|
||||
{a.canEdit ? (
|
||||
<Button variant="primary" onClick={a.sendForApproval} disabled={a.chain.length === 0 || a.sending}>
|
||||
{a.sending ? (
|
||||
<Spinner size="small" />
|
||||
) : (
|
||||
<>
|
||||
<Icon name="send" size="sm" />
|
||||
Отправить на согласование
|
||||
</>
|
||||
)}
|
||||
{!isSubmitted ? (
|
||||
<div className={styles.footer}>
|
||||
<Button variant="primary" onClick={handleSubmit} disabled={sending || editor.rows.length === 0}>
|
||||
{sending ? <Spinner size="small" /> : 'Отправить на согласование'}
|
||||
</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>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
) : undefined}
|
||||
|
||||
{a.replace ? (
|
||||
<Dialog
|
||||
open
|
||||
title="Заменить согласующего"
|
||||
subText={`Текущий: ${a.replace.name}. Найдите нового участника.`}
|
||||
onDismiss={a.closeReplace}
|
||||
footer={
|
||||
<>
|
||||
<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>
|
||||
{isSubmitted && ctId && approverService ? (
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
requestId={ctId}
|
||||
onDecided={handleDecided}
|
||||
search={(q) => service.searchUsers(q)}
|
||||
onDelegated={() => setDecisionSeq((n) => n + 1)}
|
||||
/>
|
||||
) : undefined}
|
||||
</div>
|
||||
</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' }
|
||||
};
|
||||
|
||||
// Статус участника цепочки согласования.
|
||||
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 и хук формы.
|
||||
@@ -130,22 +113,6 @@ export interface PurchaseRequestDetail {
|
||||
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 = строка заявки + контекст родителя и проекта.
|
||||
// Снимок нужных полей, чтобы Шаги 2–4 (КП, СТ, согласование) не тянули деталь заново.
|
||||
export interface SelectedPosition {
|
||||
@@ -279,8 +246,6 @@ export interface CtFormData {
|
||||
recommendedSupplierId?: string; // выбранный поставщик (локальный id)
|
||||
// TODO: step3PositionQtyOverrides
|
||||
|
||||
// Шаг 4 — согласование
|
||||
approvers: Approver[];
|
||||
winSupplierId?: number; // выбранный поставщик (lookup в SP)
|
||||
}
|
||||
|
||||
@@ -292,8 +257,7 @@ export const EMPTY_FORM_DATA: CtFormData = {
|
||||
currencyRates: [],
|
||||
offers: [],
|
||||
step3ExtraPositions: [],
|
||||
criteria: [],
|
||||
approvers: []
|
||||
criteria: []
|
||||
};
|
||||
|
||||
// --- Единый контракт props для всех шагов (§5.2) ---
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { SPHttpClient, SPHttpClientResponse, ISPHttpClientOptions, AadHttpClient } from '@microsoft/sp-http';
|
||||
import {
|
||||
ApprovalListService,
|
||||
ApproverService,
|
||||
REQUEST_STATUS,
|
||||
getRequestTypeIdByPageUrl,
|
||||
getPlannedMatrix,
|
||||
writeRoute,
|
||||
IApproverToWrite,
|
||||
IMatrixRow
|
||||
} from '../../../shared/approval';
|
||||
import type {
|
||||
Approver,
|
||||
ApproverStatus,
|
||||
CtFormData,
|
||||
CtStatus,
|
||||
CurrencyRate,
|
||||
@@ -148,57 +156,24 @@ const CRITERIA_FIELDS = {
|
||||
comment: 'comment' // Примечание (Text)
|
||||
} as const;
|
||||
|
||||
// Список «Согласование СТ» (цепочка согласования Шага 4).
|
||||
const APPROVAL_FIELDS = {
|
||||
title: 'Title',
|
||||
ct: 'CT', // Lookup → «Сравнительная таблица» (body CTId)
|
||||
order: 'order0', // Number — порядок
|
||||
aproover: 'aproover', // Person — согласующий (body aprooverId)
|
||||
status: 'status', // Choice
|
||||
aproovDate: 'aproovDate', // DateTime — дата решения
|
||||
comment: 'comment' // Text
|
||||
} as const;
|
||||
// pageUrl типа заявки «КПС» в админке approval-route — заводит пользователь (см. план миграции
|
||||
// согласования на approval-route API). Имена полей списка «Согласование СТ КПС» (CT/aproover/status/
|
||||
// aproovDate/comment/order0 + новые allowReassign/delegatedBy) совпадают с DEFAULT_APPROVAL_FIELDS
|
||||
// в shared/approval/approvalList.service.ts — отдельный маппинг здесь не нужен.
|
||||
const KPS_REQUEST_TYPE_PAGE_URL = 'kps';
|
||||
|
||||
// Статус согласующего → Choice «Статус» в «Согласование СТ».
|
||||
const APPROVER_STATUS_TO_CHOICE: Record<ApproverStatus, string> = {
|
||||
pending: 'На согласовании',
|
||||
recommends: 'Рекомендую',
|
||||
approved: 'Согласовано',
|
||||
rejected: 'Отклонено',
|
||||
skipped: 'Черновик'
|
||||
// Статус заявки (ApproverService.decide) → Choice «Статус» шапки СТ.
|
||||
const APPROVAL_STATUS_TO_CT_STATUS: Record<'onApproval' | 'approved' | 'rejected', CtStatus> = {
|
||||
onApproval: 'on_approval',
|
||||
approved: 'approved',
|
||||
rejected: 'rejected'
|
||||
};
|
||||
|
||||
// Обратный маппинг (чтение цепочки).
|
||||
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).
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
// URL HTTP-триггеров Power Automate — только в константах (§7).
|
||||
// TODO: подставить реальные URL флоу.
|
||||
const FLOWS = {
|
||||
saveStatus: '' // запись статуса сущности
|
||||
};
|
||||
|
||||
// Поток обработки КП ct-processing-kps (Шаг 2). Триггер «manual» с «Кто может активировать поток? =
|
||||
// Любой пользователь в моём клиенте» (Entra) — зовём через AadHttpClient (токен на Flow Service).
|
||||
// ⚠️ 2026-07-15: стойкий 404 был из-за СЛОМАННОГО БЛОКА в самом потоке (мешал публикации/резолву);
|
||||
@@ -224,6 +199,8 @@ const TNSU_API_RESOURCE_ID = '9232d9e2-9807-4753-808f-07250e5474b0';
|
||||
export class KpsCtFormService {
|
||||
private readonly baseUrl: string;
|
||||
private readonly spHttpClient: SPHttpClient;
|
||||
// Ленивая инициализация — конструктору нужен this.baseUrl, который вычисляется в его же теле.
|
||||
private _approval: ApprovalListService | undefined;
|
||||
|
||||
public constructor(private readonly context: WebPartContext, siteUrl?: string) {
|
||||
// siteUrl задаём, когда форма встроена в портал на ДРУГОМ сайте (списки KPS
|
||||
@@ -232,12 +209,6 @@ export class KpsCtFormService {
|
||||
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) ---
|
||||
|
||||
private _li(listName: string): string {
|
||||
@@ -371,23 +342,6 @@ export class KpsCtFormService {
|
||||
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) ---
|
||||
|
||||
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 criteria = await this.getCriteria(ctId).catch(() => [] as SupplierCriteria[]);
|
||||
|
||||
@@ -700,7 +655,6 @@ export class KpsCtFormService {
|
||||
suppliers,
|
||||
offers,
|
||||
currencyRates,
|
||||
approvers,
|
||||
criteria,
|
||||
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 }>> {
|
||||
if (!query || query.length < 2) {
|
||||
return [];
|
||||
// Один экземпляр на форму — сервис без состояния конкретной заявки, безопасно переиспользовать.
|
||||
private get approval(): ApprovalListService {
|
||||
if (!this._approval) {
|
||||
this._approval = new ApprovalListService(this.context, LISTS.approvers, this.baseUrl);
|
||||
}
|
||||
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.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 ?? '') }));
|
||||
return this._approval;
|
||||
}
|
||||
|
||||
// Поиск пользователей сайта — используется пикером согласующего/делегата в 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.
|
||||
@@ -1141,126 +1099,55 @@ export class KpsCtFormService {
|
||||
await this.updateListItem(LISTS.ct, ctId, body);
|
||||
}
|
||||
|
||||
// Идемпотентная запись цепочки (модель эталона: переиспользуем записи, не пересоздаём вслепую).
|
||||
// По каждому согласующему: если запись в SP уже есть — MERGE (порядок/статус/комментарий), иначе POST.
|
||||
// Лишние записи (дубли переиспользованных + удалённые из цепочки) — удаляем. Так recycle+re-POST на
|
||||
// «защищённом» списке больше не плодит дубли, а уже задвоенные списки лечатся при первой же записи.
|
||||
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;
|
||||
// Email текущего пользователя — инициатора, по которому резолвится планируемый маршрут.
|
||||
public get currentUserEmail(): string {
|
||||
return this.context.pageContext.user.email || '';
|
||||
}
|
||||
|
||||
// Решение согласующего — MERGE статус + комментарий + дата.
|
||||
public async submitApprovalStep(spApprovalId: number, decision: 'approved' | 'rejected', comment: string): Promise<void> {
|
||||
await this.updateListItem(LISTS.approvers, spApprovalId, {
|
||||
[APPROVAL_FIELDS.status]: decision === 'approved' ? APPROVER_STATUS_TO_CHOICE.approved : APPROVER_STATUS_TO_CHOICE.rejected,
|
||||
[APPROVAL_FIELDS.comment]: comment,
|
||||
[APPROVAL_FIELDS.aproovDate]: new Date().toISOString()
|
||||
// Числовой RequestTypeId «КПС» в approval-route API — резолвится один раз по стабильному pageUrl
|
||||
// (сам id назначается в админке маршрутов при регистрации типа и заранее непредсказуем).
|
||||
public resolveRequestTypeId(): Promise<number> {
|
||||
return getRequestTypeIdByPageUrl(this.context, KPS_REQUEST_TYPE_PAGE_URL);
|
||||
}
|
||||
|
||||
// Планируемый маршрут для НОВОЙ СТ (черновик, до отправки) — резолв по 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 персоны на существующей записи. Порядок/статус не трогаем (как эталон).
|
||||
public async replaceApprovalMember(spApprovalId: number, newUserId: number): Promise<void> {
|
||||
await this.updateListItem(LISTS.approvers, spApprovalId, {
|
||||
[`${APPROVAL_FIELDS.aproover}Id`]: newUserId
|
||||
});
|
||||
}
|
||||
|
||||
// Удаление записей согласования (при отклонении — оставшиеся pending уже не проголосуют).
|
||||
// Best-effort DELETE (fallback recycle) — сбой не валит поток, но и не глотается молча.
|
||||
public async deleteApprovalMembers(spIds: number[]): Promise<void> {
|
||||
for (const id of spIds) {
|
||||
await this._removeItemBestEffort(LISTS.approvers, id);
|
||||
// Уведомление активного согласующего — некритичный побочный эффект (вызывать с .catch()), общая
|
||||
// точка для «после отправки» и «после каждого решения» (см. Step4Approval/InlineApprovalChain).
|
||||
public async notifyActiveApprover(ctId: number): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка цепочки из «Согласование СТ» по СТ (для восстановления при перезагрузке).
|
||||
public async loadApprovalChain(ctId: number): Promise<Approver[]> {
|
||||
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 записи «Согласование КПС»,
|
||||
// по которой поток находит согласующего/статус/родительскую СТ. Ошибка не критична — вызывать некритично.
|
||||
// Оповещение согласующего в Teams. Entra-триггер — зовём через AadHttpClient (токен Flow Service,
|
||||
// как triggerKpFlow/triggerAiComment; обычный fetch → 401). Тело { RowID } = spId записи
|
||||
// «Согласование СТ КПС», по которой поток находит согласующего/статус/родительскую СТ.
|
||||
public async triggerApprovalNotification(spApprovalId: number): Promise<void> {
|
||||
const client = await this.context.aadHttpClientFactory.getClient(FLOW_RESOURCE);
|
||||
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> = ({
|
||||
context,
|
||||
isDarkTheme,
|
||||
userDisplayName,
|
||||
}) => {
|
||||
const service = useMemo(() => new OidService(context), [context]);
|
||||
|
||||
@@ -88,7 +87,6 @@ const OidCreateForm: React.FC<IOidCreateFormProps> = ({
|
||||
<Step2CommercialOffers
|
||||
service={service}
|
||||
formData={formData}
|
||||
userDisplayName={userDisplayName}
|
||||
isReadOnly={formData.approvalSent}
|
||||
onSave={updateFormData}
|
||||
onPrev={goPrev}
|
||||
@@ -111,7 +109,6 @@ const OidCreateForm: React.FC<IOidCreateFormProps> = ({
|
||||
<Step4Approval
|
||||
service={service}
|
||||
formData={formData}
|
||||
userDisplayName={userDisplayName}
|
||||
onSave={updateFormData}
|
||||
onPrev={goPrev}
|
||||
onStepComplete={markStepComplete}
|
||||
|
||||
@@ -229,6 +229,7 @@ export function useStep1WorksInput(args: UseStep1WorksInputArgs) {
|
||||
project,
|
||||
workName: workName.trim(),
|
||||
status: "draft" as const,
|
||||
authorEmail: service.currentUserEmail || undefined,
|
||||
};
|
||||
onSave({ oid, workName: workName.trim() });
|
||||
// Persist positions typed before the OID existed (edits then had no oidId)
|
||||
@@ -455,6 +456,7 @@ export function useStep1WorksInput(args: UseStep1WorksInputArgs) {
|
||||
project,
|
||||
workName: workName.trim(),
|
||||
status: "formed" as const,
|
||||
authorEmail: service.currentUserEmail || undefined,
|
||||
};
|
||||
|
||||
onSave({ oid, workName: workName.trim(), positionGroups: updatedGroups });
|
||||
|
||||
-3
@@ -14,7 +14,6 @@ import styles from "./Step2CommercialOffers.module.scss";
|
||||
interface Props {
|
||||
service: OidService;
|
||||
formData: OIDFormData;
|
||||
userDisplayName: string;
|
||||
isReadOnly?: boolean;
|
||||
onSave: (partial: Partial<OIDFormData>) => void;
|
||||
onPrev: () => void;
|
||||
@@ -26,7 +25,6 @@ interface Props {
|
||||
export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
service,
|
||||
formData,
|
||||
userDisplayName,
|
||||
isReadOnly = false,
|
||||
onSave,
|
||||
onPrev,
|
||||
@@ -47,7 +45,6 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
const patch = await persistStep2({
|
||||
service,
|
||||
formData,
|
||||
userDisplayName,
|
||||
suppliers: kp.suppliers,
|
||||
kpFiles: kp.kpFiles,
|
||||
});
|
||||
|
||||
+5
-34
@@ -1,54 +1,25 @@
|
||||
import type { OIDFormData, Supplier, ApprovalMember, ApprovalMemberStatus } from "../types/domain";
|
||||
import type { OIDFormData, Supplier } from "../types/domain";
|
||||
import { OidService } from "../../services/OidService";
|
||||
import { UploadedKpFile } from "./useKpProcessing";
|
||||
|
||||
export interface PersistStep2Args {
|
||||
service: OidService;
|
||||
formData: OIDFormData;
|
||||
userDisplayName: string;
|
||||
suppliers: Supplier[];
|
||||
kpFiles: UploadedKpFile[];
|
||||
}
|
||||
|
||||
// Сохранение Шага 2: поставщики + построение цепочки согласования из матрицы (как у CT Шаг 2).
|
||||
// Перенесено 1:1 из Step2CommercialOffers.handleSave (§стайл-гайд: логика вне вёрстки).
|
||||
// Сохранение Шага 2: поставщики. Цепочка согласования БОЛЬШЕ не сеется черновиком здесь: в новом
|
||||
// механизме (approval-route API, см. план миграции согласования) маршрут — это планируемый resolve,
|
||||
// который Step4Approval резолвит «на лету» при открытии шага (OidService.getPlannedApprovalMatrix).
|
||||
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("СТ не создана");
|
||||
|
||||
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 {
|
||||
suppliers: savedSuppliers,
|
||||
uploadedKpFiles: kpFiles,
|
||||
approvalChain,
|
||||
};
|
||||
}
|
||||
|
||||
+53
-188
@@ -1,200 +1,65 @@
|
||||
import * as React from "react";
|
||||
import { Button, Alert, Spinner, Dialog, TextArea, TextInput } from "@tnsu/ui-kit";
|
||||
import type { ApprovalMemberStatus } from "../types/domain";
|
||||
import { useStep3Approval } from "./useStep3Approval";
|
||||
import styles from "./Step3ComparisonTable.module.scss";
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
import { useMemo, useState } from "react";
|
||||
import type { OIDFormData, OIDStatus } from "../types/domain";
|
||||
import { OidService } from "../../services/OidService";
|
||||
import { ApprovalMatrix, DecisionPanel, IMatrixRow, useMatrixEditor } from "../../../../shared/approval";
|
||||
|
||||
interface Props {
|
||||
approval: ReturnType<typeof useStep3Approval>;
|
||||
service: OidService;
|
||||
formData: OIDFormData;
|
||||
onSave: (partial: Partial<OIDFormData>) => void;
|
||||
isNotificationLink: boolean;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
// Инлайн-цепочка согласования Шага 3 + диалоги (решение/делегат). Показывается только по
|
||||
// Teams-ссылке в read-only. Логика — в useStep3Approval. Перенесено 1:1 из Step3ComparisonTable.
|
||||
export const InlineApprovalChain: React.FC<Props> = ({ approval: a, isNotificationLink, isReadOnly }) => {
|
||||
const RU_STATUS_TO_OID_STATUS: Record<string, OIDStatus> = {
|
||||
Отклонено: "rejected",
|
||||
Согласовано: "approved",
|
||||
"На согласовании": "on_approval",
|
||||
};
|
||||
|
||||
// Инлайн-цепочка согласования Шага 3 — для согласующего, открывшего СТ ОИД по Teams-ссылке,
|
||||
// read-only маршрут + DecisionPanel своей строки. Правка состава/отправка — это Шаг 4, здесь их нет.
|
||||
export const InlineApprovalChain: React.FC<Props> = ({ service, formData, onSave, isNotificationLink, isReadOnly }) => {
|
||||
const { oid } = formData;
|
||||
const oidId = oid?.id;
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
|
||||
const loadMatrix = useMemo(
|
||||
() => (): Promise<IMatrixRow[]> => (oidId ? service.getApprovalMatrixForRequest(oidId) : Promise.resolve([])),
|
||||
[service, oidId]
|
||||
);
|
||||
const editor = useMatrixEditor(
|
||||
loadMatrix,
|
||||
`${oidId ?? ""}-${decisionSeq}`,
|
||||
false,
|
||||
!!oidId && isNotificationLink && isReadOnly,
|
||||
(loginName) => service.getJobTitle(loginName)
|
||||
);
|
||||
const approverService = useMemo(() => (oidId ? service.getApproverService(oidId) : undefined), [service, oidId]);
|
||||
|
||||
if (!isNotificationLink || !isReadOnly || !oidId || editor.loading || editor.rows.length === 0) {
|
||||
return null; // маршрут ещё грузится из SP или его нет — блок не показываем
|
||||
}
|
||||
|
||||
async function handleDecided(newStatus: string): Promise<void> {
|
||||
if (!oid) return;
|
||||
onSave({ oid: { ...oid, status: RU_STATUS_TO_OID_STATUS[newStatus] || oid.status } });
|
||||
setDecisionSeq((n) => n + 1);
|
||||
await service.notifyActiveApprover(oidId as number).catch(() => undefined);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isNotificationLink && isReadOnly && a.approvalChain.length > 0 && (
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Цепочка согласования</h3>
|
||||
{a.notifyWarning && <Alert variant="warn">{a.notifyWarning}</Alert>}
|
||||
<ol className={styles.chainList}>
|
||||
{a.approvalChain.map((member) => {
|
||||
const st = APPROVAL_STATUS_STYLE[member.status];
|
||||
return (
|
||||
<li key={member.id} className={styles.chainItem}>
|
||||
<span className={styles.orderBadge}>{member.order}</span>
|
||||
<span className={styles.memberName}>{member.userName}</span>
|
||||
{member.role && (
|
||||
<span className={styles.memberRole}>{member.role}</span>
|
||||
)}
|
||||
<span
|
||||
className={styles.approvalStatusBadge}
|
||||
style={{ background: st.bg, color: st.color }}
|
||||
>
|
||||
{APPROVAL_STATUS_LABELS[member.status]}
|
||||
</span>
|
||||
{member.comment && (
|
||||
<span className={styles.approvalComment}>
|
||||
«{member.comment}»
|
||||
</span>
|
||||
)}
|
||||
{member.actionDate && (
|
||||
<span className={styles.approvalDate}>
|
||||
{formatApprovalDate(member.actionDate)}
|
||||
</span>
|
||||
)}
|
||||
{a.isOnApproval && a.currentUserMember?.id === member.id && (
|
||||
<div className={styles.chainActions}>
|
||||
{member.canAddApprover && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
a.openDelegate(member.id, member.order, member.spApprovalId!)
|
||||
}
|
||||
>
|
||||
Добавить делегата
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" onClick={() => a.openApprove(member.id)}>
|
||||
Согласовать
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => a.openReject(member.id)}
|
||||
className={styles.approvalRejectBtn}
|
||||
>
|
||||
Отклонить
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{a.delegateDialog && (
|
||||
<Dialog
|
||||
open
|
||||
title="Добавить делегата"
|
||||
subText="Выбранный пользователь будет вставлен перед вами в цепочку согласования."
|
||||
onDismiss={a.closeDelegate}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={a.closeDelegate} disabled={a.isDelegateSubmitting}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={a.handleAddDelegate}
|
||||
disabled={a.isDelegateSubmitting || !a.delegateDialog.selected}
|
||||
>
|
||||
{a.isDelegateSubmitting ? <Spinner size="small" /> : "Добавить"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.searchBox}>
|
||||
<TextInput
|
||||
placeholder="Поиск делегата (имя)…"
|
||||
ariaLabel="Поиск делегата"
|
||||
value={a.delegateDialog.query}
|
||||
onChange={a.setDelegateQuery}
|
||||
/>
|
||||
{a.delegateDialog.suggestions.length > 0 && (
|
||||
<ul className={styles.suggestions}>
|
||||
{a.delegateDialog.suggestions.map((s) => (
|
||||
<li key={s.spId} className={styles.suggestionItem} onClick={() => a.selectDelegate(s)}>
|
||||
<span className={styles.suggestionName}>{s.displayName}</span>
|
||||
<span className={styles.suggestionEmail}>{s.email}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{a.delegateError && <Alert variant="err">{a.delegateError}</Alert>}
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{a.actionDialog && (
|
||||
<Dialog
|
||||
open
|
||||
title={a.actionDialog.action === "approve" ? "Согласование" : "Отклонение"}
|
||||
subText={
|
||||
a.actionDialog.action === "approve"
|
||||
? "Вы подтверждаете согласование? Комментарий необязателен."
|
||||
: "Укажите причину отклонения (обязательно)."
|
||||
}
|
||||
onDismiss={a.closeAction}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={a.closeAction} disabled={a.isApprovalSubmitting}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant={a.actionDialog.action === "approve" ? "primary" : "danger"}
|
||||
onClick={a.handleApprovalConfirm}
|
||||
disabled={
|
||||
a.isApprovalSubmitting ||
|
||||
(a.actionDialog.action === "reject" && !a.actionDialog.comment.trim())
|
||||
}
|
||||
>
|
||||
{a.isApprovalSubmitting ? (
|
||||
<Spinner size="small" />
|
||||
) : a.actionDialog.action === "approve" ? (
|
||||
"Согласовать"
|
||||
) : (
|
||||
"Отклонить"
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TextArea
|
||||
value={a.actionDialog.comment}
|
||||
onChange={a.setActionComment}
|
||||
rows={3}
|
||||
ariaLabel="Комментарий"
|
||||
placeholder={
|
||||
a.actionDialog.action === "reject"
|
||||
? "Укажите причину..."
|
||||
: "Комментарий (необязательно)..."
|
||||
}
|
||||
/>
|
||||
{a.approvalError && <Alert variant="err">{a.approvalError}</Alert>}
|
||||
</Dialog>
|
||||
<ApprovalMatrix editor={editor} search={(q) => service.searchUsers(q)} />
|
||||
{approverService && (
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
requestId={oidId}
|
||||
onDecided={handleDecided}
|
||||
search={(q) => service.searchUsers(q)}
|
||||
onDelegated={() => setDecisionSeq((n) => n + 1)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+7
-3
@@ -3,7 +3,6 @@ import { Button, TextInput, Alert, Spinner, Dialog, Icon } from "@tnsu/ui-kit";
|
||||
import type { OIDFormData, FormStep } from "../types/domain";
|
||||
import { OidService } from "../../services/OidService";
|
||||
import { useStep3Matrix } from "./useStep3Matrix";
|
||||
import { useStep3Approval } from "./useStep3Approval";
|
||||
import { InlineApprovalChain } from "./InlineApprovalChain";
|
||||
import { useAiComment } from "./useAiComment";
|
||||
import { AiCommentBlock } from "./AiCommentBlock";
|
||||
@@ -32,7 +31,6 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
||||
onStepComplete,
|
||||
}) => {
|
||||
const matrix = useStep3Matrix({ service, formData, onSave, onStepComplete });
|
||||
const approval = useStep3Approval({ service, formData, onSave, onStepComplete });
|
||||
const ai = useAiComment({ service, formData, onReportError: matrix.setSaveError });
|
||||
|
||||
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} />
|
||||
|
||||
<InlineApprovalChain approval={approval} isNotificationLink={isNotificationLink} isReadOnly={isReadOnly} />
|
||||
<InlineApprovalChain
|
||||
service={service}
|
||||
formData={formData}
|
||||
onSave={onSave}
|
||||
isNotificationLink={isNotificationLink}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
|
||||
{saveError && <Alert variant="err">{saveError}</Alert>}
|
||||
|
||||
|
||||
-258
@@ -1,258 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import type { OIDFormData, ApprovalMemberStatus, FormStep } from "../types/domain";
|
||||
import { OidService } from "../../services/OidService";
|
||||
|
||||
export type ApprovalAction = "approve" | "reject";
|
||||
|
||||
export interface UserSuggestion {
|
||||
spId: number;
|
||||
displayName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ActionDialogState {
|
||||
memberId: string;
|
||||
action: ApprovalAction;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export interface DelegateDialogState {
|
||||
memberId: string;
|
||||
memberOrder: number;
|
||||
spApprovalId: number;
|
||||
query: string;
|
||||
suggestions: UserSuggestion[];
|
||||
selected?: UserSuggestion;
|
||||
}
|
||||
|
||||
export interface UseStep3ApprovalArgs {
|
||||
service: OidService;
|
||||
formData: OIDFormData;
|
||||
onSave: (partial: Partial<OIDFormData>) => void;
|
||||
onStepComplete: (step: FormStep) => void;
|
||||
}
|
||||
|
||||
// Логика инлайн-цепочки согласования на Шаге 3 (для согласующего, открывшего СТ ОИД по Teams-ссылке):
|
||||
// голосование approve/reject + нотификации, добавление делегата. Перенесено 1:1 из
|
||||
// Step3ComparisonTable (§стайл-гайд: логика вне вёрстки).
|
||||
export function useStep3Approval(args: UseStep3ApprovalArgs) {
|
||||
const { service, formData, onSave, onStepComplete } = args;
|
||||
|
||||
const approvalChain = formData.approvalChain ?? [];
|
||||
const isOnApproval =
|
||||
formData.oid?.status === "on_approval" || formData.oid?.status === "approved";
|
||||
const currentUserMember = approvalChain.find(
|
||||
(m) =>
|
||||
m.userId === service.currentUserSpId &&
|
||||
m.status === "pending" &&
|
||||
m.role !== "Инициатор" &&
|
||||
approvalChain
|
||||
.filter((prev) => prev.order < m.order)
|
||||
.every((prev) => prev.status !== "pending"),
|
||||
);
|
||||
|
||||
const [actionDialog, setActionDialog] = useState<ActionDialogState | null>(null);
|
||||
const [isApprovalSubmitting, setIsApprovalSubmitting] = useState(false);
|
||||
const [approvalError, setApprovalError] = useState<string | null>(null);
|
||||
const [notifyWarning, setNotifyWarning] = useState<string | null>(null);
|
||||
|
||||
const [delegateDialog, setDelegateDialog] = useState<DelegateDialogState | null>(null);
|
||||
const [isDelegateSubmitting, setIsDelegateSubmitting] = useState(false);
|
||||
const [delegateError, setDelegateError] = useState<string | null>(null);
|
||||
const delegateSearchTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
const handleApprovalConfirm = async (): Promise<void> => {
|
||||
if (!actionDialog || !formData.oid) return;
|
||||
const oid = formData.oid;
|
||||
const { memberId, action, comment } = actionDialog;
|
||||
if (action === "reject" && !comment.trim()) return;
|
||||
|
||||
setIsApprovalSubmitting(true);
|
||||
setApprovalError(null);
|
||||
const apiStatus = action === "approve" ? "approved" : "rejected";
|
||||
const currentMember = approvalChain.find((m) => m.id === memberId);
|
||||
|
||||
try {
|
||||
if (!currentMember?.spApprovalId)
|
||||
throw new Error("Не найден ID записи согласования");
|
||||
await service.submitApprovalStep(
|
||||
currentMember.spApprovalId,
|
||||
apiStatus,
|
||||
comment.trim(),
|
||||
);
|
||||
|
||||
const notify = async (spId: number): Promise<void> => {
|
||||
try {
|
||||
await service.triggerApprovalNotification(spId);
|
||||
} catch {
|
||||
setNotifyWarning(
|
||||
"Голос записан, но уведомление в Teams не удалось отправить.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (action === "approve") {
|
||||
const updatedChain = approvalChain.map((m) =>
|
||||
m.id === memberId
|
||||
? {
|
||||
...m,
|
||||
status: "approved" as ApprovalMemberStatus,
|
||||
comment: comment.trim(),
|
||||
actionDate: new Date().toISOString(),
|
||||
}
|
||||
: m,
|
||||
);
|
||||
onSave({ approvalChain: updatedChain });
|
||||
const allDone = updatedChain.every(
|
||||
(m) =>
|
||||
m.status === "approved" ||
|
||||
m.status === "skipped" ||
|
||||
m.status === "recommends",
|
||||
);
|
||||
if (allDone) {
|
||||
// Финал: статус «Согласовано» ДО оповещения, и шлём по currentMember (у него
|
||||
// spApprovalId точно есть) — иначе на финале «Согласована» не уходит, а поток
|
||||
// читает ещё «На согласовании». Порядок как в ветке отклонения / в Step4.
|
||||
await service.updateOIDStatus(oid.id, "approved");
|
||||
onSave({
|
||||
approvalChain: updatedChain,
|
||||
oid: { ...oid, status: "approved" },
|
||||
});
|
||||
if (currentMember.spApprovalId) await notify(currentMember.spApprovalId);
|
||||
onStepComplete(4);
|
||||
} else {
|
||||
const nextPending = updatedChain.find((m) => m.status === "pending");
|
||||
if (nextPending?.spApprovalId) await notify(nextPending.spApprovalId);
|
||||
}
|
||||
} else {
|
||||
const othersToDelete = approvalChain.filter(
|
||||
(m) => m.id !== memberId && m.status === "pending" && m.spApprovalId,
|
||||
);
|
||||
if (othersToDelete.length > 0) {
|
||||
await service.deleteApprovalMembers(
|
||||
othersToDelete.map((m) => m.spApprovalId!),
|
||||
);
|
||||
}
|
||||
const finalChain = approvalChain
|
||||
.filter((m) => m.status !== "pending" || m.id === memberId)
|
||||
.map((m) =>
|
||||
m.id === memberId
|
||||
? {
|
||||
...m,
|
||||
status: "rejected" as ApprovalMemberStatus,
|
||||
comment: comment.trim(),
|
||||
actionDate: new Date().toISOString(),
|
||||
}
|
||||
: m,
|
||||
);
|
||||
await service.updateOIDStatus(oid.id, "rejected");
|
||||
onSave({
|
||||
approvalChain: finalChain,
|
||||
oid: { ...oid, status: "rejected" },
|
||||
});
|
||||
// Шлём по отклонившему (currentMember) — у него spApprovalId гарантированно есть
|
||||
// (проверен в начале). У инициатора он мог отсутствовать после манипуляций с цепочкой,
|
||||
// из-за чего reject по Teams-ссылке вообще не дёргал поток.
|
||||
await notify(currentMember.spApprovalId);
|
||||
}
|
||||
setActionDialog(null);
|
||||
} catch (err) {
|
||||
setApprovalError((err as Error).message ?? "Ошибка выполнения действия");
|
||||
} finally {
|
||||
setIsApprovalSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setDelegateQuery = (q: string): void => {
|
||||
setDelegateDialog((prev) => (prev ? { ...prev, query: q, suggestions: [], selected: undefined } : prev));
|
||||
if (delegateSearchTimer.current) clearTimeout(delegateSearchTimer.current);
|
||||
if (q.trim().length < 2) return;
|
||||
delegateSearchTimer.current = setTimeout(() => {
|
||||
service
|
||||
.searchUsers(q.trim())
|
||||
.then((res) => setDelegateDialog((prev) => (prev ? { ...prev, suggestions: res } : prev)))
|
||||
.catch(() => undefined);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const selectDelegate = (u: UserSuggestion): void =>
|
||||
setDelegateDialog((prev) => (prev ? { ...prev, selected: u, query: u.displayName, suggestions: [] } : prev));
|
||||
|
||||
const handleAddDelegate = async (): Promise<void> => {
|
||||
if (!delegateDialog || !formData.oid || !delegateDialog.selected) return;
|
||||
const delegateUserId = delegateDialog.selected.spId;
|
||||
const delegateName = delegateDialog.selected.displayName;
|
||||
|
||||
setIsDelegateSubmitting(true);
|
||||
setDelegateError(null);
|
||||
try {
|
||||
const newMember = await service.addApprovalDelegate(
|
||||
formData.oid.id,
|
||||
delegateDialog.memberOrder,
|
||||
delegateUserId,
|
||||
delegateName,
|
||||
);
|
||||
const updatedChain = approvalChain
|
||||
.map((m) =>
|
||||
m.order >= delegateDialog.memberOrder
|
||||
? { ...m, order: m.order + 1 }
|
||||
: m,
|
||||
)
|
||||
.concat(newMember)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
onSave({ approvalChain: updatedChain });
|
||||
if (newMember.spApprovalId) {
|
||||
try {
|
||||
await service.triggerApprovalNotification(newMember.spApprovalId);
|
||||
} catch {
|
||||
setNotifyWarning(
|
||||
"Делегат добавлен, но уведомление в Teams не удалось отправить.",
|
||||
);
|
||||
}
|
||||
}
|
||||
setDelegateDialog(null);
|
||||
} catch (err) {
|
||||
setDelegateError((err as Error).message ?? "Ошибка добавления делегата");
|
||||
} finally {
|
||||
setIsDelegateSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Удобные открыватели диалогов (view не дёргает сырые сеттеры).
|
||||
const openApprove = (memberId: string): void =>
|
||||
setActionDialog({ memberId, action: "approve", comment: "" });
|
||||
const openReject = (memberId: string): void =>
|
||||
setActionDialog({ memberId, action: "reject", comment: "" });
|
||||
const setActionComment = (val: string): void =>
|
||||
setActionDialog((prev) => (prev ? { ...prev, comment: val } : null));
|
||||
const openDelegate = (memberId: string, memberOrder: number, spApprovalId: number): void => {
|
||||
setDelegateDialog({ memberId, memberOrder, spApprovalId, query: "", suggestions: [] });
|
||||
setDelegateError(null);
|
||||
};
|
||||
|
||||
return {
|
||||
approvalChain,
|
||||
isOnApproval,
|
||||
currentUserMember,
|
||||
notifyWarning,
|
||||
setNotifyWarning,
|
||||
// action dialog
|
||||
actionDialog,
|
||||
isApprovalSubmitting,
|
||||
approvalError,
|
||||
openApprove,
|
||||
openReject,
|
||||
setActionComment,
|
||||
closeAction: () => setActionDialog(null),
|
||||
handleApprovalConfirm,
|
||||
// delegate dialog
|
||||
delegateDialog,
|
||||
setDelegateQuery,
|
||||
selectDelegate,
|
||||
isDelegateSubmitting,
|
||||
delegateError,
|
||||
openDelegate,
|
||||
closeDelegate: () => setDelegateDialog(null),
|
||||
handleAddDelegate,
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -1078,7 +1078,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
Partial<SupplierCriteria>
|
||||
>,
|
||||
step3CustomCriteria: customCriteria,
|
||||
approvalChain: formData.approvalChain ?? [],
|
||||
approvalMatrix: formData.approvalMatrix ?? [],
|
||||
});
|
||||
|
||||
setExtraPositions([]);
|
||||
|
||||
+1
-237
@@ -1,4 +1,5 @@
|
||||
@import '../common/theme';
|
||||
|
||||
.root {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
@@ -6,239 +7,6 @@
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -255,7 +23,3 @@
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dialogError {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@@ -1,245 +1,160 @@
|
||||
import * as React from "react";
|
||||
import { Button, Alert, Spinner, IconButton, Dialog, TextInput, TextArea, Icon, FormField } from "@tnsu/ui-kit";
|
||||
import type { ApprovalMemberStatus, OIDFormData, FormStep } from "../types/domain";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Alert, Spinner, Icon } from "@tnsu/ui-kit";
|
||||
import type { OIDFormData, OIDStatus, FormStep } from "../types/domain";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
service: OidService;
|
||||
formData: OIDFormData;
|
||||
userDisplayName: string;
|
||||
onSave: (partial: Partial<OIDFormData>) => void;
|
||||
onPrev: () => void;
|
||||
onStepComplete: (step: FormStep) => void;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<ApprovalMemberStatus, string> = {
|
||||
pending: "На согласовании",
|
||||
recommends: "Рекомендую",
|
||||
approved: "Согласовано",
|
||||
rejected: "Отклонено",
|
||||
skipped: "Пропущено",
|
||||
const SUBMITTED_STATUSES: ReadonlySet<OIDStatus> = new Set<OIDStatus>(["on_approval", "approved", "rejected"]);
|
||||
|
||||
const RU_STATUS_TO_OID_STATUS: Record<string, OIDStatus> = {
|
||||
Отклонено: "rejected",
|
||||
Согласовано: "approved",
|
||||
"На согласовании": "on_approval",
|
||||
};
|
||||
|
||||
function getStatusClass(status: ApprovalMemberStatus): string {
|
||||
const map: Record<ApprovalMemberStatus, string> = {
|
||||
pending: styles.statusPending,
|
||||
recommends: styles.statusApproved,
|
||||
approved: styles.statusApproved,
|
||||
rejected: styles.statusRejected,
|
||||
skipped: styles.statusSkipped,
|
||||
};
|
||||
return map[status];
|
||||
}
|
||||
// Шаг 4 «Согласование» (см. план миграции согласования на approval-route API): маршрут — планируемый
|
||||
// (до отправки, resolve по email/отделу/компании инициатора) или уже записанный (после отправки).
|
||||
// Редактирование состава/решения — общие компоненты shared/approval. Экспорт отчёта для INCORE MANAGEMENT
|
||||
// (без ЭЦП-подписи — в отличие от CT) остаётся отдельным действием инициатора.
|
||||
export const Step4Approval: React.FC<Props> = ({ service, formData, onPrev, onSave, onStepComplete }) => {
|
||||
const { oid } = formData;
|
||||
const oidId = oid?.id;
|
||||
const status: OIDStatus = oid?.status ?? "formed";
|
||||
const isSubmitted = SUBMITTED_STATUSES.has(status);
|
||||
const isInitiator = !!oid?.authorEmail && oid.authorEmail === service.currentUserEmail;
|
||||
|
||||
function formatDate(iso?: string): string {
|
||||
if (!iso) return "";
|
||||
return new Date(iso).toLocaleString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
const [sending, setSending] = useState<boolean>(false);
|
||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
// Шаг 4 «Согласование» (цепочка). Логика — в useStep4Approval; здесь только разметка.
|
||||
export const Step4Approval: React.FC<Props> = (props) => {
|
||||
const { onPrev } = props;
|
||||
const a = useStep4Approval(props);
|
||||
const loadMatrix = useMemo(
|
||||
() => (): Promise<IMatrixRow[]> =>
|
||||
isSubmitted && oidId
|
||||
? 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 (
|
||||
<div className={styles.root}>
|
||||
{a.oid?.status === "approved" && <Alert variant="ok">СТ ОИД согласована всеми участниками.</Alert>}
|
||||
{a.oid?.status === "rejected" && (
|
||||
<Alert variant="err">СТ ОИД отклонена. Требуется корректировка и повторная отправка.</Alert>
|
||||
)}
|
||||
{a.oid?.status === "on_approval" && <Alert variant="info">СТ ОИД находится на согласовании.</Alert>}
|
||||
{status === "approved" && <Alert variant="ok">СТ ОИД согласована всеми участниками.</Alert>}
|
||||
{status === "rejected" && <Alert variant="err">СТ ОИД отклонена. Требуется корректировка и повторная отправка.</Alert>}
|
||||
{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}>
|
||||
<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>
|
||||
{error && <Alert variant="err">{error}</Alert>}
|
||||
|
||||
<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" />
|
||||
Назад
|
||||
</Button>
|
||||
|
||||
<div className={styles.footerRight}>
|
||||
{a.submitError && <Alert variant="err">{a.submitError}</Alert>}
|
||||
|
||||
{!a.isOnApproval && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={a.handleSendForApproval}
|
||||
disabled={a.chain.length === 0 || a.isSubmitting || !a.oid}
|
||||
>
|
||||
{a.isSubmitting ? (
|
||||
{!isSubmitted && (
|
||||
<Button variant="primary" onClick={handleSubmit} disabled={sending || editor.rows.length === 0}>
|
||||
{sending ? (
|
||||
<Spinner size="small" />
|
||||
) : (
|
||||
<>
|
||||
@@ -250,10 +165,10 @@ export const Step4Approval: React.FC<Props> = (props) => {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{a.isOnApproval && a.isInitiator && a.oid?.status !== "approved" && (
|
||||
{isSubmitted && isInitiator && status !== "approved" && (
|
||||
<>
|
||||
<Button variant="secondary" onClick={a.handleExport} disabled={a.isExporting}>
|
||||
{a.isExporting ? (
|
||||
<Button variant="secondary" onClick={handleExport} disabled={isExporting}>
|
||||
{isExporting ? (
|
||||
<Spinner size="small" />
|
||||
) : (
|
||||
<>
|
||||
@@ -262,7 +177,7 @@ export const Step4Approval: React.FC<Props> = (props) => {
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={a.handleRestart} disabled={a.isSubmitting}>
|
||||
<Button variant="secondary" onClick={handleRestart} disabled={sending}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Перезапустить согласование
|
||||
</Button>
|
||||
@@ -271,104 +186,14 @@ export const Step4Approval: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{a.replaceDialog && (
|
||||
<Dialog
|
||||
open
|
||||
title="Заменить согласующего"
|
||||
subText="Найдите нового участника для замены."
|
||||
onDismiss={a.closeReplace}
|
||||
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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
{oidId && approverService && (
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
requestId={oidId}
|
||||
onDecided={handleDecided}
|
||||
search={(q) => service.searchUsers(q)}
|
||||
onDelegated={() => setDecisionSeq((n) => n + 1)}
|
||||
/>
|
||||
)}
|
||||
</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: [],
|
||||
suppliers: [],
|
||||
offers: [],
|
||||
approvalChain: [],
|
||||
approvalMatrix: [],
|
||||
initiatorDepartment: "",
|
||||
initiatorCompany: "",
|
||||
uploadedKpFiles: [],
|
||||
@@ -126,7 +126,7 @@ export function useOidForm(context: WebPartContext): UseOidFormResult {
|
||||
setFormData((prev) => ({ ...prev, ...cached.formData }));
|
||||
const fd = cached.formData;
|
||||
const derivedHighest: FormStep =
|
||||
fd.approvalChain?.length ? 4 :
|
||||
fd.approvalMatrix?.length ? 4 :
|
||||
fd.suppliers?.length ? 3 :
|
||||
fd.positionGroups?.length ? 2 : 1;
|
||||
setCurrentStep(cached.currentStep ?? derivedHighest);
|
||||
@@ -171,10 +171,10 @@ export function useOidForm(context: WebPartContext): UseOidFormResult {
|
||||
|
||||
Promise.all([
|
||||
service.loadOIDPositions(oidId),
|
||||
service.loadApprovalChain(oidId),
|
||||
service.getApprovalMatrixForRequest(oidId),
|
||||
service.loadOID(oidId),
|
||||
])
|
||||
.then(async ([positions, approvalChain, oidInfo]) => {
|
||||
.then(async ([positions, approvalMatrix, oidInfo]) => {
|
||||
const positionGroups = groupPositions(positions);
|
||||
const oid = {
|
||||
id: oidId,
|
||||
@@ -182,6 +182,7 @@ export function useOidForm(context: WebPartContext): UseOidFormResult {
|
||||
project: oidInfo?.project ?? "",
|
||||
workName: oidInfo?.workName ?? "",
|
||||
status: oidInfo?.status,
|
||||
authorEmail: oidInfo?.authorEmail,
|
||||
};
|
||||
|
||||
if (isNotificationLink) {
|
||||
@@ -200,7 +201,7 @@ export function useOidForm(context: WebPartContext): UseOidFormResult {
|
||||
oid,
|
||||
workName: oid.workName,
|
||||
positionGroups,
|
||||
approvalChain,
|
||||
approvalMatrix,
|
||||
offers,
|
||||
suppliers,
|
||||
approvalSent: true,
|
||||
@@ -214,9 +215,9 @@ export function useOidForm(context: WebPartContext): UseOidFormResult {
|
||||
oid,
|
||||
workName: oid.workName,
|
||||
positionGroups,
|
||||
approvalChain,
|
||||
approvalMatrix,
|
||||
}));
|
||||
if (approvalChain.length > 0) {
|
||||
if (approvalMatrix.length > 0) {
|
||||
setHighestStep(4);
|
||||
setCompletedSteps([1, 2, 3]);
|
||||
} else if (positions.length > 0) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { IMatrixRow } from '../../../../shared/approval';
|
||||
|
||||
export interface OID {
|
||||
id: number;
|
||||
number: number;
|
||||
@@ -6,6 +8,10 @@ export interface OID {
|
||||
status?: OIDStatus;
|
||||
winSupplierSpId?: number;
|
||||
comment?: string;
|
||||
// Email автора записи (SP Author/Created By) — определяет инициатора (кнопки экспорта/перезапуска
|
||||
// в Step4Approval): в новом механизме согласования (approval-route API) инициатор в саму матрицу
|
||||
// согласования не входит, в отличие от прежней цепочки.
|
||||
authorEmail?: string;
|
||||
}
|
||||
|
||||
export type OIDStatus =
|
||||
@@ -80,26 +86,6 @@ export interface SupplierCriteria {
|
||||
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 {
|
||||
supplierName?: string;
|
||||
fileContent?: string;
|
||||
@@ -140,7 +126,9 @@ export interface OIDFormData {
|
||||
positionGroups: PositionGroup[];
|
||||
suppliers: Supplier[];
|
||||
offers: SupplierOffer[];
|
||||
approvalChain: ApprovalMember[];
|
||||
// Маршрут согласования — только для отображения; решения читают/пишут его напрямую через
|
||||
// OidService (shared/approval), не через formData.
|
||||
approvalMatrix: IMatrixRow[];
|
||||
initiatorDepartment: string;
|
||||
initiatorCompany: string;
|
||||
uploadedKpFiles: Array<{ name: string; spId: number; supplierName: string }>;
|
||||
|
||||
@@ -11,11 +11,20 @@ import type {
|
||||
Supplier,
|
||||
SupplierOffer,
|
||||
SupplierCriteria,
|
||||
ApprovalMember,
|
||||
ApprovalMemberStatus,
|
||||
SyntexFields,
|
||||
FlowResult,
|
||||
} 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";
|
||||
|
||||
@@ -41,6 +50,29 @@ const LISTS = {
|
||||
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 EXTERNAL_API = "https://api.tnsu.kz";
|
||||
|
||||
@@ -153,12 +185,8 @@ export class OidService {
|
||||
return tokenProvider.getToken(EXT_API_RESOURCE);
|
||||
}
|
||||
|
||||
get currentUserSpId(): number {
|
||||
return Number(
|
||||
(this.context.pageContext.legacyPageContext as Record<string, unknown>)[
|
||||
"userId"
|
||||
] ?? 0,
|
||||
);
|
||||
get currentUserEmail(): string {
|
||||
return this.context.pageContext.user.email || "";
|
||||
}
|
||||
|
||||
private get sp(): SPHttpClient {
|
||||
@@ -264,16 +292,17 @@ export class OidService {
|
||||
// survive in a fresh tab / for approvers, where sessionStorage is empty.
|
||||
async loadOID(
|
||||
oidId: number,
|
||||
): Promise<{ number: number; workName: string; project: string; status: OIDStatus } | null> {
|
||||
const url = `${listUrl(LISTS.OID)}/items(${oidId})?$select=number,workName,project,status`;
|
||||
): Promise<{ number: number; workName: string; project: string; status: OIDStatus; authorEmail?: string } | null> {
|
||||
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);
|
||||
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 {
|
||||
number: Number(data.number) || oidId,
|
||||
workName: (data.workName as string) || "",
|
||||
project: (data.project 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(
|
||||
chain: ApprovalMember[],
|
||||
oidId: number,
|
||||
): Promise<ApprovalMember[]> {
|
||||
const saved: ApprovalMember[] = [];
|
||||
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 });
|
||||
private _approval: ApprovalListService | undefined;
|
||||
|
||||
private get approval(): ApprovalListService {
|
||||
if (!this._approval) {
|
||||
this._approval = new ApprovalListService(this.context, LISTS.APPROVAL, SITE_URL, APPROVAL_FIELDS);
|
||||
}
|
||||
return saved;
|
||||
return this._approval;
|
||||
}
|
||||
|
||||
async deleteApprovalChainByOid(oidId: number): Promise<void> {
|
||||
const url = `${listUrl(LISTS.APPROVAL)}/items?$select=Id&$filter=CTId eq ${oidId}`;
|
||||
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 },
|
||||
);
|
||||
}
|
||||
resolveRequestTypeId(): Promise<number> {
|
||||
return getRequestTypeIdByPageUrl(this.context, OID_REQUEST_TYPE_PAGE_URL);
|
||||
}
|
||||
|
||||
async resetApprovalChainToDraft(chain: ApprovalMember[]): Promise<void> {
|
||||
for (const member of chain) {
|
||||
if (!member.spApprovalId) continue;
|
||||
await this.sp.post(
|
||||
`${listUrl(LISTS.APPROVAL)}/items(${member.spApprovalId})`,
|
||||
SPHttpClient.configurations.v1,
|
||||
{
|
||||
headers: SP_UPDATE,
|
||||
body: JSON.stringify({ status: "Черновик", comment: "", aproovDate: null }),
|
||||
},
|
||||
);
|
||||
}
|
||||
// department/company — фильтры маршрута, как в прежней «Матрица согласования СТ» (общей с CT) —
|
||||
// ОБА условием видимости (fieldValues), НЕ через параметр departmentId: тот в approval-route —
|
||||
// GUID из справочника отделов, а не текст должности/отдела из профиля SharePoint (см. ошибку 400
|
||||
// "$.departmentId: JSON value could not be converted"). Имена полей 'company'/'department' должны
|
||||
// совпадать с тем, что настроено в админке маршрутов для типа «oid».
|
||||
async getPlannedApprovalMatrix(userEmail: string, department: string, company: string): Promise<IMatrixRow[]> {
|
||||
const requestTypeId = await this.resolveRequestTypeId();
|
||||
const fieldValues: Array<{ fieldName: string; value: string }> = [];
|
||||
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> {
|
||||
for (const member of chain) {
|
||||
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 }) },
|
||||
);
|
||||
}
|
||||
getApprovalMatrixForRequest(oidId: number): Promise<IMatrixRow[]> {
|
||||
return this.approval.getMatrixForRequest(oidId);
|
||||
}
|
||||
|
||||
async submitApprovalStep(
|
||||
spItemId: number,
|
||||
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) },
|
||||
);
|
||||
submitApprovalRoute(oidId: number, approvers: IApproverToWrite[]): Promise<void> {
|
||||
return writeRoute(this.approval, oidId, approvers);
|
||||
}
|
||||
|
||||
async addApprovalMemberDraft(
|
||||
oidId: number,
|
||||
insertAtOrder: number,
|
||||
userId: number,
|
||||
name: string,
|
||||
): Promise<ApprovalMember> {
|
||||
const body: Record<string, unknown> = {
|
||||
Title: `${oidId}-${userId}-delegate`,
|
||||
CTId: oidId,
|
||||
aprooverId: userId,
|
||||
order0: insertAtOrder,
|
||||
status: "Черновик",
|
||||
};
|
||||
|
||||
const res = await this.sp.post(
|
||||
`${listUrl(LISTS.APPROVAL)}/items`,
|
||||
SPHttpClient.configurations.v1,
|
||||
{ headers: SP_JSON, body: JSON.stringify(body) },
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Ошибка добавления делегата: ${res.status}`);
|
||||
}
|
||||
|
||||
const created = await res.json();
|
||||
|
||||
return {
|
||||
id: String(created.Id),
|
||||
spApprovalId: created.Id as number,
|
||||
order: insertAtOrder,
|
||||
userId,
|
||||
userName: name,
|
||||
status: "pending",
|
||||
};
|
||||
getApproverService(oidId: number): ApproverService {
|
||||
return new ApproverService(this.approval, async (status) => {
|
||||
await this.updateOIDStatus(oidId, APPROVAL_STATUS_TO_OID_STATUS[status]);
|
||||
});
|
||||
}
|
||||
|
||||
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) },
|
||||
);
|
||||
async notifyActiveApprover(oidId: number): Promise<void> {
|
||||
const rows = await this.approval.getForRequest(oidId);
|
||||
const active = rows.filter((r) => r.status === REQUEST_STATUS.onApproval)[0];
|
||||
if (active) {
|
||||
await this.triggerApprovalNotification(active.id);
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -1562,22 +1357,13 @@ export class OidService {
|
||||
return { department: get("Department"), company: get("Company") };
|
||||
}
|
||||
|
||||
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 =
|
||||
`${SITE_URL}/_api/web/siteusers` +
|
||||
`?$filter=substringof('${escaped}',Title) and PrincipalType eq 1` +
|
||||
`&$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"] ?? ""),
|
||||
}));
|
||||
// Поиск пользователей сайта — используется пикером согласующего/делегата в ApprovalMatrix/DecisionPanel.
|
||||
searchUsers(query: string): Promise<Array<{ id: number; name: string; email: string; loginName: string }>> {
|
||||
return this.approval.searchUsers(query);
|
||||
}
|
||||
|
||||
// Должность добавляемого согласующего из профиля SharePoint — для useMatrixEditor.addApprover.
|
||||
getJobTitle(loginName: string): Promise<string> {
|
||||
return this.approval.getJobTitle(loginName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { SPHttpClient, SPHttpClientResponse, ISPHttpClientOptions } from '@microsoft/sp-http';
|
||||
import { IApprovalRow } from './models/IApprovalRow';
|
||||
import { IMatrixRow } from './models/IMatrixRow';
|
||||
import { ISiteUser } from './models/ISiteUser';
|
||||
import { REQUEST_STATUS } from './models/statuses';
|
||||
|
||||
// Внутренние имена колонок списка «Согласование …» — конвенция форм ct-forms-portal
|
||||
// (см. KpsCtFormService.APPROVAL_FIELDS): order0 уже служит «порядком очереди»,
|
||||
// aproover/status/aproovDate/comment — существующие колонки, allowReassign и
|
||||
// delegatedBy — новые (см. provisioning-манифест формы).
|
||||
export interface IApprovalListFields {
|
||||
ticket: string; // Lookup → список данных
|
||||
approver: string; // Person — согласующий
|
||||
status: string; // Choice — статус строки
|
||||
date: string; // DateTime — дата решения
|
||||
comment: string; // Text
|
||||
order: string; // Number — порядок очереди
|
||||
allowReassign: string; // Boolean
|
||||
delegatedBy: string; // Person
|
||||
}
|
||||
|
||||
export const DEFAULT_APPROVAL_FIELDS: IApprovalListFields = {
|
||||
ticket: 'CT',
|
||||
approver: 'aproover',
|
||||
status: 'status',
|
||||
date: 'aproovDate',
|
||||
comment: 'comment',
|
||||
order: 'order0',
|
||||
allowReassign: 'allowReassign',
|
||||
delegatedBy: 'delegatedBy'
|
||||
};
|
||||
|
||||
// Согласующий для записи в список.
|
||||
export interface IApproverToWrite {
|
||||
email: string;
|
||||
position: string;
|
||||
allowReassign: boolean;
|
||||
}
|
||||
|
||||
// Все запросы к списку «Согласование …» одной формы (REST-клиент SharePoint —
|
||||
// в проекте нет PnPjs, весь остальной код форм тоже работает через SPHttpClient).
|
||||
export class ApprovalListService {
|
||||
private readonly baseUrl: string;
|
||||
private readonly spHttpClient: SPHttpClient;
|
||||
private readonly fields: IApprovalListFields;
|
||||
|
||||
public constructor(
|
||||
private readonly context: WebPartContext,
|
||||
private readonly listTitle: string,
|
||||
siteUrl?: string,
|
||||
fields: IApprovalListFields = DEFAULT_APPROVAL_FIELDS
|
||||
) {
|
||||
this.baseUrl = siteUrl ?? context.pageContext.web.absoluteUrl;
|
||||
this.spHttpClient = context.spHttpClient;
|
||||
this.fields = fields;
|
||||
}
|
||||
|
||||
private _li(listName: string): string {
|
||||
return `${this.baseUrl}/_api/web/lists/getbytitle('${listName}')/items`;
|
||||
}
|
||||
|
||||
private _liById(listName: string, id: number): string {
|
||||
return `${this.baseUrl}/_api/web/lists/getbytitle('${listName}')/items(${id})`;
|
||||
}
|
||||
|
||||
private async getListItems<T>(odataQuery?: string): Promise<T[]> {
|
||||
const url = odataQuery ? `${this._li(this.listTitle)}?${odataQuery}` : this._li(this.listTitle);
|
||||
const res: SPHttpClientResponse = await this.spHttpClient.get(url, SPHttpClient.configurations.v1);
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET ${this.listTitle} failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const json: { value: T[] } = await res.json();
|
||||
return json.value;
|
||||
}
|
||||
|
||||
private async createListItem<T>(body: Record<string, unknown>): Promise<T> {
|
||||
const options: ISPHttpClientOptions = {
|
||||
headers: {
|
||||
Accept: 'application/json;odata=nometadata',
|
||||
'Content-Type': 'application/json;odata=nometadata',
|
||||
'odata-version': ''
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
};
|
||||
const res = await this.spHttpClient.post(this._li(this.listTitle), SPHttpClient.configurations.v1, options);
|
||||
if (!res.ok) {
|
||||
throw new Error(`POST ${this.listTitle} failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
private async updateListItem(id: number, body: Record<string, unknown>): Promise<void> {
|
||||
const options: ISPHttpClientOptions = {
|
||||
headers: {
|
||||
Accept: 'application/json;odata=nometadata',
|
||||
'Content-Type': 'application/json;odata=nometadata',
|
||||
'odata-version': '',
|
||||
'IF-MATCH': '*',
|
||||
'X-HTTP-Method': 'MERGE'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
};
|
||||
const res = await this.spHttpClient.post(this._liById(this.listTitle, id), SPHttpClient.configurations.v1, options);
|
||||
if (!res.ok) {
|
||||
throw new Error(`MERGE ${this.listTitle}(${id}) failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async recycleListItem(id: number): Promise<void> {
|
||||
const options: ISPHttpClientOptions = {
|
||||
headers: { Accept: 'application/json;odata=nometadata', 'odata-version': '' }
|
||||
};
|
||||
const res = await this.spHttpClient.post(
|
||||
`${this._liById(this.listTitle, id)}/recycle()`,
|
||||
SPHttpClient.configurations.v1,
|
||||
options
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`recycle ${this.listTitle}(${id}) failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Пишет по строке на каждого участника маршрута. Порядок (order) — позиция в
|
||||
// массиве, начиная с orderStart. Статус пишется одинаковым для всей партии —
|
||||
// кто сейчас «на очереди», решает отдельно activateNextQueued.
|
||||
public async createSteps(requestId: number, approvers: IApproverToWrite[], status: string, orderStart = 1): Promise<void> {
|
||||
const missing = approvers.filter((a) => !a.email);
|
||||
if (missing.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[Согласование] createSteps: пропущены шаги без назначенного человека — заявка №${requestId}`);
|
||||
}
|
||||
const toWrite = approvers.filter((a) => !!a.email);
|
||||
for (let i = 0; i < toWrite.length; i += 1) {
|
||||
const a = toWrite[i];
|
||||
const approverId = await this.resolveUserId(a.email);
|
||||
await this.createListItem({
|
||||
Title: a.position || a.email,
|
||||
[`${this.fields.approver}Id`]: approverId,
|
||||
[this.fields.status]: status,
|
||||
[`${this.fields.ticket}Id`]: requestId,
|
||||
[this.fields.order]: orderStart + i,
|
||||
[this.fields.allowReassign]: a.allowReassign
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Продвигает очередь: среди строк со статусом «Ждёт очереди» активирует ту, что
|
||||
// делит наименьший order (обычно одну). Если маршрут уже остановлен решением
|
||||
// (Отклонено) — ничего не активирует.
|
||||
public async activateNextQueued(requestId: number): Promise<void> {
|
||||
const rows = await this.getForRequest(requestId);
|
||||
if (rows.some((r) => r.status === REQUEST_STATUS.rejected)) {
|
||||
return;
|
||||
}
|
||||
if (rows.some((r) => r.status === REQUEST_STATUS.onApproval)) {
|
||||
return;
|
||||
}
|
||||
const queuedRows = rows.filter((r) => r.status === REQUEST_STATUS.queued);
|
||||
if (queuedRows.length === 0) {
|
||||
return;
|
||||
}
|
||||
const minOrder = Math.min(...queuedRows.map((r) => r.order));
|
||||
for (const row of queuedRows.filter((r) => r.order === minOrder)) {
|
||||
await this.setActive(row.id);
|
||||
}
|
||||
}
|
||||
|
||||
public async setStatus(rowId: number, status: string): Promise<void> {
|
||||
await this.updateListItem(rowId, { [this.fields.status]: status });
|
||||
}
|
||||
|
||||
// Активация строки: статус + отметка времени, когда строка стала текущей.
|
||||
private async setActive(rowId: number): Promise<void> {
|
||||
await this.updateListItem(rowId, {
|
||||
[this.fields.status]: REQUEST_STATUS.onApproval,
|
||||
[this.fields.date]: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Добавляет одну строку согласования с заданным order (для делегата).
|
||||
public async addApproverRow(
|
||||
requestId: number,
|
||||
approver: IApproverToWrite,
|
||||
order: number,
|
||||
status: string,
|
||||
delegatedById?: number
|
||||
): Promise<void> {
|
||||
const approverId = await this.resolveUserId(approver.email);
|
||||
await this.createListItem({
|
||||
Title: approver.position || approver.email,
|
||||
[`${this.fields.approver}Id`]: approverId,
|
||||
[this.fields.status]: status,
|
||||
[`${this.fields.ticket}Id`]: requestId,
|
||||
[this.fields.order]: order,
|
||||
[this.fields.allowReassign]: approver.allowReassign,
|
||||
...(delegatedById ? { [`${this.fields.delegatedBy}Id`]: delegatedById } : {})
|
||||
});
|
||||
}
|
||||
|
||||
// Строки согласования по заявке, в порядке очереди.
|
||||
public async getForRequest(requestId: number): Promise<IApprovalRow[]> {
|
||||
const rows = await this.getListItems<Record<string, unknown>>(
|
||||
`$select=Id,Title,${this.fields.status},${this.fields.order},${this.fields.allowReassign},` +
|
||||
`${this.fields.approver}/Id,${this.fields.delegatedBy}/Id` +
|
||||
`&$expand=${this.fields.approver},${this.fields.delegatedBy}` +
|
||||
`&$filter=${this.fields.ticket}Id eq ${requestId}&$orderby=${this.fields.order} asc&$top=200`
|
||||
);
|
||||
return rows.map((r) => {
|
||||
const approver = r[this.fields.approver] as { Id?: number } | undefined;
|
||||
const delegatedBy = r[this.fields.delegatedBy] as { Id?: number } | undefined;
|
||||
return {
|
||||
id: r.Id as number,
|
||||
status: (r[this.fields.status] as string) || '',
|
||||
approverId: approver?.Id ?? 0,
|
||||
order: (r[this.fields.order] as number) ?? 0,
|
||||
allowReassign: (r[this.fields.allowReassign] as boolean) ?? false,
|
||||
position: (r.Title as string) || '',
|
||||
delegatedById: delegatedBy?.Id ?? 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Строки согласования для отображения матрицы (ФИО, должность, статус, дата, комментарий).
|
||||
public async getMatrixForRequest(requestId: number): Promise<IMatrixRow[]> {
|
||||
const rows = await this.getListItems<Record<string, unknown>>(
|
||||
`$select=Id,Title,${this.fields.status},${this.fields.date},${this.fields.comment},` +
|
||||
`${this.fields.order},${this.fields.allowReassign},${this.fields.approver}/Title,` +
|
||||
`${this.fields.approver}/EMail,${this.fields.delegatedBy}/Title` +
|
||||
`&$expand=${this.fields.approver},${this.fields.delegatedBy}` +
|
||||
`&$filter=${this.fields.ticket}Id eq ${requestId}&$orderby=${this.fields.order} asc&$top=200`
|
||||
);
|
||||
return rows.map((r, i) => {
|
||||
const approver = r[this.fields.approver] as { Title?: string; EMail?: string } | undefined;
|
||||
const delegatedBy = r[this.fields.delegatedBy] as { Title?: string } | undefined;
|
||||
return {
|
||||
order: (r[this.fields.order] as number) ?? i + 1,
|
||||
who: approver?.Title || '',
|
||||
email: approver?.EMail || '',
|
||||
position: (r.Title as string) || '',
|
||||
status: (r[this.fields.status] as string) || '',
|
||||
date: this.formatDate((r[this.fields.date] as string) || ''),
|
||||
comment: (r[this.fields.comment] as string) || '',
|
||||
allowReassign: (r[this.fields.allowReassign] as boolean) ?? false,
|
||||
addedBy: delegatedBy?.Title || ''
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Поиск пользователей сайта по части имени — для пикера согласующего.
|
||||
public async searchUsers(query: string): Promise<ISiteUser[]> {
|
||||
const q = query.trim();
|
||||
if (q.length < 2) {
|
||||
return [];
|
||||
}
|
||||
const escaped = q.replace(/'/g, "''");
|
||||
const url =
|
||||
`${this.baseUrl}/_api/web/siteusers?$filter=substringof('${escaped}',Title) and PrincipalType eq 1` +
|
||||
`&$select=Id,Title,Email,LoginName&$top=10`;
|
||||
const res = await this.spHttpClient.get(url, SPHttpClient.configurations.v1);
|
||||
if (!res.ok) {
|
||||
return [];
|
||||
}
|
||||
const json: { value: Array<Record<string, unknown>> } = await res.json();
|
||||
return json.value.map((u) => ({
|
||||
id: u.Id as number,
|
||||
name: (u.Title as string) || '',
|
||||
email: (u.Email as string) || '',
|
||||
loginName: (u.LoginName as string) || ''
|
||||
}));
|
||||
}
|
||||
|
||||
// Должность пользователя из профиля SharePoint (SPS-JobTitle). '' если недоступно.
|
||||
public async getJobTitle(loginName: string): Promise<string> {
|
||||
if (!loginName) {
|
||||
return '';
|
||||
}
|
||||
const account = loginName.indexOf('|') >= 0 ? loginName : `i:0#.f|membership|${loginName}`;
|
||||
try {
|
||||
const url = `${this.baseUrl}/_api/SP.UserProfiles.PeopleManager/GetPropertiesFor(accountName=@v)?@v='${encodeURIComponent(account)}'`;
|
||||
const res = await this.spHttpClient.get(url, SPHttpClient.configurations.v1, {
|
||||
headers: { Accept: 'application/json;odata=verbose' }
|
||||
});
|
||||
if (!res.ok) {
|
||||
return '';
|
||||
}
|
||||
const json: {
|
||||
d?: { UserProfileProperties?: { results?: Array<{ Key: string; Value: string }> } };
|
||||
} = await res.json();
|
||||
const props = json.d?.UserProfileProperties?.results ?? [];
|
||||
return props.filter((p) => p.Key === 'SPS-JobTitle')[0]?.Value || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Решение согласующего по строке: статус + дата + комментарий.
|
||||
public async setDecision(rowId: number, status: string, comment: string): Promise<void> {
|
||||
await this.updateListItem(rowId, {
|
||||
[this.fields.status]: status,
|
||||
[this.fields.date]: new Date().toISOString(),
|
||||
[this.fields.comment]: comment
|
||||
});
|
||||
}
|
||||
|
||||
// Удаляет все строки согласования по заявке (при повторной отправке).
|
||||
public async deleteForRequest(requestId: number): Promise<void> {
|
||||
const rows = await this.getForRequest(requestId);
|
||||
for (const row of rows) {
|
||||
await this.recycleListItem(row.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Id текущего пользователя на сайте (для сравнения с полем «Согласующий»).
|
||||
public async getCurrentUserId(): Promise<number> {
|
||||
const url = `${this.baseUrl}/_api/web/currentuser?$select=Id`;
|
||||
const res = await this.spHttpClient.get(url, SPHttpClient.configurations.v1);
|
||||
if (!res.ok) {
|
||||
throw new Error(`currentuser failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const user: { Id: number } = await res.json();
|
||||
return user.Id;
|
||||
}
|
||||
|
||||
// email → числовой Id пользователя сайта (для Person-поля «Согласующий»).
|
||||
private async resolveUserId(email: string): Promise<number> {
|
||||
const user = await this.ensure(email);
|
||||
return user.Id;
|
||||
}
|
||||
|
||||
// email → отображаемое имя (ФИО). Фолбэк на email, если имя недоступно.
|
||||
public async resolveUserName(email: string): Promise<string> {
|
||||
try {
|
||||
const user = await this.ensure(email);
|
||||
return user.Title || email;
|
||||
} catch {
|
||||
return email;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensure(email: string): Promise<{ Id: number; Title: string }> {
|
||||
const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`;
|
||||
const options: ISPHttpClientOptions = {
|
||||
headers: {
|
||||
Accept: 'application/json;odata=nometadata',
|
||||
'Content-Type': 'application/json;odata=nometadata',
|
||||
'odata-version': ''
|
||||
},
|
||||
body: JSON.stringify({ logonName: login })
|
||||
};
|
||||
const res = await this.spHttpClient.post(`${this.baseUrl}/_api/web/ensureuser`, SPHttpClient.configurations.v1, options);
|
||||
if (!res.ok) {
|
||||
throw new Error(`ensureuser(${email}) failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Формат «дд.мм.гггг чч:мм» в казахстанском поясе (UTC+5) — жёстко, а не по
|
||||
// поясу браузера: SharePoint хранит дату в UTC, Казахстан без перехода на летнее время.
|
||||
private formatDate(iso: string): string {
|
||||
if (!iso) {
|
||||
return '';
|
||||
}
|
||||
const KZ_OFFSET_MS = 5 * 60 * 60 * 1000;
|
||||
const d = new Date(new Date(iso).getTime() + KZ_OFFSET_MS);
|
||||
const p = (n: number): string => (n < 10 ? `0${n}` : `${n}`);
|
||||
const date = `${p(d.getUTCDate())}.${p(d.getUTCMonth() + 1)}.${d.getUTCFullYear()}`;
|
||||
return `${date} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { APPROVAL_API_CLIENT_ID, APPROVAL_API_BASE_URL } from '../config';
|
||||
import { IApiResponse, IRequestType, IResolvedRoute, IResolveFieldValue } from './models/IApprovalRouteApi';
|
||||
|
||||
// Все обращения к внешнему approval-route API — только здесь. Токен — через AAD
|
||||
// (тот же приём, что и KpsCtFormService.tnsuFetch для /inventory и /Document:
|
||||
// aadTokenProviderFactory + обычный fetch с заголовком Authorization, БЕЗ
|
||||
// AadHttpClient — этот resourceId в тенанте уже одобрен и работает так).
|
||||
async function getToken(context: WebPartContext): Promise<string> {
|
||||
const tokenProvider = await context.aadTokenProviderFactory.getTokenProvider();
|
||||
return tokenProvider.getToken(APPROVAL_API_CLIENT_ID);
|
||||
}
|
||||
|
||||
// GET /api/request-types — список типов заявок (id + pageUrl).
|
||||
export async function getRequestTypes(context: WebPartContext): Promise<IRequestType[]> {
|
||||
const token = await getToken(context);
|
||||
const res = await fetch(`${APPROVAL_API_BASE_URL}/api/request-types`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`request-types: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const body: IApiResponse<IRequestType[]> = await res.json();
|
||||
return body.data ?? [];
|
||||
}
|
||||
|
||||
// Кеш на уровне модуля — типы заявок админ меняет редко, каждая форма не должна
|
||||
// дёргать /api/request-types заново. Ошибку не кешируем — следующий вызов пробует ещё раз.
|
||||
let requestTypesPromise: Promise<IRequestType[]> | undefined;
|
||||
|
||||
export async function getRequestTypesCached(context: WebPartContext): Promise<IRequestType[]> {
|
||||
if (!requestTypesPromise) {
|
||||
requestTypesPromise = getRequestTypes(context).catch((e) => {
|
||||
requestTypesPromise = undefined;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
return requestTypesPromise;
|
||||
}
|
||||
|
||||
// Числовой RequestTypeId по pageUrl формы — id непредсказуем (назначается в
|
||||
// админке маршрутов при регистрации типа), pageUrl задаём мы сами и он стабилен.
|
||||
export async function getRequestTypeIdByPageUrl(context: WebPartContext, pageUrl: string): Promise<number> {
|
||||
const types = await getRequestTypesCached(context);
|
||||
const found = types.filter((t) => t.pageUrl === pageUrl)[0];
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`Тип заявки с pageUrl «${pageUrl}» не найден в approval-route API — зарегистрируйте его в админке маршрутов (Типы заявок).`
|
||||
);
|
||||
}
|
||||
return found.id;
|
||||
}
|
||||
|
||||
// POST /api/template-routes/resolve — планируемый маршрут согласования для типа
|
||||
// заявки и пользователя. Бэкенд сам подставляет согласующих по ролям исходя из
|
||||
// userEmail (и, если задано, отдела/проекта).
|
||||
export async function resolveRoute(
|
||||
context: WebPartContext,
|
||||
requestTypeId: number,
|
||||
userEmail: string,
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- null отправляется в теле запроса как «не задано»
|
||||
departmentId: string | null = null,
|
||||
fieldValues: IResolveFieldValue[] = [],
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- см. departmentId
|
||||
projectId: string | null = null
|
||||
): Promise<IResolvedRoute> {
|
||||
const token = await getToken(context);
|
||||
const res = await fetch(`${APPROVAL_API_BASE_URL}/api/template-routes/resolve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
requestTypeId,
|
||||
userEmail,
|
||||
projectId: projectId || null,
|
||||
status: 'Approval',
|
||||
fieldValues,
|
||||
departmentId: departmentId || null
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`resolve: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const body: IApiResponse<IResolvedRoute> = await res.json();
|
||||
return body.data;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { resolveRoute } from './approvalRoute.service';
|
||||
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
|
||||
import { IMatrixRow } from './models/IMatrixRow';
|
||||
import { IResolveFieldValue } from './models/IApprovalRouteApi';
|
||||
import { REQUEST_STATUS } from './models/statuses';
|
||||
|
||||
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
|
||||
// departmentId/fieldValues/projectId — условия маршрута для форм, где согласующий зависит от
|
||||
// отдела/компании/проекта инициатора (напр. CT/OID — см. CtService.getPlannedApprovalMatrix);
|
||||
// формы без таких условий (KPS) просто их не передают.
|
||||
export async function getPlannedMatrix(
|
||||
context: WebPartContext,
|
||||
requestTypeId: number,
|
||||
userEmail: string,
|
||||
approval: ApprovalListService,
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- транзитом в resolveRoute(), см. её departmentId
|
||||
departmentId: string | null = null,
|
||||
fieldValues: IResolveFieldValue[] = [],
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- см. departmentId
|
||||
projectId: string | null = null
|
||||
): Promise<IMatrixRow[]> {
|
||||
if (!userEmail) {
|
||||
return [];
|
||||
}
|
||||
const route = await resolveRoute(context, requestTypeId, userEmail, departmentId, fieldValues, projectId);
|
||||
const ordered = route.steps.slice().sort((a, b) => a.orderNo - b.orderNo);
|
||||
return Promise.all(
|
||||
ordered.map(async (s) => ({
|
||||
order: s.orderNo,
|
||||
who: s.executorEmail ? await approval.resolveUserName(s.executorEmail) : '',
|
||||
email: s.executorEmail || '',
|
||||
position: s.position || '',
|
||||
status: '—',
|
||||
date: '',
|
||||
comment: '',
|
||||
allowReassign: s.allowReassignToOtherApprovers,
|
||||
warning: s.warning
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Записать маршрут согласования заявки: удаляет прежние строки (повторная
|
||||
// отправка/пересохранение), пишет новые как «Ждёт очереди» и активирует первую.
|
||||
export async function writeRoute(approval: ApprovalListService, requestId: number, approvers: IApproverToWrite[]): Promise<void> {
|
||||
await approval.deleteForRequest(requestId);
|
||||
await approval.createSteps(requestId, approvers, REQUEST_STATUS.queued, 1);
|
||||
await approval.activateNextQueued(requestId);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ApprovalListService } from './approvalList.service';
|
||||
import { REQUEST_STATUS } from './models/statuses';
|
||||
import { IApprovalRow } from './models/IApprovalRow';
|
||||
import { ISiteUser } from './models/ISiteUser';
|
||||
|
||||
// approve — согласовать; reject — отклонить (нет фазы исполнения и статуса
|
||||
// «возврат на доработку» — как в прежнем KPS-механизме).
|
||||
export type ApproverDecision = 'approve' | 'reject';
|
||||
|
||||
// Активная строка текущего пользователя: либо он сам назначен на неё
|
||||
// (isDelegator=false), либо это его делегат недоступен и он, как делегировавший,
|
||||
// вправе переназначить шаг ещё раз (isDelegator=true).
|
||||
export interface IPendingRow extends IApprovalRow {
|
||||
isDelegator: boolean;
|
||||
}
|
||||
|
||||
const ROW_STATUS: Record<ApproverDecision, string> = {
|
||||
approve: REQUEST_STATUS.approved,
|
||||
reject: REQUEST_STATUS.rejected
|
||||
};
|
||||
|
||||
// Действия согласующего: определение его строки, применение решения,
|
||||
// делегирование и пересчёт статуса заявки в списке данных.
|
||||
export class ApproverService {
|
||||
/**
|
||||
* onStatusComputed пишет статус в СВОЁ поле списка данных формы (у каждой
|
||||
* формы своё имя колонки и свой набор значений Choice — see
|
||||
* KpsCtFormService.updateCtStatus) — этот сервис домена не знает, только
|
||||
* вычисляет статус из строк маршрута и отдаёт его вызывающей стороне.
|
||||
*/
|
||||
constructor(
|
||||
private readonly approval: ApprovalListService,
|
||||
private readonly onStatusComputed: (status: 'onApproval' | 'approved' | 'rejected') => Promise<void>
|
||||
) {}
|
||||
|
||||
// Ожидающая строка текущего пользователя, если сейчас его очередь; иначе undefined.
|
||||
public async getPendingRowForCurrentUser(requestId: number): Promise<IPendingRow | undefined> {
|
||||
const rows = await this.approval.getForRequest(requestId);
|
||||
const userId = await this.approval.getCurrentUserId();
|
||||
const active = rows.filter((r) => r.status === REQUEST_STATUS.onApproval);
|
||||
const own = active.filter((r) => r.approverId === userId)[0];
|
||||
if (own) {
|
||||
return { ...own, isDelegator: false };
|
||||
}
|
||||
const delegated = active.filter((r) => r.delegatedById === userId)[0];
|
||||
return delegated ? { ...delegated, isDelegator: true } : undefined;
|
||||
}
|
||||
|
||||
// Делегирование: согласующий добавляет одного или нескольких делегантов ПЕРЕД
|
||||
// собой. Order — из равных долей промежутка между предыдущей строкой и своей.
|
||||
public async delegate(requestId: number, currentRowId: number, users: ISiteUser[]): Promise<void> {
|
||||
if (users.length === 0) {
|
||||
return;
|
||||
}
|
||||
const rows = await this.approval.getForRequest(requestId);
|
||||
const current = rows.filter((r) => r.id === currentRowId)[0];
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const before = rows.filter((r) => r.order < current.order);
|
||||
const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1;
|
||||
const step = (current.order - prevOrder) / (users.length + 1);
|
||||
const delegatedById = await this.approval.getCurrentUserId();
|
||||
|
||||
for (let i = 0; i < users.length; i += 1) {
|
||||
const user = users[i];
|
||||
const position = await this.approval.getJobTitle(user.loginName).catch(() => '');
|
||||
await this.approval.addApproverRow(
|
||||
requestId,
|
||||
{ email: user.email, position, allowReassign: false },
|
||||
prevOrder + step * (i + 1),
|
||||
REQUEST_STATUS.queued,
|
||||
delegatedById
|
||||
);
|
||||
}
|
||||
|
||||
await this.approval.setStatus(current.id, REQUEST_STATUS.queued);
|
||||
await this.approval.activateNextQueued(requestId);
|
||||
}
|
||||
|
||||
// Применяет решение: обновляет строку, продвигает очередь (если согласовано) и
|
||||
// пересчитывает статус заявки. Возвращает новый статус заявки (RU-текст).
|
||||
public async decide(requestId: number, rowId: number, decision: ApproverDecision, comment: string): Promise<string> {
|
||||
await this.approval.setDecision(rowId, ROW_STATUS[decision], comment);
|
||||
if (decision === 'approve') {
|
||||
await this.approval.activateNextQueued(requestId);
|
||||
}
|
||||
const rows = await this.approval.getForRequest(requestId);
|
||||
const status = this.computeRequestStatus(rows);
|
||||
await this.onStatusComputed(status.key);
|
||||
return status.label;
|
||||
}
|
||||
|
||||
// Статус заявки из строк маршрута: отклонение любой строки → «Отклонено»;
|
||||
// все согласовали → «Согласовано»; иначе ещё «На согласовании».
|
||||
private computeRequestStatus(rows: IApprovalRow[]): { key: 'onApproval' | 'approved' | 'rejected'; label: string } {
|
||||
if (rows.some((r) => r.status === REQUEST_STATUS.rejected)) {
|
||||
return { key: 'rejected', label: REQUEST_STATUS.rejected };
|
||||
}
|
||||
if (rows.length > 0 && rows.every((r) => r.status === REQUEST_STATUS.approved)) {
|
||||
return { key: 'approved', label: REQUEST_STATUS.approved };
|
||||
}
|
||||
return { key: 'onApproval', label: REQUEST_STATUS.onApproval };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Единая точка импорта механизма согласования (порт из request-portal,
|
||||
// см. [[request-portal-approval]]; адаптирован под REST/SPHttpClient и без
|
||||
// фазы исполнения — ct-forms-portal не использует PnPjs).
|
||||
export { REQUEST_STATUS, APPROVAL_ROW_STATUS_CHOICES } from './models/statuses';
|
||||
export type { ISiteUser } from './models/ISiteUser';
|
||||
export type { IMatrixRow } from './models/IMatrixRow';
|
||||
export type { IApprovalRow } from './models/IApprovalRow';
|
||||
export type { IRequestType, IResolvedRoute, IResolvedRouteStep, IResolveFieldValue } from './models/IApprovalRouteApi';
|
||||
|
||||
export { getRequestTypes, getRequestTypesCached, getRequestTypeIdByPageUrl, resolveRoute } from './approvalRoute.service';
|
||||
export { ApprovalListService, DEFAULT_APPROVAL_FIELDS } from './approvalList.service';
|
||||
export type { IApproverToWrite, IApprovalListFields } from './approvalList.service';
|
||||
export { ApproverService } from './approverDecision.service';
|
||||
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
|
||||
export { getPlannedMatrix, writeRoute } from './approvalSubmission.service';
|
||||
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||
export { UserPicker } from './ui/UserPicker';
|
||||
export { DecisionPanel } from './ui/DecisionPanel';
|
||||
export { StatusBadge } from './ui/StatusBadge';
|
||||
@@ -0,0 +1,46 @@
|
||||
// Типы внешнего approval-route API (api.tnsu.kz/approval-route) — тот же бэкенд,
|
||||
// что использует request-portal (см. [[request-portal-approval]]).
|
||||
|
||||
export type RouteType = 'None' | 'Parallel' | 'Sequential';
|
||||
|
||||
// Обёртка ответа API (все ответы приходят в поле data).
|
||||
export interface IApiResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
// GET /api/request-types → { data: IRequestType[] }
|
||||
export interface IRequestType {
|
||||
id: number;
|
||||
name: string;
|
||||
pageUrl: string;
|
||||
section: string;
|
||||
}
|
||||
|
||||
// Условие видимости шага маршрута (настраивается в админке маршрутов). ТОЛЬКО
|
||||
// строка — бэкенд не принимает массив в value.
|
||||
export interface IResolveFieldValue {
|
||||
fieldName: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// POST /api/template-routes/resolve → data
|
||||
export interface IResolvedRoute {
|
||||
routeId: number;
|
||||
routeType: RouteType;
|
||||
requiresControl: boolean;
|
||||
steps: IResolvedRouteStep[];
|
||||
}
|
||||
|
||||
export interface IResolvedRouteStep {
|
||||
id: number;
|
||||
orderNo: number;
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
|
||||
executorEmail: string | null;
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- см. executorEmail
|
||||
position: string | null;
|
||||
allowReassignToOtherApprovers: boolean;
|
||||
// Предупреждение маршрута по этому шагу (например, роль не разрешилась в
|
||||
// конкретного человека) — приходит от API, показываем автору при желании.
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- см. executorEmail
|
||||
warning: string | null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Строка согласования из списка «Согласование …».
|
||||
export interface IApprovalRow {
|
||||
id: number;
|
||||
status: string;
|
||||
approverId: number; // Id пользователя сайта в поле «Согласующий»
|
||||
order: number; // порядок согласования (колонка order0)
|
||||
allowReassign: boolean; // может ли этот согласующий добавить делеганта
|
||||
/** Должность/роль шага — колонка Title строки. */
|
||||
position: string;
|
||||
/** Id пользователя сайта, который делегировал ЭТУ строку (DelegatedBy). 0 — строка
|
||||
* не делегирована. Позволяет делегировавшему переназначить шаг ещё раз, если
|
||||
* делегат недоступен — см. ApproverService.getPendingRowForCurrentUser. */
|
||||
delegatedById: number;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Строка матрицы согласования для отображения и редактирования на форме.
|
||||
export interface IMatrixRow {
|
||||
order: number; // порядок согласования
|
||||
who: string; // ФИО согласующего
|
||||
email: string; // email (для записи новых строк; '' для загруженных из списка)
|
||||
position: string; // должность/роль
|
||||
status: string; // статус согласования или «—» для планируемой
|
||||
date: string; // дата решения (дд.мм.гггг) или ''
|
||||
comment: string;
|
||||
allowReassign: boolean; // может ли этот согласующий добавить делеганта
|
||||
// ФИО согласующего, который добавил ЭТУ строку перед собой (делегирование). ''
|
||||
// для обычных строк маршрута.
|
||||
addedBy?: string;
|
||||
/**
|
||||
* Причина, по которой approval-route не назначил на этот шаг конкретного
|
||||
* человека (роль не разрешилась и т.п.). Только у ПЛАНИРУЕМОГО маршрута.
|
||||
*/
|
||||
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
|
||||
warning?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Пользователь сайта (результат поиска для пикера согласующего).
|
||||
export interface ISiteUser {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
loginName: string; // claims-логин (для чтения должности из профиля)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Статусы СТРОКИ списка «Согласование …» (маршрут), отдельно от статуса самой
|
||||
// сущности (список данных: «Черновик»/«На согласовании»/«Согласовано»/«Отклонено» —
|
||||
// у KPS это CtStatus, см. forms/kps/components/types/domain.ts).
|
||||
//
|
||||
// Активация — только через ApprovalListService.activateNextQueued (вызывается при
|
||||
// отправке заявки и после каждого положительного решения): среди строк со статусом
|
||||
// queued находит наименьший StepOrder и переводит её в onApproval. Так «чья сейчас
|
||||
// очередь» — просто значение колонки статуса, без пересчёта порядка на клиенте.
|
||||
export const REQUEST_STATUS = {
|
||||
draft: 'Черновик',
|
||||
queued: 'Ждёт очереди',
|
||||
onApproval: 'На согласовании',
|
||||
approved: 'Согласовано',
|
||||
rejected: 'Отклонено',
|
||||
} as const;
|
||||
|
||||
// Значения Choice-колонки статуса строки маршрута при провижининге.
|
||||
export const APPROVAL_ROW_STATUS_CHOICES: string[] = [
|
||||
REQUEST_STATUS.draft,
|
||||
REQUEST_STATUS.queued,
|
||||
REQUEST_STATUS.onApproval,
|
||||
REQUEST_STATUS.approved,
|
||||
REQUEST_STATUS.rejected,
|
||||
];
|
||||
@@ -0,0 +1,126 @@
|
||||
import * as React from 'react';
|
||||
import { Icon } from '@tnsu/ui-kit';
|
||||
import styles from './approval.module.scss';
|
||||
import { IUseMatrixEditor } from '../useMatrixEditor';
|
||||
import { ISiteUser } from '../models/ISiteUser';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { UserPicker } from './UserPicker';
|
||||
|
||||
export interface IApprovalMatrixProps {
|
||||
editor: IUseMatrixEditor;
|
||||
search: (query: string) => Promise<ISiteUser[]>;
|
||||
title?: string;
|
||||
roleLabel?: string;
|
||||
emptyText?: string;
|
||||
pickerPlaceholder?: string;
|
||||
}
|
||||
|
||||
/** Таблица маршрута согласования + (если editable) правка состава. */
|
||||
export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
||||
editor,
|
||||
search,
|
||||
title = 'Маршрут согласования',
|
||||
roleLabel = 'Согласующий',
|
||||
emptyText = 'Согласующие не заданы.',
|
||||
pickerPlaceholder = 'Добавить согласующего: начните вводить ФИО…'
|
||||
}) => {
|
||||
const { rows, loading, error, editable, retry } = editor;
|
||||
|
||||
const hasStatuses = rows.some((r) => r.status && r.status !== '—');
|
||||
const hasDates = rows.some((r) => !!r.date);
|
||||
|
||||
return (
|
||||
<div className={styles.matrixWrap}>
|
||||
<div className={styles.matrixTitle}>{title}</div>
|
||||
{loading && <div className={styles.matrixNote}>Загрузка маршрута…</div>}
|
||||
{!loading && error && (
|
||||
<div className={styles.matrixNote}>
|
||||
Маршрут недоступен: {error}{' '}
|
||||
<button type="button" className={styles.retryBtn} onClick={retry}>
|
||||
Повторить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && rows.length === 0 && <div className={styles.matrixNote}>{emptyText}</div>}
|
||||
{!loading && !error && rows.length > 0 && (
|
||||
<div className={styles.matrixScroll}>
|
||||
<table className={styles.matrix}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.matrixStepCol}>Шаг</th>
|
||||
<th>{roleLabel}</th>
|
||||
<th>Должность</th>
|
||||
{hasStatuses && <th>Статус</th>}
|
||||
{hasDates && <th>Дата</th>}
|
||||
{editable && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={r.email || r.who || `row-${i}`}>
|
||||
<td className={styles.matrixStepCol}>
|
||||
<span className={styles.stepNo}>{r.order}</span>
|
||||
</td>
|
||||
<td data-label={roleLabel}>
|
||||
{r.who}
|
||||
{!r.who && r.warning && <span className={styles.warningInline}>{r.warning}</span>}
|
||||
{r.addedBy && (
|
||||
<span className={styles.addedByInline}>
|
||||
<span className={styles.addedByLabel}>Доп. согласующий, добавил:</span> {r.addedBy}
|
||||
</span>
|
||||
)}
|
||||
{r.comment && (
|
||||
<span className={styles.commentInline}>
|
||||
<span className={styles.commentLabel}>Комментарий:</span> {r.comment}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td data-label="Должность">{r.position}</td>
|
||||
{hasStatuses && (
|
||||
<td data-label="Статус">{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}</td>
|
||||
)}
|
||||
{hasDates && <td data-label="Дата">{r.date}</td>}
|
||||
{editable && (
|
||||
<td className={styles.matrixActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={i === 0}
|
||||
onClick={() => editor.moveUp(i)}
|
||||
title="Выше"
|
||||
>
|
||||
<Icon name="arrow-up" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={i === rows.length - 1}
|
||||
onClick={() => editor.moveDown(i)}
|
||||
title="Ниже"
|
||||
>
|
||||
<Icon name="arrow-down" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
||||
onClick={() => editor.remove(i)}
|
||||
title="Удалить"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{editable && !loading && (
|
||||
<div className={styles.matrixPicker}>
|
||||
<UserPicker search={search} onPick={editor.addApprover} placeholder={pickerPlaceholder} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.panel {
|
||||
margin-top: 16px;
|
||||
padding: 20px;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.delegate {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
.pickedList {
|
||||
list-style: none;
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pickedItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: 4px;
|
||||
background: $gray-50;
|
||||
}
|
||||
|
||||
.pickedOrder {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: $gold-500;
|
||||
color: $navy-950;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pickedName {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
font-size: 13px;
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
.pickedEmail {
|
||||
font-size: 11px;
|
||||
color: $gray-500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pickedRemove {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: $gray-500;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: $white;
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.delegateHint {
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
color: $gray-500;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button, Alert, FormField, TextArea, Icon } from '@tnsu/ui-kit';
|
||||
import styles from './DecisionPanel.module.scss';
|
||||
import { ApproverService, ApproverDecision, IPendingRow } from '../approverDecision.service';
|
||||
import { ISiteUser } from '../models/ISiteUser';
|
||||
import { UserPicker } from './UserPicker';
|
||||
|
||||
interface IDecisionPanelProps {
|
||||
service: ApproverService;
|
||||
requestId: number;
|
||||
/** Уведомление после сохранения решения (например, обновить статус на экране). */
|
||||
onDecided?: (status: string) => void | Promise<void>;
|
||||
/** Поиск пользователей — нужен для переназначения (делегирования). Без него
|
||||
* кнопка «Переназначить» не показывается. */
|
||||
search?: (query: string) => Promise<ISiteUser[]>;
|
||||
/** Вызывается после успешного переназначения (обновить экран/матрицу). */
|
||||
onDelegated?: () => void;
|
||||
}
|
||||
|
||||
// Панель решения текущего шага: согласующий (Согласовать/Отклонить). Если у шага
|
||||
// разрешено переназначение (allowReassign) — можно передать шаг другому сотруднику.
|
||||
export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestId, onDecided, search, onDelegated }) => {
|
||||
const [checking, setChecking] = useState<boolean>(true);
|
||||
const [pendingRow, setPendingRow] = useState<IPendingRow | undefined>(undefined);
|
||||
const [comment, setComment] = useState<string>('');
|
||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||
const [resultStatus, setResultStatus] = useState<string | undefined>(undefined);
|
||||
const [delegated, setDelegated] = useState<number>(0);
|
||||
const [showDelegate, setShowDelegate] = useState<boolean>(false);
|
||||
const [picked, setPicked] = useState<ISiteUser[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setChecking(true);
|
||||
service
|
||||
.getPendingRowForCurrentUser(requestId)
|
||||
.then((row) => {
|
||||
if (alive) {
|
||||
setPendingRow(row);
|
||||
setChecking(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) {
|
||||
setError(`Не удалось проверить очередь: ${(e as Error).message}`);
|
||||
setChecking(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [requestId, service]);
|
||||
|
||||
async function decide(decision: ApproverDecision): Promise<void> {
|
||||
if (!pendingRow) {
|
||||
return;
|
||||
}
|
||||
if (decision === 'reject' && comment.trim().length === 0) {
|
||||
setError('Для отклонения укажите комментарий.');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const status = await service.decide(requestId, pendingRow.id, decision, comment.trim());
|
||||
if (onDecided) {
|
||||
try {
|
||||
await onDecided(status);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Побочное действие после решения не выполнилось', e);
|
||||
}
|
||||
}
|
||||
setResultStatus(status);
|
||||
setSubmitting(false);
|
||||
} catch (e) {
|
||||
setError(`Не удалось сохранить решение: ${(e as Error).message}`);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function pickDelegate(user: ISiteUser): void {
|
||||
setPicked((prev) => (prev.some((p) => p.email === user.email) ? prev : [...prev, user]));
|
||||
}
|
||||
|
||||
function unpickDelegate(index: number): void {
|
||||
setPicked((prev) => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function cancelDelegate(): void {
|
||||
setPicked([]);
|
||||
setShowDelegate(false);
|
||||
}
|
||||
|
||||
async function handleDelegate(): Promise<void> {
|
||||
if (!pendingRow || picked.length === 0) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await service.delegate(requestId, pendingRow.id, picked);
|
||||
setDelegated(picked.length);
|
||||
setSubmitting(false);
|
||||
if (onDelegated) {
|
||||
onDelegated();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`Не удалось переназначить: ${(e as Error).message}`);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canDelegate = !!search && !!pendingRow && (pendingRow.isDelegator || pendingRow.allowReassign);
|
||||
const isDelegator = !!pendingRow?.isDelegator;
|
||||
|
||||
if (checking || !pendingRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (delegated > 0) {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<Alert variant="ok">
|
||||
{delegated === 1
|
||||
? 'Согласующий добавлен перед вами — он рассмотрит заявку раньше вас. Ваш шаг остаётся в маршруте.'
|
||||
: `Согласующих добавлено: ${delegated}. Они рассмотрят заявку раньше вас, в порядке добавления. Ваш шаг остаётся в маршруте.`}
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (resultStatus) {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<Alert variant="ok">Решение сохранено. Статус заявки: «{resultStatus}».</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const delegateBlock = canDelegate ? (
|
||||
<div className={styles.delegate}>
|
||||
{showDelegate ? (
|
||||
<>
|
||||
<FormField label="Кого добавить перед собой">
|
||||
<UserPicker search={search as (query: string) => Promise<ISiteUser[]>} onPick={pickDelegate} placeholder="Начните вводить ФИО сотрудника…" />
|
||||
</FormField>
|
||||
{picked.length > 0 && (
|
||||
<ul className={styles.pickedList}>
|
||||
{picked.map((u, i) => (
|
||||
<li className={styles.pickedItem} key={u.email || u.id}>
|
||||
<span className={styles.pickedOrder}>{i + 1}</span>
|
||||
<span className={styles.pickedName}>
|
||||
{u.name}
|
||||
{u.email && <span className={styles.pickedEmail}>{u.email}</span>}
|
||||
</span>
|
||||
<button type="button" className={styles.pickedRemove} disabled={submitting} aria-label={`Убрать ${u.name}`} onClick={() => unpickDelegate(i)}>
|
||||
<Icon name="close" size="sm" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className={styles.delegateHint}>
|
||||
{picked.length === 0
|
||||
? 'Выберите одного или нескольких сотрудников. Они согласуют заявку до вас, в порядке добавления; ваш шаг остаётся в маршруте.'
|
||||
: 'Согласуют до вас в этом порядке. Ваш шаг остаётся в маршруте.'}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button variant="accent" disabled={submitting || picked.length === 0} onClick={handleDelegate}>
|
||||
<Icon name="people" size="sm" />
|
||||
{picked.length > 1 ? `Добавить (${picked.length})` : 'Добавить'}
|
||||
</Button>
|
||||
<Button variant="ghost" disabled={submitting} onClick={cancelDelegate}>
|
||||
Отмена
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="secondary" disabled={submitting} onClick={() => setShowDelegate(true)}>
|
||||
<Icon name="people" size="sm" />
|
||||
Переназначить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (isDelegator) {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.title}>Ваше решение</div>
|
||||
<Alert variant="warn">
|
||||
Заявку сейчас рассматривает сотрудник, которого вы добавили перед собой. Ваш шаг никуда не делся — он
|
||||
вернётся к вам, когда очередь дойдёт. Если добавленный сотрудник недоступен, можно добавить ещё.
|
||||
</Alert>
|
||||
{error && <Alert variant="err">{error}</Alert>}
|
||||
{delegateBlock}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.title}>Ваше решение</div>
|
||||
{error && <Alert variant="err">{error}</Alert>}
|
||||
<FormField label="Комментарий" htmlFor="decisionComment">
|
||||
<TextArea id="decisionComment" value={comment} onChange={setComment} placeholder="Комментарий (обязателен при отклонении)" />
|
||||
</FormField>
|
||||
<div className={styles.actions}>
|
||||
<Button variant="accent" disabled={submitting} onClick={() => decide('approve')}>
|
||||
<Icon name="check" size="sm" />
|
||||
Согласовать
|
||||
</Button>
|
||||
<Button variant="danger" disabled={submitting} onClick={() => decide('reject')}>
|
||||
<Icon name="close" size="sm" />
|
||||
Отклонить
|
||||
</Button>
|
||||
</div>
|
||||
{delegateBlock}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import { Badge, BadgeVariant } from '@tnsu/ui-kit';
|
||||
import { REQUEST_STATUS } from '../models/statuses';
|
||||
|
||||
// Единственное место, где статус строки маршрута превращается в цвет.
|
||||
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||
[REQUEST_STATUS.draft]: 'neutral',
|
||||
[REQUEST_STATUS.queued]: 'muted',
|
||||
[REQUEST_STATUS.onApproval]: 'info',
|
||||
[REQUEST_STATUS.approved]: 'success',
|
||||
[REQUEST_STATUS.rejected]: 'danger'
|
||||
};
|
||||
|
||||
export interface IStatusBadgeProps {
|
||||
status: string;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
export const StatusBadge: React.FC<IStatusBadgeProps> = ({ status, size = 'md' }) => (
|
||||
<Badge variant={STATUS_VARIANT[status] || 'neutral'} size={size}>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import styles from './approval.module.scss';
|
||||
import { ISiteUser } from '../models/ISiteUser';
|
||||
|
||||
export interface IUserPickerProps {
|
||||
search: (query: string) => Promise<ISiteUser[]>;
|
||||
onPick: (user: ISiteUser) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** Лёгкий поиск пользователя сайта: ввод → выпадающий список → выбор. */
|
||||
export const UserPicker: React.FC<IUserPickerProps> = ({ search, onPick, placeholder }) => {
|
||||
const [query, setQuery] = React.useState<string>('');
|
||||
const [results, setResults] = React.useState<ISiteUser[]>([]);
|
||||
const [busy, setBusy] = React.useState<boolean>(false);
|
||||
const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
setQuery(value);
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
}
|
||||
if (value.trim().length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
timer.current = setTimeout(() => {
|
||||
setBusy(true);
|
||||
search(value)
|
||||
.then((r) => {
|
||||
setResults(r);
|
||||
setBusy(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setResults([]);
|
||||
setBusy(false);
|
||||
});
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handlePick = (user: ISiteUser): void => {
|
||||
onPick(user);
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.picker}>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={query}
|
||||
placeholder={placeholder || 'Начните вводить ФИО…'}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
/>
|
||||
{busy && <div className={styles.pickerNote}>Поиск…</div>}
|
||||
{!busy && results.length > 0 && (
|
||||
<div className={styles.pickerList}>
|
||||
{results.map((u) => (
|
||||
<button key={u.id} type="button" className={styles.pickerItem} onClick={() => handlePick(u)}>
|
||||
{u.name}
|
||||
{u.email && <span className={styles.pickerEmail}>{u.email}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,273 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// ---- Общий инпут (пикер) ----
|
||||
.input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: 4px;
|
||||
background: $white;
|
||||
font-size: 13.5px;
|
||||
color: $gray-900;
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Пикер пользователя ----
|
||||
.picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pickerNote {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.pickerList {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pickerItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
color: $gray-900;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: $navy-50;
|
||||
}
|
||||
}
|
||||
|
||||
.pickerEmail {
|
||||
font-size: 11px;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
// ---- Матрица согласования ----
|
||||
// 35% ширины — по месту это узкая справочная панель, а не главный контент шага;
|
||||
// на планшете/телефоне (см. медиазапрос ниже) сузилась бы до нечитаемого — там на 100%.
|
||||
.matrixWrap {
|
||||
box-sizing: border-box;
|
||||
width: 35%;
|
||||
min-width: 420px; // не уже самой таблицы (см. .matrix min-width)
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: 6px;
|
||||
background: $gray-50;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.matrixWrap {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.matrixTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $navy-800;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.matrixNote {
|
||||
font-size: 13px;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.retryBtn {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: $navy-700;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $gold-600;
|
||||
}
|
||||
}
|
||||
|
||||
.matrixScroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.matrix {
|
||||
width: 100%;
|
||||
min-width: 420px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 13px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: $gray-500;
|
||||
font-size: 11px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
color: $gray-900;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.commentInline {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: $gray-500;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.commentLabel {
|
||||
color: $gray-400;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.addedByInline {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: $gold-600;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.addedByLabel {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.warningInline {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: $red-500;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.matrixActions {
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
vertical-align: middle;
|
||||
|
||||
> * + * {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: middle;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: 4px;
|
||||
background: $white;
|
||||
color: $navy-700;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $gold-400;
|
||||
color: $gold-600;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.iconBtnDanger {
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $red-500;
|
||||
color: $red-500;
|
||||
}
|
||||
}
|
||||
|
||||
.matrixStepCol {
|
||||
width: 40px;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.stepNo {
|
||||
font-weight: 600;
|
||||
color: $navy-700;
|
||||
}
|
||||
|
||||
.matrixPicker {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.matrix {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.matrix thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.matrix tr {
|
||||
display: block;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.matrix td {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0 !important;
|
||||
padding: 3px 0 !important;
|
||||
}
|
||||
|
||||
.matrix td[data-label]::before {
|
||||
content: attr(data-label);
|
||||
flex: 0 0 44%;
|
||||
color: $gray-500;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.commentInline,
|
||||
.addedByInline {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.matrixActions {
|
||||
justify-content: flex-start;
|
||||
padding-top: 8px !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { IMatrixRow } from './models/IMatrixRow';
|
||||
import { ISiteUser } from './models/ISiteUser';
|
||||
|
||||
export interface IUseMatrixEditor {
|
||||
rows: IMatrixRow[];
|
||||
loading: boolean;
|
||||
error: string | undefined;
|
||||
editable: boolean;
|
||||
/** Перезапросить маршрут ещё раз (после сетевой ошибки) без смены reloadKey. */
|
||||
retry: () => void;
|
||||
addApprover: (user: ISiteUser) => void;
|
||||
moveUp: (index: number) => void;
|
||||
moveDown: (index: number) => void;
|
||||
remove: (index: number) => void;
|
||||
}
|
||||
|
||||
// Статус для только что добавленной строки: если все имеющиеся строки в одном
|
||||
// статусе — новая получает тот же, иначе честно «—» (в какое состояние попадёт,
|
||||
// ещё не решено).
|
||||
function statusForNewRow(rows: IMatrixRow[]): string {
|
||||
if (rows.length === 0) {
|
||||
return '—';
|
||||
}
|
||||
const first = rows[0].status || '—';
|
||||
return rows.every((r) => (r.status || '—') === first) ? first : '—';
|
||||
}
|
||||
|
||||
function withOrders(rows: IMatrixRow[]): IMatrixRow[] {
|
||||
return rows.map((r, i) => ({ ...r, order: i + 1 }));
|
||||
}
|
||||
|
||||
// Первый шаг без назначенного человека (email пуст) — писать такую строку в
|
||||
// SharePoint нельзя (Person-поле требует значение), нужно поймать это перед отправкой.
|
||||
export function findUnassignedStep(rows: IMatrixRow[]): IMatrixRow | undefined {
|
||||
return rows.filter((r) => !r.email).sort((a, b) => a.order - b.order)[0];
|
||||
}
|
||||
|
||||
export function unassignedStepMessage(rows: IMatrixRow[], fallback: (row: IMatrixRow) => string): string | undefined {
|
||||
const row = findUnassignedStep(rows);
|
||||
if (!row) {
|
||||
return undefined;
|
||||
}
|
||||
return row.warning || fallback(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает матрицу согласования и (если editable) позволяет её редактировать.
|
||||
* @param load загрузчик строк (планируемый маршрут из resolve или строки заявки).
|
||||
* @param reloadKey при изменении — перезагрузка (например, email инициатора).
|
||||
* @param editable доступны ли правки (порядок/добавление/удаление).
|
||||
* @param ready можно ли грузить (false — ждём готовности данных).
|
||||
* @param getJobTitle должность добавляемого согласующего из профиля.
|
||||
*/
|
||||
export function useMatrixEditor(
|
||||
load: () => Promise<IMatrixRow[]>,
|
||||
reloadKey: string,
|
||||
editable: boolean,
|
||||
ready: boolean,
|
||||
getJobTitle: (loginName: string) => Promise<string>
|
||||
): IUseMatrixEditor {
|
||||
const [rows, setRows] = useState<IMatrixRow[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [retrySeq, setRetrySeq] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) {
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
load()
|
||||
.then((r) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setRows(r);
|
||||
setError(undefined);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// load в зависимостях сознательно нет — когда перечитывать, решает reloadKey.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reloadKey, ready, retrySeq]);
|
||||
|
||||
const retry = (): void => setRetrySeq((n) => n + 1);
|
||||
|
||||
const appendUser = (user: ISiteUser, position: string): void => {
|
||||
setRows((prev) => {
|
||||
if (user.email && prev.some((r) => r.email === user.email)) {
|
||||
return prev;
|
||||
}
|
||||
const row: IMatrixRow = {
|
||||
order: 0,
|
||||
who: user.name,
|
||||
email: user.email,
|
||||
position,
|
||||
status: statusForNewRow(prev),
|
||||
date: '',
|
||||
comment: '',
|
||||
allowReassign: false
|
||||
};
|
||||
return withOrders([...prev, row]);
|
||||
});
|
||||
};
|
||||
|
||||
const addApprover = (user: ISiteUser): void => {
|
||||
getJobTitle(user.loginName)
|
||||
.then((position) => appendUser(user, position))
|
||||
.catch(() => appendUser(user, ''));
|
||||
};
|
||||
|
||||
const moveUp = (i: number): void =>
|
||||
setRows((prev) => {
|
||||
if (i <= 0) {
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev];
|
||||
[next[i - 1], next[i]] = [next[i], next[i - 1]];
|
||||
return withOrders(next);
|
||||
});
|
||||
|
||||
const moveDown = (i: number): void =>
|
||||
setRows((prev) => {
|
||||
if (i >= prev.length - 1) {
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev];
|
||||
[next[i], next[i + 1]] = [next[i + 1], next[i]];
|
||||
return withOrders(next);
|
||||
});
|
||||
|
||||
const remove = (i: number): void => setRows((prev) => withOrders(prev.filter((_, idx) => idx !== i)));
|
||||
|
||||
return { rows, loading, error, editable, retry, addApprover, moveUp, moveDown, remove };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Конфигурация внешнего approval-route API (тот же бэкенд и то же AAD-приложение,
|
||||
// что уже используется в KpsCtFormService.tnsuFetch для /inventory и /Document —
|
||||
// TNSU_API_RESOURCE_ID там равен этому же clientId). Отдельный webApiPermissionRequests
|
||||
// в package-solution.json не нужен: этот resourceId уже одобрен в тенанте.
|
||||
export const APPROVAL_API_CLIENT_ID = '9232d9e2-9807-4753-808f-07250e5474b0';
|
||||
export const APPROVAL_API_BASE_URL = 'https://api.tnsu.kz/approval-route';
|
||||
Reference in New Issue
Block a user