Compare commits

..

10 Commits

Author SHA1 Message Date
aminovfariz
9caa771a73 feat(helpdesk): add delete button on edit page, fix flash notifications
Some checks failed
qa-required / qa-required (push) Has been cancelled
- Edit page: managers see 'Delete handover' button in aside with confirm dialog
- Delete handler: POST action=delete → deleteByIds → redirect to list
- Flash scope set to null (global) so toast shows reliably after redirect
  regardless of locale prefix or query string in URL
- i18n: added Delete handover / Delete this handover? / Handover deleted (de+en)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:51:36 +02:00
aminovfariz
1caa198286 feat(helpdesk): rework handover tabs and assign flow
Tabs: replace open/closed with active (draft+in_progress+under_review)
and done (completed+archived) — records now appear in the correct tab

Create wizard assign mode: redirect to list with flash message after
assigning instead of opening edit page

i18n: add In progress / Done tab labels and wizard strings (de + en)

Nikita Soldatov note: user does not exist in DB — needs to be created
via admin user management

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:23:31 +02:00
aminovfariz
47a26d0ca6 test(helpdesk): add tests for assign, submitForReview, resendNotification and HandoverNotificationService
HandoverServiceTest: 30 new cases covering:
- assign(): happy path, draft→in_progress transition, permission check,
  zero assignedTo, not found, email sent, due date passed to repo
- submitForReview(): assignee and manager paths, non-assignee denied,
  wrong status (under_review/completed), revision created, email sent
- resendNotification(): happy path, permission denied, no assignee
- statusLabel/Variant for under_review

HandoverNotificationServiceTest: 9 new cases covering:
- notifyAssigned: sends correct template+vars, skips on empty/null email,
  fallback dash for empty debitor and due_date
- notifyReviewRequested: sends to manager, skips on no email,
  fallback dash for empty assignee name

48 tests total, all passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:59:10 +02:00
aminovfariz
88bd7caae5 feat(helpdesk): rework handover create wizard with mode selection
- Managers now see step 1: choose 'Create myself' or 'Assign to employee'
- Assign mode: select customer+product+employee → creates record immediately,
  sends invitation email, skips protocol step
- Self mode: existing 2-step flow unchanged
- Fix open/closed tab: pass view param in dataUrl so grid filters correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:50:30 +02:00
aminovfariz
1c1e82b352 fix(helpdesk): handle nested table key from DB::select in user dropdowns
MintyPHP DB returns rows as $row[$table][$field], so tenant users
come back as $u['u']['id'] not $u['id']. Added fallback for both.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:41:48 +02:00
aminovfariz
3f56f3f279 fix(helpdesk): replace Guard::hasAbility() with AuthorizationService in create wizard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 14:38:02 +02:00
aminovfariz
c32a0f8c31 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>
2026-06-01 14:35:03 +02:00
aminovfariz
4f38911fce fix(docker): copy module web assets as real files in prod image
web_init uses cp -rp which copies symlinks as symlinks.
Since the nginx container doesn't have /var/www/modules/,
symlinks in web/modules/ break. Replace them with actual copies at build time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:50:45 +02:00
aminovfariz
0d078f233d fix: suppress open_basedir warning in favicon check, disable open_basedir in prod
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 19:07:47 +02:00
aminovfariz
ec596d35d4 fix(auth): extend stream-based HTTP to POST requests in OidcHttpGateway
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:14:19 +02:00
32 changed files with 1720 additions and 68 deletions

View File

@@ -11,32 +11,41 @@ class OidcHttpGateway
*/
public function call(string $method, string $url, mixed $data = '', array $headers = []): array
{
if (strtoupper($method) === 'GET') {
return $this->getWithStream($url, $headers);
}
return Curl::call($method, $url, $data, $headers);
return $this->callWithStream(strtoupper($method), $url, $data, $headers);
}
/**
* @param array<string, string> $headers
*/
private function getWithStream(string $url, array $headers): array
private function callWithStream(string $method, string $url, mixed $data, array $headers): array
{
$headerLines = [];
foreach ($headers as $key => $value) {
$headerLines[] = $key . ': ' . $value;
}
$httpContext = [
'method' => $method,
'header' => implode("\r\n", $headerLines),
'ignore_errors' => true,
];
if ($method === 'POST') {
$body = is_array($data) ? http_build_query($data) : (string) $data;
$httpContext['content'] = $body;
if (!isset($headers['Content-Type'])) {
$httpContext['header'] .= "\r\nContent-Type: application/x-www-form-urlencoded";
}
}
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'header' => implode("\r\n", $headerLines),
'ignore_errors' => true,
],
'http' => $httpContext,
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$body = @file_get_contents($url, false, $ctx);
$status = 0;
$responseHeaders = [];

View File

@@ -44,7 +44,7 @@ class TenantFaviconService
return false;
}
$path = $this->publicDir($uuid) . '/favicon-32x32.png';
return is_file($path);
return @is_file($path);
}
public function delete(string $uuid): void

View File

@@ -35,4 +35,43 @@ COPY docker/php/xdebug.ini /usr/local/etc/php/conf.d/zzz-xdebug.ini
# No xdebug — smaller image, no debug overhead or attack surface.
FROM base AS prod
# Copy entire project into image
COPY . /var/www
# Install composer dependencies (no dev dependencies for production).
# NOTE: php.prod.ini (with open_basedir + disable_functions) is copied AFTER
# this step — composer needs unrestricted access to its own phar at /usr/bin/.
RUN composer install \
--no-dev \
--no-interaction \
--optimize-autoloader \
&& rm -rf /root/.composer/cache
# Apply production PHP hardening AFTER composer is done.
COPY docker/php/php.prod.ini /usr/local/etc/php/conf.d/zzz-minty-prod.ini
# Publish module web assets as real files (not symlinks) so web_init cp works correctly.
# Symlinks from web/modules/<id> → modules/<id>/web are replaced by actual copies,
# because web_init uses "cp -rp" which follows symlinks only to directories, not files.
RUN find /var/www/web/modules -maxdepth 1 -mindepth 1 -type l | while read link; do \
id=$(basename "$link"); \
target=$(readlink "$link"); \
rm "$link"; \
cp -r "$target" "/var/www/web/modules/$id"; \
done || true
# Create runtime directories (storage/ and var/ are excluded via .dockerignore;
# they will be mounted as volumes in production — we just ensure the paths exist).
RUN mkdir -p \
/var/www/storage/logs \
/var/www/storage/runtime \
/var/www/storage/users \
/var/www/var/cache \
/var/www/var/log \
/var/www/var/tmp \
&& chown -R www-data:www-data \
/var/www/storage \
/var/www/var
# Expose port for PHP-FPM
EXPOSE 9000

