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:
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user