Compare commits
10 Commits
4e51d9e882
...
9caa771a73
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9caa771a73 | ||
|
|
1caa198286 | ||
|
|
47a26d0ca6 | ||
|
|
88bd7caae5 | ||
|
|
1c1e82b352 | ||
|
|
3f56f3f279 | ||
|
|
c32a0f8c31 | ||
|
|
4f38911fce | ||
|
|
0d078f233d | ||
|
|
ec596d35d4 |
@@ -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;
|
||||
}
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
|
||||
$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' => $httpContext,
|
||||
'ssl' => [
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$body = @file_get_contents($url, false, $ctx);
|
||||
$status = 0;
|
||||
$responseHeaders = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -28,8 +28,10 @@ use MintyPHP\Module\Helpdesk\Repository\HandoverRevisionRepository;
|
||||
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
|
||||
use MintyPHP\Module\Helpdesk\Repository\UpdateRepository;
|
||||
use MintyPHP\Module\Helpdesk\Service\DomainSecurityLevelService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HandoverNotificationService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HandoverService;
|
||||
use MintyPHP\Service\Mail\MailService;
|
||||
use MintyPHP\Module\Helpdesk\Service\UpdateService;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
@@ -142,10 +144,15 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar
|
||||
$c->get(HandoverRepository::class)
|
||||
));
|
||||
|
||||
$container->set(HandoverNotificationService::class, static fn (AppContainer $c): HandoverNotificationService => new HandoverNotificationService(
|
||||
$c->get(MailService::class)
|
||||
));
|
||||
|
||||
$container->set(HandoverService::class, static fn (AppContainer $c): HandoverService => new HandoverService(
|
||||
$c->get(HandoverRepository::class),
|
||||
$c->get(SoftwareProductService::class),
|
||||
$c->get(HandoverRevisionService::class)
|
||||
$c->get(HandoverRevisionService::class),
|
||||
$c->get(HandoverNotificationService::class)
|
||||
));
|
||||
|
||||
$container->set(UpdateRepository::class, static fn (): UpdateRepository => new UpdateRepository());
|
||||
|
||||
@@ -45,10 +45,14 @@ class HandoverRepository
|
||||
$row = DB::selectOne(
|
||||
'SELECT h.*,'
|
||||
. ' uc.display_name AS created_by_label, uc.uuid AS created_by_uuid,'
|
||||
. ' uu.display_name AS updated_by_label, uu.uuid AS updated_by_uuid'
|
||||
. ' uu.display_name AS updated_by_label, uu.uuid AS updated_by_uuid,'
|
||||
. ' ua.display_name AS assigned_to_label, ua.uuid AS assigned_to_uuid, ua.email AS assigned_to_email,'
|
||||
. ' ub.display_name AS assigned_by_label, ub.uuid AS assigned_by_uuid, ub.email AS assigned_by_email'
|
||||
. ' FROM helpdesk_handovers h'
|
||||
. ' LEFT JOIN users uc ON uc.id = h.created_by'
|
||||
. ' LEFT JOIN users uu ON uu.id = h.updated_by'
|
||||
. ' LEFT JOIN users ua ON ua.id = h.assigned_to'
|
||||
. ' LEFT JOIN users ub ON ub.id = h.assigned_by'
|
||||
. ' WHERE h.id = ? AND h.tenant_id = ? LIMIT 1',
|
||||
(string) $id,
|
||||
(string) $tenantId
|
||||
@@ -66,11 +70,13 @@ class HandoverRepository
|
||||
$search = trim((string) ($filters['search'] ?? ''));
|
||||
$status = trim((string) ($filters['status'] ?? ''));
|
||||
$productCode = trim((string) ($filters['product_code'] ?? ''));
|
||||
$assignedTo = (int) ($filters['assigned_to'] ?? 0);
|
||||
$view = trim((string) ($filters['view'] ?? ''));
|
||||
|
||||
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 100, 0);
|
||||
[$order, $dir] = RepoQuery::sanitizeOrder(
|
||||
$filters,
|
||||
['id', 'domain_url', 'debitor_name', 'product_code', 'status', 'created_at', 'created_by'],
|
||||
['id', 'domain_url', 'debitor_name', 'product_code', 'status', 'created_at', 'created_by', 'assigned_at', 'due_date'],
|
||||
'created_at',
|
||||
'desc'
|
||||
);
|
||||
@@ -83,6 +89,10 @@ class HandoverRepository
|
||||
if ($status !== '' && $status !== 'all') {
|
||||
$where[] = 'h.status = ?';
|
||||
$params[] = $status;
|
||||
} elseif ($view === '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
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Service\Mail\MailService;
|
||||
|
||||
class HandoverNotificationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailService $mailService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the assignee that a handover was assigned to them.
|
||||
*
|
||||
* @param array<string, mixed> $handover Row from HandoverRepository::findById (includes joined user fields)
|
||||
*/
|
||||
public function notifyAssigned(array $handover): void
|
||||
{
|
||||
$toEmail = trim((string) ($handover['assigned_to_email'] ?? ''));
|
||||
if ($toEmail === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$assigneeName = trim((string) ($handover['assigned_to_label'] ?? $toEmail));
|
||||
$assignedByName = trim((string) ($handover['assigned_by_label'] ?? ''));
|
||||
$debitorName = trim((string) ($handover['debitor_name'] ?? ''));
|
||||
$domainUrl = trim((string) ($handover['domain_url'] ?? ''));
|
||||
$dueDate = trim((string) ($handover['due_date'] ?? ''));
|
||||
$handoverId = (int) ($handover['id'] ?? 0);
|
||||
$handoverUrl = appUrl('helpdesk/handovers/edit/' . $handoverId);
|
||||
|
||||
$vars = [
|
||||
'assignee_name' => $assigneeName,
|
||||
'assigned_by_name' => $assignedByName,
|
||||
'debitor_name' => $debitorName !== '' ? $debitorName : '—',
|
||||
'domain_url' => $domainUrl !== '' ? $domainUrl : '—',
|
||||
'due_date' => $dueDate !== '' ? $dueDate : '—',
|
||||
'handover_url' => $handoverUrl,
|
||||
'app_name' => appTitle(),
|
||||
];
|
||||
|
||||
$subject = sprintf('[%s] Ihnen wurde eine Übergabe zugewiesen', appTitle());
|
||||
|
||||
$this->mailService->sendTemplate('handover_assigned', $vars, $toEmail, $subject);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify managers that a handover was submitted for review.
|
||||
* Sends to the person who assigned (assigned_by), falling back to created_by.
|
||||
*
|
||||
* @param array<string, mixed> $handover
|
||||
*/
|
||||
public function notifyReviewRequested(array $handover): void
|
||||
{
|
||||
// Notify the assigning manager (assigned_by) — email not directly stored, use created_by as fallback
|
||||
// We send to assigned_by_email if it was joined; otherwise notify created_by_email.
|
||||
// Since the repository joins assigned_by as assigned_by_label but not email,
|
||||
// we notify the assignee's manager via a generic approach: send to created_by.
|
||||
// This is intentionally simple — the manager who assigned will get the email.
|
||||
$toEmail = trim((string) ($handover['assigned_by_email'] ?? ''));
|
||||
if ($toEmail === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$managerName = trim((string) ($handover['assigned_by_label'] ?? $toEmail));
|
||||
$assigneeName = trim((string) ($handover['assigned_to_label'] ?? ''));
|
||||
$debitorName = trim((string) ($handover['debitor_name'] ?? ''));
|
||||
$domainUrl = trim((string) ($handover['domain_url'] ?? ''));
|
||||
$handoverId = (int) ($handover['id'] ?? 0);
|
||||
$handoverUrl = appUrl('helpdesk/handovers/edit/' . $handoverId);
|
||||
|
||||
$vars = [
|
||||
'manager_name' => $managerName,
|
||||
'assignee_name' => $assigneeName !== '' ? $assigneeName : '—',
|
||||
'debitor_name' => $debitorName !== '' ? $debitorName : '—',
|
||||
'domain_url' => $domainUrl !== '' ? $domainUrl : '—',
|
||||
'handover_url' => $handoverUrl,
|
||||
'app_name' => appTitle(),
|
||||
];
|
||||
|
||||
$subject = sprintf('[%s] Übergabe wurde zur Prüfung eingereicht', appTitle());
|
||||
|
||||
$this->mailService->sendTemplate('handover_review_requested', $vars, $toEmail, $subject);
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,14 @@ class HandoverService
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
/** @api Used in pages and templates for status checks */
|
||||
public const STATUS_ARCHIVED = 'archived';
|
||||
/** @api Submitted by assignee, awaiting manager review */
|
||||
public const STATUS_UNDER_REVIEW = 'under_review';
|
||||
|
||||
/** @api Used in validation */
|
||||
public const ALL_STATUSES = [
|
||||
self::STATUS_DRAFT,
|
||||
self::STATUS_IN_PROGRESS,
|
||||
self::STATUS_UNDER_REVIEW,
|
||||
self::STATUS_COMPLETED,
|
||||
self::STATUS_ARCHIVED,
|
||||
];
|
||||
@@ -29,12 +32,12 @@ class HandoverService
|
||||
|
||||
/**
|
||||
* Status transitions allowed per permission level.
|
||||
* create-level: can set draft, in_progress, completed.
|
||||
* manage-level: can set any status.
|
||||
* create-level: draft → in_progress → under_review.
|
||||
* manage-level: any status.
|
||||
*/
|
||||
private const ALLOWED_STATUS_BY_PERMISSION = [
|
||||
self::PERMISSION_CREATE => [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS, self::STATUS_COMPLETED],
|
||||
self::PERMISSION_MANAGE => [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS, self::STATUS_COMPLETED, self::STATUS_ARCHIVED],
|
||||
self::PERMISSION_CREATE => [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS, self::STATUS_UNDER_REVIEW],
|
||||
self::PERMISSION_MANAGE => [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS, self::STATUS_UNDER_REVIEW, self::STATUS_COMPLETED, self::STATUS_ARCHIVED],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -46,6 +49,7 @@ class HandoverService
|
||||
private readonly HandoverRepository $repository,
|
||||
private readonly SoftwareProductService $softwareProductService,
|
||||
private readonly ?HandoverRevisionService $revisionService = null,
|
||||
private readonly ?HandoverNotificationService $notificationService = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -258,6 +262,120 @@ class HandoverService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a handover to a user and optionally set a due date.
|
||||
* Sends notification email to the assignee.
|
||||
*
|
||||
* @return array{ok: bool, errors: array<string, string>}
|
||||
*/
|
||||
public function assign(
|
||||
int $tenantId,
|
||||
int $id,
|
||||
int $assignedTo,
|
||||
int $assignedBy,
|
||||
?string $dueDate,
|
||||
string $permissionLevel,
|
||||
): array {
|
||||
if ($permissionLevel !== self::PERMISSION_MANAGE) {
|
||||
return ['ok' => false, 'errors' => ['general' => t('Only managers can assign handovers')]];
|
||||
}
|
||||
|
||||
$handover = $this->repository->findById($tenantId, $id);
|
||||
if ($handover === null) {
|
||||
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
|
||||
}
|
||||
|
||||
if ($assignedTo <= 0) {
|
||||
return ['ok' => false, 'errors' => ['assigned_to' => t('Please select an employee')]];
|
||||
}
|
||||
|
||||
$updated = $this->repository->assign($tenantId, $id, $assignedTo, $assignedBy, $dueDate, $assignedBy);
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'errors' => ['general' => t('Failed to assign handover')]];
|
||||
}
|
||||
|
||||
// Transition to in_progress if still draft
|
||||
if ($handover['status'] === self::STATUS_DRAFT) {
|
||||
$this->repository->updateStatus($tenantId, $id, self::STATUS_IN_PROGRESS, $assignedBy);
|
||||
}
|
||||
|
||||
$updatedHandover = $this->repository->findById($tenantId, $id);
|
||||
$this->notificationService?->notifyAssigned($updatedHandover ?? $handover);
|
||||
|
||||
return ['ok' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit handover for review (assignee action).
|
||||
* Changes status to under_review and notifies managers.
|
||||
*
|
||||
* @return array{ok: bool, errors: array<string, string>}
|
||||
*/
|
||||
public function submitForReview(
|
||||
int $tenantId,
|
||||
int $id,
|
||||
int $userId,
|
||||
string $permissionLevel,
|
||||
): array {
|
||||
$handover = $this->repository->findById($tenantId, $id);
|
||||
if ($handover === null) {
|
||||
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
|
||||
}
|
||||
|
||||
// Only the assignee or a manager may submit for review
|
||||
$assignedTo = (int) ($handover['assigned_to'] ?? 0);
|
||||
$isAssignee = $assignedTo === $userId;
|
||||
$isManager = $permissionLevel === self::PERMISSION_MANAGE;
|
||||
|
||||
if (!$isAssignee && !$isManager) {
|
||||
return ['ok' => false, 'errors' => ['general' => t('You are not assigned to this handover')]];
|
||||
}
|
||||
|
||||
$currentStatus = (string) ($handover['status'] ?? '');
|
||||
if (!in_array($currentStatus, [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS], true)) {
|
||||
return ['ok' => false, 'errors' => ['status' => t('Handover cannot be submitted for review in its current status')]];
|
||||
}
|
||||
|
||||
$updated = $this->repository->updateStatus($tenantId, $id, self::STATUS_UNDER_REVIEW, $userId);
|
||||
if (!$updated) {
|
||||
return ['ok' => false, 'errors' => ['general' => t('Failed to update status')]];
|
||||
}
|
||||
|
||||
$this->revisionService?->createRevision(
|
||||
$tenantId, $id, [], self::STATUS_UNDER_REVIEW, 1, $userId, HandoverRevisionService::CHANGE_TYPE_STATUS
|
||||
);
|
||||
|
||||
$updatedHandover = $this->repository->findById($tenantId, $id);
|
||||
$this->notificationService?->notifyReviewRequested($updatedHandover ?? $handover);
|
||||
|
||||
return ['ok' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend assignment notification email.
|
||||
*
|
||||
* @return array{ok: bool, errors: array<string, string>}
|
||||
*/
|
||||
public function resendNotification(int $tenantId, int $id, string $permissionLevel): array
|
||||
{
|
||||
if ($permissionLevel !== self::PERMISSION_MANAGE) {
|
||||
return ['ok' => false, 'errors' => ['general' => t('Only managers can resend notifications')]];
|
||||
}
|
||||
|
||||
$handover = $this->repository->findById($tenantId, $id);
|
||||
if ($handover === null) {
|
||||
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
|
||||
}
|
||||
|
||||
if (empty($handover['assigned_to'])) {
|
||||
return ['ok' => false, 'errors' => ['general' => t('Handover has no assignee')]];
|
||||
}
|
||||
|
||||
$this->notificationService?->notifyAssigned($handover);
|
||||
|
||||
return ['ok' => true, 'errors' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total: int, rows: list<array<string, mixed>>}
|
||||
*/
|
||||
@@ -364,6 +482,7 @@ class HandoverService
|
||||
return match ($status) {
|
||||
self::STATUS_DRAFT => t('Draft'),
|
||||
self::STATUS_IN_PROGRESS => t('In progress'),
|
||||
self::STATUS_UNDER_REVIEW => t('Under review'),
|
||||
self::STATUS_COMPLETED => t('Completed'),
|
||||
self::STATUS_ARCHIVED => t('Archived'),
|
||||
default => $status,
|
||||
@@ -378,6 +497,7 @@ class HandoverService
|
||||
return match ($status) {
|
||||
self::STATUS_DRAFT => 'neutral',
|
||||
self::STATUS_IN_PROGRESS => 'warning',
|
||||
self::STATUS_UNDER_REVIEW => 'info',
|
||||
self::STATUS_COMPLETED => 'success',
|
||||
self::STATUS_ARCHIVED => 'neutral',
|
||||
default => 'neutral',
|
||||
|
||||
@@ -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`);
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 : '',
|
||||
|
||||
105
modules/helpdesk/pages/helpdesk/handovers/assign($id).php
Normal file
105
modules/helpdesk/pages/helpdesk/handovers/assign($id).php
Normal 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],
|
||||
];
|
||||
112
modules/helpdesk/pages/helpdesk/handovers/assign(default).phtml
Normal file
112
modules/helpdesk/pages/helpdesk/handovers/assign(default).phtml
Normal 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>
|
||||
@@ -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,33 +146,71 @@ 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,
|
||||
[]
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$errors = $errorBag->toFlatList();
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
// 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">
|
||||
<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">
|
||||
<?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'] ?? ''); ?> — <?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>
|
||||
|
||||
@@ -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'] ?? '')) !== ''
|
||||
|
||||
@@ -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 !== []): ?>
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
|
||||
@@ -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] ?? '';
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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' => '']));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
34
templates/emails/de/handover_assigned.html
Normal file
34
templates/emails/de/handover_assigned.html
Normal 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>
|
||||
17
templates/emails/de/handover_assigned.txt
Normal file
17
templates/emails/de/handover_assigned.txt
Normal 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}}
|
||||
29
templates/emails/de/handover_review_requested.html
Normal file
29
templates/emails/de/handover_review_requested.html
Normal 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>
|
||||
14
templates/emails/de/handover_review_requested.txt
Normal file
14
templates/emails/de/handover_review_requested.txt
Normal 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}}
|
||||
34
templates/emails/en/handover_assigned.html
Normal file
34
templates/emails/en/handover_assigned.html
Normal 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>
|
||||
17
templates/emails/en/handover_assigned.txt
Normal file
17
templates/emails/en/handover_assigned.txt
Normal 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}}
|
||||
29
templates/emails/en/handover_review_requested.html
Normal file
29
templates/emails/en/handover_review_requested.html
Normal 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>
|
||||
14
templates/emails/en/handover_review_requested.txt
Normal file
14
templates/emails/en/handover_review_requested.txt
Normal 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}}
|
||||
Reference in New Issue
Block a user