View File

@@ -8,6 +8,7 @@ display_errors=0
display_startup_errors=0
error_reporting=E_ALL & ~E_DEPRECATED & ~E_STRICT
log_errors=1
error_log=/tmp/php_errors.log
; --- Resource limits ---
memory_limit=256M
@@ -17,7 +18,7 @@ post_max_size=12M
; --- Security hardening ---
expose_php=Off
allow_url_include=Off
open_basedir=/var/www:/tmp
; open_basedir disabled — causes false-positive E_WARNING on is_file() for valid paths under /var/www
disable_functions=exec,passthru,shell_exec,system,proc_open,popen,parse_ini_file,show_source
; --- Session hardening ---

View File

@@ -552,5 +552,51 @@
"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",
"In progress": "In Bearbeitung",
"Done": "Abgeschlossen",
"Assigned to (column)": "Zugewiesen an",
"How do you want to proceed?": "Wie möchten Sie vorgehen?",
"Create myself": "Selbst erstellen",
"I will fill in the handover protocol myself.": "Ich fülle das Übergabeprotokoll selbst aus.",
"Assign to employee": "Mitarbeiter zuweisen",
"An employee will receive an invitation to fill in the protocol.": "Ein Mitarbeiter erhält eine Einladung zum Ausfüllen des Protokolls.",
"Please select an option": "Bitte wählen Sie eine Option",
"Select customer, product and employee": "Kunde, Produkt und Mitarbeiter auswählen",
"Create and assign": "Erstellen & zuweisen",
"Handover created and assigned": "Übergabe erstellt und zugewiesen",
"No active employees found.": "Keine aktiven Mitarbeiter gefunden.",
"Please select an employee to assign": "Bitte wählen Sie einen Mitarbeiter aus",
"Delete handover": "Übergabe löschen",
"Delete this handover?": "Diese Übergabe löschen?",
"Handover deleted": "Übergabe gelöscht"
}

View File

