diff --git a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts index 19dfc3e..03917fa 100644 --- a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts +++ b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts @@ -63,35 +63,52 @@ export class ApproverService { return delegated ? { ...delegated, isDelegator: true } : undefined; } - // Делегирование: согласующий добавляет делеганта ПЕРЕД собой (Order — между - // предыдущей строкой и своей). Делегат согласует раньше и сам не делегирует - // (allowReassign всегда false у новой строки) — зато делегировавший (текущий - // пользователь) сохраняет право переназначить эту строку ещё раз, если делегат - // тоже окажется недоступен (см. getPendingRowForCurrentUser/isDelegator). + // Делегирование: согласующий добавляет одного или нескольких делегантов ПЕРЕД + // собой. Order каждому — из равных долей промежутка между предыдущей строкой и + // своей, В ПОРЯДКЕ ВЫБОРА: выбранный первым согласует первым. Делеганты сами не + // делегируют (allowReassign всегда false) — зато делегировавший (текущий + // пользователь) сохраняет право переназначить строку ещё раз, если кто-то из них + // окажется недоступен (см. getPendingRowForCurrentUser/isDelegator). + // + // Дробный Order — осознанно: он позволяет вклиниться между уже существующими + // шагами, не переписывая их (в SharePoint StepOrder — Number, дробные значения + // хранит). Делители растут с числом делегантов, но при реальных количествах + // (единицы) точности double хватает с колоссальным запасом. public async delegate( requestId: number, currentRowId: number, - user: ISiteUser + users: ISiteUser[] ): Promise { + if (users.length === 0) return; const rows = await this.approval.getForRequest(requestId); const current = rows.filter((r) => r.id === currentRowId)[0]; if (!current) return; + // Предыдущий шаг — с меньшим Order. У параллельных шагов Order общий, поэтому + // берём именно последний меньший, а не «строку выше по списку». const before = rows.filter((r) => r.order < current.order); const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1; - const newOrder = (prevOrder + current.order) / 2; - const position = await this.approval.getJobTitle(user.loginName); + const step = (current.order - prevOrder) / (users.length + 1); const delegatedById = await this.approval.getCurrentUserId(); - // Делегат наследует фазу той строки, вместо которой согласует (согласование/исполнение). - // Обе строки — делегат и прежний согласующий — уходят в «Ждёт очереди», - // activateNextQueued затем активирует делегата (у него меньший order). - await this.approval.addApproverRow( - requestId, - { email: user.email, position, allowReassign: false }, - newOrder, - REQUEST_STATUS.queued, - current.phase, - delegatedById - ); + + // Последовательно, а не Promise.all: добавление строки резолвит пользователя и + // пишет в список — параллельные пачки упираются в троттлинг SharePoint, а + // выигрыш во времени на единицах строк незаметен. + 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, + current.phase, + delegatedById + ); + } + + // Все строки — и делеганты, и прежний согласующий — уходят в «Ждёт очереди», + // activateNextQueued затем активирует первого делеганта (у него меньший order). await this.approval.setStatus(current.id, REQUEST_STATUS.queued); await this.approval.activateNextQueued(requestId); emitRequestsChanged(); diff --git a/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.module.scss b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.module.scss index 827c7ad..2ae97bf 100644 --- a/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.module.scss +++ b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.module.scss @@ -31,6 +31,92 @@ border-top: 1px solid $gray-200; } +// Выбранные, но ещё не записанные делеганты. Список нумерованный: номер — это +// очерёдность согласования, а не просто маркер, поэтому он рисуется явно, а не +// через list-style (его вид не настраивается достаточно предсказуемо). +.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: $r-sm; + 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: $fs-caption; + font-weight: 600; +} + +.pickedName { + flex: 1 1 auto; + min-width: 0; // иначе длинный email распирает строку и ломает выравнивание кнопки + display: flex; + flex-direction: column; + gap: 1px; + font-size: $fs-sm; + color: $gray-900; +} + +.pickedEmail { + font-size: $fs-caption; + 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: $r-sm; + background: transparent; + color: $gray-500; + cursor: pointer; + transition: .16s; + + &:hover:not(:disabled) { + background: $white; + color: $red-500; + } + + &:disabled { + opacity: .4; + cursor: default; + } +} + +.delegateHint { + margin-top: 10px; + font-size: $fs-caption; + color: $gray-500; + line-height: 1.45; +} + .state { margin-top: 16px; padding: 24px; diff --git a/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx index 26f0848..6b00abb 100644 --- a/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx @@ -39,8 +39,11 @@ export const DecisionPanel: React.FC = ({ const [comment, setComment] = useState(''); const [submitting, setSubmitting] = useState(false); const [resultStatus, setResultStatus] = useState(undefined); - const [delegated, setDelegated] = useState(false); + const [delegated, setDelegated] = useState(0); const [showDelegate, setShowDelegate] = useState(false); + // Выбранные делеганты копятся здесь и пишутся одной пачкой по кнопке — иначе + // добавить второго человека можно было бы только переоткрыв заявку. + const [picked, setPicked] = useState([]); const [error, setError] = useState(null); useEffect(() => { @@ -91,15 +94,30 @@ export const DecisionPanel: React.FC = ({ } } - async function handleDelegate(user: ISiteUser): Promise { - if (!pendingRow) { + // Выбор в пикере только копит список; запись — в handleDelegate по кнопке. + // Дубли отсекаем по email: одного человека дважды в маршрут ставить незачем. + 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 { + if (!pendingRow || picked.length === 0) { return; } setError(null); setSubmitting(true); try { - await service.delegate(requestId, pendingRow.id, user); - setDelegated(true); + await service.delegate(requestId, pendingRow.id, picked); + setDelegated(picked.length); setSubmitting(false); if (onDelegated) { onDelegated(); @@ -123,10 +141,15 @@ export const DecisionPanel: React.FC = ({ return null; } - if (delegated) { + if (delegated > 0) { return (
- Шаг переназначен — заявка передана выбранному сотруднику. + + {delegated === 1 + ? 'Согласующий добавлен перед вами — он рассмотрит заявку раньше вас. Ваш шаг остаётся в маршруте.' + : `Согласующих добавлено: ${delegated}. Они рассмотрят заявку раньше вас, в порядке добавления. ` + + 'Ваш шаг остаётся в маршруте.'} +
); } @@ -146,13 +169,55 @@ export const DecisionPanel: React.FC = ({ const delegateBlock = canDelegate ? (
{showDelegate ? ( - - - + <> + + + + {picked.length > 0 && ( +
    + {picked.map((u, i) => ( +
  • + {i + 1} + + {u.name} + {u.email && {u.email}} + + +
  • + ))} +
+ )} +
+ {picked.length === 0 + ? 'Выберите одного или нескольких сотрудников. Они согласуют заявку до вас, в порядке добавления; ваш шаг остаётся в маршруте.' + : 'Согласуют до вас в этом порядке. Ваш шаг остаётся в маршруте.'} +
+
+ + +
+ ) : (