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

@@ -460,5 +460,25 @@
"Progress": "Fortschritt", "Progress": "Fortschritt",
"Created by": "Erstellt von", "Created by": "Erstellt von",
"Search handovers...": "Übergaben suchen...", "Search handovers...": "Übergaben suchen...",
"ID": "ID" "ID": "ID",
"History": "Verlauf",
"Current": "Aktuell",
"Viewing version %d": "Version %d wird angezeigt",
"compared with version %d": "verglichen mit Version %d",
"Back to current": "Zurück zur aktuellen Version",
"Restore this version": "Diese Version wiederherstellen",
"Restore this version?": "Diese Version wiederherstellen?",
"Version restored": "Version wiederhergestellt",
"Only managers can restore revisions": "Nur Manager können Versionen wiederherstellen",
"Revision not found": "Version nicht gefunden",
"Created": "Erstellt",
"Fields changed": "Felder geändert",
"Status changed": "Status geändert",
"Fields and status changed": "Felder und Status geändert",
"Restored": "Wiederhergestellt",
"was: %s": "war: %s",
"Yes": "Ja",
"No": "Nein",
"Changed": "Geändert",
"Compare with current": "Mit aktueller Version vergleichen"
} }

View File

@@ -460,5 +460,25 @@
"Progress": "Progress", "Progress": "Progress",
"Created by": "Created by", "Created by": "Created by",
"Search handovers...": "Search handovers...", "Search handovers...": "Search handovers...",
"ID": "ID" "ID": "ID",
"History": "History",
"Current": "Current",
"Viewing version %d": "Viewing version %d",
"compared with version %d": "compared with version %d",
"Back to current": "Back to current",
"Restore this version": "Restore this version",
"Restore this version?": "Restore this version?",
"Version restored": "Version restored",
"Only managers can restore revisions": "Only managers can restore revisions",
"Revision not found": "Revision not found",
"Created": "Created",
"Fields changed": "Fields changed",
"Status changed": "Status changed",
"Fields and status changed": "Fields and status changed",
"Restored": "Restored",
"was: %s": "was: %s",
"Yes": "Yes",
"No": "No",
"Changed": "Changed",
"Compare with current": "Compare with current"
} }

View File

@@ -21,7 +21,9 @@ use MintyPHP\Module\Helpdesk\Service\SystemRecommendationEngine;
use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService; use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService;
use MintyPHP\Module\Helpdesk\Handler\SoftwareProductSyncJobHandler; use MintyPHP\Module\Helpdesk\Handler\SoftwareProductSyncJobHandler;
use MintyPHP\Module\Helpdesk\Repository\HandoverRepository; use MintyPHP\Module\Helpdesk\Repository\HandoverRepository;
use MintyPHP\Module\Helpdesk\Repository\HandoverRevisionRepository;
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository; use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService;
use MintyPHP\Module\Helpdesk\Service\HandoverService; use MintyPHP\Module\Helpdesk\Service\HandoverService;
use MintyPHP\Service\Access\PermissionService; use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Settings\SettingServicesFactory; use MintyPHP\Service\Settings\SettingServicesFactory;
@@ -109,9 +111,17 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar
$container->set(HandoverRepository::class, static fn (): HandoverRepository => new HandoverRepository()); $container->set(HandoverRepository::class, static fn (): HandoverRepository => new HandoverRepository());
$container->set(HandoverRevisionRepository::class, static fn (): HandoverRevisionRepository => new HandoverRevisionRepository());
$container->set(HandoverRevisionService::class, static fn (AppContainer $c): HandoverRevisionService => new HandoverRevisionService(
$c->get(HandoverRevisionRepository::class),
$c->get(HandoverRepository::class)
));
$container->set(HandoverService::class, static fn (AppContainer $c): HandoverService => new HandoverService( $container->set(HandoverService::class, static fn (AppContainer $c): HandoverService => new HandoverService(
$c->get(HandoverRepository::class), $c->get(HandoverRepository::class),
$c->get(SoftwareProductService::class) $c->get(SoftwareProductService::class),
$c->get(HandoverRevisionService::class)
)); ));
} }
} }

View File