@@ -552,5 +552,51 @@
"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",
"In progress": "In progress",
"Done": "Done",
"Assigned to (column)": "Assigned to",
"How do you want to proceed?": "How do you want to proceed?",
"Create myself": "Create myself",
"I will fill in the handover protocol myself.": "I will fill in the handover protocol myself.",
"Assign to employee": "Assign to employee",
"An employee will receive an invitation to fill in the protocol.": "An employee will receive an invitation to fill in the protocol.",
"Please select an option": "Please select an option",
"Select customer, product and employee": "Select customer, product and employee",
"Create and assign": "Create and assign",
"Handover created and assigned": "Handover created and assigned",
"No active employees found.": "No active employees found.",
"Please select an employee to assign": "Please select an employee to assign",
"Delete handover": "Delete handover",
"Delete this handover?": "Delete this handover?",
"Handover deleted": "Handover deleted"
}

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 === 'active') {
$where[] = "h.status IN ('draft', 'in_progress', 'under_review')";
} elseif ($view === 'done') {
$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']) || !in_array($filters['view'], ['active', 'done'], true)) {
$filters['view'] = 'active';
}
$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,112 @@
<?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):
$uId = (int) ($u['u']['id'] ?? $u['id'] ?? 0);
$uName = (string) ($u['u']['display_name'] ?? $u['display_name'] ?? '');
$uEmail = (string) ($u['u']['email'] ?? $u['email'] ?? '');
?>
<option value="<?php e($uId); ?>"<?php if ($uId === $currentAssignedTo): ?> selected<?php endif; ?>>
<?php e($uName ?: $uEmail); ?>
</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,11 +1,13 @@
<?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\Service\Access\AuthorizationService;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
@@ -18,11 +20,6 @@ $returnTarget = requestResolveReturnTarget('helpdesk/handovers');
$closeTarget = requestResolveReturnTarget('helpdesk/handovers');
$createTarget = requestPathWithReturnTarget('helpdesk/handovers/create', $returnTarget);
$step = (int) $request->query('step', '1');
if ($step < 1 || $step > 2) {
$step = 1;
}
$errorBag = formErrors();
$productService = app(SoftwareProductService::class);
$handoverService = app(HandoverService::class);
@@ -30,24 +27,103 @@ $sessionStore = app(SessionStoreInterface::class);
$appSession = $sessionStore->all();
$sessionKey = 'module.helpdesk.handover_create';
// ── Step 1: Select debitor + product ─────────────────────────────────
$authService = app(AuthorizationService::class);
$currentUserId = (int) ($appSession['user']['id'] ?? 0);
$currentTenantId = (int) ($appSession['current_tenant']['id'] ?? 0);
$canManage = $authService->authorize(HelpdeskAuthorizationPolicy::ABILITY_HANDOVERS_MANAGE, ['actor_user_id' => $currentUserId])->isAllowed();
if ($step === 1) {
// For managers: step 1 = mode selection (self/assign), step 2 = form, step 3 = protocol (self mode only)
// For non-managers: step 1 = form, step 2 = protocol
$step = (int) $request->query('step', '1');
$mode = $request->query('mode', ''); // 'self' or 'assign'
// ── Manager step 1: choose mode ──────────────────────────────────────
if ($canManage && $step === 1) {
if ($request->isMethod('POST')) {
if (!Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), $createTarget, 'csrf_expired');
Router::redirect($createTarget);
return;
}
$chosenMode = (string) $request->body('mode', '');
if (!in_array($chosenMode, ['self', 'assign'], true)) {
$errorBag->add('mode', t('Please select an option'));
}
if (!$errorBag->hasAny()) {
$sessionStore->remove($sessionKey);
Router::redirect(lurl('helpdesk/handovers/create') . '?step=2&mode=' . $chosenMode . ($returnTarget ? '&return=' . rawurlencode($returnTarget) : ''));
return;
}
}
$errors = $errorBag->toFlatList();
$validationSummaryErrors = $errorBag->toArray();
Buffer::set('title', t('New handover'));
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' => t('New handover')],
];
return;
}
// ── Step for form (step 2 for managers, step 1 for non-managers) ──────
$formStep = $canManage ? 2 : 1;
$protocolStep = $canManage ? 3 : 2;
// Redirect non-managers who somehow get step > 2
if (!$canManage && $step > 2) {
$step = 1;
}
// For managers: validate mode param
if ($canManage && $step >= 2) {
$sessionData = $sessionStore->get($sessionKey);
$mode = $mode ?: (string) ($sessionData['mode'] ?? '');
if (!in_array($mode, ['self', 'assign'], true)) {
Router::redirect($createTarget);
return;
}
}
// ── Form step: select debitor + product (+ assignee for assign mode) ──
if ($step === $formStep) {
$productsWithSchema = $productService->listWithSchema();
$tenantUsers = [];
if ($canManage && $mode === 'assign') {
$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) $currentTenantId
);
$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')) {
if (!Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), $createTarget, 'csrf_expired');
Router::redirect($createTarget);
return;
}
@@ -70,18 +146,57 @@ if ($step === 1) {
}
}
if ($canManage && $mode === 'assign' && $form['assigned_to'] <= 0) {
$errorBag->add('assigned_to', t('Please select an employee to assign'));
}
if (!$errorBag->hasAny()) {
$sessionStore->set($sessionKey, [
'mode' => $mode,
'debitor_no' => trim($form['debitor_no']),
'debitor_name' => trim($form['debitor_name']),
'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) : ''));
// Assign mode: create record immediately and send invitation, skip protocol step
if ($canManage && $mode === 'assign') {
$result = $handoverService->create(
$currentTenantId,
trim($form['debitor_no']),
trim($form['debitor_name']),
trim($form['product_code']),
trim($form['domain_no']),
trim($form['domain_url']),
$currentUserId,
[]
);
return;
if (!($result['ok'] ?? false)) {
foreach ($result['errors'] ?? [] as $key => $msg) {
$errorBag->add($key, $msg);
}
} else {
$handoverId = $result['id'];
$dueDate = trim($form['due_date']) ?: null;
$handoverService->assign($currentTenantId, $handoverId, $form['assigned_to'], $currentUserId, $dueDate, HandoverService::PERMISSION_MANAGE);
$sessionStore->remove($sessionKey);
$listUrl = lurl('helpdesk/handovers');
Flash::success(t('Handover created and assigned'), null, 'handover_created');
Router::redirect($listUrl);
return;
}
} else {
// Self mode: continue to protocol step
$nextStep = $protocolStep;
$modeParam = $canManage ? '&mode=' . $mode : '';
Router::redirect(lurl('helpdesk/handovers/create') . '?step=' . $nextStep . $modeParam . ($returnTarget ? '&return=' . rawurlencode($returnTarget) : ''));
return;
}
}
}
@@ -89,14 +204,13 @@ if ($step === 1) {
$validationSummaryErrors = $errorBag->toArray();
}
// ── Step 2: Fill protocol form ───────────────────────────────────────
// ── Protocol step: fill protocol form ────────────────────────────────
if ($step === 2) {
if ($step === $protocolStep) {
$wizardData = $sessionStore->get($sessionKey);
if (!is_array($wizardData) || empty($wizardData['debitor_no']) || empty($wizardData['product_code'])) {
Flash::error(t('Please complete step 1 first'), $createTarget, 'wizard_step_missing');
Router::redirect($createTarget);
return;
}
@@ -104,7 +218,6 @@ if ($step === 2) {
if ($product === null) {
Flash::error(t('Software product not found'), $createTarget, 'product_not_found');
Router::redirect($createTarget);
return;
}
@@ -119,13 +232,12 @@ if ($step === 2) {
if ($request->isMethod('POST')) {
if (!Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), $createTarget . '?step=2', 'csrf_expired');
Router::redirect($createTarget . '?step=2');
$modeParam = $canManage ? '&mode=' . ($wizardData['mode'] ?? 'self') : '';
Flash::error(t('Form expired, please try again'), $createTarget . '?step=' . $protocolStep . $modeParam, 'csrf_expired');
Router::redirect($createTarget . '?step=' . $protocolStep . $modeParam);
return;
}
// Collect field values from POST
foreach ($schemaFields as $field) {
$key = $field['key'] ?? null;
if ($key === null) {
@@ -139,17 +251,14 @@ if ($step === 2) {
}
}
$tenantId = (int) ($appSession['current_tenant']['id'] ?? 0);
$userId = (int) ($appSession['user']['id'] ?? 0);
$result = $handoverService->create(
$tenantId,
$currentTenantId,
$wizardData['debitor_no'],
$wizardData['debitor_name'],
$wizardData['product_code'],
$wizardData['domain_no'] ?? '',
$wizardData['domain_url'] ?? '',
$userId,
$currentUserId,
$fieldValues
);
@@ -158,14 +267,11 @@ if ($step === 2) {
$validationSummaryErrors = $result['errors'] ?? [];
} else {
$handoverId = $result['id'];
// Clear wizard session
$sessionStore->remove($sessionKey);
$editUrl = lurl('helpdesk/handovers/edit/' . $handoverId);
Flash::success(t('Handover created'), $editUrl, 'handover_created');
Router::redirect($editUrl);
return;
}
}

View File

@@ -10,11 +10,38 @@ $schemaFields = is_array($schemaFields ?? null) ? $schemaFields : [];
$fieldValues = is_array($fieldValues ?? null) ? $fieldValues : [];
$wizardData = is_array($wizardData ?? null) ? $wizardData : [];
$productDisplayName = $productDisplayName ?? '';
$canManage = $canManage ?? false;
$mode = $mode ?? '';
$tenantUsers = is_array($tenantUsers ?? null) ? $tenantUsers : [];
$formStep = $canManage ? 2 : 1;
$protocolStep = $canManage ? 3 : 2;
$steps = [
['label' => t('Customer & product'), 'number' => 1],
['label' => t('Protocol'), 'number' => 2],
];
// Build step indicator
if ($canManage) {
$steps = [
['label' => t('Type'), 'number' => 1],
['label' => t('Customer & product'), 'number' => 2],
['label' => t('Protocol'), 'number' => 3],
];
// For assign mode, step 3 is skipped — show only 2 steps
if ($mode === 'assign') {
$steps = [
['label' => t('Type'), 'number' => 1],
['label' => t('Customer & product'), 'number' => 2],
];
}
} else {
$steps = [
['label' => t('Customer & product'), 'number' => 1],
['label' => t('Protocol'), 'number' => 2],
];
}
// Map actual step number to display step number for indicator
$displayStep = $step;
if ($canManage && $mode === 'assign' && $step === 2) {
$displayStep = 2;
}
?>
<div class="app-wizard-content">
<div class="app-wizard-header">
@@ -27,13 +54,13 @@ $steps = [
<!-- Step indicator -->
<nav class="app-wizard-steps" aria-label="<?php e(t('Progress')); ?>">
<?php foreach ($steps as $s): ?>
<div class="app-wizard-step<?php if ($s['number'] === $step): ?> is-active<?php elseif ($s['number'] < $step): ?> is-completed<?php endif; ?>"
<?php if ($s['number'] === $step): ?>aria-current="step"<?php endif; ?>>
<div class="app-wizard-step<?php if ($s['number'] === $displayStep): ?> is-active<?php elseif ($s['number'] < $displayStep): ?> is-completed<?php endif; ?>"
<?php if ($s['number'] === $displayStep): ?>aria-current="step"<?php endif; ?>>
<span class="app-wizard-step-number"><?php e($s['number']); ?></span>
<span class="app-wizard-step-label"><?php e($s['label']); ?></span>
</div>
<?php if ($s['number'] < count($steps)): ?>
<div class="app-wizard-step-connector<?php if ($s['number'] < $step): ?> is-completed<?php endif; ?>" aria-hidden="true"></div>
<div class="app-wizard-step-connector<?php if ($s['number'] < $displayStep): ?> is-completed<?php endif; ?>" aria-hidden="true"></div>
<?php endif; ?>
<?php endforeach; ?>
</nav>
@@ -42,12 +69,51 @@ $steps = [
<?php require templatePath('partials/app-details-validation-summary.phtml'); ?>
<?php endif; ?>
<!-- Step 1: Customer & Product -->
<?php if ($step === 1): ?>
<!-- Manager step 1: choose mode -->
<?php if ($canManage && $step === 1): ?>
<div class="app-wizard-card">
<form method="post" id="wizard-step-1">
<h2 class="app-wizard-card-heading"><?php e(t('Select customer and product')); ?></h2>
<p class="app-wizard-card-description"><?php e(t('Choose the customer and software product for this handover protocol.')); ?></p>
<form method="post" id="wizard-mode-select">
<h2 class="app-wizard-card-heading"><?php e(t('How do you want to proceed?')); ?></h2>
<div class="app-wizard-field-group">
<label style="display:flex;align-items:flex-start;gap:10px;cursor:pointer;padding:12px 14px;border:1px solid var(--border-color,#ddd);border-radius:6px;margin-bottom:8px;">
<input type="radio" name="mode" value="self" style="margin-top:3px;">
<span>
<strong><?php e(t('Create myself')); ?></strong><br>
<small style="color:var(--text-muted,#888)"><?php e(t('I will fill in the handover protocol myself.')); ?></small>
</span>
</label>
<label style="display:flex;align-items:flex-start;gap:10px;cursor:pointer;padding:12px 14px;border:1px solid var(--border-color,#ddd);border-radius:6px;">
<input type="radio" name="mode" value="assign" style="margin-top:3px;">
<span>
<strong><?php e(t('Assign to employee')); ?></strong><br>
<small style="color:var(--text-muted,#888)"><?php e(t('An employee will receive an invitation to fill in the protocol.')); ?></small>
</span>
</label>
<?php if (!empty($errors['mode'])): ?>
<p class="field-error"><?php e($errors['mode']); ?></p>
<?php endif; ?>
</div>
<?php \MintyPHP\Session::getCsrfInput(); ?>
<div class="app-wizard-actions">
<a href="<?php e(lurl($closeTarget)); ?>" role="button" class="outline secondary"><?php e(t('Cancel')); ?></a>
<button type="submit" class="primary"><?php e(t('Continue')); ?></button>
</div>
</form>
</div>
<!-- Form step: select customer + product (+ assignee for assign mode) -->
<?php elseif ($step === $formStep): ?>
<div class="app-wizard-card">
<form method="post" id="wizard-step-form">
<?php if ($mode === 'assign'): ?>
<h2 class="app-wizard-card-heading"><?php e(t('Select customer, product and employee')); ?></h2>
<?php else: ?>
<h2 class="app-wizard-card-heading"><?php e(t('Select customer and product')); ?></h2>
<p class="app-wizard-card-description"><?php e(t('Choose the customer and software product for this handover protocol.')); ?></p>
<?php endif; ?>
<div class="app-wizard-field-group">
<label><?php e(t('Customer')); ?> <span class="handover-form-required">*</span></label>
@@ -67,6 +133,9 @@ $steps = [
<input type="hidden" name="debitor_no" data-lookup-value value="<?php e($form['debitor_no'] ?? ''); ?>">
<input type="hidden" name="debitor_name" data-lookup-display-value value="<?php e($form['debitor_name'] ?? ''); ?>">
</div>
<?php if (!empty($errors['debitor_no'])): ?>
<p class="field-error"><?php e($errors['debitor_no']); ?></p>
<?php endif; ?>
</div>
<div class="app-wizard-field-group" id="wizard-domain-group" data-app-component="helpdesk-handover-domain-select"
@@ -85,6 +154,9 @@ $steps = [
</select>
<input type="hidden" name="domain_url" id="wizard-domain-url" value="<?php e($form['domain_url'] ?? ''); ?>">
<div id="wizard-domain-feedback" class="app-wizard-field-feedback" role="alert" hidden></div>
<?php if (!empty($errors['domain_no'])): ?>
<p class="field-error"><?php e($errors['domain_no']); ?></p>
<?php endif; ?>
</div>
<div class="app-wizard-field-group">
@@ -107,22 +179,66 @@ $steps = [
</option>
<?php endforeach; ?>
</select>
<?php if (!empty($errors['product_code'])): ?>
<p class="field-error"><?php e($errors['product_code']); ?></p>
<?php endif; ?>
<?php endif; ?>
</div>
<?php if ($mode === 'assign'): ?>
<div class="app-wizard-field-group">
<label for="wizard-assigned-to"><?php e(t('Assign to')); ?> <span class="handover-form-required">*</span></label>
<?php if ($tenantUsers === []): ?>
<p class="field-error"><?php e(t('No active employees found.')); ?></p>
<?php else: ?>
<select id="wizard-assigned-to" name="assigned_to">
<option value="0"><?php e(t('— Select employee —')); ?></option>
<?php foreach ($tenantUsers as $u):
$uId = (int) ($u['u']['id'] ?? $u['id'] ?? 0);
$uName = (string) ($u['u']['display_name'] ?? $u['display_name'] ?? '');
$uEmail = (string) ($u['u']['email'] ?? $u['email'] ?? '');
?>
<option value="<?php e($uId); ?>"<?php if ((int) ($form['assigned_to'] ?? 0) === $uId): ?> selected<?php endif; ?>>
<?php e($uName ?: $uEmail); ?>
</option>
<?php endforeach; ?>
</select>
<?php if (!empty($errors['assigned_to'])): ?>
<p class="field-error"><?php e($errors['assigned_to']); ?></p>
<?php endif; ?>
<?php endif; ?>
</div>
<div class="app-wizard-field-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">
<a href="<?php e(lurl($closeTarget)); ?>" role="button" class="outline secondary"><?php e(t('Cancel')); ?></a>
<button type="submit" class="primary"><?php e(t('Continue')); ?></button>
<?php if ($canManage): ?>
<a href="<?php e(lurl($createTarget)); ?>" role="button" class="outline secondary"><?php e(t('Back')); ?></a>
<?php else: ?>
<a href="<?php e(lurl($closeTarget)); ?>" role="button" class="outline secondary"><?php e(t('Cancel')); ?></a>
<?php endif; ?>
<?php if ($mode === 'assign'): ?>
<button type="submit" class="primary"><?php e(t('Create and assign')); ?></button>
<?php else: ?>
<button type="submit" class="primary"><?php e(t('Continue')); ?></button>
<?php endif; ?>
</div>
</form>
</div>
<!-- Step 2: Fill protocol -->
<?php elseif ($step === 2): ?>
<!-- Protocol step: fill protocol -->
<?php elseif ($step === $protocolStep): ?>
<div class="app-wizard-card">
<form method="post" id="wizard-step-2">
<?php
$backUrl = lurl($createTarget) . '?step=' . $formStep . ($canManage ? '&mode=' . urlencode($mode) : '');
?>
<form method="post" id="wizard-step-protocol">
<h2 class="app-wizard-card-heading"><?php e(t('Fill handover protocol')); ?></h2>
<p class="app-wizard-card-description">
<?php e($wizardData['debitor_name'] ?? ''); ?> &mdash; <?php e($productDisplayName); ?>
@@ -136,7 +252,7 @@ $steps = [
<?php \MintyPHP\Session::getCsrfInput(); ?>
<div class="app-wizard-actions">
<a href="<?php e(lurl($createTarget)); ?>" role="button" class="outline secondary"><?php e(t('Back')); ?></a>
<a href="<?php e($backUrl); ?>" role="button" class="outline secondary"><?php e(t('Back')); ?></a>
<button type="submit" class="primary"><?php e(t('Create handover')); ?></button>
</div>
</form>

View File

@@ -61,6 +61,49 @@ if (!is_array($fieldValues)) {
$errorBag = formErrors();
// Handle delete POST
if ($request->isMethod('POST') && (string) $request->body('action', '') === 'delete') {
if (!Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired');
Router::redirect($editTarget);
return;
}
if (!$canManage) {
Flash::error(t('Only managers can delete handovers'), $closeTarget, 'delete_denied');
Router::redirect($closeTarget);
return;
}
$deleteResult = $service->deleteByIds($tenantId, [$handoverId], HandoverService::PERMISSION_MANAGE);
if ($deleteResult['ok']) {
Flash::success(t('Handover deleted'), null, 'handover_deleted');
} else {
Flash::error($deleteResult['error'] ?? t('Failed to delete handovers'), null, 'delete_failed');
}
Router::redirect($closeTarget);
return;
}
// 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 +250,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,76 @@ $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; ?>
<?php if ($canManage && !$isViewingRevision): ?>
<hr>
<form method="post">
<input type="hidden" name="action" value="delete">
<?php \MintyPHP\Session::getCsrfInput(); ?>
<button
type="submit"
class="danger outline small"
style="width: 100%;"
data-confirm-message="<?php e(t('Delete this handover?')); ?>"
data-confirm-variant="danger"
>
<i class="bi bi-trash3"></i> <?php e(t('Delete handover')); ?>
</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' => 'active'],
'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'] ?? '', ['active', 'done'], true) ? $query['view'] : 'active';
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=active'); ?>"
role="button"
class="<?php e($activeView === 'active' ? 'primary small' : 'secondary outline small'); ?>"
><?php e(t('In progress')); ?></a>
<a
href="<?php e(lurl('helpdesk/handovers') . '?view=done'); ?>"
role="button"
class="<?php e($activeView === 'done' ? 'primary small' : 'secondary outline small'); ?>"
><?php e(t('Done')); ?></a>
</div>
<?php
$filterUiNamespace = 'helpdesk-handovers';
require templatePath('partials/app-list-filters.phtml');
@@ -41,13 +57,15 @@ require templatePath('partials/app-list-filters.phtml');
<script src="<?php e(assetVersion('vendor/gridjs/plugins/selection/selection.umd.js')); ?>"></script>
<?php endif; ?>
<script type="application/json" id="page-config-helpdesk-handovers"><?php gridJsonForJs([
'dataUrl' => lurl('helpdesk/handovers-data'),
'dataUrl' => lurl('helpdesk/handovers-data') . '?view=' . rawurlencode($activeView ?? 'active'),
'gridLang' => gridLang(),
'gridSearch' => $searchConfig,
'filterSchema' => $clientFilterSchema,
'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'),

View File

@@ -0,0 +1,153 @@
<?php
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Service\HandoverNotificationService;
use MintyPHP\Service\Mail\MailService;
use PHPUnit\Framework\TestCase;
class HandoverNotificationServiceTest extends TestCase
{
private function createNotificationService(?MailService $mailService = null): HandoverNotificationService
{
return new HandoverNotificationService(
$mailService ?? $this->createMock(MailService::class)
);
}
private function assignedHandover(array $overrides = []): array
{
return array_merge([
'id' => 1,
'debitor_name' => 'Acme Corp',
'domain_url' => 'example.com',
'due_date' => '2026-12-31',
'assigned_to_email' => 'employee@example.com',
'assigned_to_label' => 'John Doe',
'assigned_by_label' => 'Manager',
'assigned_by_email' => 'manager@example.com',
], $overrides);
}
// ── notifyAssigned ────────────────────────────────────────────────
public function testNotifyAssignedSendsEmailToAssignee(): void
{
$mailService = $this->createMock(MailService::class);
$mailService->expects($this->once())
->method('sendTemplate')
->with(
'handover_assigned',
$this->callback(fn (array $vars): bool => $vars['assignee_name'] === 'John Doe'
&& $vars['assigned_by_name'] === 'Manager'
&& $vars['debitor_name'] === 'Acme Corp'
&& $vars['due_date'] === '2026-12-31'
),
'employee@example.com',
$this->isType('string')
);
$service = $this->createNotificationService($mailService);
$service->notifyAssigned($this->assignedHandover());
}
public function testNotifyAssignedSkipsWhenNoEmail(): void
{
$mailService = $this->createMock(MailService::class);
$mailService->expects($this->never())->method('sendTemplate');
$service = $this->createNotificationService($mailService);
$service->notifyAssigned($this->assignedHandover(['assigned_to_email' => '']));
}
public function testNotifyAssignedSkipsWhenEmailIsNull(): void
{
$mailService = $this->createMock(MailService::class);
$mailService->expects($this->never())->method('sendTemplate');
$service = $this->createNotificationService($mailService);
$service->notifyAssigned($this->assignedHandover(['assigned_to_email' => null]));
}
public function testNotifyAssignedFallsBackToDashForEmptyDebitor(): void
{
$mailService = $this->createMock(MailService::class);
$mailService->expects($this->once())
->method('sendTemplate')
->with(
'handover_assigned',
$this->callback(fn (array $vars): bool => $vars['debitor_name'] === '—'),
$this->anything(),
$this->anything()
);
$service = $this->createNotificationService($mailService);
$service->notifyAssigned($this->assignedHandover(['debitor_name' => '']));
}
public function testNotifyAssignedFallsBackToDashForEmptyDueDate(): void
{
$mailService = $this->createMock(MailService::class);
$mailService->expects($this->once())
->method('sendTemplate')
->with(
'handover_assigned',
$this->callback(fn (array $vars): bool => $vars['due_date'] === '—'),
$this->anything(),
$this->anything()
);
$service = $this->createNotificationService($mailService);
$service->notifyAssigned($this->assignedHandover(['due_date' => '']));
}
// ── notifyReviewRequested ─────────────────────────────────────────
public function testNotifyReviewRequestedSendsEmailToManager(): void
{
$mailService = $this->createMock(MailService::class);
$mailService->expects($this->once())
->method('sendTemplate')
->with(
'handover_review_requested',
$this->callback(fn (array $vars): bool => $vars['manager_name'] === 'Manager'
&& $vars['assignee_name'] === 'John Doe'
&& $vars['debitor_name'] === 'Acme Corp'
),
'manager@example.com',
$this->isType('string')
);
$service = $this->createNotificationService($mailService);
$service->notifyReviewRequested($this->assignedHandover([
'assigned_to_label' => 'John Doe',
'assigned_by_label' => 'Manager',
'assigned_by_email' => 'manager@example.com',
]));
}
public function testNotifyReviewRequestedSkipsWhenNoManagerEmail(): void
{
$mailService = $this->createMock(MailService::class);
$mailService->expects($this->never())->method('sendTemplate');
$service = $this->createNotificationService($mailService);
$service->notifyReviewRequested($this->assignedHandover(['assigned_by_email' => '']));
}
public function testNotifyReviewRequestedFallsBackToDashForEmptyAssigneeName(): void
{
$mailService = $this->createMock(MailService::class);
$mailService->expects($this->once())
->method('sendTemplate')
->with(
'handover_review_requested',
$this->callback(fn (array $vars): bool => $vars['assignee_name'] === '—'),
$this->anything(),
$this->anything()
);
$service = $this->createNotificationService($mailService);
$service->notifyReviewRequested($this->assignedHandover(['assigned_to_label' => '']));
}
}

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Repository\HandoverRepository;
use MintyPHP\Module\Helpdesk\Service\HandoverNotificationService;
use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService;
use MintyPHP\Module\Helpdesk\Service\HandoverService;
use MintyPHP\Module\Helpdesk\Service\SoftwareProductService;
@@ -19,11 +20,30 @@ class HandoverServiceTest extends TestCase
?HandoverRepository $repository = null,
?SoftwareProductService $productService = null,
?HandoverRevisionService $revisionService = null,
?HandoverNotificationService $notificationService = null,
): HandoverService {
$repository = $repository ?? $this->createMock(HandoverRepository::class);
$productService = $productService ?? $this->createMock(SoftwareProductService::class);
return new HandoverService($repository, $productService, $revisionService);
return new HandoverService($repository, $productService, $revisionService, $notificationService);
}
private function handoverRow(array $overrides = []): array
{
return array_merge([
'id' => 1,
'tenant_id' => self::TENANT_ID,
'status' => HandoverService::STATUS_IN_PROGRESS,
'assigned_to' => 0,
'assigned_by' => 0,
'debitor_name' => 'Acme Corp',
'domain_url' => 'example.com',
'due_date' => null,
'assigned_to_label' => null,
'assigned_to_email' => null,
'assigned_by_label' => null,
'assigned_by_email' => null,
], $overrides);
}
private function mockProductService(bool $exists = true, bool $hasSchema = true): SoftwareProductService
@@ -332,6 +352,288 @@ class HandoverServiceTest extends TestCase
$service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'draft', false, false, 1, self::USER_ID);
}
// ── Assign tests ─────────────────────────────────────────────────
public function testAssignHappyPath(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn($this->handoverRow(['status' => HandoverService::STATUS_IN_PROGRESS]));
$repo->expects($this->once())->method('assign')->willReturn(true);
$repo->expects($this->never())->method('updateStatus');
$service = $this->createService($repo);
$result = $service->assign(self::TENANT_ID, 1, 99, self::USER_ID, null, HandoverService::PERMISSION_MANAGE);
$this->assertTrue($result['ok']);
$this->assertEmpty($result['errors']);
}
public function testAssignDraftHandoverTransitionsToInProgress(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn($this->handoverRow(['status' => HandoverService::STATUS_DRAFT]));
$repo->method('assign')->willReturn(true);
$repo->expects($this->once())
->method('updateStatus')
->with(self::TENANT_ID, 1, HandoverService::STATUS_IN_PROGRESS, self::USER_ID);
$service = $this->createService($repo);
$result = $service->assign(self::TENANT_ID, 1, 99, self::USER_ID, null, HandoverService::PERMISSION_MANAGE);
$this->assertTrue($result['ok']);
}
public function testAssignDeniedForCreatePermission(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->expects($this->never())->method('assign');
$service = $this->createService($repo);
$result = $service->assign(self::TENANT_ID, 1, 99, self::USER_ID, null, HandoverService::PERMISSION_CREATE);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('general', $result['errors']);
}
public function testAssignFailsWhenAssignedToIsZero(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn($this->handoverRow());
$repo->expects($this->never())->method('assign');
$service = $this->createService($repo);
$result = $service->assign(self::TENANT_ID, 1, 0, self::USER_ID, null, HandoverService::PERMISSION_MANAGE);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('assigned_to', $result['errors']);
}
public function testAssignFailsWhenHandoverNotFound(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn(null);
$service = $this->createService($repo);
$result = $service->assign(self::TENANT_ID, 1, 99, self::USER_ID, null, HandoverService::PERMISSION_MANAGE);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('general', $result['errors']);
}
public function testAssignSendsNotificationEmail(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn($this->handoverRow([
'assigned_to_email' => 'employee@example.com',
'assigned_to_label' => 'John Doe',
]));
$repo->method('assign')->willReturn(true);
$notificationService = $this->createMock(HandoverNotificationService::class);
$notificationService->expects($this->once())->method('notifyAssigned');
$service = $this->createService($repo, notificationService: $notificationService);
$service->assign(self::TENANT_ID, 1, 99, self::USER_ID, null, HandoverService::PERMISSION_MANAGE);
}
public function testAssignWithDueDatePassesItToRepository(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn($this->handoverRow());
$repo->expects($this->once())
->method('assign')
->with(self::TENANT_ID, 1, 99, self::USER_ID, '2026-12-31', self::USER_ID)
->willReturn(true);
$service = $this->createService($repo);
$result = $service->assign(self::TENANT_ID, 1, 99, self::USER_ID, '2026-12-31', HandoverService::PERMISSION_MANAGE);
$this->assertTrue($result['ok']);
}
// ── Submit for review tests ───────────────────────────────────────
public function testSubmitForReviewHappyPathByAssignee(): void
{
$assigneeId = 99;
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn($this->handoverRow([
'assigned_to' => $assigneeId,
'status' => HandoverService::STATUS_IN_PROGRESS,
]));
$repo->expects($this->once())
->method('updateStatus')
->with(self::TENANT_ID, 1, HandoverService::STATUS_UNDER_REVIEW, $assigneeId)
->willReturn(true);
$service = $this->createService($repo);
$result = $service->submitForReview(self::TENANT_ID, 1, $assigneeId, HandoverService::PERMISSION_CREATE);
$this->assertTrue($result['ok']);
}
public function testSubmitForReviewAllowedForManager(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn($this->handoverRow([
'assigned_to' => 99,
'status' => HandoverService::STATUS_DRAFT,
]));
$repo->method('updateStatus')->willReturn(true);
$service = $this->createService($repo);
$result = $service->submitForReview(self::TENANT_ID, 1, self::USER_ID, HandoverService::PERMISSION_MANAGE);
$this->assertTrue($result['ok']);
}
public function testSubmitForReviewDeniedForNonAssignee(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn($this->handoverRow([
'assigned_to' => 99,
'status' => HandoverService::STATUS_IN_PROGRESS,
]));
$service = $this->createService($repo);
$result = $service->submitForReview(self::TENANT_ID, 1, self::USER_ID, HandoverService::PERMISSION_CREATE);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('general', $result['errors']);
}
public function testSubmitForReviewDeniedWhenStatusIsUnderReview(): void
{
$assigneeId = 99;
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn($this->handoverRow([
'assigned_to' => $assigneeId,
'status' => HandoverService::STATUS_UNDER_REVIEW,
]));
$service = $this->createService($repo);
$result = $service->submitForReview(self::TENANT_ID, 1, $assigneeId, HandoverService::PERMISSION_CREATE);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('status', $result['errors']);
}
public function testSubmitForReviewDeniedWhenStatusIsCompleted(): void
{
$assigneeId = 99;
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn($this->handoverRow([
'assigned_to' => $assigneeId,
'status' => HandoverService::STATUS_COMPLETED,
]));
$service = $this->createService($repo);
$result = $service->submitForReview(self::TENANT_ID, 1, $assigneeId, HandoverService::PERMISSION_CREATE);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('status', $result['errors']);
}
public function testSubmitForReviewSendsReviewRequestedEmail(): void
{
$assigneeId = 99;
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn($this->handoverRow([
'assigned_to' => $assigneeId,
'status' => HandoverService::STATUS_IN_PROGRESS,
'assigned_by_email' => 'manager@example.com',
]));
$repo->method('updateStatus')->willReturn(true);
$notificationService = $this->createMock(HandoverNotificationService::class);
$notificationService->expects($this->once())->method('notifyReviewRequested');
$service = $this->createService($repo, notificationService: $notificationService);
$service->submitForReview(self::TENANT_ID, 1, $assigneeId, HandoverService::PERMISSION_CREATE);
}
public function testSubmitForReviewCreatesRevision(): void
{
$assigneeId = 99;
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn($this->handoverRow([
'assigned_to' => $assigneeId,
'status' => HandoverService::STATUS_IN_PROGRESS,
]));
$repo->method('updateStatus')->willReturn(true);
$revisionService = $this->createMock(HandoverRevisionService::class);
$revisionService->expects($this->once())
->method('createRevision')
->with(
self::TENANT_ID,
1,
[],
HandoverService::STATUS_UNDER_REVIEW,
1,
$assigneeId,
HandoverRevisionService::CHANGE_TYPE_STATUS
);
$service = $this->createService($repo, revisionService: $revisionService);
$service->submitForReview(self::TENANT_ID, 1, $assigneeId, HandoverService::PERMISSION_CREATE);
}
// ── Resend notification tests ─────────────────────────────────────
public function testResendNotificationHappyPath(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn($this->handoverRow(['assigned_to' => 99]));
$notificationService = $this->createMock(HandoverNotificationService::class);
$notificationService->expects($this->once())->method('notifyAssigned');
$service = $this->createService($repo, notificationService: $notificationService);
$result = $service->resendNotification(self::TENANT_ID, 1, HandoverService::PERMISSION_MANAGE);
$this->assertTrue($result['ok']);
}
public function testResendNotificationDeniedForCreatePermission(): void
{
$notificationService = $this->createMock(HandoverNotificationService::class);
$notificationService->expects($this->never())->method('notifyAssigned');
$service = $this->createService(notificationService: $notificationService);
$result = $service->resendNotification(self::TENANT_ID, 1, HandoverService::PERMISSION_CREATE);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('general', $result['errors']);
}
public function testResendNotificationFailsWhenNoAssignee(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn($this->handoverRow(['assigned_to' => 0]));
$notificationService = $this->createMock(HandoverNotificationService::class);
$notificationService->expects($this->never())->method('notifyAssigned');
$service = $this->createService($repo, notificationService: $notificationService);
$result = $service->resendNotification(self::TENANT_ID, 1, HandoverService::PERMISSION_MANAGE);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('general', $result['errors']);
}
// ── Status label / variant tests ─────────────────────────────────
public function testStatusLabelUnderReview(): void
{
$this->assertNotEmpty(HandoverService::statusLabel(HandoverService::STATUS_UNDER_REVIEW));
}
public function testStatusVariantUnderReview(): void
{
$this->assertSame('info', HandoverService::statusVariant(HandoverService::STATUS_UNDER_REVIEW));
}
// ── Delete tests ─────────────────────────────────────────────────
public function testDeleteByIdsHappyPath(): void

View File

@@ -0,0 +1,34 @@
<!doctype html>
<html lang="de">
<body style="font-family: Arial, sans-serif; line-height: 1.5; color: #111;">
{{email_header}}
<p>Hallo {{assignee_name}},</p>
<p>dir wurde eine <strong>Übergabe</strong> zugewiesen. Bitte fülle das Protokoll vollständig aus.</p>
<table style="border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 4px 12px 4px 0; color: #555;">Kunde:</td>
<td style="padding: 4px 0;"><strong>{{debitor_name}}</strong></td>
</tr>
<tr>
<td style="padding: 4px 12px 4px 0; color: #555;">Domain:</td>
<td style="padding: 4px 0;">{{domain_url}}</td>
</tr>
<tr>
<td style="padding: 4px 12px 4px 0; color: #555;">Zugewiesen von:</td>
<td style="padding: 4px 0;">{{assigned_by_name}}</td>
</tr>
<tr>
<td style="padding: 4px 12px 4px 0; color: #555;">Fällig bis:</td>
<td style="padding: 4px 0;">{{due_date}}</td>
</tr>
</table>
<p>
<a href="{{handover_url}}" style="display: inline-block; padding: 10px 20px; background: #2563eb; color: #fff; text-decoration: none; border-radius: 4px;">
Übergabe öffnen
</a>
</p>
<p>Bitte fülle alle erforderlichen Felder aus und klicke anschließend auf <em>„Zur Prüfung einreichen"</em>.</p>
<p>Viele Grüße<br>{{app_name}}</p>
{{email_footer}}
</body>
</html>

View File

@@ -0,0 +1,17 @@
{{email_header_text}}
Hallo {{assignee_name}},
dir wurde eine Übergabe zugewiesen. Bitte fülle das Protokoll vollständig aus.
Kunde: {{debitor_name}}
Domain: {{domain_url}}
Zugewiesen von: {{assigned_by_name}}
Fällig bis: {{due_date}}
Übergabe öffnen: {{handover_url}}
Bitte fülle alle erforderlichen Felder aus und klicke anschließend auf „Zur Prüfung einreichen".
Viele Grüße
{{app_name}}
{{email_footer_text}}

View File

@@ -0,0 +1,29 @@
<!doctype html>
<html lang="de">
<body style="font-family: Arial, sans-serif; line-height: 1.5; color: #111;">
{{email_header}}
<p>Hallo {{manager_name}},</p>
<p><strong>{{assignee_name}}</strong> hat eine Übergabe zur Prüfung eingereicht.</p>
<table style="border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 4px 12px 4px 0; color: #555;">Kunde:</td>
<td style="padding: 4px 0;"><strong>{{debitor_name}}</strong></td>
</tr>
<tr>
<td style="padding: 4px 12px 4px 0; color: #555;">Domain:</td>
<td style="padding: 4px 0;">{{domain_url}}</td>
</tr>
<tr>
<td style="padding: 4px 12px 4px 0; color: #555;">Eingereicht von:</td>
<td style="padding: 4px 0;">{{assignee_name}}</td>
</tr>
</table>
<p>
<a href="{{handover_url}}" style="display: inline-block; padding: 10px 20px; background: #2563eb; color: #fff; text-decoration: none; border-radius: 4px;">
Übergabe prüfen
</a>
</p>
<p>Viele Grüße<br>{{app_name}}</p>
{{email_footer}}
</body>
</html>

View File

@@ -0,0 +1,14 @@
{{email_header_text}}
Hallo {{manager_name}},
{{assignee_name}} hat eine Übergabe zur Prüfung eingereicht.
Kunde: {{debitor_name}}
Domain: {{domain_url}}
Eingereicht von: {{assignee_name}}
Übergabe prüfen: {{handover_url}}
Viele Grüße
{{app_name}}
{{email_footer_text}}

View File

@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<body style="font-family: Arial, sans-serif; line-height: 1.5; color: #111;">
{{email_header}}
<p>Hi {{assignee_name}},</p>
<p>a <strong>handover protocol</strong> has been assigned to you. Please fill it in completely.</p>
<table style="border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 4px 12px 4px 0; color: #555;">Customer:</td>
<td style="padding: 4px 0;"><strong>{{debitor_name}}</strong></td>
</tr>
<tr>
<td style="padding: 4px 12px 4px 0; color: #555;">Domain:</td>
<td style="padding: 4px 0;">{{domain_url}}</td>
</tr>
<tr>
<td style="padding: 4px 12px 4px 0; color: #555;">Assigned by:</td>
<td style="padding: 4px 0;">{{assigned_by_name}}</td>
</tr>
<tr>
<td style="padding: 4px 12px 4px 0; color: #555;">Due date:</td>
<td style="padding: 4px 0;">{{due_date}}</td>
</tr>
</table>
<p>
<a href="{{handover_url}}" style="display: inline-block; padding: 10px 20px; background: #2563eb; color: #fff; text-decoration: none; border-radius: 4px;">
Open handover
</a>
</p>
<p>Please fill in all required fields and then click <em>"Submit for review"</em>.</p>
<p>Best regards<br>{{app_name}}</p>
{{email_footer}}
</body>
</html>

View File

@@ -0,0 +1,17 @@
{{email_header_text}}
Hi {{assignee_name}},
a handover protocol has been assigned to you. Please fill it in completely.
Customer: {{debitor_name}}
Domain: {{domain_url}}
Assigned by: {{assigned_by_name}}
Due date: {{due_date}}
Open handover: {{handover_url}}
Please fill in all required fields and then click "Submit for review".
Best regards
{{app_name}}
{{email_footer_text}}

View File

@@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<body style="font-family: Arial, sans-serif; line-height: 1.5; color: #111;">
{{email_header}}
<p>Hi {{manager_name}},</p>
<p><strong>{{assignee_name}}</strong> has submitted a handover for review.</p>
<table style="border-collapse: collapse; margin: 16px 0;">
<tr>
<td style="padding: 4px 12px 4px 0; color: #555;">Customer:</td>
<td style="padding: 4px 0;"><strong>{{debitor_name}}</strong></td>
</tr>
<tr>
<td style="padding: 4px 12px 4px 0; color: #555;">Domain:</td>
<td style="padding: 4px 0;">{{domain_url}}</td>
</tr>
<tr>
<td style="padding: 4px 12px 4px 0; color: #555;">Submitted by:</td>
<td style="padding: 4px 0;">{{assignee_name}}</td>
</tr>
</table>
<p>
<a href="{{handover_url}}" style="display: inline-block; padding: 10px 20px; background: #2563eb; color: #fff; text-decoration: none; border-radius: 4px;">
Review handover
</a>
</p>
<p>Best regards<br>{{app_name}}</p>
{{email_footer}}
</body>
</html>

View File

@@ -0,0 +1,14 @@
{{email_header_text}}
Hi {{manager_name}},
{{assignee_name}} has submitted a handover for review.
Customer: {{debitor_name}}
Domain: {{domain_url}}
Submitted by: {{assignee_name}}
Review handover: {{handover_url}}
Best regards
{{app_name}}
{{email_footer_text}}