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';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
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
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -319,6 +320,12 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -327,6 +328,19 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
||||
waitingText="Укажите начальника участка и проект, чтобы построить маршрут согласования."
|
||||
/>
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId
|
||||
@@ -155,6 +156,12 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -341,6 +342,20 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
||||
|
||||
<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
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -283,6 +284,12 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||
</div>
|
||||
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый).
|
||||
Итог автосоздания обходного листа — тут же, а не в шапке: он появляется
|
||||
именно как следствие решения «Согласовать», логичнее видеть его рядом. */}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -303,6 +304,19 @@ const EmployeeTransferForm: React.FC<IRequestFormProps> = ({
|
||||
|
||||
<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
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId
|
||||
@@ -152,6 +153,12 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
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
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -417,6 +418,12 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
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
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -380,6 +381,12 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
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
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -238,6 +239,12 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -387,6 +388,19 @@ const ServiceRequestForm: React.FC<IRequestFormProps> = ({
|
||||
waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования."
|
||||
/>
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -181,6 +182,12 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||
</div>
|
||||
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -421,6 +422,19 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
||||
emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С."
|
||||
/>
|
||||
|
||||
{/* Автору на доработке важнее всего видеть, почему вернули или
|
||||
отклонили. Только у уже существующей заявки: у новой истории нет. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -247,6 +248,12 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
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
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -207,6 +208,12 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -315,6 +316,19 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
||||
|
||||
<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
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId
|
||||
@@ -152,6 +153,12 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
@@ -314,6 +315,19 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
||||
|
||||
<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
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId
|
||||
@@ -152,6 +153,12 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
||||
</div>
|
||||
|
||||
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
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
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
@@ -274,6 +275,12 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* История согласования: маршрут пересохраняется патчем (см.
|
||||
writeRoute): прошлые решения лежат в версиях строк. */}
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
</div>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { IMatrixRow } from './models/IMatrixRow';
|
||||
import { ISiteUser } from './models/ISiteUser';
|
||||
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
|
||||
import { userSearchVariants } from './userSearchVariants';
|
||||
import { planRoutePatch } from './routePatchPlan';
|
||||
|
||||
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
||||
const FIELDS = {
|
||||
@@ -141,10 +142,26 @@ export class ApprovalListService {
|
||||
// он разрешится вместе со всеми остальными после execute() ниже.
|
||||
// eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error
|
||||
void batchedList.items.add({
|
||||
Title: a.position || a.email,
|
||||
[`${FIELDS.approver}Id`]: approverIds[i],
|
||||
[FIELDS.status]: status,
|
||||
...this.stepFields(a, approverIds[i], status, phase, order),
|
||||
[`${FIELDS.ticket}Id`]: requestId,
|
||||
});
|
||||
});
|
||||
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,
|
||||
@@ -152,8 +169,73 @@ export class ApprovalListService {
|
||||
[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();
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
public async getForRequest(requestId: number): Promise<IApprovalRow[]> {
|
||||
const rows = (await this.list()
|
||||
|
||||
@@ -5,6 +5,15 @@ import { IMatrixRow } from './models/IMatrixRow';
|
||||
import { ApprovalStatus, IResolveFieldValue } from './models/IApprovalRouteApi';
|
||||
import { APPROVAL_PHASE, REQUEST_STATUS, isSubmittedStatus } from './models/statuses';
|
||||
|
||||
/**
|
||||
* Выключатель пересохранения маршрута патчем — ДЛЯ ВСЕХ форм сразу (см.
|
||||
* writeRoute). Проверено пилотами на «Заявлении на увольнение» и «Организации
|
||||
* рабочего места» 2026-09-29: обновление строк на месте, обе фазы, уход
|
||||
* выпавших шагов в корзину. false — аварийный откат к прежнему «удалить всё и
|
||||
* создать заново» (история прошлых отправок снова перестанет сохраняться).
|
||||
*/
|
||||
const ROUTE_PATCH_ENABLED = true;
|
||||
|
||||
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
|
||||
// status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution'
|
||||
// (исполнители). Имена подтягиваются по executorEmail (бэкенд отдаёт email + должность).
|
||||
@@ -72,6 +81,12 @@ export async function writeApprovers(
|
||||
// очереди. Первого исполнителя переключит на «На исполнении»
|
||||
// approval.activateNextQueued, когда фаза согласования будет пройдена целиком
|
||||
// (см. approverDecision.service.ts/decide).
|
||||
//
|
||||
// Пересохранение — ПАТЧЕМ (ApprovalListService.patchSteps, см. ROUTE_PATCH_ENABLED):
|
||||
// строки, что остаются в маршруте, обновляются на месте, и их прошлые решения
|
||||
// (кто вернул/отклонил и с каким комментарием) сохраняются в истории версий —
|
||||
// из неё строится «История согласования». Раньше маршрут удалялся и создавался
|
||||
// заново, и история прошлых отправок терялась.
|
||||
export async function writeRoute(
|
||||
approval: ApprovalListService,
|
||||
requestId: number,
|
||||
@@ -79,8 +94,6 @@ export async function writeRoute(
|
||||
executors: IApproverToWrite[],
|
||||
status: string
|
||||
): Promise<void> {
|
||||
await approval.deleteForRequest(requestId);
|
||||
|
||||
// При ОТПРАВКЕ все шаги пишем «Ждёт очереди», затем активируем первый —
|
||||
// так активен ровно текущий шаг, остальные ждут. В черновике — статус заявки
|
||||
// (Черновик), без активации.
|
||||
@@ -102,6 +115,17 @@ export async function writeRoute(
|
||||
0
|
||||
);
|
||||
|
||||
if (ROUTE_PATCH_ENABLED) {
|
||||
await approval.patchSteps(
|
||||
requestId,
|
||||
[
|
||||
{ approvers, phase: APPROVAL_PHASE.approval, orderStart: 1 },
|
||||
{ approvers: executors, phase: APPROVAL_PHASE.execution, orderStart: lastApprovalStep + 1 },
|
||||
],
|
||||
rowStatus
|
||||
);
|
||||
} else {
|
||||
await approval.deleteForRequest(requestId);
|
||||
await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1);
|
||||
await approval.createSteps(
|
||||
requestId,
|
||||
@@ -110,6 +134,7 @@ export async function writeRoute(
|
||||
APPROVAL_PHASE.execution,
|
||||
lastApprovalStep + 1
|
||||
);
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
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 { canEditAllFields } from './fullEditRights';
|
||||
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 { DecisionPanel } from './ui/DecisionPanel';
|
||||
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