feat: история согласования — маршрут пересохраняется патчем

При доработке и повторной отправке writeRoute раньше удалял все строки
маршрута и создавал заново — кто вернул или отклонил заявку и с каким
комментарием, терялось. Теперь маршрут пересохраняется патчем
(planRoutePatch + ApprovalListService.patchSteps): строки, что остаются в
маршруте, обновляются на месте, и их прошлые решения лежат в истории
версий; недостающие шаги создаются, выпавшие и строки делегатов уходят в
корзину. Всё одним пакетом. Проверено пилотами на увольнении и рабочем
месте (обе фазы, уход в корзину). Аварийный откат для всех форм —
ROUTE_PATCH_ENABLED = false.

Из версий строк маршрута и заявки собирается лента «История
согласования» (shared/approval/history): отправки, решения с
комментариями и временем на шаге, делегирование, замена исполнителя,
у кого заявка сейчас. Свёрнутый раздел в карточке и в форме доработки
всех форм, грузится только при раскрытии.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-09-29 12:42:42 +05:00
parent 5b1e94bf42
commit 153c966f0b
40 changed files with 1334 additions and 20 deletions
@@ -14,6 +14,7 @@ import {
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
@@ -339,6 +340,19 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
/> />
)} )}
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions <SubmitActions
onDelete={handleDelete} onDelete={handleDelete}
submit={submit} submit={submit}
@@ -8,6 +8,7 @@ import {
DecisionPanel, DecisionPanel,
StatusBadge, StatusBadge,
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
useMatrixEditor, useMatrixEditor,
notifyActiveStep, notifyActiveStep,
useRequestTypeId, useRequestTypeId,
@@ -319,6 +320,12 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
</div> </div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print"> <div className="rp-no-print">
<DecisionPanel <DecisionPanel
service={approverService} service={approverService}
@@ -14,6 +14,7 @@ import {
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
@@ -327,6 +328,19 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
waitingText="Укажите начальника участка и проект, чтобы построить маршрут согласования." waitingText="Укажите начальника участка и проект, чтобы построить маршрут согласования."
/> />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions <SubmitActions
onDelete={handleDelete} onDelete={handleDelete}
submit={submit} submit={submit}
@@ -8,6 +8,7 @@ import {
DecisionPanel, DecisionPanel,
StatusBadge, StatusBadge,
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
useMatrixEditor, useMatrixEditor,
notifyActiveStep, notifyActiveStep,
useRequestTypeId useRequestTypeId
@@ -155,6 +156,12 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
</div> </div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print"> <div className="rp-no-print">
<DecisionPanel <DecisionPanel
service={approverService} service={approverService}
@@ -14,6 +14,7 @@ import {
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
@@ -341,6 +342,20 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} /> <ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили, — поэтому история и в форме, а не только в просмотре.
Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions <SubmitActions
onDelete={handleDelete} onDelete={handleDelete}
submit={submit} submit={submit}
@@ -8,6 +8,7 @@ import {
DecisionPanel, DecisionPanel,
StatusBadge, StatusBadge,
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
useMatrixEditor, useMatrixEditor,
notifyActiveStep, notifyActiveStep,
useRequestTypeId, useRequestTypeId,
@@ -283,6 +284,12 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} /> <ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
</div> </div>
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый).
Итог автосоздания обходного листа — тут же, а не в шапке: он появляется Итог автосоздания обходного листа — тут же, а не в шапке: он появляется
именно как следствие решения «Согласовать», логичнее видеть его рядом. */} именно как следствие решения «Согласовать», логичнее видеть его рядом. */}
@@ -14,6 +14,7 @@ import {
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
@@ -303,6 +304,19 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} /> <ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions <SubmitActions
onDelete={handleDelete} onDelete={handleDelete}
submit={submit} submit={submit}
@@ -8,6 +8,7 @@ import {
DecisionPanel, DecisionPanel,
StatusBadge, StatusBadge,
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
useMatrixEditor, useMatrixEditor,
notifyActiveStep, notifyActiveStep,
useRequestTypeId useRequestTypeId
@@ -152,6 +153,12 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
</div> </div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print"> <div className="rp-no-print">
<DecisionPanel <DecisionPanel
service={approverService} service={approverService}
@@ -21,6 +21,7 @@ import {
import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges'; import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
import { import {
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
@@ -469,6 +470,19 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
/> />
)} )}
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions <SubmitActions
onDelete={handleDelete} onDelete={handleDelete}
submit={submit} submit={submit}
@@ -23,6 +23,7 @@ import {
DecisionPanel, DecisionPanel,
StatusBadge, StatusBadge,
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
useMatrixEditor, useMatrixEditor,
notifyActiveStep, notifyActiveStep,
useRequestTypeId, useRequestTypeId,
@@ -417,6 +418,12 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
</div> </div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print"> <div className="rp-no-print">
<DecisionPanel <DecisionPanel
service={approverService} service={approverService}
@@ -21,6 +21,7 @@ import {
import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges'; import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
import { import {
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
@@ -459,6 +460,19 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
/> />
)} )}
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions <SubmitActions
onDelete={handleDelete} onDelete={handleDelete}
submit={submit} submit={submit}
@@ -22,6 +22,7 @@ import {
DecisionPanel, DecisionPanel,
StatusBadge, StatusBadge,
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
useMatrixEditor, useMatrixEditor,
notifyActiveStep, notifyActiveStep,
useRequestTypeId, useRequestTypeId,
@@ -380,6 +381,12 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
</div> </div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print"> <div className="rp-no-print">
<DecisionPanel <DecisionPanel
service={approverService} service={approverService}
@@ -18,6 +18,7 @@ import {
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
@@ -478,6 +479,19 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
/> />
)} )}
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions <SubmitActions
onDelete={handleDelete} onDelete={handleDelete}
submit={submit} submit={submit}
@@ -18,6 +18,7 @@ import {
DecisionPanel, DecisionPanel,
StatusBadge, StatusBadge,
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
useMatrixEditor, useMatrixEditor,
notifyActiveStep, notifyActiveStep,
useRequestTypeId, useRequestTypeId,
@@ -238,6 +239,12 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
</div> </div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print"> <div className="rp-no-print">
<DecisionPanel <DecisionPanel
service={approverService} service={approverService}
@@ -14,6 +14,7 @@ import {
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
@@ -387,6 +388,19 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования." waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования."
/> />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions <SubmitActions
onDelete={handleDelete} onDelete={handleDelete}
submit={submit} submit={submit}
@@ -18,6 +18,7 @@ import {
DecisionPanel, DecisionPanel,
StatusBadge, StatusBadge,
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
useMatrixEditor, useMatrixEditor,
notifyActiveStep, notifyActiveStep,
useRequestTypeId, useRequestTypeId,
@@ -181,6 +182,12 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} /> <ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
</div> </div>
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print"> <div className="rp-no-print">
<DecisionPanel <DecisionPanel
service={approverService} service={approverService}
@@ -18,6 +18,7 @@ import {
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
@@ -421,6 +422,19 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С." emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С."
/> />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions <SubmitActions
onDelete={handleDelete} onDelete={handleDelete}
submit={submit} submit={submit}
@@ -20,6 +20,7 @@ import {
DecisionPanel, DecisionPanel,
StatusBadge, StatusBadge,
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
useMatrixEditor, useMatrixEditor,
notifyActiveStep, notifyActiveStep,
useRequestTypeId, useRequestTypeId,
@@ -247,6 +248,12 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
/> />
</div> </div>
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print"> <div className="rp-no-print">
<DecisionPanel <DecisionPanel
service={approverService} service={approverService}
@@ -18,6 +18,7 @@ import {
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
@@ -514,6 +515,19 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
/> />
)} )}
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions <SubmitActions
onDelete={handleDelete} onDelete={handleDelete}
submit={submit} submit={submit}
@@ -21,6 +21,7 @@ import {
DecisionPanel, DecisionPanel,
StatusBadge, StatusBadge,
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
useMatrixEditor, useMatrixEditor,
notifyActiveStep, notifyActiveStep,
useRequestTypeId, useRequestTypeId,
@@ -207,6 +208,12 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
)} )}
</div> </div>
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print"> <div className="rp-no-print">
<DecisionPanel <DecisionPanel
service={approverService} service={approverService}
@@ -14,6 +14,7 @@ import {
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
@@ -315,6 +316,19 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} /> <ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions <SubmitActions
onDelete={handleDelete} onDelete={handleDelete}
submit={submit} submit={submit}
@@ -8,6 +8,7 @@ import {
DecisionPanel, DecisionPanel,
StatusBadge, StatusBadge,
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
useMatrixEditor, useMatrixEditor,
notifyActiveStep, notifyActiveStep,
useRequestTypeId useRequestTypeId
@@ -152,6 +153,12 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
</div> </div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print"> <div className="rp-no-print">
<DecisionPanel <DecisionPanel
service={approverService} service={approverService}
@@ -14,6 +14,7 @@ import {
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
@@ -314,6 +315,19 @@ const VacationForm: React.FC<IRequestFormProps> = ({
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} /> <ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions <SubmitActions
onDelete={handleDelete} onDelete={handleDelete}
submit={submit} submit={submit}
@@ -8,6 +8,7 @@ import {
DecisionPanel, DecisionPanel,
StatusBadge, StatusBadge,
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
useMatrixEditor, useMatrixEditor,
notifyActiveStep, notifyActiveStep,
useRequestTypeId useRequestTypeId
@@ -152,6 +153,12 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
</div> </div>
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print"> <div className="rp-no-print">
<DecisionPanel <DecisionPanel
service={approverService} service={approverService}
@@ -18,6 +18,7 @@ import {
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
@@ -402,6 +403,19 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
/> />
)} )}
{/* Автору на доработке важнее всего видеть, почему вернули или
отклонили. Только у уже существующей заявки: у новой истории нет. */}
{editId && (
<div className="rp-no-print">
<ApprovalHistory
approval={approval}
dataListRef={editListId || dataListTitle(FORM_ID)}
requestId={editId}
reloadKey={requestStatus}
/>
</div>
)}
<SubmitActions <SubmitActions
onDelete={handleDelete} onDelete={handleDelete}
submit={submit} submit={submit}
@@ -21,6 +21,7 @@ import {
DecisionPanel, DecisionPanel,
StatusBadge, StatusBadge,
ApprovalMatrix, ApprovalMatrix,
ApprovalHistory,
useMatrixEditor, useMatrixEditor,
notifyActiveStep, notifyActiveStep,
useRequestTypeId, useRequestTypeId,
@@ -274,6 +275,12 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
)} )}
</div> </div>
{/* История согласования: маршрут пересохраняется патчем (см.
writeRoute): прошлые решения лежат в версиях строк. */}
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print"> <div className="rp-no-print">
<DecisionPanel <DecisionPanel
service={approverService} service={approverService}
@@ -12,6 +12,7 @@ import { IMatrixRow } from './models/IMatrixRow';
import { ISiteUser } from './models/ISiteUser'; import { ISiteUser } from './models/ISiteUser';
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses'; import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
import { userSearchVariants } from './userSearchVariants'; import { userSearchVariants } from './userSearchVariants';
import { planRoutePatch } from './routePatchPlan';
// Внутренние имена колонок списка согласования (создаются провижинингом). // Внутренние имена колонок списка согласования (создаются провижинингом).
const FIELDS = { const FIELDS = {
@@ -141,22 +142,103 @@ export class ApprovalListService {
// он разрешится вместе со всеми остальными после execute() ниже. // он разрешится вместе со всеми остальными после execute() ниже.
// eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error // eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error
void batchedList.items.add({ void batchedList.items.add({
Title: a.position || a.email, ...this.stepFields(a, approverIds[i], status, phase, order),
[`${FIELDS.approver}Id`]: approverIds[i],
[FIELDS.status]: status,
[`${FIELDS.ticket}Id`]: requestId, [`${FIELDS.ticket}Id`]: requestId,
[FIELDS.order]: order,
[FIELDS.allowReassign]: a.allowReassign,
[FIELDS.phase]: phase,
// ?? null, а не || null: ноль часов — значимое значение.
[FIELDS.slaHours]: a.slaHours ?? null,
[FIELDS.askOverdueReason]: a.askOverdueReason ?? null,
[FIELDS.autoApprove]: a.autoApprove ?? false,
}); });
}); });
await execute(); await execute();
} }
// Поля строки маршрута, которые задаёт сам шаг (без привязки к заявке) —
// общие для создания (createSteps) и обновления на месте (patchSteps).
private stepFields(
a: IApproverToWrite,
approverId: number,
status: string,
phase: string,
order: number
): Record<string, unknown> {
return {
Title: a.position || a.email,
[`${FIELDS.approver}Id`]: approverId,
[FIELDS.status]: status,
[FIELDS.order]: order,
[FIELDS.allowReassign]: a.allowReassign,
[FIELDS.phase]: phase,
// ?? null, а не || null: ноль часов — значимое значение.
[FIELDS.slaHours]: a.slaHours ?? null,
[FIELDS.askOverdueReason]: a.askOverdueReason ?? null,
[FIELDS.autoApprove]: a.autoApprove ?? false,
};
}
/**
* Пересохранить маршрут заявки ПАТЧЕМ вместо «удалить всё и создать заново»
* (deleteForRequest + createSteps). Строки, которые остаются в маршруте,
* обновляются на месте — их прошлые значения (кто, когда, с каким
* комментарием решил) SharePoint сохраняет в истории версий. Лишние строки
* уходят в корзину, недостающие шаги создаются. Какую строку куда — решает
* planRoutePatch (см. routePatchPlan.ts).
*
* phases — фазы маршрута с их смещением порядка, как в writeRoute. Всё
* пишется ОДНИМ пакетом. Вызывается из writeRoute (выключатель —
* ROUTE_PATCH_ENABLED в approvalSubmission.service.ts).
*/
public async patchSteps(
requestId: number,
phases: Array<{ approvers: IApproverToWrite[]; phase: string; orderStart: number }>,
status: string
): Promise<void> {
// Шаги всех фаз одним списком, с итоговым порядком — как их записал бы createSteps.
const steps = phases.reduce<Array<{ a: IApproverToWrite; phase: string; order: number }>>(
(all, p) =>
all.concat(p.approvers.map((a, i) => ({ a, phase: p.phase, order: p.orderStart - 1 + (a.order ?? i + 1) }))),
[]
);
// Шаг без человека не записать — см. тот же случай в createSteps.
const toWrite = steps.filter((s) => !!s.a.email);
if (toWrite.length < steps.length) {
console.error(`[Согласование] patchSteps: пропущены шаги без назначенного человека — заявка №${requestId}`);
}
const [existing, approverIds] = await Promise.all([
this.getForRequest(requestId),
Promise.all(toWrite.map((s) => this.resolveUserId(s.a.email))),
]);
const plan = planRoutePatch(
existing,
toWrite.map((s, i) => ({ phase: s.phase, approverId: approverIds[i] }))
);
const fieldsOf = (stepIndex: number): Record<string, unknown> => {
const s = toWrite[stepIndex];
return this.stepFields(s.a, approverIds[stepIndex], status, s.phase, s.order);
};
// Батч — см. createSteps: список переиспользуем, .items/.getById берём заново.
const [batchedSP, execute] = getSP().batched();
const batchedList = batchedSP.web.lists.getByTitle(this.listTitle);
/* eslint-disable no-void -- промисы батча разрешатся вместе после execute(), см. createSteps */
plan.update.forEach(({ rowId, stepIndex }) => {
void batchedList.items.getById(rowId).update({
...fieldsOf(stepIndex),
// Состояние прошлого круга сбрасываем — оно остаётся в истории версий строки.
[FIELDS.date]: null,
[FIELDS.comment]: null,
[`${FIELDS.delegatedBy}Id`]: null,
});
});
plan.create.forEach((stepIndex) => {
void batchedList.items.add({ ...fieldsOf(stepIndex), [`${FIELDS.ticket}Id`]: requestId });
});
// В корзину, а не насовсем: у выпавшего шага могла быть история решений.
plan.remove.forEach((rowId) => {
void batchedList.items.getById(rowId).recycle();
});
/* eslint-enable no-void */
await execute();
}
// Продвигает очередь: среди ещё не активных строк со статусом «Ждёт очереди» // Продвигает очередь: среди ещё не активных строк со статусом «Ждёт очереди»
// в текущей фазе активирует те, что делят наименьший StepOrder (обычно одна // в текущей фазе активирует те, что делят наименьший StepOrder (обычно одна
// строка; несколько — параллельный шаг). Фаза исполнения активируется, только // строка; несколько — параллельный шаг). Фаза исполнения активируется, только
@@ -247,6 +329,25 @@ export class ApprovalListService {
}); });
} }
/**
* История версий каждой строки маршрута заявки — сырьё для «Истории
* согласования» (см. history/approvalHistory.service.ts). Версии всех строк —
* одним пакетом. Порядок версий внутри строки не гарантирован, сортирует
* вызывающая сторона.
*/
public async getRowVersions(requestId: number): Promise<Array<Array<Record<string, unknown>>>> {
const rows = await this.getForRequest(requestId);
if (rows.length === 0) return [];
const [batchedSP, execute] = getSP().batched();
const batchedList = batchedSP.web.lists.getByTitle(this.listTitle);
const pending = rows.map(
(r) => batchedList.items.getById(r.id).versions() as Promise<Array<Record<string, unknown>>>
);
await execute();
return Promise.all(pending);
}
// Строки согласования по заявке, в порядке согласования (по Order). // Строки согласования по заявке, в порядке согласования (по Order).
public async getForRequest(requestId: number): Promise<IApprovalRow[]> { public async getForRequest(requestId: number): Promise<IApprovalRow[]> {
const rows = (await this.list() const rows = (await this.list()
@@ -5,6 +5,15 @@ import { IMatrixRow } from './models/IMatrixRow';
import { ApprovalStatus, IResolveFieldValue } from './models/IApprovalRouteApi'; import { ApprovalStatus, IResolveFieldValue } from './models/IApprovalRouteApi';
import { APPROVAL_PHASE, REQUEST_STATUS, isSubmittedStatus } from './models/statuses'; import { APPROVAL_PHASE, REQUEST_STATUS, isSubmittedStatus } from './models/statuses';
/**
* Выключатель пересохранения маршрута патчем — ДЛЯ ВСЕХ форм сразу (см.
* writeRoute). Проверено пилотами на «Заявлении на увольнение» и «Организации
* рабочего места» 2026-09-29: обновление строк на месте, обе фазы, уход
* выпавших шагов в корзину. false — аварийный откат к прежнему «удалить всё и
* создать заново» (история прошлых отправок снова перестанет сохраняться).
*/
const ROUTE_PATCH_ENABLED = true;
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы. // Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
// status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution' // status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution'
// (исполнители). Имена подтягиваются по executorEmail (бэкенд отдаёт email + должность). // (исполнители). Имена подтягиваются по executorEmail (бэкенд отдаёт email + должность).
@@ -72,6 +81,12 @@ export async function writeApprovers(
// очереди. Первого исполнителя переключит на «На исполнении» // очереди. Первого исполнителя переключит на «На исполнении»
// approval.activateNextQueued, когда фаза согласования будет пройдена целиком // approval.activateNextQueued, когда фаза согласования будет пройдена целиком
// (см. approverDecision.service.ts/decide). // (см. approverDecision.service.ts/decide).
//
// Пересохранение — ПАТЧЕМ (ApprovalListService.patchSteps, см. ROUTE_PATCH_ENABLED):
// строки, что остаются в маршруте, обновляются на месте, и их прошлые решения
// (кто вернул/отклонил и с каким комментарием) сохраняются в истории версий —
// из неё строится «История согласования». Раньше маршрут удалялся и создавался
// заново, и история прошлых отправок терялась.
export async function writeRoute( export async function writeRoute(
approval: ApprovalListService, approval: ApprovalListService,
requestId: number, requestId: number,
@@ -79,8 +94,6 @@ export async function writeRoute(
executors: IApproverToWrite[], executors: IApproverToWrite[],
status: string status: string
): Promise<void> { ): Promise<void> {
await approval.deleteForRequest(requestId);
// При ОТПРАВКЕ все шаги пишем «Ждёт очереди», затем активируем первый — // При ОТПРАВКЕ все шаги пишем «Ждёт очереди», затем активируем первый —
// так активен ровно текущий шаг, остальные ждут. В черновике — статус заявки // так активен ровно текущий шаг, остальные ждут. В черновике — статус заявки
// (Черновик), без активации. // (Черновик), без активации.
@@ -102,14 +115,26 @@ export async function writeRoute(
0 0
); );
await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1); if (ROUTE_PATCH_ENABLED) {
await approval.createSteps( await approval.patchSteps(
requestId, requestId,
executors, [
rowStatus, { approvers, phase: APPROVAL_PHASE.approval, orderStart: 1 },
APPROVAL_PHASE.execution, { approvers: executors, phase: APPROVAL_PHASE.execution, orderStart: lastApprovalStep + 1 },
lastApprovalStep + 1 ],
); rowStatus
);
} else {
await approval.deleteForRequest(requestId);
await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1);
await approval.createSteps(
requestId,
executors,
rowStatus,
APPROVAL_PHASE.execution,
lastApprovalStep + 1
);
}
if (submitted) { if (submitted) {
await approval.activateNextQueued(requestId); await approval.activateNextQueued(requestId);
@@ -0,0 +1,110 @@
@import '../../../../../styles/design-tokens';
// Ширина колонки со временем — под «29.09.2026 11:02»; уже — текст переносится.
$time-col-width: 124px;
// Ниже этой ширины время встаёт над текстом события, а не слева от него.
$feed-stack-breakpoint: 640px;
.feed {
display: flex;
flex-direction: column;
gap: $sp-4;
}
.round {
display: flex;
flex-direction: column;
gap: $sp-2;
}
.roundTitle {
padding-bottom: $sp-1;
border-bottom: 1px solid $gray-200;
font-size: $fs-overline;
font-weight: 600;
letter-spacing: 1.4px;
text-transform: uppercase;
color: $gray-500;
}
.events {
display: flex;
flex-direction: column;
gap: $sp-3;
margin: 0;
padding: 0;
list-style: none;
}
.event {
display: flex;
align-items: flex-start;
gap: $sp-3;
font-size: $fs-sm;
color: $gray-900;
}
.current {
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: $gold-50;
}
.marker {
flex: 0 0 auto;
width: 20px;
text-align: center;
font-weight: 700;
line-height: 1.4;
}
.markerNeutral { color: $navy-700; }
.markerPositive { color: $green-600; }
.markerWarning { color: $amber-700; }
.markerNegative { color: $red-500; }
.markerCurrent { color: $gold-600; }
.eventBody {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.eventLine {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: $sp-1 $sp-3;
}
.time {
flex: 0 0 $time-col-width;
color: $gray-500;
font-variant-numeric: tabular-nums;
@media (max-width: $feed-stack-breakpoint) {
flex-basis: 100%;
}
}
.meta {
font-size: $fs-label;
color: $gray-500;
}
.comment {
color: $gray-700;
font-style: italic;
overflow-wrap: anywhere;
}
.note {
font-size: $fs-label;
color: $gray-500;
}
.error {
font-size: $fs-sm;
color: $red-500;
}
@@ -0,0 +1,28 @@
import * as React from 'react';
import { CollapseSection } from '@tnsu/ui-kit';
import { ApprovalListService } from '../approvalList.service';
import { ApprovalHistoryFeed } from './ApprovalHistoryFeed';
import { HISTORY_TEXT } from './historyTexts';
interface Props {
approval: ApprovalListService;
/** Title или серверный url списка данных — как у ApproverService. */
dataListRef: string;
requestId: number;
/**
* Меняется после решения/делегирования — лента перечитывается (тот же
* decisionSeq, что у матрицы маршрута в просмотре заявки).
*/
reloadKey?: string | number;
}
/**
* Раздел «История согласования» в карточке заявки — свёрнут по умолчанию:
* лента грузит версии всех строк маршрута, и делать это при каждом открытии
* заявки незачем. Не печатается — оборачивает вызывающая сторона (rp-no-print).
*/
export const ApprovalHistory: React.FC<Props> = ({ approval, dataListRef, requestId, reloadKey }) => (
<CollapseSection title={HISTORY_TEXT.title} icon="calendar" defaultOpen={false}>
<ApprovalHistoryFeed key={reloadKey} approval={approval} dataListRef={dataListRef} requestId={requestId} />
</CollapseSection>
);
@@ -0,0 +1,67 @@
import * as React from 'react';
import { ApprovalListService } from '../approvalList.service';
import { IApprovalHistory } from './historyModel';
import { loadApprovalHistory } from './approvalHistory.service';
import { CurrentHolderRow } from './CurrentHolderRow';
import { HistoryEventRow } from './HistoryEventRow';
import { HISTORY_TEXT } from './historyTexts';
import styles from './ApprovalHistory.module.scss';
interface Props {
approval: ApprovalListService;
dataListRef: string;
requestId: number;
}
type LoadState =
| { kind: 'loading' }
| { kind: 'error'; message: string }
| { kind: 'ready'; history: IApprovalHistory };
// Лента «Истории согласования». Монтируется только в раскрытом разделе
// (CollapseSection рендерит тело лишь открытым), поэтому версии грузятся не
// при каждом открытии заявки, а когда историю действительно смотрят.
export const ApprovalHistoryFeed: React.FC<Props> = ({ approval, dataListRef, requestId }) => {
const [state, setState] = React.useState<LoadState>({ kind: 'loading' });
React.useEffect(() => {
let alive = true;
setState({ kind: 'loading' });
loadApprovalHistory(approval, dataListRef, requestId)
.then((history) => {
if (alive) setState({ kind: 'ready', history });
})
.catch((e) => {
if (alive) setState({ kind: 'error', message: (e as Error).message });
});
return () => {
alive = false;
};
}, [approval, dataListRef, requestId]);
if (state.kind === 'loading') return <div className={styles.note}>{HISTORY_TEXT.loading}</div>;
if (state.kind === 'error') {
return <div className={styles.error}>{`${HISTORY_TEXT.errorPrefix} ${state.message}`}</div>;
}
const { rounds, current } = state.history;
if (rounds.length === 0) return <div className={styles.note}>{HISTORY_TEXT.empty}</div>;
return (
<div className={styles.feed}>
{rounds.map((round, i) => (
<section key={round.number} className={styles.round}>
<div className={styles.roundTitle}>{HISTORY_TEXT.round(round.number)}</div>
<ul className={styles.events}>
{/* «Сейчас» — только у последней (верхней) отправки. */}
{i === 0 && current.map((holder, j) => <CurrentHolderRow key={`current-${j}`} holder={holder} />)}
{round.events.map((event, j) => (
<HistoryEventRow key={`${event.time}-${event.kind}-${j}`} event={event} />
))}
</ul>
</section>
))}
<div className={styles.note}>{HISTORY_TEXT.partialNote}</div>
</div>
);
};
@@ -0,0 +1,28 @@
import * as React from 'react';
import { formatDuration, ICurrentHolder } from './historyModel';
import { HISTORY_TEXT } from './historyTexts';
import styles from './ApprovalHistory.module.scss';
interface Props {
holder: ICurrentHolder;
}
/** Верхняя строка ленты: у кого заявка сейчас и сколько уже ждёт. */
export const CurrentHolderRow: React.FC<Props> = ({ holder }) => {
const waitedMs = holder.since ? Date.now() - new Date(holder.since).getTime() : NaN;
return (
<li className={`${styles.event} ${styles.current}`}>
<span className={`${styles.marker} ${styles.markerCurrent}`} aria-hidden="true">
●
</span>
<div className={styles.eventBody}>
<div className={styles.eventLine}>
<span className={styles.time}>{HISTORY_TEXT.now}</span>
<strong>{holder.person || '—'}</strong>
{waitedMs > 0 && <span className={styles.meta}>{HISTORY_TEXT.waiting(formatDuration(waitedMs))}</span>}
</div>
{holder.stepTitle && <div className={styles.meta}>{holder.stepTitle}</div>}
</div>
</li>
);
};
@@ -0,0 +1,52 @@
import * as React from 'react';
import { formatDuration, HistoryEventKind, IHistoryEvent } from './historyModel';
import { eventAction, HISTORY_TEXT } from './historyTexts';
import styles from './ApprovalHistory.module.scss';
interface Props {
event: IHistoryEvent;
}
// Значок и цвет события — по смыслу решения, как у статусов в StatusBadge.
const MARKER: Record<HistoryEventKind, { symbol: string; className: string }> = {
submitted: { symbol: '↗', className: styles.markerNeutral },
resubmitted: { symbol: '↗', className: styles.markerNeutral },
approved: { symbol: '✓', className: styles.markerPositive },
executed: { symbol: '✓', className: styles.markerPositive },
returned: { symbol: '↩', className: styles.markerWarning },
rejected: { symbol: '✕', className: styles.markerNegative },
delegated: { symbol: '→', className: styles.markerNeutral },
replaced: { symbol: '⇄', className: styles.markerNeutral },
};
export function formatDateTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const pad = (n: number): string => (n < 10 ? `0${n}` : `${n}`);
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** Одно событие ленты: время · кто — что сделал · за сколько + комментарий. */
export const HistoryEventRow: React.FC<Props> = ({ event }) => {
const marker = MARKER[event.kind];
return (
<li className={styles.event}>
<span className={`${styles.marker} ${marker.className}`} aria-hidden="true">
{marker.symbol}
</span>
<div className={styles.eventBody}>
<div className={styles.eventLine}>
<span className={styles.time}>{formatDateTime(event.time)}</span>
<span>
<strong>{event.actor || '—'}</strong> — {eventAction(event)}
</span>
{event.durationMs !== undefined && (
<span className={styles.meta}>{HISTORY_TEXT.took(formatDuration(event.durationMs))}</span>
)}
</div>
{event.stepTitle && <div className={styles.meta}>{event.stepTitle}</div>}
{event.comment && <div className={styles.comment}>«{event.comment}»</div>}
</div>
</li>
);
};
@@ -0,0 +1,86 @@
import { getSP } from '../../pnp/pnpjs';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import { IList } from '@pnp/sp/lists';
import '@pnp/sp/items';
import { ApprovalListService } from '../approvalList.service';
import { buildApprovalHistory, IApprovalHistory, IRequestVersion, IRouteRowVersion } from './historyModel';
// Внутренние имена колонок строки маршрута — те же, что FIELDS в approvalList.service.ts.
const ROW_FIELDS = {
status: 'ApprovalStatus',
approver: 'TansuSiteApprover',
comment: 'Comment',
date: 'Date',
delegatedBy: 'DelegatedBy',
};
/**
* Имя человека из поля версии. В версиях SharePoint Person-поле приходит
* объектом { LookupId, LookupValue, Email }, но на всякий случай принимаем и
* строку, и Title — пустая строка лучше, чем падение всей ленты.
*/
function personName(value: unknown): string {
if (!value) return '';
if (typeof value === 'string') return value;
const user = value as { LookupValue?: string; Title?: string };
return (user.LookupValue || user.Title || '').trim();
}
function text(value: unknown): string {
return typeof value === 'string' ? value : '';
}
// Версии — от старой к новой: VersionId растёт с каждой версией (512 на мажорную).
function byVersion(a: Record<string, unknown>, b: Record<string, unknown>): number {
return Number(a.VersionId) - Number(b.VersionId);
}
// Момент сохранения версии: Modified этой версии. Created — запасной вариант
// (у элемента он один на все версии, у версии — время её создания).
function versionTime(v: Record<string, unknown>): string {
return text(v.Modified) || text(v.Created);
}
function toRowVersion(v: Record<string, unknown>): IRouteRowVersion {
return {
time: versionTime(v),
status: text(v[ROW_FIELDS.status]),
approverName: personName(v[ROW_FIELDS.approver]),
editorName: personName(v.Editor),
stepTitle: text(v.Title),
comment: text(v[ROW_FIELDS.comment]).trim(),
date: text(v[ROW_FIELDS.date]),
delegatedByName: personName(v[ROW_FIELDS.delegatedBy]),
};
}
// dataListRef — Title или серверный url списка данных, как у ApproverService.
function dataList(dataListRef: string): IList {
return dataListRef.startsWith('/')
? getSP().web.getList(dataListRef)
: getSP().web.lists.getByTitle(dataListRef);
}
async function requestVersions(dataListRef: string, requestId: number): Promise<IRequestVersion[]> {
const versions = (await dataList(dataListRef).items.getById(requestId).versions()) as Array<Record<string, unknown>>;
return [...versions].sort(byVersion).map((v) => ({
time: versionTime(v),
status: text(v.RequestStatus),
editorName: personName(v.Editor),
}));
}
/** Загрузить и собрать «Историю согласования» заявки. */
export async function loadApprovalHistory(
approval: ApprovalListService,
dataListRef: string,
requestId: number
): Promise<IApprovalHistory> {
const [rowVersions, reqVersions] = await Promise.all([
approval.getRowVersions(requestId),
requestVersions(dataListRef, requestId),
]);
const rows = rowVersions.map((versions) => [...versions].sort(byVersion).map(toRowVersion));
return buildApprovalHistory(rows, reqVersions);
}
@@ -0,0 +1,141 @@
import {
buildApprovalHistory,
formatDuration,
IRequestVersion,
IRouteRowVersion,
rowEvents,
submitEvents,
} from './historyModel';
function rv(over: Partial<IRouteRowVersion>): IRouteRowVersion {
return {
time: '2026-09-24T10:00:00Z',
status: 'Ждёт очереди',
approverName: 'Анна Ким',
editorName: 'Антон Огай',
stepTitle: 'Начальник расчётной группы',
comment: '',
date: '',
delegatedByName: '',
...over,
};
}
function req(time: string, status: string, editorName = 'Антон Огай'): IRequestVersion {
return { time, status, editorName };
}
describe('rowEvents', () => {
it('решение даёт событие с комментарием и временем, сколько шаг был у человека', () => {
const events = rowEvents([
rv({ time: '2026-09-24T10:00:00Z', status: 'Ждёт очереди' }),
rv({ time: '2026-09-24T10:00:05Z', status: 'На согласовании', date: '2026-09-24T10:00:05Z' }),
rv({ time: '2026-09-24T13:00:05Z', status: 'Возвращена на доработку', date: '2026-09-24T13:00:05Z', comment: 'Уточните оклад' }),
]);
expect(events).toEqual([
{
kind: 'returned',
time: '2026-09-24T13:00:05Z',
actor: 'Анна Ким',
stepTitle: 'Начальник расчётной группы',
comment: 'Уточните оклад',
durationMs: 3 * 60 * 60 * 1000,
},
]);
});
it('после пересохранения патчем в той же строке видны оба круга', () => {
const events = rowEvents([
rv({ status: 'На согласовании', date: '2026-09-24T10:00:00Z' }),
rv({ status: 'Возвращена на доработку', date: '2026-09-25T10:00:00Z', comment: 'Исправьте' }),
rv({ time: '2026-09-26T09:00:00Z', status: 'Ждёт очереди' }),
rv({ status: 'На согласовании', date: '2026-09-26T09:00:00Z' }),
rv({ status: 'Согласовано', date: '2026-09-26T10:00:00Z' }),
]);
expect(events.map((e) => e.kind)).toEqual(['returned', 'approved']);
expect(events[1].durationMs).toBe(60 * 60 * 1000);
});
it('первая версия с «Кем переназначено» — событие делегирования', () => {
const events = rowEvents([
rv({ time: '2026-09-25T09:00:00Z', approverName: 'Асель Самаева', delegatedByName: 'Анна Ким' }),
]);
expect(events).toEqual([
{
kind: 'delegated',
time: '2026-09-25T09:00:00Z',
actor: 'Анна Ким',
target: 'Асель Самаева',
stepTitle: 'Начальник расчётной группы',
},
]);
});
it('смена человека без смены статуса — замена исполнителя', () => {
const events = rowEvents([
rv({ status: 'На исполнении', approverName: 'Петров Пётр' }),
rv({ time: '2026-09-26T12:00:00Z', status: 'На исполнении', approverName: 'Сидорова Анна', editorName: 'Алтынай Бултурукова' }),
]);
expect(events).toEqual([
{
kind: 'replaced',
time: '2026-09-26T12:00:00Z',
actor: 'Алтынай Бултурукова',
target: 'Сидорова Анна',
previous: 'Петров Пётр',
stepTitle: 'Начальник расчётной группы',
},
]);
});
});
describe('submitEvents', () => {
it('отправка из черновика и повторная после возврата; ход по маршруту отправкой не считается', () => {
const events = submitEvents([
req('2026-09-24T09:00:00Z', 'Черновик'),
req('2026-09-24T10:00:00Z', 'На согласовании'),
req('2026-09-25T10:00:00Z', 'Возвращена на доработку', 'Анна Ким'),
req('2026-09-26T09:00:00Z', 'На согласовании'),
req('2026-09-26T10:00:00Z', 'Выполнение', 'Анна Ким'),
]);
expect(events.map((e) => [e.kind, e.time])).toEqual([
['submitted', '2026-09-24T10:00:00Z'],
['resubmitted', '2026-09-26T09:00:00Z'],
]);
});
});
describe('buildApprovalHistory', () => {
it('разбивает ленту на отправки, новые сверху, и показывает, у кого заявка сейчас', () => {
const history = buildApprovalHistory(
[
[
rv({ status: 'На согласовании', date: '2026-09-24T10:00:00Z' }),
rv({ status: 'Возвращена на доработку', date: '2026-09-25T10:00:00Z' }),
rv({ time: '2026-09-26T09:00:00Z', status: 'На согласовании', date: '2026-09-26T09:00:00Z' }),
],
],
[req('2026-09-24T10:00:00Z', 'На согласовании'), req('2026-09-25T10:00:00Z', 'Возвращена на доработку'), req('2026-09-26T09:00:00Z', 'На согласовании')]
);
expect(history.rounds.map((r) => r.number)).toEqual([2, 1]);
expect(history.rounds[0].events.map((e) => e.kind)).toEqual(['resubmitted']);
expect(history.rounds[1].events.map((e) => e.kind)).toEqual(['returned', 'submitted']);
expect(history.current).toEqual([
{ person: 'Анна Ким', stepTitle: 'Начальник расчётной группы', since: '2026-09-26T09:00:00Z' },
]);
});
it('без версий — пусто', () => {
expect(buildApprovalHistory([], [])).toEqual({ rounds: [], current: [] });
});
});
describe('formatDuration', () => {
it('минуты, часы, дни', () => {
expect(formatDuration(30 * 1000)).toBe('1 мин');
expect(formatDuration(15 * 60 * 1000)).toBe('15 мин');
expect(formatDuration(3 * 60 * 60 * 1000)).toBe('3 ч');
expect(formatDuration(2 * 24 * 60 * 60 * 1000)).toBe('2 дн');
});
});
@@ -0,0 +1,193 @@
import { REQUEST_STATUS } from '../models/statuses';
/**
* «История согласования» заявки — лента событий, собранная из истории версий
* SharePoint: строк маршрута (решения, делегирование, замены) и самой заявки
* (отправки). Чистые функции, без SharePoint — загрузка в approvalHistory.service.ts.
*
* Прошлые отправки видны, потому что маршрут пересохраняется патчем (см.
* writeRoute): строки остаются, и их прошлые решения лежат в версиях. Решения
* из отправок ДО включения патча (2026-09-29) были удалены вместе со строками —
* у старых заявок видна только их последняя отправка.
*/
/** Одна версия строки маршрута — поля, нужные для ленты. */
export interface IRouteRowVersion {
/** Когда создана версия (ISO). */
time: string;
status: string;
/** Человек в строке («Согласующий»). */
approverName: string;
/** Кто сохранил эту версию (Editor). */
editorName: string;
/** Должность шага — Title строки. */
stepTitle: string;
comment: string;
/** Колонка «Дата»: момент активации или решения (ISO), '' — пусто. */
date: string;
/** «Кем переназначено» — у строк делегатов. */
delegatedByName: string;
}
/** Одна версия заявки — для событий «отправлено». */
export interface IRequestVersion {
time: string;
status: string;
editorName: string;
}
export type HistoryEventKind =
| 'submitted'
| 'resubmitted'
| 'approved'
| 'executed'
| 'returned'
| 'rejected'
| 'delegated'
| 'replaced';
export interface IHistoryEvent {
kind: HistoryEventKind;
time: string;
/** Кто сделал. */
actor: string;
/** Над кем: кому передали / кого поставили вместо. */
target?: string;
/** Кого заменили (для 'replaced'). */
previous?: string;
stepTitle?: string;
comment?: string;
/** Сколько шаг был у человека до решения, мс. */
durationMs?: number;
}
/** Шаг, который сейчас у человека. */
export interface ICurrentHolder {
person: string;
stepTitle: string;
/** С какого момента у него (ISO), '' — неизвестно. */
since: string;
}
export interface IHistoryRound {
/** Номер отправки, с 1. */
number: number;
/** События отправки, НОВЫЕ СВЕРХУ. */
events: IHistoryEvent[];
}
export interface IApprovalHistory {
/** Отправки, НОВЫЕ СВЕРХУ. */
rounds: IHistoryRound[];
current: ICurrentHolder[];
}
const ACTIVE_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution];
const DECISION_KINDS: Record<string, HistoryEventKind> = {
[REQUEST_STATUS.approved]: 'approved',
[REQUEST_STATUS.executed]: 'executed',
[REQUEST_STATUS.returned]: 'returned',
[REQUEST_STATUS.rejected]: 'rejected',
};
// Статусы заявки, ИЗ которых переход в работу — это отправка автором, а не
// движение по маршруту (На согласовании → Выполнение отправкой не считается).
const BEFORE_SUBMIT_STATUSES: string[] = ['', REQUEST_STATUS.draft, REQUEST_STATUS.returned, REQUEST_STATUS.rejected];
const SUBMITTED_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.execution];
function msBetween(fromIso: string, toIso: string): number | undefined {
const diff = new Date(toIso).getTime() - new Date(fromIso).getTime();
return isNaN(diff) || diff < 0 ? undefined : diff;
}
/** События одной строки маршрута по её версиям (от старой к новой). */
export function rowEvents(versions: IRouteRowVersion[]): IHistoryEvent[] {
const events: IHistoryEvent[] = [];
let arrivedAt = '';
versions.forEach((v, i) => {
const prev = i > 0 ? versions[i - 1] : undefined;
const time = v.date || v.time;
if (!prev && v.delegatedByName) {
events.push({ kind: 'delegated', time: v.time, actor: v.delegatedByName, target: v.approverName, stepTitle: v.stepTitle });
}
// Тот же шаг, другой человек — замена (не пересохранение: статус не менялся).
if (prev && prev.approverName !== v.approverName && prev.status === v.status) {
events.push({ kind: 'replaced', time: v.time, actor: v.editorName, target: v.approverName, previous: prev.approverName, stepTitle: v.stepTitle });
}
if (ACTIVE_STATUSES.indexOf(v.status) >= 0 && (!prev || ACTIVE_STATUSES.indexOf(prev.status) < 0)) {
arrivedAt = time;
}
const decision = DECISION_KINDS[v.status];
if (decision && (!prev || prev.status !== v.status)) {
events.push({
kind: decision,
time,
actor: v.approverName,
stepTitle: v.stepTitle,
comment: v.comment || undefined,
durationMs: arrivedAt ? msBetween(arrivedAt, time) : undefined,
});
arrivedAt = '';
}
});
return events;
}
/** Отправки заявки автором — по версиям заявки (от старой к новой). */
export function submitEvents(versions: IRequestVersion[]): IHistoryEvent[] {
const events: IHistoryEvent[] = [];
versions.forEach((v, i) => {
const prevStatus = i > 0 ? versions[i - 1].status : '';
if (SUBMITTED_STATUSES.indexOf(v.status) < 0 || BEFORE_SUBMIT_STATUSES.indexOf(prevStatus) < 0) return;
const kind: HistoryEventKind = events.length === 0 ? 'submitted' : 'resubmitted';
events.push({ kind, time: v.time, actor: v.editorName });
});
return events;
}
/** Кто держит заявку сейчас — по последним версиям строк. */
function currentHolders(rows: IRouteRowVersion[][]): ICurrentHolder[] {
return rows
.filter((versions) => versions.length > 0 && ACTIVE_STATUSES.indexOf(versions[versions.length - 1].status) >= 0)
.map((versions) => {
const last = versions[versions.length - 1];
return { person: last.approverName, stepTitle: last.stepTitle, since: last.date };
});
}
/**
* Лента целиком: события по времени, разбитые на отправки (каждая отправка
* автором открывает новую), новые сверху.
*/
export function buildApprovalHistory(rows: IRouteRowVersion[][], requestVersions: IRequestVersion[]): IApprovalHistory {
const all = rows
.reduce<IHistoryEvent[]>((acc, versions) => acc.concat(rowEvents(versions)), [])
.concat(submitEvents(requestVersions))
.sort((a, b) => a.time.localeCompare(b.time));
const rounds: IHistoryRound[] = [];
all.forEach((event) => {
const startsRound = event.kind === 'submitted' || event.kind === 'resubmitted';
if (startsRound || rounds.length === 0) rounds.push({ number: rounds.length + 1, events: [] });
rounds[rounds.length - 1].events.push(event);
});
return {
rounds: rounds.map((r) => ({ ...r, events: [...r.events].reverse() })).reverse(),
current: currentHolders(rows),
};
}
const MINUTE_MS = 60 * 1000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
/** «15 мин», «3 ч», «2 дн» — грубо, для подписи «сколько держал шаг». */
export function formatDuration(ms: number): string {
if (ms < HOUR_MS) return `${Math.max(1, Math.round(ms / MINUTE_MS))} мин`;
if (ms < DAY_MS) return `${Math.round(ms / HOUR_MS)} ч`;
return `${Math.round(ms / DAY_MS)} дн`;
}
@@ -0,0 +1,36 @@
import { HistoryEventKind, IHistoryEvent } from './historyModel';
// Тексты «Истории согласования» — в одном месте (задел под локализацию).
// Формулировки безличные («согласовано», а не «согласовала»): пол по имени не
// угадываем.
export const HISTORY_TEXT = {
title: 'История согласования',
loading: 'Загрузка истории…',
empty: 'Пока нет событий: заявка ещё не отправлялась.',
errorPrefix: 'Не удалось загрузить историю:',
round: (n: number): string => `Отправка ${n}`,
now: 'сейчас',
waiting: (duration: string): string => `ждёт ${duration}`,
took: (duration: string): string => `за ${duration}`,
partialNote:
'Показаны события, сохранённые в SharePoint. Решения из отправок до включения истории могли не сохраниться.',
};
const ACTION: Record<HistoryEventKind, string> = {
submitted: 'отправлено на согласование',
resubmitted: 'отправлено повторно',
approved: 'согласовано',
executed: 'выполнено',
returned: 'возвращено на доработку',
rejected: 'отклонено',
delegated: 'передано',
replaced: 'заменён исполнитель',
};
/** Что произошло — «согласовано», «передано: Асель Самаева», «заменён исполнитель: А → Б». */
export function eventAction(event: IHistoryEvent): string {
const action = ACTION[event.kind];
if (event.kind === 'delegated' && event.target) return `${action}: ${event.target}`;
if (event.kind === 'replaced') return `${action}: ${event.previous || '—'} → ${event.target || '—'}`;
return action;
}
@@ -68,6 +68,9 @@ export type { IApproverOrgInfo } from './approverOrgInfo';
export { canEditRoute } from './routeEditRights'; export { canEditRoute } from './routeEditRights';
export { canEditAllFields } from './fullEditRights'; export { canEditAllFields } from './fullEditRights';
export { ApprovalMatrix } from './ui/ApprovalMatrix'; export { ApprovalMatrix } from './ui/ApprovalMatrix';
export { ApprovalHistory } from './history/ApprovalHistory';
export { ApprovalHistoryFeed } from './history/ApprovalHistoryFeed';
export { HISTORY_TEXT } from './history/historyTexts';
export { UserPicker } from './ui/UserPicker'; export { UserPicker } from './ui/UserPicker';
export { DecisionPanel } from './ui/DecisionPanel'; export { DecisionPanel } from './ui/DecisionPanel';
export { StatusBadge } from './ui/StatusBadge'; export { StatusBadge } from './ui/StatusBadge';
@@ -0,0 +1,94 @@
import { IExistingRouteRow, planRoutePatch } from './routePatchPlan';
const APPROVAL = 'Согласование';
const EXECUTION = 'Исполнение';
function row(id: number, approverId: number, phase = APPROVAL, delegatedById = 0): IExistingRouteRow {
return { id, approverId, phase, delegatedById };
}
describe('planRoutePatch', () => {
it('маршрут не изменился — все строки обновляются на месте, ничего не создаётся и не удаляется', () => {
const plan = planRoutePatch(
[row(10, 1), row(11, 2), row(12, 3)],
[
{ phase: APPROVAL, approverId: 1 },
{ phase: APPROVAL, approverId: 2 },
{ phase: APPROVAL, approverId: 3 },
]
);
expect(plan.update).toEqual([
{ rowId: 10, stepIndex: 0 },
{ rowId: 11, stepIndex: 1 },
{ rowId: 12, stepIndex: 2 },
]);
expect(plan.create).toEqual([]);
expect(plan.remove).toEqual([]);
});
it('согласующий сменился — старая строка убирается, новая создаётся', () => {
const plan = planRoutePatch(
[row(10, 1), row(11, 2)],
[
{ phase: APPROVAL, approverId: 1 },
{ phase: APPROVAL, approverId: 9 },
]
);
expect(plan.update).toEqual([{ rowId: 10, stepIndex: 0 }]);
expect(plan.create).toEqual([1]);
expect(plan.remove).toEqual([11]);
});
it('порядок шагов поменялся — строки сопоставляются по человеку, а не по позиции', () => {
const plan = planRoutePatch(
[row(10, 1), row(11, 2)],
[
{ phase: APPROVAL, approverId: 2 },
{ phase: APPROVAL, approverId: 1 },
]
);
expect(plan.update).toEqual([
{ rowId: 11, stepIndex: 0 },
{ rowId: 10, stepIndex: 1 },
]);
expect(plan.create).toEqual([]);
expect(plan.remove).toEqual([]);
});
it('один человек дважды в фазе — пары по порядку, лишняя строка убирается', () => {
const plan = planRoutePatch([row(10, 1), row(11, 1), row(12, 1)], [
{ phase: APPROVAL, approverId: 1 },
{ phase: APPROVAL, approverId: 1 },
]);
expect(plan.update).toEqual([
{ rowId: 10, stepIndex: 0 },
{ rowId: 11, stepIndex: 1 },
]);
expect(plan.remove).toEqual([12]);
});
it('фаза важна — тот же человек в другой фазе считается другим шагом', () => {
const plan = planRoutePatch([row(10, 1, APPROVAL)], [{ phase: EXECUTION, approverId: 1 }]);
expect(plan.update).toEqual([]);
expect(plan.create).toEqual([0]);
expect(plan.remove).toEqual([10]);
});
it('строка делегата не сопоставляется и убирается, даже если человек есть в новом маршруте', () => {
const plan = planRoutePatch([row(10, 1), row(11, 5, APPROVAL, 1)], [
{ phase: APPROVAL, approverId: 1 },
{ phase: APPROVAL, approverId: 5 },
]);
expect(plan.update).toEqual([{ rowId: 10, stepIndex: 0 }]);
expect(plan.create).toEqual([1]);
expect(plan.remove).toEqual([11]);
});
it('первая отправка (строк ещё нет) — всё создаётся', () => {
const plan = planRoutePatch([], [
{ phase: APPROVAL, approverId: 1 },
{ phase: EXECUTION, approverId: 2 },
]);
expect(plan).toEqual({ update: [], create: [0, 1], remove: [] });
});
});
@@ -0,0 +1,76 @@
/**
* План пересохранения маршрута «патчем»: какие существующие строки обновить,
* какие шаги создать заново и какие строки убрать. Чистая функция, без
* SharePoint — её выполняет ApprovalListService.patchSteps.
*
* Зачем патч, а не «удалить всё и создать заново» (см. writeRoute): строка,
* которая остаётся в маршруте, обновляется на месте, и SharePoint сохраняет её
* прошлые значения в истории версий — кто, когда и с каким комментарием решал
* в прошлом круге (напр. вернул на доработку). При удалении это терялось.
*
* Строка и шаг считаются «одним и тем же», если совпадают фаза и согласующий.
* Если один человек стоит в фазе несколько раз, пары сопоставляются по порядку.
* Строки делегатов (delegatedById > 0) добавлены переназначением, а не
* маршрутом — новому маршруту они не соответствуют и убираются.
*/
/** Существующая строка маршрута — то, что нужно для сопоставления. */
export interface IExistingRouteRow {
id: number;
phase: string;
approverId: number;
/** Кто переназначил — у строк делегатов; 0 у обычных строк маршрута. */
delegatedById: number;
}
/** Шаг нового маршрута. index — его позиция во входном массиве. */
export interface IPlannedRouteStep {
phase: string;
approverId: number;
}
export interface IRoutePatchPlan {
/** Обновить строку rowId значениями шага stepIndex. */
update: Array<{ rowId: number; stepIndex: number }>;
/** Индексы шагов, для которых подходящей строки нет, — создать. */
create: number[];
/** Строки, которым в новом маршруте ничего не соответствует, — убрать. */
remove: number[];
}
function matchKey(phase: string, approverId: number): string {
return `${phase}|${approverId}`;
}
/**
* existing — в порядке маршрута (как отдаёт getForRequest, по StepOrder):
* от этого зависит, какая строка достанется какому шагу, если один человек
* стоит в фазе дважды.
*/
export function planRoutePatch(existing: IExistingRouteRow[], planned: IPlannedRouteStep[]): IRoutePatchPlan {
// Свободные строки по ключу, в порядке маршрута — первый шаг забирает первую.
const freeRows = new Map<string, number[]>();
existing
.filter((row) => row.delegatedById === 0)
.forEach((row) => {
const key = matchKey(row.phase, row.approverId);
freeRows.set(key, (freeRows.get(key) ?? []).concat(row.id));
});
const plan: IRoutePatchPlan = { update: [], create: [], remove: [] };
const usedRowIds = new Set<number>();
planned.forEach((step, stepIndex) => {
const candidates = freeRows.get(matchKey(step.phase, step.approverId)) ?? [];
const rowId = candidates.shift();
if (rowId === undefined) {
plan.create.push(stepIndex);
return;
}
usedRowIds.add(rowId);
plan.update.push({ rowId, stepIndex });
});
plan.remove = existing.filter((row) => !usedRowIds.has(row.id)).map((row) => row.id);
return plan;
}