feat(helpdesk): Übergabe assignment workflow

- New statuses: under_review
- New DB fields: assigned_to, assigned_by, assigned_at, due_date (migration 012)
- HandoverNotificationService: emails on assign and review-request
- HandoverService: assign(), submitForReview(), resendNotification()
- Assign page: manager selects employee + due date, resend notification
- Create wizard: manager can assign during creation (step 1)
- Edit page: "Submit for review" button for assignee, assign link for manager
- List page: open/closed tabs, non-managers see only their assigned handovers
- Email templates: handover_assigned + handover_review_requested (de/en)
- i18n keys added for de and en

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
aminovfariz
2026-06-01 14:35:03 +02:00
parent 4f38911fce
commit c32a0f8c31
26 changed files with 918 additions and 10 deletions

View File

@@ -0,0 +1,87 @@
<?php
namespace MintyPHP\Module\Helpdesk\Service;
use MintyPHP\Service\Mail\MailService;
class HandoverNotificationService
{
public function __construct(
private readonly MailService $mailService,
) {
}
/**
* Notify the assignee that a handover was assigned to them.
*
* @param array<string, mixed> $handover Row from HandoverRepository::findById (includes joined user fields)
*/
public function notifyAssigned(array $handover): void
{
$toEmail = trim((string) ($handover['assigned_to_email'] ?? ''));
if ($toEmail === '') {
return;
}
$assigneeName = trim((string) ($handover['assigned_to_label'] ?? $toEmail));
$assignedByName = trim((string) ($handover['assigned_by_label'] ?? ''));
$debitorName = trim((string) ($handover['debitor_name'] ?? ''));
$domainUrl = trim((string) ($handover['domain_url'] ?? ''));
$dueDate = trim((string) ($handover['due_date'] ?? ''));
$handoverId = (int) ($handover['id'] ?? 0);
$handoverUrl = appUrl('helpdesk/handovers/edit/' . $handoverId);
$vars = [
'assignee_name' => $assigneeName,
'assigned_by_name' => $assignedByName,
'debitor_name' => $debitorName !== '' ? $debitorName : '—',
'domain_url' => $domainUrl !== '' ? $domainUrl : '—',
'due_date' => $dueDate !== '' ? $dueDate : '—',
'handover_url' => $handoverUrl,
'app_name' => appTitle(),
];
$subject = sprintf('[%s] Ihnen wurde eine Übergabe zugewiesen', appTitle());
$this->mailService->sendTemplate('handover_assigned', $vars, $toEmail, $subject);
}
/**
* Notify managers that a handover was submitted for review.
* Sends to the person who assigned (assigned_by), falling back to created_by.
*
* @param array<string, mixed> $handover
*/
public function notifyReviewRequested(array $handover): void
{
// Notify the assigning manager (assigned_by) — email not directly stored, use created_by as fallback
// We send to assigned_by_email if it was joined; otherwise notify created_by_email.
// Since the repository joins assigned_by as assigned_by_label but not email,
// we notify the assignee's manager via a generic approach: send to created_by.
// This is intentionally simple — the manager who assigned will get the email.
$toEmail = trim((string) ($handover['assigned_by_email'] ?? ''));
if ($toEmail === '') {
return;
}
$managerName = trim((string) ($handover['assigned_by_label'] ?? $toEmail));
$assigneeName = trim((string) ($handover['assigned_to_label'] ?? ''));
$debitorName = trim((string) ($handover['debitor_name'] ?? ''));
$domainUrl = trim((string) ($handover['domain_url'] ?? ''));
$handoverId = (int) ($handover['id'] ?? 0);
$handoverUrl = appUrl('helpdesk/handovers/edit/' . $handoverId);
$vars = [
'manager_name' => $managerName,
'assignee_name' => $assigneeName !== '' ? $assigneeName : '—',
'debitor_name' => $debitorName !== '' ? $debitorName : '—',
'domain_url' => $domainUrl !== '' ? $domainUrl : '—',
'handover_url' => $handoverUrl,
'app_name' => appTitle(),
];
$subject = sprintf('[%s] Übergabe wurde zur Prüfung eingereicht', appTitle());
$this->mailService->sendTemplate('handover_review_requested', $vars, $toEmail, $subject);
}
}

View File