@@ -0,0 +1,121 @@
<?php
namespace MintyPHP\Module\Helpdesk\Repository;
use MintyPHP\DB;
class HandoverRevisionRepository
{
/**
* @param array<string, mixed> $data
* @return int|null Inserted ID or null on failure
*/
public function insert(array $data): ?int
{
$result = DB::insert(
'INSERT INTO helpdesk_handover_revisions '
. '(tenant_id, handover_id, revision, field_values, status, schema_version, change_type, changed_by) '
. 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
(string) ($data['tenant_id'] ?? 0),
(string) ($data['handover_id'] ?? 0),
(string) ($data['revision'] ?? 1),
$data['field_values'] ?? null,
(string) ($data['status'] ?? ''),
(string) ($data['schema_version'] ?? 1),
(string) ($data['change_type'] ?? 'fields'),
(string) ($data['changed_by'] ?? 0)
);
if ($result === false) {
return null;
}
return (int) $result;
}
/**
* Get the latest revision number for a handover.
*/
public function getLatestRevisionNumber(int $tenantId, int $handoverId): int
{
$value = DB::selectValue(
'SELECT MAX(revision) FROM helpdesk_handover_revisions WHERE tenant_id = ? AND handover_id = ?',
(string) $tenantId,
(string) $handoverId
);
return $value !== null ? (int) $value : 0;
}
/**
* Find a specific revision by handover ID and revision number.
*
* @return array<string, mixed>|null
*/
public function findByRevision(int $tenantId, int $handoverId, int $revision): ?array
{
$row = DB::selectOne(
'SELECT r.*, u.display_name AS changed_by_label, u.uuid AS changed_by_uuid'
. ' FROM helpdesk_handover_revisions r'
. ' LEFT JOIN users u ON u.id = r.changed_by'
. ' WHERE r.tenant_id = ? AND r.handover_id = ? AND r.revision = ? LIMIT 1',
(string) $tenantId,
(string) $handoverId,
(string) $revision
);
return $this->normalizeRow($row);
}
/**
* List all revisions for a handover, newest first.
*
* @return list<array<string, mixed>>
*/
public function listByHandover(int $tenantId, int $handoverId): array
{
$rows = DB::select(
'SELECT r.*, u.display_name AS changed_by_label'
. ' FROM helpdesk_handover_revisions r'
. ' LEFT JOIN users u ON u.id = r.changed_by'
. ' WHERE r.tenant_id = ? AND r.handover_id = ?'
. ' ORDER BY r.revision DESC',
(string) $tenantId,
(string) $handoverId
);
$result = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = $this->normalizeRow($row);
if ($item !== null) {
$result[] = $item;
}
}
}
return $result;
}
private function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$item = [];
foreach ($row as $key => $value) {
if (is_array($value)) {
$item = array_merge($item, $value);
} else {
$item[$key] = $value;
}
}
if (!isset($item['id'])) {
return null;
}
return $item;
}
}

View File

