feat(helpdesk): add handover revision history with timeline, diff, and restore

Every save creates an immutable revision snapshot. The aside shows a
commit-graph-style timeline (newest first) with vertical line, circle
nodes, version numbers, change type, user, and date. Clicking a past
revision renders it readonly with inline git-diff-style highlights
(changed/added/removed with tinted backgrounds). Compare mode allows
diffing any two arbitrary revisions. MANAGE users can restore old
versions, which creates a new revision preserving full history.

New: HandoverRevisionService, HandoverRevisionRepository,
migration 006, 14 PHPUnit tests, DE/EN translations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 22:14:40 +02:00
parent 320f0a5a00
commit 03e15eaf08
13 changed files with 1240 additions and 96 deletions

View File

@@ -6,10 +6,13 @@
* $schemaFields — list of field definitions from schema_snapshot['fields']
* $fieldValues — associative array of current field values (key => value)
* $readonly — bool, if true all inputs are disabled
* $fieldDiff — (optional) associative array of diffs: key => ['type' => added|removed|changed, 'old' => ..., 'new' => ...]
*/
$schemaFields = is_array($schemaFields ?? null) ? $schemaFields : [];
$fieldValues = is_array($fieldValues ?? null) ? $fieldValues : [];
$fieldDiff = is_array($fieldDiff ?? null) ? $fieldDiff : [];
$readonly = !empty($readonly);
$hasDiff = $fieldDiff !== [];
?>
<?php if ($schemaFields === []): ?>
<p class="app-muted"><?php e(t('No protocol fields defined.')); ?></p>
@@ -21,6 +24,8 @@ $readonly = !empty($readonly);
$required = !empty($field['required']);
$value = $key !== '' ? (string) ($fieldValues[$key] ?? '') : '';
$inputId = 'handover-field-' . $index;
$diff = ($key !== '' && isset($fieldDiff[$key])) ? $fieldDiff[$key] : null;
$diffClass = $diff !== null ? ' handover-diff-' . ($diff['type'] ?? '') : '';
?>
<?php if ($type === 'heading'): ?>
<h3 class="handover-form-heading"><?php e($label); ?></h3>
@@ -29,50 +34,69 @@ $readonly = !empty($readonly);
<p class="app-muted"><?php e($label); ?></p>
<?php elseif ($type === 'checkbox'): ?>
<label class="handover-form-checkbox-label">
<input
type="checkbox"
id="<?php e($inputId); ?>"
name="field_<?php e($key); ?>"
value="1"
<?php if ($value === '1'): ?> checked<?php endif; ?>
<?php if ($readonly): ?> disabled<?php endif; ?>
>
<?php e($label); ?>
<?php if ($required): ?><span class="handover-form-required"> *</span><?php endif; ?>
</label>
<div class="handover-diff-field<?php e($diffClass); ?>">
<label class="handover-form-checkbox-label">
<input
type="checkbox"
id="<?php e($inputId); ?>"
name="field_<?php e($key); ?>"
value="1"
<?php if ($value === '1'): ?> checked<?php endif; ?>
<?php if ($readonly): ?> disabled<?php endif; ?>
>
<?php e($label); ?>
<?php if ($required): ?><span class="handover-form-required"> *</span><?php endif; ?>
</label>
<?php if ($diff !== null): ?>
<span class="handover-diff-indicator" aria-label="<?php e(t('Changed')); ?>">
<?php if ($diff['type'] === 'changed'): ?>
<small class="app-muted"><?php e(t('was: %s', ($diff['old'] ?? '') === '1' ? t('Yes') : t('No'))); ?></small>
<?php endif; ?>
</span>
<?php endif; ?>
</div>
<?php elseif ($type === 'select'): ?>
<label for="<?php e($inputId); ?>">
<?php e($label); ?>
<?php if ($required): ?><span class="handover-form-required"> *</span><?php endif; ?>
</label>
<select
id="<?php e($inputId); ?>"
name="field_<?php e($key); ?>"
<?php if ($required): ?> aria-required="true"<?php endif; ?>
<?php if ($readonly): ?> disabled<?php endif; ?>
>
<option value=""><?php e(t('Please select...')); ?></option>
<?php foreach (($field['options'] ?? []) as $opt): ?>
<option value="<?php e((string) ($opt['value'] ?? '')); ?>"<?php if ($value === (string) ($opt['value'] ?? '')): ?> selected<?php endif; ?>>
<?php e((string) ($opt['label'] ?? '')); ?>
</option>
<?php endforeach; ?>
</select>
<div class="handover-diff-field<?php e($diffClass); ?>">
<label for="<?php e($inputId); ?>">
<?php e($label); ?>
<?php if ($required): ?><span class="handover-form-required"> *</span><?php endif; ?>
</label>
<select
id="<?php e($inputId); ?>"
name="field_<?php e($key); ?>"
<?php if ($required): ?> aria-required="true"<?php endif; ?>
<?php if ($readonly): ?> disabled<?php endif; ?>
>
<option value=""><?php e(t('Please select...')); ?></option>
<?php foreach (($field['options'] ?? []) as $opt): ?>
<option value="<?php e((string) ($opt['value'] ?? '')); ?>"<?php if ($value === (string) ($opt['value'] ?? '')): ?> selected<?php endif; ?>>
<?php e((string) ($opt['label'] ?? '')); ?>
</option>
<?php endforeach; ?>
</select>
<?php if ($diff !== null && $diff['type'] === 'changed'): ?>
<small class="app-muted handover-diff-indicator" aria-label="<?php e(t('Changed')); ?>"><?php e(t('was: %s', (string) ($diff['old'] ?? ''))); ?></small>
<?php endif; ?>
</div>
<?php elseif ($type === 'textarea'): ?>
<label for="<?php e($inputId); ?>">
<?php e($label); ?>
<?php if ($required): ?><span class="handover-form-required"> *</span><?php endif; ?>
</label>
<textarea
id="<?php e($inputId); ?>"
name="field_<?php e($key); ?>"
rows="4"
<?php if ($required): ?> aria-required="true"<?php endif; ?>
<?php if ($readonly): ?> disabled<?php endif; ?>
><?php e($value); ?></textarea>
<div class="handover-diff-field<?php e($diffClass); ?>">
<label for="<?php e($inputId); ?>">
<?php e($label); ?>
<?php if ($required): ?><span class="handover-form-required"> *</span><?php endif; ?>
</label>
<textarea
id="<?php e($inputId); ?>"
name="field_<?php e($key); ?>"
rows="4"
<?php if ($required): ?> aria-required="true"<?php endif; ?>
<?php if ($readonly): ?> disabled<?php endif; ?>
><?php e($value); ?></textarea>
<?php if ($diff !== null && $diff['type'] === 'changed'): ?>
<small class="app-muted handover-diff-indicator" aria-label="<?php e(t('Changed')); ?>"><?php e(t('was: %s', (string) ($diff['old'] ?? ''))); ?></small>
<?php endif; ?>
</div>
<?php else: ?>
<?php
@@ -82,18 +106,23 @@ $readonly = !empty($readonly);
default => 'text',
};
?>
<label for="<?php e($inputId); ?>">
<?php e($label); ?>
<?php if ($required): ?><span class="handover-form-required"> *</span><?php endif; ?>
</label>
<input
type="<?php e($inputType); ?>"
id="<?php e($inputId); ?>"
name="field_<?php e($key); ?>"
value="<?php e($value); ?>"
<?php if ($required): ?> aria-required="true"<?php endif; ?>
<?php if ($readonly): ?> disabled<?php endif; ?>
>
<div class="handover-diff-field<?php e($diffClass); ?>">
<label for="<?php e($inputId); ?>">
<?php e($label); ?>
<?php if ($required): ?><span class="handover-form-required"> *</span><?php endif; ?>
</label>
<input
type="<?php e($inputType); ?>"
id="<?php e($inputId); ?>"
name="field_<?php e($key); ?>"
value="<?php e($value); ?>"
<?php if ($required): ?> aria-required="true"<?php endif; ?>
<?php if ($readonly): ?> disabled<?php endif; ?>
>
<?php if ($diff !== null && $diff['type'] === 'changed'): ?>
<small class="app-muted handover-diff-indicator" aria-label="<?php e(t('Changed')); ?>"><?php e(t('was: %s', (string) ($diff['old'] ?? ''))); ?></small>
<?php endif; ?>
</div>
<?php endif; ?>
<?php endforeach; ?>
<?php endif; ?>

