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

@@ -552,5 +552,37 @@
"No tenant context": "Kein Mandantenkontext",
"Missing domain number": "Domain-Nummer fehlt",
"Invalid security level": "Ungültige Sicherheitsstufe",
"Failed to save security level": "Sicherheitsstufe konnte nicht gespeichert werden"
"Failed to save security level": "Sicherheitsstufe konnte nicht gespeichert werden",
"Under review": "In Prüfung",
"Assignment": "Zuweisung",
"Assigned to": "Zugewiesen an",
"Assigned at": "Zugewiesen am",
"Not assigned yet": "Noch nicht zugewiesen",
"Assign": "Zuweisen",
"Change assignment": "Zuweisung ändern",
"Assign handover": "Übergabe zuweisen",
"Assign to": "Zuweisen an",
"— Select employee —": "— Mitarbeiter auswählen —",
"Due date (optional)": "Fälligkeitsdatum (optional)",
"Due date": "Fälligkeitsdatum",
"Currently assigned to": "Aktuell zugewiesen an",
"Resend notification": "Benachrichtigung erneut senden",
"Notification resent": "Benachrichtigung erneut gesendet",
"Failed to resend notification": "Benachrichtigung konnte nicht erneut gesendet werden",
"Handover assigned": "Übergabe zugewiesen",
"Failed to assign handover": "Übergabe konnte nicht zugewiesen werden",
"Only managers can assign handovers": "Nur Manager können Übergaben zuweisen",
"Only managers can resend notifications": "Nur Manager können Benachrichtigungen erneut senden",
"Handover has no assignee": "Übergabe hat keinen Bearbeiter",
"Please select an employee": "Bitte Mitarbeiter auswählen",
"Submit for review": "Zur Prüfung einreichen",
"Submit this handover for review?": "Diese Übergabe zur Prüfung einreichen?",
"Handover submitted for review": "Übergabe zur Prüfung eingereicht",
"Failed to submit for review": "Einreichen zur Prüfung fehlgeschlagen",
"You are not assigned to this handover": "Sie sind dieser Übergabe nicht zugewiesen",
"Handover cannot be submitted for review in its current status": "Übergabe kann im aktuellen Status nicht zur Prüfung eingereicht werden",
"Open": "Offen",
"Closed": "Abgeschlossen",
"Assigned to (column)": "Zugewiesen an"
}

View File

@@ -552,5 +552,37 @@
"No tenant context": "No tenant context",
"Missing domain number": "Missing domain number",
"Invalid security level": "Invalid security level",
"Failed to save security level": "Failed to save security level"
"Failed to save security level": "Failed to save security level",
"Under review": "Under review",
"Assignment": "Assignment",
"Assigned to": "Assigned to",
"Assigned at": "Assigned at",
"Not assigned yet": "Not assigned yet",
"Assign": "Assign",
"Change assignment": "Change assignment",
"Assign handover": "Assign handover",
"Assign to": "Assign to",
"— Select employee —": "— Select employee —",
"Due date (optional)": "Due date (optional)",
"Due date": "Due date",
"Currently assigned to": "Currently assigned to",
"Resend notification": "Resend notification",
"Notification resent": "Notification resent",
"Failed to resend notification": "Failed to resend notification",
"Handover assigned": "Handover assigned",
"Failed to assign handover": "Failed to assign handover",
"Only managers can assign handovers": "Only managers can assign handovers",
"Only managers can resend notifications": "Only managers can resend notifications",
"Handover has no assignee": "Handover has no assignee",
"Please select an employee": "Please select an employee",
"Submit for review": "Submit for review",
"Submit this handover for review?": "Submit this handover for review?",
"Handover submitted for review": "Handover submitted for review",
"Failed to submit for review": "Failed to submit for review",
"You are not assigned to this handover": "You are not assigned to this handover",
"Handover cannot be submitted for review in its current status": "Handover cannot be submitted for review in its current status",
"Open": "Open",
"Closed": "Closed",
"Assigned to (column)": "Assigned to"
}

