Уведомления назначенным пользователям (#9)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/9
This commit was merged in pull request #9.
This commit is contained in:
+13
-1
@@ -10,6 +10,8 @@ export interface IManageRequestsApproverModalProps {
|
||||
initialEmail?: string;
|
||||
employees: Employee[];
|
||||
employeesLoading: boolean;
|
||||
/** Email'ы (в любом регистре), уже занятые другими шагами той же фазы — этот человек не может встать в фазу второй раз. */
|
||||
excludedEmails: string[];
|
||||
onSave: (email: string, position: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -27,18 +29,28 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
|
||||
initialEmail = '',
|
||||
employees,
|
||||
employeesLoading,
|
||||
excludedEmails,
|
||||
onSave,
|
||||
onClose
|
||||
}) => {
|
||||
const [email, setEmail] = React.useState(initialEmail);
|
||||
const submit = useAsyncSubmit();
|
||||
|
||||
// Тот, кто уже занят в этой фазе, исключён из списка выбора — дубликат
|
||||
// физически нельзя выбрать (см. excludedEmails в ManageRequestsScreen).
|
||||
const excludedSet = new Set(excludedEmails.map(e => e.toLowerCase()));
|
||||
const selectableEmployees = employees.filter(e => !excludedSet.has(e.email.toLowerCase()));
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
const trimmedEmail = email.trim();
|
||||
if (!trimmedEmail) {
|
||||
submit.fail(texts.manageRequests.validationApprover);
|
||||
return;
|
||||
}
|
||||
if (excludedSet.has(trimmedEmail.toLowerCase())) {
|
||||
submit.fail(texts.manageRequests.validationDuplicateApprover);
|
||||
return;
|
||||
}
|
||||
const position = findEmployeeByEmail(employees, trimmedEmail)?.position || '';
|
||||
submit.run(() => onSave(trimmedEmail, position));
|
||||
};
|
||||
@@ -48,7 +60,7 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
|
||||
<FormGrid>
|
||||
<Field label={texts.manageRequests.approverLabel} required full>
|
||||
<SearchableSelect
|
||||
options={employees.map(toEmployeeOption)}
|
||||
options={selectableEmployees.map(toEmployeeOption)}
|
||||
value={email}
|
||||
loading={employeesLoading}
|
||||
placeholder={texts.manageRequests.approverPlaceholder}
|
||||
|
||||
+28
-2
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import tableStyles from '../ui/DataTable.module.scss';
|
||||
import styles from './ManageRequestsScreen.module.scss';
|
||||
import { Employee, ManagedApprovalRow, isEditableRowStatus, isMovableRowStatus } from '../../models';
|
||||
import { Employee, ManagedApprovalRow, REQUEST_STATUS, isEditableRowStatus, isMovableRowStatus } from '../../models';
|
||||
import { texts } from '../../texts';
|
||||
import { toEmployeeOption } from '../../utils/employee';
|
||||
import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks';
|
||||
@@ -27,6 +27,9 @@ export interface IManageRequestsPhaseTableProps {
|
||||
statusVariant: (status: string) => BadgeVariant;
|
||||
onReassign: (row: ManagedApprovalRow) => void;
|
||||
onRemove: (row: ManagedApprovalRow) => void;
|
||||
onNotify: (row: ManagedApprovalRow) => void;
|
||||
/** Id строки, для которой сейчас в процессе отправка уведомления — кнопка на ней отключена. */
|
||||
notifyingRowId?: number;
|
||||
employees: Employee[];
|
||||
employeesLoading: boolean;
|
||||
/** Плейсхолдер плашки-пикера под таблицей — разный для согласования/исполнения. */
|
||||
@@ -77,6 +80,8 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
||||
statusVariant,
|
||||
onReassign,
|
||||
onRemove,
|
||||
onNotify,
|
||||
notifyingRowId,
|
||||
employees,
|
||||
employeesLoading,
|
||||
addPlaceholder,
|
||||
@@ -127,6 +132,13 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
||||
// добавлять туда исполнителя всё равно бессмысленно).
|
||||
const phaseClosed = requestRejected || (rows.length > 0 && rows.every(r => !isEditableRowStatus(r.status)));
|
||||
|
||||
// Один и тот же человек не может стоять в фазе дважды (в т.ч. призрачной
|
||||
// строкой, которая ещё не подтверждена сервером, — см. handleAddApprover в
|
||||
// ManageRequestsScreen) — исключаем уже занятых из пикера, а не проверяем
|
||||
// постфактум: так дубликат физически нельзя выбрать.
|
||||
const usedEmails = new Set(rows.map(r => r.approverEmail.toLowerCase()).filter(Boolean));
|
||||
const addableEmployees = employees.filter(e => !usedEmails.has(e.email.toLowerCase()));
|
||||
|
||||
const applyReorder = (mutate: (rows: Row[]) => Row[]): void => {
|
||||
const renumbered = withOrdersFrom(mutate(withParallel), 0);
|
||||
const updates = diffOrders(rows, renumbered);
|
||||
@@ -186,6 +198,13 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
||||
const isBlockStart = block.start === i;
|
||||
const movable = blockIndex > lastLockedBlockIndex;
|
||||
const rowEditable = isEditableRowStatus(r.status);
|
||||
// Уведомлять можно только активный шаг (флоу шлёт письмо тому,
|
||||
// чей RowID мы передали, независимо от статуса — сам он не
|
||||
// проверяет очередь). «Ждёт очереди» — шаг ещё не наступил,
|
||||
// слать по нему нечего: согласующий/исполнитель пока не
|
||||
// должен действовать (см. request-portal/notify.service.ts,
|
||||
// где по той же причине уведомляется только активный шаг).
|
||||
const notifiable = r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution;
|
||||
// Переключение параллельности — тоже перестановка (меняет order
|
||||
// относительно соседней строки), поэтому смотрим на isMovableRowStatus,
|
||||
// а не на rowEditable: у «Возвращена на доработку» кнопка
|
||||
@@ -261,6 +280,13 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
||||
disabled={!canToggle}
|
||||
onClick={() => toggleParallel(i)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="mail"
|
||||
className={styles.iconBtnVisible}
|
||||
label={notifiable ? texts.manageRequests.notify : texts.manageRequests.notifyNotActive}
|
||||
disabled={!notifiable || notifyingRowId === r.id}
|
||||
onClick={() => onNotify(r)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="edit"
|
||||
className={styles.iconBtnVisible}
|
||||
@@ -303,7 +329,7 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
||||
{!readOnly && !phaseClosed && (
|
||||
<div className={styles.addRow}>
|
||||
<SearchableSelect
|
||||
options={employees.map(toEmployeeOption)}
|
||||
options={addableEmployees.map(toEmployeeOption)}
|
||||
value=""
|
||||
loading={employeesLoading}
|
||||
placeholder={addPlaceholder}
|
||||
|
||||
@@ -180,6 +180,8 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
const [modal, setModal] = React.useState<ManagedApprovalRow | undefined>();
|
||||
const [deleteRow, setDeleteRow] = React.useState<ManagedApprovalRow | undefined>();
|
||||
const [saveError, setSaveError] = React.useState<string | undefined>();
|
||||
const [notifyResult, setNotifyResult] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>();
|
||||
const [notifyingRowId, setNotifyingRowId] = React.useState<number | undefined>();
|
||||
// Отрицательные Id — «призрачные» строки: добавлены на клиенте, ответ сервера
|
||||
// (и настоящий Id из SharePoint) ещё не долетел. Реальные Id списка всегда
|
||||
// положительные, поэтому пересечься эти диапазоны не могут.
|
||||
@@ -347,6 +349,18 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
const openReassign = (row: ManagedApprovalRow): void => setModal(row);
|
||||
const closeModal = (): void => setModal(undefined);
|
||||
|
||||
// Кого нельзя выбрать при переназначении — все остальные согласующие/
|
||||
// исполнители ТОЙ ЖЕ фазы (сама строка modal исключена, иначе нельзя было
|
||||
// бы оставить текущего человека на месте). Один человек не может стоять в
|
||||
// фазе дважды — та же гарантия, что и addableEmployees в
|
||||
// ManageRequestsPhaseTable для добавления нового шага.
|
||||
const reassignExcludedEmails = matrix && modal
|
||||
? matrix.rows
|
||||
.filter(r => r.phase === modal.phase && r.id !== modal.id)
|
||||
.map(r => r.approverEmail.toLowerCase())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
// Переназначение — тоже оптимистично: patch конкретной строки сразу по ответу
|
||||
// сервера, без похода за всей матрицей заново. approverName берём из уже
|
||||
// загруженного справочника сотрудников (тот же источник, что подставляет
|
||||
@@ -459,6 +473,19 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
});
|
||||
};
|
||||
|
||||
// Отправка уведомления — по явному клику (в отличие от request-portal, где
|
||||
// это происходит автоматически после решения), поэтому здесь показываем
|
||||
// пользователю результат, а не глушим ошибку молча.
|
||||
const handleNotify = (row: ManagedApprovalRow): void => {
|
||||
if (!matrix || !requestType) return;
|
||||
setNotifyingRowId(row.id);
|
||||
requestManage
|
||||
.notifyRow(requestType.id, matrix, row.id)
|
||||
.then(() => setNotifyResult({ message: texts.manageRequests.notifySuccess, kind: 'ok' }))
|
||||
.catch((e: Error) => setNotifyResult({ message: reportError(e, 'handleNotify: notifyRow'), kind: 'err' }))
|
||||
.finally(() => setNotifyingRowId(undefined));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.manageRequests.breadcrumb }]} />
|
||||
@@ -522,6 +549,8 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
statusVariant={statusVariant}
|
||||
onReassign={openReassign}
|
||||
onRemove={askDelete}
|
||||
onNotify={handleNotify}
|
||||
notifyingRowId={notifyingRowId}
|
||||
employees={employees.items}
|
||||
employeesLoading={employees.isLoading}
|
||||
addPlaceholder={texts.manageRequests.addApproverPlaceholder}
|
||||
@@ -536,6 +565,8 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
statusVariant={statusVariant}
|
||||
onReassign={openReassign}
|
||||
onRemove={askDelete}
|
||||
onNotify={handleNotify}
|
||||
notifyingRowId={notifyingRowId}
|
||||
employees={employees.items}
|
||||
employeesLoading={employees.isLoading}
|
||||
addPlaceholder={texts.manageRequests.addExecutorPlaceholder}
|
||||
@@ -553,6 +584,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
initialEmail={modal.approverEmail}
|
||||
employees={employees.items}
|
||||
employeesLoading={employees.isLoading}
|
||||
excludedEmails={reassignExcludedEmails}
|
||||
onSave={saveModal}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
@@ -571,6 +603,14 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
||||
{saveError && (
|
||||
<Toast message={`${texts.manageRequests.saveError}: ${saveError}`} onClose={() => setSaveError(undefined)} />
|
||||
)}
|
||||
|
||||
{notifyResult && (
|
||||
<Toast
|
||||
message={notifyResult.kind === 'ok' ? notifyResult.message : `${texts.manageRequests.notifyError}: ${notifyResult.message}`}
|
||||
kind={notifyResult.kind}
|
||||
onClose={() => setNotifyResult(undefined)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,6 +23,12 @@
|
||||
animation: toastIn .15s ease-out;
|
||||
}
|
||||
|
||||
.ok {
|
||||
background: $green-50;
|
||||
color: $green-600;
|
||||
border-color: #BBF7D0;
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -4,28 +4,30 @@ import { Icon } from './Icon';
|
||||
|
||||
export interface IToastProps {
|
||||
message: string;
|
||||
/** 'err' (по умолчанию) — красный, для ошибок; 'ok' — зелёный, для успешных действий (см. handleNotify в ManageRequestsScreen). */
|
||||
kind?: 'err' | 'ok';
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AUTO_HIDE_MS = 6000;
|
||||
|
||||
/**
|
||||
* Всплывающее сообщение об ошибке в углу экрана — для действий, которые не
|
||||
* ломают уже отрисованный экран (переназначение/перестановка/добавление шага):
|
||||
* пользователь и так видит, что изменение не применилось (см. reloadMatrix
|
||||
* после ошибки в ManageRequestsScreen), поэтому постоянный Alert-блок сверху
|
||||
* страницы был бы избыточен. Автоматически прячется через AUTO_HIDE_MS, либо
|
||||
* закрывается вручную.
|
||||
* Всплывающее сообщение в углу экрана — для действий, которые не ломают уже
|
||||
* отрисованный экран (переназначение/перестановка/добавление шага/отправка
|
||||
* уведомления): пользователь и так видит, что изменение не применилось (см.
|
||||
* reloadMatrix после ошибки в ManageRequestsScreen), поэтому постоянный
|
||||
* Alert-блок сверху страницы был бы избыточен. Автоматически прячется через
|
||||
* AUTO_HIDE_MS, либо закрывается вручную.
|
||||
*/
|
||||
export const Toast: React.FC<IToastProps> = ({ message, onClose }) => {
|
||||
export const Toast: React.FC<IToastProps> = ({ message, kind = 'err', onClose }) => {
|
||||
React.useEffect(() => {
|
||||
const timer = window.setTimeout(onClose, AUTO_HIDE_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [message, onClose]);
|
||||
|
||||
return (
|
||||
<div className={styles.toast} role="alert">
|
||||
<Icon name="warning" size="sm" />
|
||||
<div className={`${styles.toast} ${kind === 'ok' ? styles.ok : ''}`} role="alert">
|
||||
<Icon name={kind === 'ok' ? 'check' : 'warning'} size="sm" />
|
||||
<span className={styles.message}>{message}</span>
|
||||
<button type="button" className={styles.close} onClick={onClose} aria-label="Закрыть">×</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user