View File

@@ -3,6 +3,7 @@
use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService;
use MintyPHP\Module\Helpdesk\Service\HandoverService;
use MintyPHP\Router;
use MintyPHP\Session;
@@ -16,13 +17,15 @@ $request = requestInput();
$returnTarget = requestResolveReturnTarget();
$closeTarget = requestResolveReturnTarget('helpdesk/handovers');
$handoverId = (int) ($id ?? 0);
$editTarget = requestPathWithReturnTarget('helpdesk/handovers/edit/' . $handoverId, $returnTarget);
$editBasePath = 'helpdesk/handovers/edit/' . $handoverId;
$editTarget = requestPathWithReturnTarget($editBasePath, $returnTarget);
$session = app(SessionStoreInterface::class)->all();
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
$userId = (int) ($session['user']['id'] ?? 0);
$service = app(HandoverService::class);
$revisionService = app(HandoverRevisionService::class);
$handover = $service->findById($tenantId, $handoverId);
if ($handover === null) {
@@ -47,6 +50,7 @@ $allowedStatuses = $service->getAllowedStatuses($permissionLevel);
$schemaRaw = (string) ($handover['schema_snapshot'] ?? '{}');
$schema = json_decode($schemaRaw, true);
$schemaFields = is_array($schema) ? ($schema['fields'] ?? []) : [];
$schemaVersion = is_array($schema) ? (int) ($schema['version'] ?? 1) : 1;
// Parse field values
$fieldValuesRaw = (string) ($handover['field_values'] ?? '{}');
@@ -57,6 +61,31 @@ if (!is_array($fieldValues)) {
$errorBag = formErrors();
// Handle restore POST
if ($request->isMethod('POST') && (string) $request->body('action', '') === 'restore') {
if (!Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired');
Router::redirect($editTarget);
return;
}
$restoreRevision = (int) $request->body('restore_revision', 0);
$restoreResult = $revisionService->restoreRevision($tenantId, $handoverId, $restoreRevision, $userId, $permissionLevel);
if ($restoreResult['ok']) {
Flash::success(t('Version restored'), $editTarget, 'revision_restored');
} else {
$firstError = reset($restoreResult['errors']) ?: t('Failed to restore');
Flash::error($firstError, $editTarget, 'revision_restore_failed');
}
Router::redirect($editTarget);
return;
}
// Handle normal save POST
if ($request->isMethod('POST') && $canEdit) {
if (!Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired');
@@ -80,17 +109,34 @@ if ($request->isMethod('POST') && $canEdit) {
}
}
$previousStatus = $currentStatus;
$updateResult = $service->updateFields($tenantId, $handoverId, $submittedValues, $userId, $permissionLevel);
$errorBag->merge($updateResult['errors'] ?? []);
// Handle status change
$newStatus = (string) $request->body('status', '');
$statusChanged = false;
if ($newStatus !== '' && $newStatus !== $currentStatus) {
$statusResult = $service->changeStatus($tenantId, $handoverId, $newStatus, $userId, $permissionLevel);
$errorBag->merge($statusResult['errors'] ?? []);
if (empty($statusResult['errors'])) {
$statusChanged = true;
}
}
if (!$errorBag->hasAny()) {
// Create a single revision for the combined save
$finalStatus = $statusChanged ? $newStatus : $currentStatus;
$service->createRevisionAfterSave(
$tenantId,
$handoverId,
$submittedValues,
$finalStatus,
$previousStatus,
$schemaVersion,
$userId
);
$handover = $service->findById($tenantId, $handoverId);
$fieldValues = $submittedValues;
$currentStatus = (string) ($handover['status'] ?? $currentStatus);
@@ -111,11 +157,61 @@ if ($request->isMethod('POST') && $canEdit) {
$validationSummaryErrors = $errorBag->toArray();
$errors = $errorBag->toFlatList();
// Load revision data
$revisions = $revisionService->listByHandover($tenantId, $handoverId);
$activeRevisionNumber = (int) $request->query('revision', 0);
$compareRevisionNumber = (int) $request->query('compare', 0);
$isViewingRevision = $activeRevisionNumber > 0;
$activeRevision = null;
$fieldDiff = [];
if ($isViewingRevision) {
$activeRevision = $revisionService->findRevision($tenantId, $handoverId, $activeRevisionNumber);
if ($activeRevision === null) {
// Invalid revision number — fall back to current
$isViewingRevision = false;
$activeRevisionNumber = 0;
} else {
// Load this revision's field values
$revisionFieldValues = json_decode((string) ($activeRevision['field_values'] ?? '{}'), true);
if (!is_array($revisionFieldValues)) {
$revisionFieldValues = [];
}
// Compute diff
if ($compareRevisionNumber > 0) {
// Compare against a specific revision
$compareRevision = $revisionService->findRevision($tenantId, $handoverId, $compareRevisionNumber);
if ($compareRevision !== null) {
$compareValues = json_decode((string) ($compareRevision['field_values'] ?? '{}'), true);
if (!is_array($compareValues)) {
$compareValues = [];
}
$fieldDiff = $revisionService->computeDiff($compareValues, $revisionFieldValues);
}
} elseif ($activeRevisionNumber > 1) {
// Compare against previous revision
$prevRevision = $revisionService->findRevision($tenantId, $handoverId, $activeRevisionNumber - 1);
if ($prevRevision !== null) {
$prevValues = json_decode((string) ($prevRevision['field_values'] ?? '{}'), true);
if (!is_array($prevValues)) {
$prevValues = [];
}
$fieldDiff = $revisionService->computeDiff($prevValues, $revisionFieldValues);
}
}
$fieldValues = $revisionFieldValues;
$currentStatus = (string) ($activeRevision['status'] ?? $currentStatus);
}
}
$productCode = (string) ($handover['product_code'] ?? '');
$product = app(\MintyPHP\Module\Helpdesk\Service\SoftwareProductService::class)->findByCode($productCode);
$productName = trim((string) ($product['name'] ?? '')) !== ''
? $product['name']
: (trim((string) ($product['bc_description'] ?? '')) !== '' ? $product['bc_description'] : $productCode);
$canViewSoftwareProducts = $authService->authorize(HelpdeskAuthorizationPolicy::ABILITY_SOFTWARE_PRODUCTS_MANAGE, $actorContext)->isAllowed();
$titleText = t('Handover') . ' ' . $productName;
Buffer::set('title', $titleText);
Buffer::set('style_groups', json_encode(['helpdesk']));

View File

@@ -1,5 +1,6 @@
<?php
use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService;
use MintyPHP\Module\Helpdesk\Service\HandoverService;
$handover = is_array($handover ?? null) ? $handover : [];
@@ -11,9 +12,17 @@ $closeTarget = $closeTarget ?? 'helpdesk/handovers';
$canEdit = $canEdit ?? false;
$allowedStatuses = is_array($allowedStatuses ?? null) ? $allowedStatuses : [];
$currentStatus = $currentStatus ?? 'draft';
$revisions = is_array($revisions ?? null) ? $revisions : [];
$isViewingRevision = !empty($isViewingRevision);
$activeRevisionNumber = $activeRevisionNumber ?? 0;
$compareRevisionNumber = $compareRevisionNumber ?? 0;
$fieldDiff = is_array($fieldDiff ?? null) ? $fieldDiff : [];
$canManage = $canManage ?? false;
$formId = 'handover-form';
$readonly = !$canEdit;
$readonly = !$canEdit || $isViewingRevision;
$editTarget = $editTarget ?? ('helpdesk/handovers/edit/' . ($handover['id'] ?? 0));
$editBasePath = $editBasePath ?? ('helpdesk/handovers/edit/' . ($handover['id'] ?? 0));
?>
<div class="app-details-container">
<section>
@@ -24,7 +33,7 @@ $readonly = !$canEdit;
'title' => $titleText ?? t('Handover'),
'backHref' => $closeTarget,
'backTitle' => t('Back to list'),
'actions' => $canEdit ? [
'actions' => ($canEdit && !$isViewingRevision) ? [
[
'form' => $formId,
'name' => 'action',
@@ -44,9 +53,22 @@ $readonly = !$canEdit;
require templatePath('partials/app-details-titlebar.phtml');
?>
<?php if ($isViewingRevision): ?>
<div class="handover-revision-banner">
<p>
<i class="bi bi-clock-history"></i>
<?php e(t('Viewing version %d', $activeRevisionNumber)); ?>
<?php if ($compareRevisionNumber > 0): ?>
<?php e(t('compared with version %d', $compareRevisionNumber)); ?>
<?php endif; ?>
</p>
<a href="<?php e(lurl($editBasePath)); ?>" role="button" class="outline secondary small"><?php e(t('Back to current')); ?></a>
</div>
<?php endif; ?>
<?php require templatePath('partials/app-details-validation-summary.phtml'); ?>
<?php if ($canEdit): ?>
<?php if ($canEdit && !$isViewingRevision): ?>
<form id="<?php e($formId); ?>" method="post" data-standard-detail-form="1">
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="helpdesk-handover-form">
<div class="app-tabs-nav">
@@ -75,19 +97,30 @@ $readonly = !$canEdit;
<aside id="app-details-aside-section">
<div class="app-details-aside-section">
<?php
$debitorNo = trim((string) ($handover['debitor_no'] ?? ''));
$debitorName = trim((string) ($handover['debitor_name'] ?? ''));
$canViewSoftwareProducts = $canViewSoftwareProducts ?? false;
$productCode = $handover['product_code'] ?? '';
?>
<hgroup>
<h2>#<?php e($handover['id'] ?? ''); ?></h2>
<p><?php e(t('Handover')); ?></p>
<h2>
<?php if ($debitorNo !== ''): ?>
<a href="<?php e(lurl('helpdesk/debitor/' . rawurlencode($debitorNo))); ?>"><?php e($debitorName !== '' ? $debitorName : $debitorNo); ?></a>
<?php else: ?>
<?php e($debitorName !== '' ? $debitorName : '—'); ?>
<?php endif; ?>
</h2>
<p>
<?php if ($canViewSoftwareProducts && $productCode !== ''): ?>
<a href="<?php e(lurl('helpdesk/software-products/edit/' . rawurlencode($productCode))); ?>"><?php e($productName ?? $productCode); ?></a>
<?php else: ?>
<?php e($productName ?? $productCode); ?>
<?php endif; ?>
</p>
</hgroup>
<div class="badge-list">
<span class="badge" data-variant="<?php e(HandoverService::statusVariant($currentStatus)); ?>">
<?php e(HandoverService::statusLabel($currentStatus)); ?>
</span>
</div>
<?php if ($canEdit && $allowedStatuses && count($allowedStatuses) > 1): ?>
<hr>
<?php if (!$isViewingRevision && $canEdit && $allowedStatuses && count($allowedStatuses) > 1): ?>
<label for="handover-status"><small><?php e(t('Status')); ?></small></label>
<select id="handover-status" name="status" form="<?php e($formId); ?>">
<?php foreach ($allowedStatuses as $status): ?>
@@ -96,37 +129,65 @@ $readonly = !$canEdit;
</option>
<?php endforeach; ?>
</select>
<?php else: ?>
<div class="badge-list">
<span class="badge" data-variant="<?php e(HandoverService::statusVariant($currentStatus)); ?>">
<?php e(HandoverService::statusLabel($currentStatus)); ?>
</span>
</div>
<?php endif; ?>
<?php
$debitorNo = trim((string) ($handover['debitor_no'] ?? ''));
$debitorName = trim((string) ($handover['debitor_name'] ?? ''));
?>
<details name="handover-aside" open>
<summary><?php e(t('Customer')); ?></summary>
<hr>
<p>
<?php if ($debitorNo !== ''): ?>
<a href="<?php e(lurl('helpdesk/debitor/' . rawurlencode($debitorNo))); ?>"><?php e($debitorName !== '' ? $debitorName : $debitorNo); ?></a>
<?php else: ?>
<?php e($debitorName); ?>
<?php endif; ?>
</p>
<?php if ($debitorNo !== '' && $debitorName !== ''): ?>
<p><small><?php e(t('Debitor No.')); ?></small><br><?php e($debitorNo); ?></p>
<?php endif; ?>
</details>
<hr>
<details name="handover-aside" open>
<summary><?php e(t('Software product')); ?></summary>
<?php if ($revisions !== []): ?>
<details name="handover-aside" open>
<summary><?php e(t('History')); ?></summary>
<hr>
<?php $latestRevNum = (int) ($revisions[0]['revision'] ?? 0); ?>
<div class="handover-revision-timeline">
<?php foreach ($revisions as $rev):
$revNum = (int) ($rev['revision'] ?? 0);
$isActive = $revNum === $activeRevisionNumber;
$isCurrent = $revNum === $latestRevNum;
$revUrl = lurl($editBasePath . '?revision=' . $revNum);
$compareUrl = lurl($editBasePath . '?revision=' . $latestRevNum . '&compare=' . $revNum);
?>
<div class="handover-revision-item<?php if ($isActive): ?> is-active<?php endif; ?>"
<?php if ($isActive): ?>aria-current="true"<?php endif; ?>>
<a href="<?php e($revUrl); ?>" class="handover-revision-link">
<span class="handover-revision-number">
<?php e('v' . $revNum); ?>
<?php if ($isCurrent): ?>
<span class="badge small" data-variant="neutral"><?php e(t('Current')); ?></span>
<?php endif; ?>
</span>
<span class="handover-revision-meta">
<span class="handover-revision-change"><?php e(HandoverRevisionService::changeTypeLabel((string) ($rev['change_type'] ?? ''))); ?></span>
<span><?php e($rev['changed_by_label'] ?? ('#' . ($rev['changed_by'] ?? ''))); ?> · <?php e(dt($rev['created_at'] ?? '')); ?></span>
</span>
</a>
<?php if (!$isCurrent): ?>
<a href="<?php e($compareUrl); ?>" class="handover-revision-compare" aria-label="<?php e(t('Compare with current')); ?>" data-tooltip="<?php e(t('Compare with current')); ?>">
<i class="bi bi-arrow-left-right"></i>
</a>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php if ($isViewingRevision && $canManage && $activeRevisionNumber < (int) ($revisions[0]['revision'] ?? 0)): ?>
<form method="post" class="handover-revision-restore">
<input type="hidden" name="action" value="restore">
<input type="hidden" name="restore_revision" value="<?php e($activeRevisionNumber); ?>">
<?php \MintyPHP\Session::getCsrfInput(); ?>
<button type="submit" class="secondary outline small" onclick="return confirm('<?php e(t('Restore this version?')); ?>');">
<i class="bi bi-arrow-counterclockwise"></i> <?php e(t('Restore this version')); ?>
</button>
</form>
<?php endif; ?>
</details>
<hr>
<p><?php e($productName ?? ($handover['product_code'] ?? '')); ?></p>
<?php if (($productName ?? '') !== ($handover['product_code'] ?? '')): ?>
<p><small><?php e(t('Code')); ?></small><br><span class="badge" data-variant="neutral"><?php e($handover['product_code'] ?? ''); ?></span></p>
<?php endif; ?>
</details>
<hr>
<?php endif; ?>
<?php
$asideAuditDetailsName = 'handover-aside';