View File

@@ -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());

View File

@@ -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
*/

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',

View File

@@ -0,0 +1,10 @@
ALTER TABLE `helpdesk_handovers`
ADD COLUMN `assigned_to` INT UNSIGNED NULL DEFAULT NULL AFTER `updated_by`,
ADD COLUMN `assigned_by` INT UNSIGNED NULL DEFAULT NULL AFTER `assigned_to`,
ADD COLUMN `assigned_at` DATETIME NULL DEFAULT NULL AFTER `assigned_by`,
ADD COLUMN `due_date` DATE NULL DEFAULT NULL AFTER `assigned_at`,
MODIFY COLUMN `status` VARCHAR(20) NOT NULL DEFAULT 'draft'
COMMENT 'draft|in_progress|under_review|completed|archived';
ALTER TABLE `helpdesk_handovers`
ADD INDEX `idx_assigned_to` (`tenant_id`, `assigned_to`);

View File

@@ -53,6 +53,7 @@ return [
['path' => 'helpdesk/handovers/create', 'target' => 'helpdesk/handovers/create'],
['path' => 'helpdesk/handovers/edit/{id}', 'target' => 'helpdesk/handovers/edit'],
['path' => 'helpdesk/handovers/bulk/{action}', 'target' => 'helpdesk/handovers/bulk'],
['path' => 'helpdesk/handovers/assign/{id}', 'target' => 'helpdesk/handovers/assign'],
['path' => 'helpdesk/debitor-lookup-data', 'target' => 'helpdesk/debitor-lookup-data'],
['path' => 'helpdesk/handover-domains-data', 'target' => 'helpdesk/handover-domains-data'],
['path' => 'helpdesk/updates', 'target' => 'helpdesk/updates'],

View File

@@ -13,6 +13,21 @@ $filters = gridParseFiltersFromSchemaFile(__DIR__ . '/handovers/filter-schema.ph
$session = app(SessionStoreInterface::class)->all();
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
$userId = (int) ($session['user']['id'] ?? 0);
$authService = app(\MintyPHP\Service\Access\AuthorizationService::class);
$canManage = $authService->authorize(HelpdeskAuthorizationPolicy::ABILITY_HANDOVERS_MANAGE, ['actor_user_id' => $userId])->isAllowed();
// Non-managers see only handovers assigned to them
if (!$canManage) {
$filters['assigned_to'] = $userId;
}
// Pass view (open/closed) to repository filter
if (!isset($filters['view']) || $filters['view'] === '') {
$filters['view'] = 'open';
}
$service = app(HandoverService::class);
$result = $service->listPaged($tenantId, $filters);
@@ -38,6 +53,8 @@ foreach ($rows as $row) {
'status' => $status,
'status_label' => HandoverService::statusLabel($status),
'status_variant' => HandoverService::statusVariant($status),
'assigned_to_name' => (string) ($row['assigned_to_name'] ?? ''),
'due_date' => (string) ($row['due_date'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
'created_by_name' => (string) ($row['created_by_name'] ?? ''),
'edit_url' => $id > 0 ? $editBaseUrl . $id : '',

View File

@@ -0,0 +1,105 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\DB;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\HandoverService;
use MintyPHP\Module\Helpdesk\Service\SoftwareProductService;
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_HANDOVERS_MANAGE);
$request = requestInput();
$returnTarget = requestResolveReturnTarget('helpdesk/handovers');
$handoverId = (int) ($id ?? 0);
$editTarget = lurl('helpdesk/handovers/edit/' . $handoverId);
$assignTarget = lurl('helpdesk/handovers/assign/' . $handoverId);
$session = app(SessionStoreInterface::class)->all();
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
$userId = (int) ($session['user']['id'] ?? 0);
$service = app(HandoverService::class);
$handover = $service->findById($tenantId, $handoverId);
if ($handover === null) {
Flash::error(t('Handover not found'), $returnTarget, 'handover_not_found');
Router::redirect($returnTarget);
return;
}
$errorBag = formErrors();
if ($request->isMethod('POST')) {
if (!Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), $assignTarget, 'csrf_expired');
Router::redirect($assignTarget);
return;
}
$assignedTo = (int) $request->body('assigned_to', 0);
$dueDate = trim((string) $request->body('due_date', ''));
$resend = (string) $request->body('action', '') === 'resend';
if ($resend) {
$result = $service->resendNotification($tenantId, $handoverId, HandoverService::PERMISSION_MANAGE);
if ($result['ok']) {
Flash::success(t('Notification resent'), $editTarget, 'notification_resent');
} else {
$firstError = reset($result['errors']) ?: t('Failed to resend notification');
Flash::error($firstError, $editTarget, 'notification_resend_failed');
}
Router::redirect($editTarget);
return;
}
$result = $service->assign($tenantId, $handoverId, $assignedTo, $userId, $dueDate ?: null, HandoverService::PERMISSION_MANAGE);
if ($result['ok']) {
Flash::success(t('Handover assigned'), $editTarget, 'handover_assigned');
Router::redirect($editTarget);
return;
}
foreach ($result['errors'] as $key => $msg) {
$errorBag->add($key, $msg);
}
}
// Load active users for this tenant for the assignee dropdown
$tenantUsers = DB::select(
'SELECT u.id, u.display_name, u.email'
. ' FROM users u'
. ' JOIN user_tenants ut ON ut.user_id = u.id'
. ' WHERE ut.tenant_id = ? AND u.active = 1'
. ' ORDER BY u.display_name ASC',
(string) $tenantId
);
if (!is_array($tenantUsers)) {
$tenantUsers = [];
}
$productCode = (string) ($handover['product_code'] ?? '');
$product = app(SoftwareProductService::class)->findByCode($productCode);
$productName = trim((string) ($product['name'] ?? '')) !== ''
? $product['name']
: (trim((string) ($product['bc_description'] ?? '')) !== '' ? $product['bc_description'] : $productCode);
$validationSummaryErrors = $errorBag->toArray();
$errors = $errorBag->toFlatList();
$titleText = t('Assign handover');
Buffer::set('title', $titleText);
Buffer::set('style_groups', json_encode(['helpdesk']));
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Helpdesk'), 'path' => 'helpdesk'],
['label' => t('Handovers'), 'path' => 'helpdesk/handovers'],
['label' => $productName, 'path' => 'helpdesk/handovers/edit/' . $handoverId],
['label' => $titleText],
];

View File

@@ -0,0 +1,108 @@
<?php
use MintyPHP\Module\Helpdesk\Service\HandoverService;
$handover = is_array($handover ?? null) ? $handover : [];
$tenantUsers = is_array($tenantUsers ?? null) ? $tenantUsers : [];
$validationSummaryErrors = is_array($validationSummaryErrors ?? null) ? $validationSummaryErrors : [];
$errors = is_array($errors ?? null) ? $errors : [];
$editTarget = $editTarget ?? 'helpdesk/handovers';
$formId = 'handover-assign-form';
$currentAssignedTo = (int) ($handover['assigned_to'] ?? 0);
$currentDueDate = (string) ($handover['due_date'] ?? '');
$hasAssignee = $currentAssignedTo > 0;
$currentStatus = (string) ($handover['status'] ?? '');
?>
<div class="app-details-container">
<section>
<?php
$titlebar = [
'title' => $titleText ?? t('Assign handover'),
'backHref' => $editTarget,
'backTitle' => t('Back'),
'actions' => [
[
'form' => $formId,
'name' => 'action',
'value' => 'assign',
'class' => 'primary',
'label' => t('Assign'),
],
],
];
require templatePath('partials/app-details-titlebar.phtml');
?>
<?php require templatePath('partials/app-details-validation-summary.phtml'); ?>
<form id="<?php e($formId); ?>" method="post">
<div class="app-field-group">
<label for="assigned_to">
<?php e(t('Assign to')); ?>
<span aria-hidden="true" class="required-indicator">*</span>
</label>
<select
id="assigned_to"
name="assigned_to"
<?php if (!empty($errors['assigned_to'])): ?>aria-describedby="assigned_to_error"<?php endif; ?>
>
<option value=""><?php e(t('— Select employee —')); ?></option>
<?php foreach ($tenantUsers as $u): ?>
<option value="<?php e($u['id']); ?>"<?php if ((int) $u['id'] === $currentAssignedTo): ?> selected<?php endif; ?>>
<?php e($u['display_name'] ?: $u['email']); ?>
</option>
<?php endforeach; ?>
</select>
<?php if (!empty($errors['assigned_to'])): ?>
<p id="assigned_to_error" class="field-error"><?php e($errors['assigned_to']); ?></p>
<?php endif; ?>
</div>
<div class="app-field-group">
<label for="due_date"><?php e(t('Due date (optional)')); ?></label>
<input
type="date"
id="due_date"
name="due_date"
value="<?php e($currentDueDate); ?>"
>
</div>
<?php \MintyPHP\Session::getCsrfInput(); ?>
</form>
</section>
<aside id="app-details-aside-section">
<div class="app-details-aside-section">
<hgroup>
<h2><?php e((string) ($handover['debitor_name'] ?? '—')); ?></h2>
<p><?php e((string) ($handover['domain_url'] ?? '—')); ?></p>
<p><?php e($productName ?? ''); ?></p>
</hgroup>
<div class="badge-list">
<span class="badge" data-variant="<?php e(HandoverService::statusVariant($currentStatus)); ?>">
<?php e(HandoverService::statusLabel($currentStatus)); ?>
</span>
</div>
<?php if ($hasAssignee): ?>
<hr>
<p><small><?php e(t('Currently assigned to')); ?></small></p>
<p><strong><?php e((string) ($handover['assigned_to_label'] ?? '#' . $currentAssignedTo)); ?></strong></p>
<?php if ($currentDueDate !== ''): ?>
<p><small><?php e(t('Due date')); ?>: <?php e($currentDueDate); ?></small></p>
<?php endif; ?>
<form method="post" style="margin-top: 8px;">
<input type="hidden" name="action" value="resend">
<?php \MintyPHP\Session::getCsrfInput(); ?>
<button type="submit" class="secondary outline small">
<i class="bi bi-envelope-arrow-up"></i> <?php e(t('Resend notification')); ?>
</button>
</form>
<?php endif; ?>
</div>
</aside>
</div>

View File

@@ -1,6 +1,7 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\DB;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\HandoverService;
@@ -18,6 +19,8 @@ $returnTarget = requestResolveReturnTarget('helpdesk/handovers');
$closeTarget = requestResolveReturnTarget('helpdesk/handovers');
$createTarget = requestPathWithReturnTarget('helpdesk/handovers/create', $returnTarget);
$canManage = Guard::hasAbility(HelpdeskAuthorizationPolicy::ABILITY_HANDOVERS_MANAGE);
$step = (int) $request->query('step', '1');
if ($step < 1 || $step > 2) {
$step = 1;
@@ -35,12 +38,29 @@ $sessionKey = 'module.helpdesk.handover_create';
if ($step === 1) {
$productsWithSchema = $productService->listWithSchema();
$tenantUsers = [];
if ($canManage) {
$session = app(SessionStoreInterface::class)->all();
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
$rows = DB::select(
'SELECT u.id, u.display_name, u.email'
. ' FROM users u'
. ' JOIN user_tenants ut ON ut.user_id = u.id'
. ' WHERE ut.tenant_id = ? AND u.active = 1'
. ' ORDER BY u.display_name ASC',
(string) $tenantId
);
$tenantUsers = is_array($rows) ? $rows : [];
}
$form = [
'debitor_no' => (string) $request->body('debitor_no', ''),
'debitor_name' => (string) $request->body('debitor_name', ''),
'product_code' => (string) $request->body('product_code', ''),
'domain_no' => (string) $request->body('domain_no', ''),
'domain_url' => (string) $request->body('domain_url', ''),
'assigned_to' => (int) $request->body('assigned_to', 0),
'due_date' => (string) $request->body('due_date', ''),
];
if ($request->isMethod('POST')) {
@@ -77,6 +97,8 @@ if ($step === 1) {
'product_code' => trim($form['product_code']),
'domain_no' => trim($form['domain_no']),
'domain_url' => trim($form['domain_url']),
'assigned_to' => $form['assigned_to'],
'due_date' => trim($form['due_date']),
]);
Router::redirect(lurl('helpdesk/handovers/create') . '?step=2' . ($returnTarget ? '&return=' . rawurlencode($returnTarget) : ''));
@@ -159,6 +181,13 @@ if ($step === 2) {
} else {
$handoverId = $result['id'];
// Auto-assign if an assignee was selected (managers only)
$assignedTo = (int) ($wizardData['assigned_to'] ?? 0);
if ($assignedTo > 0 && $canManage) {
$dueDate = trim((string) ($wizardData['due_date'] ?? '')) ?: null;
$handoverService->assign($tenantId, $handoverId, $assignedTo, $userId, $dueDate, HandoverService::PERMISSION_MANAGE);
}
// Clear wizard session
$sessionStore->remove($sessionKey);

View File

@@ -15,6 +15,8 @@ $steps = [
['label' => t('Customer & product'), 'number' => 1],
['label' => t('Protocol'), 'number' => 2],
];
$canManage = $canManage ?? false;
$tenantUsers = is_array($tenantUsers ?? null) ? $tenantUsers : [];
?>
<div class="app-wizard-content">
<div class="app-wizard-header">
@@ -110,6 +112,30 @@ $steps = [
<?php endif; ?>
</div>
<?php if ($canManage && $tenantUsers !== []): ?>
<div class="app-wizard-field-group">
<label for="wizard-assigned-to"><?php e(t('Assign to (optional)')); ?></label>
<select id="wizard-assigned-to" name="assigned_to">
<option value="0"><?php e(t('— Not assigned yet —')); ?></option>
<?php foreach ($tenantUsers as $u): ?>
<option value="<?php e($u['id']); ?>"<?php if ((int) ($form['assigned_to'] ?? 0) === (int) $u['id']): ?> selected<?php endif; ?>>
<?php e($u['display_name'] ?: $u['email']); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="app-wizard-field-group" id="wizard-due-date-group">
<label for="wizard-due-date"><?php e(t('Due date (optional)')); ?></label>
<input
type="date"
id="wizard-due-date"
name="due_date"
value="<?php e($form['due_date'] ?? ''); ?>"
>
</div>
<?php endif; ?>
<?php \MintyPHP\Session::getCsrfInput(); ?>
<div class="app-wizard-actions">

View File

@@ -61,6 +61,25 @@ if (!is_array($fieldValues)) {
$errorBag = formErrors();
// Handle submit-for-review POST
if ($request->isMethod('POST') && (string) $request->body('action', '') === 'submit_for_review') {
if (!Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired');
Router::redirect($editTarget);
return;
}
$reviewResult = $service->submitForReview($tenantId, $handoverId, $userId, $permissionLevel);
if ($reviewResult['ok']) {
Flash::success(t('Handover submitted for review'), $editTarget, 'handover_submitted');
} else {
$firstError = reset($reviewResult['errors']) ?: t('Failed to submit for review');
Flash::error($firstError, $editTarget, 'submit_failed');
}
Router::redirect($editTarget);
return;
}
// Handle restore POST
if ($request->isMethod('POST') && (string) $request->body('action', '') === 'restore') {
if (!Session::checkCsrfToken()) {
@@ -207,6 +226,16 @@ if ($isViewingRevision) {
}
}
// Can the current user submit for review?
// Assignee (create-level) or manager may submit when status is draft/in_progress.
$assignedTo = (int) ($handover['assigned_to'] ?? 0);
$isAssignee = $assignedTo === $userId;
$canSubmitForReview = !$isViewingRevision
&& ($isAssignee || $canManage)
&& in_array($currentStatus, [HandoverService::STATUS_DRAFT, HandoverService::STATUS_IN_PROGRESS], true);
$assignUrl = $canManage ? lurl('helpdesk/handovers/assign/' . $handoverId) : '';
$productCode = (string) ($handover['product_code'] ?? '');
$product = app(\MintyPHP\Module\Helpdesk\Service\SoftwareProductService::class)->findByCode($productCode);
$productName = trim((string) ($product['name'] ?? '')) !== ''

View File

@@ -18,6 +18,8 @@ $activeRevisionNumber = $activeRevisionNumber ?? 0;
$compareRevisionNumber = $compareRevisionNumber ?? 0;
$fieldDiff = is_array($fieldDiff ?? null) ? $fieldDiff : [];
$canManage = $canManage ?? false;
$canSubmitForReview = $canSubmitForReview ?? false;
$assignUrl = $assignUrl ?? '';
$formId = 'handover-form';
$readonly = !$canEdit || $isViewingRevision;
@@ -146,6 +148,59 @@ $editBasePath = $editBasePath ?? ('helpdesk/handovers/edit/' . ($handover['id']
</div>
<?php endif; ?>
<?php
$assignedToLabel = trim((string) ($handover['assigned_to_label'] ?? ''));
$assignedAt = trim((string) ($handover['assigned_at'] ?? ''));
$dueDate = trim((string) ($handover['due_date'] ?? ''));
$hasAssignee = !empty($handover['assigned_to']);
?>
<?php if ($hasAssignee || $canManage): ?>
<hr>
<details name="handover-aside" open>
<summary><small><?php e(t('Assignment')); ?></small></summary>
<hr>
<?php if ($hasAssignee): ?>
<p>
<small><?php e(t('Assigned to')); ?></small><br>
<strong><?php e($assignedToLabel ?: ('# ' . ($handover['assigned_to'] ?? ''))); ?></strong>
</p>
<?php if ($assignedAt !== ''): ?>
<p><small><?php e(t('Assigned at')); ?>: <?php e(dt($assignedAt)); ?></small></p>
<?php endif; ?>
<?php if ($dueDate !== ''): ?>
<p><small><?php e(t('Due date')); ?>: <?php e($dueDate); ?></small></p>
<?php endif; ?>
<?php else: ?>
<p><small><?php e(t('Not assigned yet')); ?></small></p>
<?php endif; ?>
<?php if ($canManage && $assignUrl !== '' && !$isViewingRevision): ?>
<a href="<?php e($assignUrl); ?>" role="button" class="secondary outline small" style="margin-top: 6px; display: inline-block;">
<i class="bi bi-person-check"></i>
<?php e($hasAssignee ? t('Change assignment') : t('Assign')); ?>
</a>
<?php endif; ?>
</details>
<?php endif; ?>
<?php if ($canSubmitForReview && !$isViewingRevision && in_array($currentStatus, [\MintyPHP\Module\Helpdesk\Service\HandoverService::STATUS_DRAFT, \MintyPHP\Module\Helpdesk\Service\HandoverService::STATUS_IN_PROGRESS], true)): ?>
<hr>
<form method="post">
<input type="hidden" name="action" value="submit_for_review">
<?php \MintyPHP\Session::getCsrfInput(); ?>
<button
type="submit"
class="primary small"
style="width: 100%;"
data-confirm-message="<?php e(t('Submit this handover for review?')); ?>"
data-confirm-variant="warning"
>
<i class="bi bi-send-check"></i> <?php e(t('Submit for review')); ?>
</button>
</form>
<?php endif; ?>
<hr>
<?php if ($revisions !== []): ?>

View File

@@ -4,11 +4,12 @@ return gridFilterSchema([
'query' => [
'search' => ['type' => 'string'],
'status' => ['type' => 'string', 'default' => 'all'],
'view' => ['type' => 'string', 'default' => 'open'],
'limit' => ['type' => 'int', 'default' => 20, 'min' => 1, 'max' => 100],
'offset' => ['type' => 'int', 'default' => 0, 'min' => 0],
'order' => [
'type' => 'order',
'allowed' => ['id', 'debitor_name', 'product_code', 'status', 'created_at', 'created_by'],
'allowed' => ['id', 'debitor_name', 'product_code', 'status', 'created_at', 'created_by', 'assigned_at', 'due_date'],
'default' => 'created_at',
],
'dir' => ['type' => 'dir', 'default' => 'desc'],
@@ -34,6 +35,7 @@ return gridFilterSchema([
['id' => 'all', 'description' => 'All'],
['id' => 'draft', 'description' => 'Draft'],
['id' => 'in_progress', 'description' => 'In progress'],
['id' => 'under_review', 'description' => 'Under review'],
['id' => 'completed', 'description' => 'Completed'],
['id' => 'archived', 'description' => 'Archived'],
],

View File

@@ -46,6 +46,12 @@ $canManage = $authService->authorize(HelpdeskAuthorizationPolicy::ABILITY_HANDOV
$canCreate = $authService->authorize(HelpdeskAuthorizationPolicy::ABILITY_HANDOVERS_CREATE, $actorContext)->isAllowed()
|| $canManage;
// Non-managers see only their own assigned handovers
$myAssignedOnly = !$canManage;
// Active tab: open (default) or closed
$activeView = in_array($query['view'] ?? '', ['open', 'closed'], true) ? $query['view'] : 'open';
if ($canManage) {
$csrfKey = Session::$csrfSessionKey;
$csrfToken = $session[$csrfKey] ?? '';

View File

@@ -9,6 +9,8 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
$filterChipMeta = is_array($filterChipMeta ?? null) ? $filterChipMeta : [];
$canCreate = $canCreate ?? false;
$canManage = $canManage ?? false;
$activeView = $activeView ?? 'open';
$myAssignedOnly = $myAssignedOnly ?? false;
?>
<?php
$listTitle = t('Handovers');
@@ -28,6 +30,20 @@ ob_start();
$listTitleActionsHtml = ob_get_clean();
require templatePath('partials/app-list-titlebar.phtml');
?>
<div class="app-tabs-nav" style="margin-bottom: 12px;">
<a
href="<?php e(lurl('helpdesk/handovers') . '?view=open'); ?>"
role="button"
class="<?php e($activeView === 'open' ? 'primary small' : 'secondary outline small'); ?>"
><?php e(t('Open')); ?></a>
<a
href="<?php e(lurl('helpdesk/handovers') . '?view=closed'); ?>"
role="button"
class="<?php e($activeView === 'closed' ? 'primary small' : 'secondary outline small'); ?>"
><?php e(t('Closed')); ?></a>
</div>
<?php
$filterUiNamespace = 'helpdesk-handovers';
require templatePath('partials/app-list-filters.phtml');
@@ -48,6 +64,8 @@ require templatePath('partials/app-list-filters.phtml');
'filterChipMeta' => $filterChipMeta,
'editBaseUrl' => lurl('helpdesk/handovers/edit/'),
'canManage' => $canManage,
'activeView' => $activeView,
'myAssignedOnly' => $myAssignedOnly,
'bulkUrl' => $canManage ? lurl('helpdesk/handovers/bulk/') : '',
'csrfKey' => $csrfKey ?? '',
'csrfToken' => $csrfToken ?? '',
@@ -56,6 +74,8 @@ require templatePath('partials/app-list-filters.phtml');
'status' => t('Status'),
'debitorName' => t('Customer'),
'productDisplay' => t('Software product'),
'assignedTo' => t('Assigned to'),
'dueDate' => t('Due date'),
'createdAt' => t('Created'),
'createdBy' => t('Created by'),
'selectAll' => t('Select all'),