fix: не сообщать об успехе раньше времени — ни о решении, ни о пустом списке

1) DecisionPanel ждёт onDecided, если тот вернул Promise: «Решение сохранено.
Статус заявки: «Согласовано»» появляется только после побочных действий решения —
для заявления на увольнение это созданный обходной лист. Пока идёт создание,
кнопки остаются заблокированными, а на месте результата — «Создаём обходной
лист…». Провал побочного действия статус решения не отменяет (оно уже применено на
сервере) — о проблеме сообщает сам обработчик своим предупреждением.

2) «Вы ещё не создавали заявок» больше не мигает на недогруженном списке.
«Мои заявки» и «Доработка» собираются из двух источников (Search + REST-довесок по
свежести), и onPartial показывал тот, что ответил первым, даже если он пустой — на
экране на мгновение появлялась пустота вместо «Загрузка…». Теперь пустой
промежуточный результат не показывается вовсе, а вывод «заявок нет» делается только
по ЗАВЕРШЁННОЙ загрузке именно этой таблицы (loadedKey), а не по любому пустому rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-08-25 14:04:57 +05:00
parent b3dee2047c
commit 467ceb0d48
3 changed files with 56 additions and 16 deletions
@@ -421,6 +421,12 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
? undefined ? undefined
: (partial: IPage): void => { : (partial: IPage): void => {
if (cancelled) return; if (cancelled) return;
// ПУСТОЙ промежуточный результат не показываем: смысл onPartial —
// не держать экран пустым, пока отвечает более медленный источник,
// а пустая страница как раз пустой экран и даёт, только вместо
// «Загрузка…» на нём появляется «Вы ещё не создавали заявок» —
// и через мгновение исчезает, когда придёт второй источник.
if (partial.rows.length === 0) return;
setRows(partial.rows); setRows(partial.rows);
setTotalRows(partial.totalRows); setTotalRows(partial.totalRows);
}; };
@@ -477,6 +483,18 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
const q = debouncedQuery.trim(); const q = debouncedQuery.trim();
const total = totalRows; const total = totalRows;
const visible = rows || []; const visible = rows || [];
/**
* Можно ли уже судить о содержимом таблицы.
*
* Пустоту («Вы ещё не создавали заявок») объявляем ТОЛЬКО по завершённой
* загрузке именно ЭТОЙ таблицы (loadedKey), а не по любому пустому rows: до
* этого rows может быть пустым по совершенно другой причине — часть источников
* ещё в пути (см. onPartial выше, «Мои заявки» собираются из Search + REST).
* Промежуточный НЕПУСТОЙ результат показывать можно и нужно — в нём уже есть
* что смотреть.
*/
const loaded = loadedKey === dataKey || visible.length > 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// Страница может оказаться за пределами набора: пришла из старой ссылки или // Страница может оказаться за пределами набора: пришла из старой ссылки или
@@ -527,8 +545,8 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
</div> </div>
{error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>} {error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>}
{!error && rows === undefined && <div className={styles.empty}>Загрузка…</div>} {!error && !loaded && <div className={styles.empty}>Загрузка…</div>}
{!error && rows && total === 0 && ( {!error && loaded && total === 0 && (
<div className={styles.empty}> <div className={styles.empty}>
{q ? `По запросу «${debouncedQuery}» ничего не найдено.` : EMPTY_TEXTS[view]} {q ? `По запросу «${debouncedQuery}» ничего не найдено.` : EMPTY_TEXTS[view]}
</div> </div>
@@ -121,9 +121,10 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
// выводятся из заявления, заполнять его руками нечего — см. createClearance.ts). // выводятся из заявления, заполнять его руками нечего — см. createClearance.ts).
// Свой статус решения при этом уже применён: неудача автосоздания не отменяет // Свой статус решения при этом уже применён: неудача автосоздания не отменяет
// согласование, поэтому она показывается предупреждением, а не ошибкой заявки. // согласование, поэтому она показывается предупреждением, а не ошибкой заявки.
const createClearance = (): void => { const createClearance = (): Promise<void> => {
setClearanceError(''); setClearanceError('');
ensureClearanceForDismissal(context, itemId, listId) setClearance('Создаём обходной лист по этому заявлению…');
return ensureClearanceForDismissal(context, itemId, listId)
.then((result) => { .then((result) => {
setClearance( setClearance(
result.existing result.existing
@@ -135,11 +136,12 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
// только после перезагрузки страницы. // только после перезагрузки страницы.
setData((prev) => ({ ...prev, ClearanceUrl: result.url })); setData((prev) => ({ ...prev, ClearanceUrl: result.url }));
}) })
.catch((e) => .catch((e) => {
setClearance('');
setClearanceError( setClearanceError(
`Заявление согласовано, но обходной лист создать не удалось: ${friendlyErrorMessage(e)} — заведите его вручную.` `Заявление согласовано, но обходной лист создать не удалось: ${friendlyErrorMessage(e)} — заведите его вручную.`
)
); );
});
}; };
return ( return (
@@ -185,10 +187,10 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
notify(); notify();
// «Согласовано» = маршрут заявления пройден целиком (фазы исполнения у // «Согласовано» = маршрут заявления пройден целиком (фазы исполнения у
// него нет, см. README формы) — значит увольнение подтверждено и пора // него нет, см. README формы) — значит увольнение подтверждено и пора
// заводить обходной лист. // заводить обходной лист. Промис ВОЗВРАЩАЕМ: пока он не разрешится,
if (newStatus === REQUEST_STATUS.approved) { // панель решения не пишет «Решение сохранено» (см. её onDecided) —
createClearance(); // иначе успех появлялся бы раньше, чем обходной лист создан.
} return newStatus === REQUEST_STATUS.approved ? createClearance() : undefined;
}} }}
onDelegated={() => { onDelegated={() => {
setDecisionSeq((n) => n + 1); setDecisionSeq((n) => n + 1);
@@ -11,8 +11,20 @@ interface IDecisionPanelProps {
service: ApproverService; service: ApproverService;
/** Id заявки (элемент списка данных). */ /** Id заявки (элемент списка данных). */
requestId: number; requestId: number;
/** Уведомление после сохранения решения (например, обновить статус на экране). */ /**
onDecided?: (status: string) => void; * Уведомление после сохранения решения (например, обновить статус на экране).
*
* Может вернуть Promise — тогда панель ДОЖДЁТСЯ его и только потом покажет
* «Решение сохранено». Так работают побочные действия решения, без которых
* ответ «согласовано» неполон: по согласованному заявлению на увольнение
* автоматически создаётся обходной лист (см. forms/Dismissal), и сообщать об
* успехе, пока он не создан, — значит показывать успех раньше времени.
*
* Отказ (reject) панель НЕ считает провалом решения — решение к этому моменту
* уже применено на сервере; сообщить о проблеме побочного действия должен сам
* обработчик (у него для этого есть своя область экрана).
*/
onDecided?: (status: string) => void | Promise<void>;
/** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные /** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные
* согласующим). Если бросит — решение не применяется, показывается ошибка. */ * согласующим). Если бросит — решение не применяется, показывается ошибка. */
onBeforeDecide?: () => Promise<void>; onBeforeDecide?: () => Promise<void>;
@@ -83,11 +95,19 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
await onBeforeDecide(); await onBeforeDecide();
} }
const status = await service.decide(requestId, pendingRow.id, decision, comment.trim()); const status = await service.decide(requestId, pendingRow.id, decision, comment.trim());
// Сначала побочные действия решения, потом «сохранено»: пока onDecided не
// отработал, кнопки остаются заблокированными (submitting), и пользователь не
// видит успеха раньше, чем всё сделано. Провал onDecided на статус решения не
// влияет — оно уже применено, поэтому сообщение показываем всё равно.
if (onDecided) {
try {
await onDecided(status);
} catch (e) {
console.error('Побочное действие после решения не выполнилось', e);
}
}
setResultStatus(status); setResultStatus(status);
setSubmitting(false); setSubmitting(false);
if (onDecided) {
onDecided(status);
}
} catch (e) { } catch (e) {
setError(`Не удалось сохранить решение: ${(e as Error).message}`); setError(`Не удалось сохранить решение: ${(e as Error).message}`);
setSubmitting(false); setSubmitting(false);