feat(helpdesk): rework handover create wizard with mode selection

- Managers now see step 1: choose 'Create myself' or 'Assign to employee'
- Assign mode: select customer+product+employee → creates record immediately,
  sends invitation email, skips protocol step
- Self mode: existing 2-step flow unchanged
- Fix open/closed tab: pass view param in dataUrl so grid filters correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
aminovfariz
2026-06-01 14:50:30 +02:00
parent 1c1e82b352
commit 88bd7caae5
3 changed files with 240 additions and 80 deletions

View File

@@ -20,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);
@@ -33,25 +28,84 @@ $appSession = $sessionStore->all();
$sessionKey = 'module.helpdesk.handover_create';
$authService = app(AuthorizationService::class);
$userId0 = (int) ($appSession['user']['id'] ?? 0);
$canManage = $authService->authorize(HelpdeskAuthorizationPolicy::ABILITY_HANDOVERS_MANAGE, ['actor_user_id' => $userId0])->isAllowed();
$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();
// ── Step 1: Select debitor + product ─────────────────────────────────
// 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'
if ($step === 1) {
// ── 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) {
$session = app(SessionStoreInterface::class)->all();
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
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) $tenantId
(string) $currentTenantId
);
$tenantUsers = is_array($rows) ? $rows : [];
}
@@ -70,7 +124,6 @@ if ($step === 1) {
if (!Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), $createTarget, 'csrf_expired');
Router::redirect($createTarget);
return;
}
@@ -93,8 +146,13 @@ 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']),
@@ -104,9 +162,41 @@ if ($step === 1) {
'due_date' => trim($form['due_date']),
]);
Router::redirect(lurl('helpdesk/handovers/create') . '?step=2' . ($returnTarget ? '&return=' . rawurlencode($returnTarget) : ''));
// Assign mode: create record immediately and send invitation, skip protocol step
if ($canManage && $mode === 'assign') {
$result = $handoverService->create(
$currentTenantId,
trim($form['debitor_no']),
trim($form['debitor_name']),
trim($form['product_code']),
trim($form['domain_no']),
trim($form['domain_url']),
$currentUserId,
[]
);
return;
if (!($result['ok'] ?? false)) {
foreach ($result['errors'] ?? [] as $key => $msg) {
$errorBag->add($key, $msg);
}
} else {
$handoverId = $result['id'];
$dueDate = trim($form['due_date']) ?: null;
$handoverService->assign($currentTenantId, $handoverId, $form['assigned_to'], $currentUserId, $dueDate, HandoverService::PERMISSION_MANAGE);
$sessionStore->remove($sessionKey);
$editUrl = lurl('helpdesk/handovers/edit/' . $handoverId);
Flash::success(t('Handover created and assigned'), $editUrl, 'handover_created');
Router::redirect($editUrl);
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;
}
}
}
@@ -114,14 +204,13 @@ if ($step === 1) {
$validationSummaryErrors = $errorBag->toArray();
}
// ── Step 2: Fill protocol form ───────────────────────────────────────
// ── Protocol step: fill protocol form ────────────────────────────────
if ($step === 2) {
if ($step === $protocolStep) {
$wizardData = $sessionStore->get($sessionKey);
if (!is_array($wizardData) || empty($wizardData['debitor_no']) || empty($wizardData['product_code'])) {
Flash::error(t('Please complete step 1 first'), $createTarget, 'wizard_step_missing');
Router::redirect($createTarget);
return;
}
@@ -129,7 +218,6 @@ if ($step === 2) {
if ($product === null) {
Flash::error(t('Software product not found'), $createTarget, 'product_not_found');
Router::redirect($createTarget);
return;
}
@@ -144,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) {
@@ -164,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
);
@@ -183,21 +267,11 @@ if ($step === 2) {
$validationSummaryErrors = $result['errors'] ?? [];
} else {
$handoverId = $result['id'];
// Auto-assign if an assignee was selected (managers only)
$assignedTo = (int) ($wizardData['assigned_to'] ?? 0);
if ($assignedTo > 0 && $canManage) {
$dueDate = trim((string) ($wizardData['due_date'] ?? '')) ?: null;
$handoverService->assign($tenantId, $handoverId, $assignedTo, $userId, $dueDate, HandoverService::PERMISSION_MANAGE);
}
// Clear wizard session
$sessionStore->remove($sessionKey);
$editUrl = lurl('helpdesk/handovers/edit/' . $handoverId);
Flash::success(t('Handover created'), $editUrl, 'handover_created');
Router::redirect($editUrl);
return;
}
}

View File

@@ -10,13 +10,38 @@ $schemaFields = is_array($schemaFields ?? null) ? $schemaFields : [];
$fieldValues = is_array($fieldValues ?? null) ? $fieldValues : [];
$wizardData = is_array($wizardData ?? null) ? $wizardData : [];
$productDisplayName = $productDisplayName ?? '';
$steps = [
['label' => t('Customer & product'), 'number' => 1],
['label' => t('Protocol'), 'number' => 2],
];
$canManage = $canManage ?? false;
$mode = $mode ?? '';
$tenantUsers = is_array($tenantUsers ?? null) ? $tenantUsers : [];
$formStep = $canManage ? 2 : 1;
$protocolStep = $canManage ? 3 : 2;
// Build step indicator
if ($canManage) {
$steps = [
['label' => t('Type'), 'number' => 1],
['label' => t('Customer & product'), 'number' => 2],
['label' => t('Protocol'), 'number' => 3],
];
// For assign mode, step 3 is skipped — show only 2 steps
if ($mode === 'assign') {
$steps = [
['label' => t('Type'), 'number' => 1],
['label' => t('Customer & product'), 'number' => 2],
];
}
} else {
$steps = [
['label' => t('Customer & product'), 'number' => 1],
['label' => t('Protocol'), 'number' => 2],
];
}
// Map actual step number to display step number for indicator
$displayStep = $step;
if ($canManage && $mode === 'assign' && $step === 2) {
$displayStep = 2;
}
?>
<div class="app-wizard-content">
<div class="app-wizard-header">
@@ -29,13 +54,13 @@ $tenantUsers = is_array($tenantUsers ?? null) ? $tenantUsers : [];
<!-- 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>
@@ -44,12 +69,51 @@ $tenantUsers = is_array($tenantUsers ?? null) ? $tenantUsers : [];
<?php require templatePath('partials/app-details-validation-summary.phtml'); ?>
<?php endif; ?>
<!-- Step 1: Customer & Product -->
<?php if ($step === 1): ?>
<!-- Manager step 1: choose mode -->
<?php if ($canManage && $step === 1): ?>
<div class="app-wizard-card">
<form method="post" id="wizard-step-1">
<h2 class="app-wizard-card-heading"><?php e(t('Select customer and product')); ?></h2>
<p class="app-wizard-card-description"><?php e(t('Choose the customer and software product for this handover protocol.')); ?></p>
<form method="post" id="wizard-mode-select">
<h2 class="app-wizard-card-heading"><?php e(t('How do you want to proceed?')); ?></h2>
<div class="app-wizard-field-group">
<label style="display:flex;align-items:flex-start;gap:10px;cursor:pointer;padding:12px 14px;border:1px solid var(--border-color,#ddd);border-radius:6px;margin-bottom:8px;">
<input type="radio" name="mode" value="self" style="margin-top:3px;">
<span>
<strong><?php e(t('Create myself')); ?></strong><br>
<small style="color:var(--text-muted,#888)"><?php e(t('I will fill in the handover protocol myself.')); ?></small>
</span>
</label>
<label style="display:flex;align-items:flex-start;gap:10px;cursor:pointer;padding:12px 14px;border:1px solid var(--border-color,#ddd);border-radius:6px;">
<input type="radio" name="mode" value="assign" style="margin-top:3px;">
<span>
<strong><?php e(t('Assign to employee')); ?></strong><br>
<small style="color:var(--text-muted,#888)"><?php e(t('An employee will receive an invitation to fill in the protocol.')); ?></small>
</span>
</label>
<?php if (!empty($errors['mode'])): ?>
<p class="field-error"><?php e($errors['mode']); ?></p>
<?php endif; ?>
</div>
<?php \MintyPHP\Session::getCsrfInput(); ?>
<div class="app-wizard-actions">
<a href="<?php e(lurl($closeTarget)); ?>" role="button" class="outline secondary"><?php e(t('Cancel')); ?></a>
<button type="submit" class="primary"><?php e(t('Continue')); ?></button>
</div>
</form>
</div>
<!-- Form step: select customer + product (+ assignee for assign mode) -->
<?php elseif ($step === $formStep): ?>
<div class="app-wizard-card">
<form method="post" id="wizard-step-form">
<?php if ($mode === 'assign'): ?>
<h2 class="app-wizard-card-heading"><?php e(t('Select customer, product and employee')); ?></h2>
<?php else: ?>
<h2 class="app-wizard-card-heading"><?php e(t('Select customer and product')); ?></h2>
<p class="app-wizard-card-description"><?php e(t('Choose the customer and software product for this handover protocol.')); ?></p>
<?php endif; ?>
<div class="app-wizard-field-group">
<label><?php e(t('Customer')); ?> <span class="handover-form-required">*</span></label>
@@ -69,6 +133,9 @@ $tenantUsers = is_array($tenantUsers ?? null) ? $tenantUsers : [];
<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"
@@ -87,6 +154,9 @@ $tenantUsers = is_array($tenantUsers ?? null) ? $tenantUsers : [];
</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">
@@ -109,50 +179,66 @@ $tenantUsers = is_array($tenantUsers ?? null) ? $tenantUsers : [];
</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 ($canManage && $tenantUsers !== []): ?>
<?php if ($mode === 'assign'): ?>
<div class="app-wizard-field-group">
<label for="wizard-assigned-to"><?php e(t('Assign to (optional)')); ?></label>
<select id="wizard-assigned-to" name="assigned_to">
<option value="0"><?php e(t('Not assigned yet —')); ?></option>
<?php foreach ($tenantUsers as $u):
$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>
<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" id="wizard-due-date-group">
<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'] ?? ''); ?>"
>
<input type="date" id="wizard-due-date" name="due_date" value="<?php e($form['due_date'] ?? ''); ?>">
</div>
<?php endif; ?>
<?php \MintyPHP\Session::getCsrfInput(); ?>
<div class="app-wizard-actions">
<a href="<?php e(lurl($closeTarget)); ?>" role="button" class="outline secondary"><?php e(t('Cancel')); ?></a>
<button type="submit" class="primary"><?php e(t('Continue')); ?></button>
<?php if ($canManage): ?>
<a href="<?php e(lurl($createTarget)); ?>" role="button" class="outline secondary"><?php e(t('Back')); ?></a>
<?php else: ?>
<a href="<?php e(lurl($closeTarget)); ?>" role="button" class="outline secondary"><?php e(t('Cancel')); ?></a>
<?php endif; ?>
<?php if ($mode === 'assign'): ?>
<button type="submit" class="primary"><?php e(t('Create and assign')); ?></button>
<?php else: ?>
<button type="submit" class="primary"><?php e(t('Continue')); ?></button>
<?php endif; ?>
</div>
</form>
</div>
<!-- Step 2: Fill protocol -->
<?php elseif ($step === 2): ?>
<!-- Protocol step: fill protocol -->
<?php elseif ($step === $protocolStep): ?>
<div class="app-wizard-card">
<form method="post" id="wizard-step-2">
<?php
$backUrl = lurl($createTarget) . '?step=' . $formStep . ($canManage ? '&mode=' . urlencode($mode) : '');
?>
<form method="post" id="wizard-step-protocol">
<h2 class="app-wizard-card-heading"><?php e(t('Fill handover protocol')); ?></h2>
<p class="app-wizard-card-description">
<?php e($wizardData['debitor_name'] ?? ''); ?> &mdash; <?php e($productDisplayName); ?>
@@ -166,7 +252,7 @@ $tenantUsers = is_array($tenantUsers ?? null) ? $tenantUsers : [];
<?php \MintyPHP\Session::getCsrfInput(); ?>
<div class="app-wizard-actions">
<a href="<?php e(lurl($createTarget)); ?>" role="button" class="outline secondary"><?php e(t('Back')); ?></a>
<a href="<?php e($backUrl); ?>" role="button" class="outline secondary"><?php e(t('Back')); ?></a>
<button type="submit" class="primary"><?php e(t('Create handover')); ?></button>
</div>
</form>

View File

@@ -57,7 +57,7 @@ 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),
'gridLang' => gridLang(),
'gridSearch' => $searchConfig,
'filterSchema' => $clientFilterSchema,