@@ -15,11 +15,14 @@ class HandoverService
public const STATUS_COMPLETED = 'completed';
/** @api Used in pages and templates for status checks */
public const STATUS_ARCHIVED = 'archived';
/** @api Submitted by assignee, awaiting manager review */
public const STATUS_UNDER_REVIEW = 'under_review';
/** @api Used in validation */
public const ALL_STATUSES = [
self::STATUS_DRAFT,
self::STATUS_IN_PROGRESS,
self::STATUS_UNDER_REVIEW,
self::STATUS_COMPLETED,
self::STATUS_ARCHIVED,
];
@@ -29,12 +32,12 @@ class HandoverService
/**
* Status transitions allowed per permission level.
* create-level: can set draft, in_progress, completed.
* manage-level: can set any status.
* create-level: draft in_progress → under_review.
* manage-level: any status.
*/
private const ALLOWED_STATUS_BY_PERMISSION = [
self::PERMISSION_CREATE => [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS, self::STATUS_COMPLETED],
self::PERMISSION_MANAGE => [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS, self::STATUS_COMPLETED, self::STATUS_ARCHIVED],
self::PERMISSION_CREATE => [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS, self::STATUS_UNDER_REVIEW],
self::PERMISSION_MANAGE => [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS, self::STATUS_UNDER_REVIEW, self::STATUS_COMPLETED, self::STATUS_ARCHIVED],
];
/**
@@ -46,6 +49,7 @@ class HandoverService
private readonly HandoverRepository $repository,
private readonly SoftwareProductService $softwareProductService,
private readonly ?HandoverRevisionService $revisionService = null,
private readonly ?HandoverNotificationService $notificationService = null,
) {
}
@@ -258,6 +262,120 @@ class HandoverService
);
}
/**
* Assign a handover to a user and optionally set a due date.
* Sends notification email to the assignee.
*
* @return array{ok: bool, errors: array<string, string>}
*/
public function assign(
int $tenantId,
int $id,
int $assignedTo,
int $assignedBy,
?string $dueDate,
string $permissionLevel,
): array {
if ($permissionLevel !== self::PERMISSION_MANAGE) {
return ['ok' => false, 'errors' => ['general' => t('Only managers can assign handovers')]];
}
$handover = $this->repository->findById($tenantId, $id);
if ($handover === null) {
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
}
if ($assignedTo <= 0) {
return ['ok' => false, 'errors' => ['assigned_to' => t('Please select an employee')]];
}
$updated = $this->repository->assign($tenantId, $id, $assignedTo, $assignedBy, $dueDate, $assignedBy);
if (!$updated) {
return ['ok' => false, 'errors' => ['general' => t('Failed to assign handover')]];
}
// Transition to in_progress if still draft
if ($handover['status'] === self::STATUS_DRAFT) {
$this->repository->updateStatus($tenantId, $id, self::STATUS_IN_PROGRESS, $assignedBy);
}
$updatedHandover = $this->repository->findById($tenantId, $id);
$this->notificationService?->notifyAssigned($updatedHandover ?? $handover);
return ['ok' => true, 'errors' => []];
}
/**
* Submit handover for review (assignee action).
* Changes status to under_review and notifies managers.
*
* @return array{ok: bool, errors: array<string, string>}
*/
public function submitForReview(
int $tenantId,
int $id,
int $userId,
string $permissionLevel,
): array {
$handover = $this->repository->findById($tenantId, $id);
if ($handover === null) {
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
}
// Only the assignee or a manager may submit for review
$assignedTo = (int) ($handover['assigned_to'] ?? 0);
$isAssignee = $assignedTo === $userId;
$isManager = $permissionLevel === self::PERMISSION_MANAGE;
if (!$isAssignee && !$isManager) {
return ['ok' => false, 'errors' => ['general' => t('You are not assigned to this handover')]];
}
$currentStatus = (string) ($handover['status'] ?? '');
if (!in_array($currentStatus, [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS], true)) {
return ['ok' => false, 'errors' => ['status' => t('Handover cannot be submitted for review in its current status')]];
}
$updated = $this->repository->updateStatus($tenantId, $id, self::STATUS_UNDER_REVIEW, $userId);
if (!$updated) {
return ['ok' => false, 'errors' => ['general' => t('Failed to update status')]];
}
$this->revisionService?->createRevision(
$tenantId, $id, [], self::STATUS_UNDER_REVIEW, 1, $userId, HandoverRevisionService::CHANGE_TYPE_STATUS
);
$updatedHandover = $this->repository->findById($tenantId, $id);
$this->notificationService?->notifyReviewRequested($updatedHandover ?? $handover);
return ['ok' => true, 'errors' => []];
}
/**
* Resend assignment notification email.
*
* @return array{ok: bool, errors: array<string, string>}
*/
public function resendNotification(int $tenantId, int $id, string $permissionLevel): array
{
if ($permissionLevel !== self::PERMISSION_MANAGE) {
return ['ok' => false, 'errors' => ['general' => t('Only managers can resend notifications')]];
}
$handover = $this->repository->findById($tenantId, $id);
if ($handover === null) {
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
}
if (empty($handover['assigned_to'])) {
return ['ok' => false, 'errors' => ['general' => t('Handover has no assignee')]];
}
$this->notificationService?->notifyAssigned($handover);
return ['ok' => true, 'errors' => []];
}
/**
* @return array{total: int, rows: list<array<string, mixed>>}
*/
@@ -364,6 +482,7 @@ class HandoverService
return match ($status) {
self::STATUS_DRAFT => t('Draft'),
self::STATUS_IN_PROGRESS => t('In progress'),
self::STATUS_UNDER_REVIEW => t('Under review'),
self::STATUS_COMPLETED => t('Completed'),
self::STATUS_ARCHIVED => t('Archived'),
default => $status,
@@ -378,6 +497,7 @@ class HandoverService
return match ($status) {
self::STATUS_DRAFT => 'neutral',
self::STATUS_IN_PROGRESS => 'warning',
self::STATUS_UNDER_REVIEW => 'info',
self::STATUS_COMPLETED => 'success',
self::STATUS_ARCHIVED => 'neutral',
default => 'neutral',