@@ -0,0 +1,176 @@
<?php
namespace MintyPHP\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Repository\HandoverRepository;
use MintyPHP\Module\Helpdesk\Repository\HandoverRevisionRepository;
/** @api Called from pages/helpdesk/handovers action files */
class HandoverRevisionService
{
public const CHANGE_TYPE_INITIAL = 'initial';
public const CHANGE_TYPE_FIELDS = 'fields';
public const CHANGE_TYPE_STATUS = 'status';
public const CHANGE_TYPE_BOTH = 'both';
public const CHANGE_TYPE_RESTORE = 'restore';
public function __construct(
private readonly HandoverRevisionRepository $revisionRepository,
private readonly HandoverRepository $handoverRepository,
) {
}
/**
* Create a new revision snapshot.
*
* @param array<string, mixed> $fieldValues
*/
public function createRevision(
int $tenantId,
int $handoverId,
array $fieldValues,
string $status,
int $schemaVersion,
int $userId,
string $changeType = self::CHANGE_TYPE_FIELDS,
): ?int {
$nextRevision = $this->revisionRepository->getLatestRevisionNumber($tenantId, $handoverId) + 1;
$fieldValuesJson = json_encode($fieldValues, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return $this->revisionRepository->insert([
'tenant_id' => $tenantId,
'handover_id' => $handoverId,
'revision' => $nextRevision,
'field_values' => $fieldValuesJson,
'status' => $status,
'schema_version' => $schemaVersion,
'change_type' => $changeType,
'changed_by' => $userId,
]);
}
/**
* List all revisions for a handover, newest first.
*
* @return list<array<string, mixed>>
*/
public function listByHandover(int $tenantId, int $handoverId): array
{
return $this->revisionRepository->listByHandover($tenantId, $handoverId);
}
/**
* Find a specific revision.
*
* @return array<string, mixed>|null
*/
public function findRevision(int $tenantId, int $handoverId, int $revision): ?array
{
return $this->revisionRepository->findByRevision($tenantId, $handoverId, $revision);
}
/**
* Compute the diff between two sets of field values.
*
* @param array<string, mixed> $oldValues
* @param array<string, mixed> $newValues
* @return array<string, array{type: string, old: mixed, new: mixed}>
*/
public function computeDiff(array $oldValues, array $newValues): array
{
$diff = [];
$allKeys = array_unique(array_merge(array_keys($oldValues), array_keys($newValues)));
foreach ($allKeys as $key) {
$oldVal = $oldValues[$key] ?? null;
$newVal = $newValues[$key] ?? null;
if ($oldVal === null && $newVal !== null) {
$diff[$key] = ['type' => 'added', 'old' => null, 'new' => $newVal];
} elseif ($oldVal !== null && $newVal === null) {
$diff[$key] = ['type' => 'removed', 'old' => $oldVal, 'new' => null];
} elseif ((string) $oldVal !== (string) $newVal) {
$diff[$key] = ['type' => 'changed', 'old' => $oldVal, 'new' => $newVal];
}
}
return $diff;
}
/**
* Restore a handover to a previous revision's values.
* Creates a new revision with the restored values.
*
* @return array{ok: bool, errors: array<string, string>}
*/
public function restoreRevision(
int $tenantId,
int $handoverId,
int $revisionNumber,
int $userId,
string $permissionLevel,
): array {
if ($permissionLevel !== HandoverService::PERMISSION_MANAGE) {
return ['ok' => false, 'errors' => ['general' => t('Only managers can restore revisions')]];
}
$revision = $this->revisionRepository->findByRevision($tenantId, $handoverId, $revisionNumber);
if ($revision === null) {
return ['ok' => false, 'errors' => ['general' => t('Revision not found')]];
}
$handover = $this->handoverRepository->findById($tenantId, $handoverId);
if ($handover === null) {
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
}
$restoredValues = json_decode((string) ($revision['field_values'] ?? '{}'), true);
if (!is_array($restoredValues)) {
$restoredValues = [];
}
$restoredStatus = (string) ($revision['status'] ?? $handover['status']);
// Update the handover with restored values
$fieldValuesJson = json_encode($restoredValues, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$this->handoverRepository->updateFieldValues($tenantId, $handoverId, $fieldValuesJson, $userId);
$currentStatus = (string) ($handover['status'] ?? '');
if ($restoredStatus !== $currentStatus) {
$this->handoverRepository->updateStatus($tenantId, $handoverId, $restoredStatus, $userId);
}
// Determine schema version from current handover
$schema = json_decode((string) ($handover['schema_snapshot'] ?? '{}'), true);
$schemaVersion = is_array($schema) ? (int) ($schema['version'] ?? 1) : 1;
// Create a new revision marking the restore
$this->createRevision(
$tenantId,
$handoverId,
$restoredValues,
$restoredStatus,
$schemaVersion,
$userId,
self::CHANGE_TYPE_RESTORE
);
return ['ok' => true, 'errors' => []];
}
/**
* Get a human-readable label for a change type.
*/
public static function changeTypeLabel(string $changeType): string
{
return match ($changeType) {
self::CHANGE_TYPE_INITIAL => t('Created'),
self::CHANGE_TYPE_FIELDS => t('Fields changed'),
self::CHANGE_TYPE_STATUS => t('Status changed'),
self::CHANGE_TYPE_BOTH => t('Fields and status changed'),
self::CHANGE_TYPE_RESTORE => t('Restored'),
default => $changeType,
};
}
}

View File

@@ -45,6 +45,7 @@ class HandoverService
public function __construct( public function __construct(
private readonly HandoverRepository $repository, private readonly HandoverRepository $repository,
private readonly SoftwareProductService $softwareProductService, private readonly SoftwareProductService $softwareProductService,
private readonly ?HandoverRevisionService $revisionService = null,
) { ) {
} }
@@ -122,6 +123,17 @@ class HandoverService
return ['ok' => false, 'id' => null, 'errors' => ['general' => t('Failed to create handover')]]; return ['ok' => false, 'id' => null, 'errors' => ['general' => t('Failed to create handover')]];
} }
// Create initial revision (revision 1)
$this->revisionService?->createRevision(
$tenantId,
$id,
$fieldValues,
self::STATUS_DRAFT,
(int) ($schema['version'] ?? 1),
$userId,
HandoverRevisionService::CHANGE_TYPE_INITIAL
);
return ['ok' => true, 'id' => $id, 'errors' => []]; return ['ok' => true, 'id' => $id, 'errors' => []];
} }
@@ -199,6 +211,76 @@ class HandoverService
return ['ok' => true, 'errors' => []]; return ['ok' => true, 'errors' => []];
} }
/**
* Create a revision snapshot after a combined save (fields + optional status change).
* Call this from the action after both updateFields and changeStatus have succeeded.
*
* @param array<string, mixed> $fieldValues The final field values
*/
public function createRevisionAfterSave(
int $tenantId,
int $handoverId,
array $fieldValues,
string $status,
string $previousStatus,
int $schemaVersion,
int $userId,
): void {
if ($this->revisionService === null) {
return;
}
$statusChanged = $status !== $previousStatus;
$changeType = $statusChanged
? HandoverRevisionService::CHANGE_TYPE_BOTH
: HandoverRevisionService::CHANGE_TYPE_FIELDS;
$this->revisionService->createRevision(
$tenantId,
$handoverId,
$fieldValues,
$status,
$schemaVersion,
$userId,
$changeType
);
}
/**
* Create a revision for a status-only change.
*/
public function createRevisionForStatusChange(
int $tenantId,
int $handoverId,
string $newStatus,
int $schemaVersion,
int $userId,
): void {
if ($this->revisionService === null) {
return;
}
$handover = $this->repository->findById($tenantId, $handoverId);
if ($handover === null) {
return;
}
$fieldValues = json_decode((string) ($handover['field_values'] ?? '{}'), true);
if (!is_array($fieldValues)) {
$fieldValues = [];
}
$this->revisionService->createRevision(
$tenantId,
$handoverId,
$fieldValues,
$newStatus,
$schemaVersion,
$userId,
HandoverRevisionService::CHANGE_TYPE_STATUS
);
}
/** /**
* @return array{total: int, rows: list<array<string, mixed>>} * @return array{total: int, rows: list<array<string, mixed>>}
*/ */

View File

