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:
@@ -28,8 +28,10 @@ use MintyPHP\Module\Helpdesk\Repository\HandoverRevisionRepository;
|
||||
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
|
||||
use MintyPHP\Module\Helpdesk\Repository\UpdateRepository;
|
||||
use MintyPHP\Module\Helpdesk\Service\DomainSecurityLevelService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HandoverNotificationService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HandoverService;
|
||||
use MintyPHP\Service\Mail\MailService;
|
||||
use MintyPHP\Module\Helpdesk\Service\UpdateService;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
@@ -142,10 +144,15 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar
|
||||
$c->get(HandoverRepository::class)
|
||||
));
|
||||
|
||||
$container->set(HandoverNotificationService::class, static fn (AppContainer $c): HandoverNotificationService => new HandoverNotificationService(
|
||||
$c->get(MailService::class)
|
||||
));
|
||||
|
||||
$container->set(HandoverService::class, static fn (AppContainer $c): HandoverService => new HandoverService(
|
||||
$c->get(HandoverRepository::class),
|
||||
$c->get(SoftwareProductService::class),
|
||||
$c->get(HandoverRevisionService::class)
|
||||
$c->get(HandoverRevisionService::class),
|
||||
$c->get(HandoverNotificationService::class)
|
||||
));
|
||||
|
||||
$container->set(UpdateRepository::class, static fn (): UpdateRepository => new UpdateRepository());
|
||||
|
||||
@@ -45,10 +45,14 @@ class HandoverRepository
|
||||
$row = DB::selectOne(
|
||||
'SELECT h.*,'
|
||||
. ' uc.display_name AS created_by_label, uc.uuid AS created_by_uuid,'
|
||||
. ' uu.display_name AS updated_by_label, uu.uuid AS updated_by_uuid'
|
||||
. ' uu.display_name AS updated_by_label, uu.uuid AS updated_by_uuid,'
|
||||
. ' ua.display_name AS assigned_to_label, ua.uuid AS assigned_to_uuid, ua.email AS assigned_to_email,'
|
||||
. ' ub.display_name AS assigned_by_label, ub.uuid AS assigned_by_uuid, ub.email AS assigned_by_email'
|
||||
. ' FROM helpdesk_handovers h'
|
||||
. ' LEFT JOIN users uc ON uc.id = h.created_by'
|
||||
. ' LEFT JOIN users uu ON uu.id = h.updated_by'
|
||||
. ' LEFT JOIN users ua ON ua.id = h.assigned_to'
|
||||
. ' LEFT JOIN users ub ON ub.id = h.assigned_by'
|
||||
. ' WHERE h.id = ? AND h.tenant_id = ? LIMIT 1',
|
||||
(string) $id,
|
||||
(string) $tenantId
|
||||
@@ -66,11 +70,13 @@ class HandoverRepository
|
||||
$search = trim((string) ($filters['search'] ?? ''));
|
||||
$status = trim((string) ($filters['status'] ?? ''));
|
||||
$productCode = trim((string) ($filters['product_code'] ?? ''));
|
||||
$assignedTo = (int) ($filters['assigned_to'] ?? 0);
|
||||
$view = trim((string) ($filters['view'] ?? ''));
|
||||
|
||||
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 100, 0);
|
||||
[$order, $dir] = RepoQuery::sanitizeOrder(
|
||||
$filters,
|
||||
['id', 'domain_url', 'debitor_name', 'product_code', 'status', 'created_at', 'created_by'],
|
||||
['id', 'domain_url', 'debitor_name', 'product_code', 'status', 'created_at', 'created_by', 'assigned_at', 'due_date'],
|
||||
'created_at',
|
||||
'desc'
|
||||
);
|
||||
@@ -83,6 +89,10 @@ class HandoverRepository
|
||||
if ($status !== '' && $status !== 'all') {
|
||||
$where[] = 'h.status = ?';
|
||||
$params[] = $status;
|
||||
} elseif ($view === 'open') {
|
||||
$where[] = "h.status NOT IN ('completed', 'archived')";
|
||||
} elseif ($view === 'closed') {
|
||||
$where[] = "h.status IN ('completed', 'archived')";
|
||||
}
|
||||
|
||||
if ($productCode !== '') {
|
||||
@@ -90,14 +100,21 @@ class HandoverRepository
|
||||
$params[] = $productCode;
|
||||
}
|
||||
|
||||
if ($assignedTo > 0) {
|
||||
$where[] = 'h.assigned_to = ?';
|
||||
$params[] = (string) $assignedTo;
|
||||
}
|
||||
|
||||
$whereSql = ' WHERE ' . implode(' AND ', $where);
|
||||
$total = (int) (DB::selectValue('SELECT COUNT(*) FROM helpdesk_handovers h' . $whereSql, ...$params) ?? 0);
|
||||
|
||||
$rows = DB::select(
|
||||
'SELECT h.*, u.display_name AS created_by_name,'
|
||||
. ' ua.display_name AS assigned_to_name,'
|
||||
. ' sp.name AS product_name, sp.bc_description AS product_bc_description'
|
||||
. ' FROM helpdesk_handovers h'
|
||||
. ' LEFT JOIN users u ON u.id = h.created_by'
|
||||
. ' LEFT JOIN users ua ON ua.id = h.assigned_to'
|
||||
. ' LEFT JOIN helpdesk_software_products sp ON sp.code = h.product_code'
|
||||
. $whereSql
|
||||
. sprintf(' ORDER BY h.`%s` %s LIMIT ? OFFSET ?', $order, $dir),
|
||||
@@ -146,6 +163,23 @@ class HandoverRepository
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function assign(int $tenantId, int $id, int $assignedTo, int $assignedBy, ?string $dueDate, int $updatedBy): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'UPDATE helpdesk_handovers'
|
||||
. ' SET assigned_to = ?, assigned_by = ?, assigned_at = NOW(), due_date = ?, updated_by = ?'
|
||||
. ' WHERE id = ? AND tenant_id = ?',
|
||||
(string) $assignedTo,
|
||||
(string) $assignedBy,
|
||||
($dueDate !== null && $dueDate !== '') ? $dueDate : null,
|
||||
(string) $updatedBy,
|
||||
(string) $id,
|
||||
(string) $tenantId
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $ids
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user