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:
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>>}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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; ?>
|
||||||
|
|||||||
@@ -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']));
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user