@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS `helpdesk_handover_revisions` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tenant_id` INT UNSIGNED NOT NULL,
`handover_id` BIGINT UNSIGNED NOT NULL,
`revision` INT UNSIGNED NOT NULL,
`field_values` TEXT NULL DEFAULT NULL,
`status` VARCHAR(20) NOT NULL,
`schema_version` INT UNSIGNED NOT NULL DEFAULT 1,
`change_type` VARCHAR(20) NOT NULL DEFAULT 'fields',
`changed_by` INT UNSIGNED NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_hr_handover_revision` (`handover_id`, `revision`),
KEY `idx_hr_tenant_handover` (`tenant_id`, `handover_id`),
KEY `idx_hr_handover_created` (`handover_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

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

View File

@@ -3,6 +3,7 @@
use MintyPHP\Buffer; use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy; use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService;
use MintyPHP\Module\Helpdesk\Service\HandoverService; use MintyPHP\Module\Helpdesk\Service\HandoverService;
use MintyPHP\Router; use MintyPHP\Router;
use MintyPHP\Session; use MintyPHP\Session;
@@ -16,13 +17,15 @@ $request = requestInput();
$returnTarget = requestResolveReturnTarget(); $returnTarget = requestResolveReturnTarget();
$closeTarget = requestResolveReturnTarget('helpdesk/handovers'); $closeTarget = requestResolveReturnTarget('helpdesk/handovers');
$handoverId = (int) ($id ?? 0); $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(); $session = app(SessionStoreInterface::class)->all();
$tenantId = (int) ($session['current_tenant']['id'] ?? 0); $tenantId = (int) ($session['current_tenant']['id'] ?? 0);
$userId = (int) ($session['user']['id'] ?? 0); $userId = (int) ($session['user']['id'] ?? 0);
$service = app(HandoverService::class); $service = app(HandoverService::class);
$revisionService = app(HandoverRevisionService::class);
$handover = $service->findById($tenantId, $handoverId); $handover = $service->findById($tenantId, $handoverId);
if ($handover === null) { if ($handover === null) {
@@ -47,6 +50,7 @@ $allowedStatuses = $service->getAllowedStatuses($permissionLevel);
$schemaRaw = (string) ($handover['schema_snapshot'] ?? '{}'); $schemaRaw = (string) ($handover['schema_snapshot'] ?? '{}');
$schema = json_decode($schemaRaw, true); $schema = json_decode($schemaRaw, true);
$schemaFields = is_array($schema) ? ($schema['fields'] ?? []) : []; $schemaFields = is_array($schema) ? ($schema['fields'] ?? []) : [];
$schemaVersion = is_array($schema) ? (int) ($schema['version'] ?? 1) : 1;
// Parse field values // Parse field values
$fieldValuesRaw = (string) ($handover['field_values'] ?? '{}'); $fieldValuesRaw = (string) ($handover['field_values'] ?? '{}');
@@ -57,6 +61,31 @@ if (!is_array($fieldValues)) {
$errorBag = formErrors(); $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 ($request->isMethod('POST') && $canEdit) {
if (!Session::checkCsrfToken()) { if (!Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired'); 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); $updateResult = $service->updateFields($tenantId, $handoverId, $submittedValues, $userId, $permissionLevel);
$errorBag->merge($updateResult['errors'] ?? []); $errorBag->merge($updateResult['errors'] ?? []);
// Handle status change // Handle status change
$newStatus = (string) $request->body('status', ''); $newStatus = (string) $request->body('status', '');
$statusChanged = false;
if ($newStatus !== '' && $newStatus !== $currentStatus) { if ($newStatus !== '' && $newStatus !== $currentStatus) {
$statusResult = $service->changeStatus($tenantId, $handoverId, $newStatus, $userId, $permissionLevel); $statusResult = $service->changeStatus($tenantId, $handoverId, $newStatus, $userId, $permissionLevel);
$errorBag->merge($statusResult['errors'] ?? []); $errorBag->merge($statusResult['errors'] ?? []);
if (empty($statusResult['errors'])) {
$statusChanged = true;
}
} }
if (!$errorBag->hasAny()) { 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); $handover = $service->findById($tenantId, $handoverId);
$fieldValues = $submittedValues; $fieldValues = $submittedValues;
$currentStatus = (string) ($handover['status'] ?? $currentStatus); $currentStatus = (string) ($handover['status'] ?? $currentStatus);
@@ -111,11 +157,61 @@ if ($request->isMethod('POST') && $canEdit) {
$validationSummaryErrors = $errorBag->toArray(); $validationSummaryErrors = $errorBag->toArray();
$errors = $errorBag->toFlatList(); $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'] ?? ''); $productCode = (string) ($handover['product_code'] ?? '');
$product = app(\MintyPHP\Module\Helpdesk\Service\SoftwareProductService::class)->findByCode($productCode); $product = app(\MintyPHP\Module\Helpdesk\Service\SoftwareProductService::class)->findByCode($productCode);
$productName = trim((string) ($product['name'] ?? '')) !== '' $productName = trim((string) ($product['name'] ?? '')) !== ''
? $product['name'] ? $product['name']
: (trim((string) ($product['bc_description'] ?? '')) !== '' ? $product['bc_description'] : $productCode); : (trim((string) ($product['bc_description'] ?? '')) !== '' ? $product['bc_description'] : $productCode);
$canViewSoftwareProducts = $authService->authorize(HelpdeskAuthorizationPolicy::ABILITY_SOFTWARE_PRODUCTS_MANAGE, $actorContext)->isAllowed();
$titleText = t('Handover') . ' ' . $productName; $titleText = t('Handover') . ' ' . $productName;
Buffer::set('title', $titleText); Buffer::set('title', $titleText);
Buffer::set('style_groups', json_encode(['helpdesk'])); Buffer::set('style_groups', json_encode(['helpdesk']));

View File

@@ -1,5 +1,6 @@
<?php <?php
use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService;
use MintyPHP\Module\Helpdesk\Service\HandoverService; use MintyPHP\Module\Helpdesk\Service\HandoverService;
$handover = is_array($handover ?? null) ? $handover : []; $handover = is_array($handover ?? null) ? $handover : [];
@@ -11,9 +12,17 @@ $closeTarget = $closeTarget ?? 'helpdesk/handovers';
$canEdit = $canEdit ?? false; $canEdit = $canEdit ?? false;
$allowedStatuses = is_array($allowedStatuses ?? null) ? $allowedStatuses : []; $allowedStatuses = is_array($allowedStatuses ?? null) ? $allowedStatuses : [];
$currentStatus = $currentStatus ?? 'draft'; $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'; $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"> <div class="app-details-container">
<section> <section>
@@ -24,7 +33,7 @@ $readonly = !$canEdit;
'title' => $titleText ?? t('Handover'), 'title' => $titleText ?? t('Handover'),
'backHref' => $closeTarget, 'backHref' => $closeTarget,
'backTitle' => t('Back to list'), 'backTitle' => t('Back to list'),
'actions' => $canEdit ? [ 'actions' => ($canEdit && !$isViewingRevision) ? [
[ [
'form' => $formId, 'form' => $formId,
'name' => 'action', 'name' => 'action',
@@ -44,9 +53,22 @@ $readonly = !$canEdit;
require templatePath('partials/app-details-titlebar.phtml'); 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 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"> <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" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="helpdesk-handover-form">
<div class="app-tabs-nav"> <div class="app-tabs-nav">
@@ -75,19 +97,30 @@ $readonly = !$canEdit;
<aside id="app-details-aside-section"> <aside id="app-details-aside-section">
<div class="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> <hgroup>
<h2>#<?php e($handover['id'] ?? ''); ?></h2> <h2>
<p><?php e(t('Handover')); ?></p> <?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> </hgroup>
<div class="badge-list"> <?php if (!$isViewingRevision && $canEdit && $allowedStatuses && count($allowedStatuses) > 1): ?>
<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>
<label for="handover-status"><small><?php e(t('Status')); ?></small></label> <label for="handover-status"><small><?php e(t('Status')); ?></small></label>
<select id="handover-status" name="status" form="<?php e($formId); ?>"> <select id="handover-status" name="status" form="<?php e($formId); ?>">
<?php foreach ($allowedStatuses as $status): ?> <?php foreach ($allowedStatuses as $status): ?>
@@ -96,37 +129,65 @@ $readonly = !$canEdit;
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </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 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> <hr>
<details name="handover-aside" open> <?php if ($revisions !== []): ?>
<summary><?php e(t('Software product')); ?></summary> <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> <hr>
<p><?php e($productName ?? ($handover['product_code'] ?? '')); ?></p> <?php endif; ?>
<?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 <?php
$asideAuditDetailsName = 'handover-aside'; $asideAuditDetailsName = 'handover-aside';

View File

@@ -0,0 +1,192 @@
<?php
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Repository\HandoverRepository;
use MintyPHP\Module\Helpdesk\Repository\HandoverRevisionRepository;
use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService;
use MintyPHP\Module\Helpdesk\Service\HandoverService;
use PHPUnit\Framework\TestCase;
class HandoverRevisionServiceTest extends TestCase
{
private const TENANT_ID = 1;
private const USER_ID = 42;
private const HANDOVER_ID = 10;
private function createService(
?HandoverRevisionRepository $revisionRepo = null,
?HandoverRepository $handoverRepo = null,
): HandoverRevisionService {
$revisionRepo = $revisionRepo ?? $this->createMock(HandoverRevisionRepository::class);
$handoverRepo = $handoverRepo ?? $this->createMock(HandoverRepository::class);
return new HandoverRevisionService($revisionRepo, $handoverRepo);
}
// ── createRevision ───────────────────────────────────────────────
public function testCreateRevisionIncrementsNumber(): void
{
$revisionRepo = $this->createMock(HandoverRevisionRepository::class);
$revisionRepo->method('getLatestRevisionNumber')->willReturn(3);
$revisionRepo->expects($this->once())
->method('insert')
->with($this->callback(function (array $data): bool {
return $data['revision'] === 4;
}))
->willReturn(100);
$service = $this->createService($revisionRepo);
$result = $service->createRevision(self::TENANT_ID, self::HANDOVER_ID, ['name' => 'Test'], 'draft', 1, self::USER_ID);
$this->assertSame(100, $result);
}
public function testCreateRevisionStoresSnapshot(): void
{
$revisionRepo = $this->createMock(HandoverRevisionRepository::class);
$revisionRepo->method('getLatestRevisionNumber')->willReturn(0);
$revisionRepo->expects($this->once())
->method('insert')
->with($this->callback(function (array $data): bool {
return $data['status'] === 'in_progress'
&& $data['schema_version'] === 2
&& $data['change_type'] === 'fields'
&& json_decode($data['field_values'], true) === ['name' => 'Acme'];
}))
->willReturn(1);
$service = $this->createService($revisionRepo);
$service->createRevision(self::TENANT_ID, self::HANDOVER_ID, ['name' => 'Acme'], 'in_progress', 2, self::USER_ID);
}
// ── computeDiff ──────────────────────────────────────────────────
public function testComputeDiffDetectsAddedFields(): void
{
$service = $this->createService();
$diff = $service->computeDiff([], ['name' => 'New']);
$this->assertArrayHasKey('name', $diff);
$this->assertSame('added', $diff['name']['type']);
$this->assertNull($diff['name']['old']);
$this->assertSame('New', $diff['name']['new']);
}
public function testComputeDiffDetectsRemovedFields(): void
{
$service = $this->createService();
$diff = $service->computeDiff(['name' => 'Old'], []);
$this->assertArrayHasKey('name', $diff);
$this->assertSame('removed', $diff['name']['type']);
$this->assertSame('Old', $diff['name']['old']);
$this->assertNull($diff['name']['new']);
}
public function testComputeDiffDetectsChangedFields(): void
{
$service = $this->createService();
$diff = $service->computeDiff(['name' => 'Old', 'priority' => 'low'], ['name' => 'New', 'priority' => 'low']);
$this->assertArrayHasKey('name', $diff);
$this->assertSame('changed', $diff['name']['type']);
$this->assertSame('Old', $diff['name']['old']);
$this->assertSame('New', $diff['name']['new']);
$this->assertArrayNotHasKey('priority', $diff);
}
public function testComputeDiffHandlesEmptyArrays(): void
{
$service = $this->createService();
$diff = $service->computeDiff([], []);
$this->assertSame([], $diff);
}
// ── restoreRevision ──────────────────────────────────────────────
public function testRestoreRevisionRequiresManagePermission(): void
{
$service = $this->createService();
$result = $service->restoreRevision(self::TENANT_ID, self::HANDOVER_ID, 1, self::USER_ID, HandoverService::PERMISSION_CREATE);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('general', $result['errors']);
}
public function testRestoreRevisionRejectsInvalidRevision(): void
{
$revisionRepo = $this->createMock(HandoverRevisionRepository::class);
$revisionRepo->method('findByRevision')->willReturn(null);
$service = $this->createService($revisionRepo);
$result = $service->restoreRevision(self::TENANT_ID, self::HANDOVER_ID, 999, self::USER_ID, HandoverService::PERMISSION_MANAGE);
$this->assertFalse($result['ok']);
}
public function testRestoreRevisionCreatesNewRevision(): void
{
$revisionRepo = $this->createMock(HandoverRevisionRepository::class);
$revisionRepo->method('findByRevision')->willReturn([
'id' => 1,
'revision' => 2,
'field_values' => '{"name":"Old value"}',
'status' => 'draft',
]);
$revisionRepo->method('getLatestRevisionNumber')->willReturn(5);
$revisionRepo->expects($this->once())
->method('insert')
->with($this->callback(function (array $data): bool {
return $data['revision'] === 6
&& $data['change_type'] === 'restore'
&& json_decode($data['field_values'], true) === ['name' => 'Old value'];
}))
->willReturn(200);
$handoverRepo = $this->createMock(HandoverRepository::class);
$handoverRepo->method('findById')->willReturn([
'id' => self::HANDOVER_ID,
'status' => 'in_progress',
'field_values' => '{"name":"Current value"}',
'schema_snapshot' => '{"version":1,"fields":[]}',
]);
$handoverRepo->expects($this->once())->method('updateFieldValues');
$handoverRepo->expects($this->once())->method('updateStatus');
$service = $this->createService($revisionRepo, $handoverRepo);
$result = $service->restoreRevision(self::TENANT_ID, self::HANDOVER_ID, 2, self::USER_ID, HandoverService::PERMISSION_MANAGE);
$this->assertTrue($result['ok']);
}
public function testRestoreRevisionSkipsStatusUpdateWhenSame(): void
{
$revisionRepo = $this->createMock(HandoverRevisionRepository::class);
$revisionRepo->method('findByRevision')->willReturn([
'id' => 1,
'revision' => 1,
'field_values' => '{"name":"Old"}',
'status' => 'draft',
]);
$revisionRepo->method('getLatestRevisionNumber')->willReturn(3);
$revisionRepo->method('insert')->willReturn(100);
$handoverRepo = $this->createMock(HandoverRepository::class);
$handoverRepo->method('findById')->willReturn([
'id' => self::HANDOVER_ID,
'status' => 'draft',
'field_values' => '{"name":"Current"}',
'schema_snapshot' => '{"version":1,"fields":[]}',
]);
$handoverRepo->expects($this->once())->method('updateFieldValues');
$handoverRepo->expects($this->never())->method('updateStatus');
$service = $this->createService($revisionRepo, $handoverRepo);
$result = $service->restoreRevision(self::TENANT_ID, self::HANDOVER_ID, 1, self::USER_ID, HandoverService::PERMISSION_MANAGE);
$this->assertTrue($result['ok']);
}
}

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Tests\Module\Helpdesk\Service; namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Repository\HandoverRepository; use MintyPHP\Module\Helpdesk\Repository\HandoverRepository;
use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService;
use MintyPHP\Module\Helpdesk\Service\HandoverService; use MintyPHP\Module\Helpdesk\Service\HandoverService;
use MintyPHP\Module\Helpdesk\Service\SoftwareProductService; use MintyPHP\Module\Helpdesk\Service\SoftwareProductService;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -17,11 +18,12 @@ class HandoverServiceTest extends TestCase
private function createService( private function createService(
?HandoverRepository $repository = null, ?HandoverRepository $repository = null,
?SoftwareProductService $productService = null, ?SoftwareProductService $productService = null,
?HandoverRevisionService $revisionService = null,
): HandoverService { ): HandoverService {
$repository = $repository ?? $this->createMock(HandoverRepository::class); $repository = $repository ?? $this->createMock(HandoverRepository::class);
$productService = $productService ?? $this->createMock(SoftwareProductService::class); $productService = $productService ?? $this->createMock(SoftwareProductService::class);
return new HandoverService($repository, $productService); return new HandoverService($repository, $productService, $revisionService);
} }
private function mockProductService(bool $exists = true, bool $hasSchema = true): SoftwareProductService private function mockProductService(bool $exists = true, bool $hasSchema = true): SoftwareProductService
@@ -193,4 +195,99 @@ class HandoverServiceTest extends TestCase
$this->assertTrue($result['ok']); $this->assertTrue($result['ok']);
} }
// ── Revision integration tests ──────────────────────────────────
public function testCreateCreatesInitialRevision(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->expects($this->once())->method('insert')->willReturn(99);
$revisionService = $this->createMock(HandoverRevisionService::class);
$revisionService->expects($this->once())
->method('createRevision')
->with(
self::TENANT_ID,
99,
[],
HandoverService::STATUS_DRAFT,
1,
self::USER_ID,
HandoverRevisionService::CHANGE_TYPE_INITIAL
);
$service = $this->createService($repo, $this->mockProductService(), $revisionService);
$result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', self::USER_ID);
$this->assertTrue($result['ok']);
}
public function testCreateRevisionAfterSaveDeterminesChangeType(): void
{
$repo = $this->createMock(HandoverRepository::class);
$revisionService = $this->createMock(HandoverRevisionService::class);
$revisionService->expects($this->once())
->method('createRevision')
->with(
self::TENANT_ID,
1,
['name' => 'Test'],
'in_progress',
1,
self::USER_ID,
HandoverRevisionService::CHANGE_TYPE_BOTH
);
$service = $this->createService($repo, null, $revisionService);
$service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'in_progress', 'draft', 1, self::USER_ID);
}
public function testCreateRevisionAfterSaveFieldsOnlyChangeType(): void
{
$repo = $this->createMock(HandoverRepository::class);
$revisionService = $this->createMock(HandoverRevisionService::class);
$revisionService->expects($this->once())
->method('createRevision')
->with(
$this->anything(),
$this->anything(),
$this->anything(),
$this->anything(),
$this->anything(),
$this->anything(),
HandoverRevisionService::CHANGE_TYPE_FIELDS
);
$service = $this->createService($repo, null, $revisionService);
$service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'draft', 'draft', 1, self::USER_ID);
}
public function testCreateRevisionForStatusChange(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn([
'id' => 1,
'status' => 'in_progress',
'field_values' => '{"name":"Test"}',
'schema_snapshot' => self::VALID_SCHEMA_JSON,
]);
$revisionService = $this->createMock(HandoverRevisionService::class);
$revisionService->expects($this->once())
->method('createRevision')
->with(
self::TENANT_ID,
1,
['name' => 'Test'],
'completed',
1,
self::USER_ID,
HandoverRevisionService::CHANGE_TYPE_STATUS
);
$service = $this->createService($repo, null, $revisionService);
$service->createRevisionForStatusChange(self::TENANT_ID, 1, 'completed', 1, self::USER_ID);
}
} }

View File

@@ -2738,6 +2738,17 @@
border-top: 1px solid var(--app-card-border-color, #e5e7eb); border-top: 1px solid var(--app-card-border-color, #e5e7eb);
} }
/* Handover aside — subtle links in hgroup */
.app-details-aside-section hgroup a {
color: inherit;
text-decoration: none;
}
.app-details-aside-section hgroup a:hover {
text-decoration: underline;
text-underline-offset: 0.15em;
}
/* Handover form — shared styles */ /* Handover form — shared styles */
.handover-form-heading { .handover-form-heading {
margin-top: calc(var(--app-spacing) * 1.25); margin-top: calc(var(--app-spacing) * 1.25);
@@ -2775,4 +2786,217 @@
padding: var(--app-spacing); padding: var(--app-spacing);
} }
} }
/* ── Revision timeline (commit graph) ────────────────────────────── */
.handover-revision-timeline {
position: relative;
display: flex;
flex-direction: column;
padding-left: 1.25rem;
}
/* Vertical line */
.handover-revision-timeline::before {
content: '';
position: absolute;
left: 0.4375rem;
top: 0.5rem;
bottom: 0.5rem;
width: 2px;
background: var(--app-border);
}
.handover-revision-item {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.25rem;
padding: calc(var(--app-spacing) * 0.4) 0;
}
/* Circle node */
.handover-revision-item::before {
content: '';
position: absolute;
left: -1.25rem;
top: calc(var(--app-spacing) * 0.4 + 0.35rem);
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
border: 2px solid var(--app-border);
background: var(--app-background-color);
z-index: 1;
flex-shrink: 0;
}
.handover-revision-item.is-active::before {
border-color: var(--app-primary);
background: var(--app-primary);
}
.handover-revision-item:first-child::before {
border-color: var(--app-primary);
background: var(--app-primary);
}
.handover-revision-link {
display: flex;
flex-direction: column;
gap: 0.1rem;
flex: 1;
min-width: 0;
padding: calc(var(--app-spacing) * 0.25) calc(var(--app-spacing) * 0.4);
border-radius: var(--app-border-radius);
text-decoration: none;
color: inherit;
transition: background 0.15s ease;
}
.handover-revision-link:hover {
background: color-mix(in srgb, var(--app-primary) 6%, transparent);
text-decoration: none;
}
.handover-revision-item.is-active .handover-revision-link {
background: color-mix(in srgb, var(--app-primary) 10%, transparent);
}
.handover-revision-compare {
flex-shrink: 0;
padding: 0.2rem;
margin-top: calc(var(--app-spacing) * 0.25);
color: var(--app-muted-color);
text-decoration: none;
border-radius: var(--app-border-radius);
opacity: 0;
transition: opacity 0.15s ease, color 0.15s ease;
}
.handover-revision-item:hover .handover-revision-compare {
opacity: 1;
}
.handover-revision-compare:hover {
color: var(--app-primary);
}
.handover-revision-number {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: var(--font-semibold, 600);
font-size: var(--text-sm);
}
.handover-revision-number .badge {
font-size: var(--text-xs);
padding: 0.05em 0.4em;
}
.handover-revision-meta {
display: flex;
flex-direction: column;
font-size: var(--text-xs);
color: var(--app-muted-color);
line-height: 1.35;
}
.handover-revision-change {
color: var(--app-contrast, #1f2937);
font-size: var(--text-xs);
}
.handover-revision-restore {
margin-top: calc(var(--app-spacing) * 0.75);
}
.handover-revision-restore button {
width: 100%;
margin-bottom: 0;
}
/* ── Revision banner ───────────────────────────────────────────────── */
.handover-revision-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--app-spacing);
padding: calc(var(--app-spacing) * 0.6) calc(var(--app-spacing) * 1);
margin: 0 calc(var(--app-spacing) * 2) calc(var(--app-spacing) * 0.5);
background: color-mix(in srgb, var(--app-primary) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--app-primary) 20%, transparent);
border-radius: var(--app-border-radius);
font-size: var(--text-sm);
}
.handover-revision-banner p {
margin: 0;
display: flex;
align-items: center;
gap: 0.4rem;
}
.handover-revision-banner a {
flex-shrink: 0;
margin-bottom: 0;
}
/* ── Diff highlighting (git-diff style) ──────────────────────────── */
.handover-diff-field {
position: relative;
border-radius: var(--app-border-radius);
padding: calc(var(--app-spacing) * 0.5) calc(var(--app-spacing) * 0.75);
margin-left: calc(var(--app-spacing) * -0.75);
margin-right: calc(var(--app-spacing) * -0.75);
}
.handover-diff-changed {
background: color-mix(in srgb, hsl(40 90% 50%) 8%, transparent);
border-left: 3px solid hsl(40 90% 50%);
}
.handover-diff-added {
background: color-mix(in srgb, hsl(145 60% 42%) 8%, transparent);
border-left: 3px solid hsl(145 60% 42%);
}
.handover-diff-removed {
background: color-mix(in srgb, hsl(0 70% 55%) 8%, transparent);
border-left: 3px solid hsl(0 70% 55%);
}
.handover-diff-removed input,
.handover-diff-removed select,
.handover-diff-removed textarea {
text-decoration: line-through;
opacity: 0.5;
}
.handover-diff-indicator {
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: calc(var(--app-spacing) * 0.15);
font-size: var(--text-xs);
line-height: 1.3;
}
.handover-diff-indicator::before {
content: '±';
font-weight: var(--font-semibold, 600);
color: hsl(40 70% 45%);
}
.handover-diff-added .handover-diff-indicator::before {
content: '+';
color: hsl(145 60% 38%);
}
.handover-diff-removed .handover-diff-indicator::before {
content: '';
color: hsl(0 70% 50%);
}
} }