feat(helpdesk): add handover protocol management

Implement handover protocols as a new entity in the helpdesk module,
allowing users to create fillable protocol records from admin-defined
software product schemas.

Key additions:
- DB migration (helpdesk_handovers table) with tenant scope
- HandoverService with status workflow (draft/in_progress/completed/archived)
- Three-tier permissions (view/create/manage)
- Two-step creation wizard (Stripe-style assistant)
- Grid.js list page with search and status filter
- Edit/detail page with aside metadata and status controls
- Reusable core autocomplete lookup component (app-lookup-field)
- Debitor lookup data endpoint for autocomplete
- Dynamic form rendering from schema snapshots
- 11 PHPUnit tests for HandoverService
- DE+EN i18n translations (48 keys each)

Also includes: PHPStan fixes (dead code removal, stale baseline cleanup),
software product edit title improvement, fieldset simplification,
and Stripe-style hover for schema preview.

Workflow: HD-HANDOVERS-001 (.agents/runs/HD-HANDOVERS-001/)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 20:42:41 +02:00
parent 57b7920098
commit a26c106083
34 changed files with 2572 additions and 91 deletions

View File

@@ -0,0 +1,147 @@
@layer components {
/*
* Autocomplete lookup field.
*
* A text input with a dropdown list of search results.
* Driven by data attributes via app-lookup-field.js.
*/
.app-lookup-field {
position: relative;
}
/* ── Input wrapper ──────────────────────────────────────────────────── */
.app-lookup-field-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.app-lookup-field-input {
padding-right: 2.5rem;
margin-bottom: 0;
}
.app-lookup-field-input[aria-expanded="true"] {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-color: var(--app-primary, #635bff);
}
/* ── Clear / spinner icons ──────────────────────────────────────────── */
.app-lookup-field-indicator {
position: absolute;
right: 0.625rem;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
pointer-events: none;
color: var(--app-muted-color);
}
.app-lookup-field-clear {
pointer-events: auto;
cursor: pointer;
background: none;
border: none;
padding: 0;
color: var(--app-muted-color);
font-size: var(--text-base, 1rem);
line-height: var(--leading-none);
transition: color 0.15s ease;
}
.app-lookup-field-clear:hover {
color: var(--app-color);
}
.app-lookup-field-spinner {
width: 1rem;
height: 1rem;
border: 2px solid var(--app-border);
border-top-color: var(--app-primary, #635bff);
border-radius: 50%;
animation: app-lookup-spin 0.6s linear infinite;
}
@keyframes app-lookup-spin {
to { transform: rotate(360deg); }
}
/* ── Dropdown ───────────────────────────────────────────────────────── */
.app-lookup-field-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
background: var(--app-card-background-color, #fff);
border: 1px solid var(--app-primary, #635bff);
border-top: none;
border-radius: 0 0 var(--app-border-radius) var(--app-border-radius);
box-shadow: 0 4px 12px rgb(0 0 0 / 0.08);
max-height: 16rem;
overflow-y: auto;
overscroll-behavior: contain;
list-style: none;
margin: 0;
padding: 0.25rem 0;
}
.app-lookup-field-dropdown[hidden] {
display: none;
}
/* ── Option items ───────────────────────────────────────────────────── */
.app-lookup-field-option {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: var(--text-sm, 0.875rem);
color: var(--app-color);
transition: background-color 0.1s ease;
}
.app-lookup-field-option:hover,
.app-lookup-field-option[aria-selected="true"] {
background: color-mix(in srgb, var(--app-primary, #635bff) 8%, transparent);
}
.app-lookup-field-option[aria-selected="true"] {
color: var(--app-primary, #635bff);
}
.app-lookup-field-option-label {
font-weight: var(--font-medium, 500);
}
.app-lookup-field-option-meta {
font-size: var(--text-xs, 0.75rem);
color: var(--app-muted-color);
}
/* ── Empty / error messages ─────────────────────────────────────────── */
.app-lookup-field-message {
padding: 0.75rem;
font-size: var(--text-sm, 0.875rem);
color: var(--app-muted-color);
text-align: center;
}
/* ── Selected state ─────────────────────────────────────────────────── */
.app-lookup-field-input[data-lookup-selected] {
background: var(--app-card-background-color, #fff);
}
}

View File

@@ -0,0 +1,329 @@
/**
* Generic autocomplete lookup field.
*
* Usage:
* <div data-app-lookup
* data-lookup-url="/helpdesk/debitor-lookup-data"
* data-lookup-min-chars="2"
* data-lookup-debounce="300"
* data-lookup-value-key="value"
* data-lookup-display-key="label"
* data-lookup-placeholder="Search...">
* <input type="hidden" name="debitor_no" data-lookup-value>
* <input type="hidden" name="debitor_name" data-lookup-display-value>
* </div>
*
* The component creates a visible text input, a dropdown, and indicator icons.
* On selection, it sets the hidden input values and fires a 'lookup:select' CustomEvent.
*/
/** @param {HTMLElement} container */
export function initLookupField(container) {
const url = container.dataset.lookupUrl || '';
const minChars = parseInt(container.dataset.lookupMinChars || '2', 10);
const debounceMs = parseInt(container.dataset.lookupDebounce || '300', 10);
const valueKey = container.dataset.lookupValueKey || 'value';
const displayKey = container.dataset.lookupDisplayKey || 'label';
const placeholder = container.dataset.lookupPlaceholder || '';
const initialDisplay = container.dataset.lookupInitialDisplay || '';
const initialValue = container.dataset.lookupInitialValue || '';
const hiddenValue = container.querySelector('[data-lookup-value]');
const hiddenDisplay = container.querySelector('[data-lookup-display-value]');
// Build DOM
container.classList.add('app-lookup-field');
const wrap = document.createElement('div');
wrap.className = 'app-lookup-field-input-wrap';
const input = document.createElement('input');
input.type = 'text';
input.className = 'app-lookup-field-input';
input.placeholder = placeholder;
input.setAttribute('role', 'combobox');
input.setAttribute('aria-expanded', 'false');
input.setAttribute('aria-autocomplete', 'list');
input.setAttribute('autocomplete', 'off');
const indicator = document.createElement('div');
indicator.className = 'app-lookup-field-indicator';
wrap.appendChild(input);
wrap.appendChild(indicator);
const dropdown = document.createElement('ul');
dropdown.className = 'app-lookup-field-dropdown';
dropdown.setAttribute('role', 'listbox');
dropdown.hidden = true;
container.appendChild(wrap);
container.appendChild(dropdown);
// State
let results = [];
let activeIndex = -1;
let debounceTimer = null;
let abortController = null;
let selected = false;
// Restore initial state
if (initialValue && initialDisplay) {
input.value = initialDisplay;
selected = true;
input.dataset.lookupSelected = '';
showClear();
}
// ── Indicator helpers ──────────────────────────────────────────────
function showSpinner() {
clearElement(indicator);
const spinner = document.createElement('div');
spinner.className = 'app-lookup-field-spinner';
indicator.appendChild(spinner);
}
function showClear() {
clearElement(indicator);
const button = document.createElement('button');
button.type = 'button';
button.className = 'app-lookup-field-clear';
button.setAttribute('aria-label', 'Clear');
button.addEventListener('click', clearSelection);
const icon = document.createElement('i');
icon.className = 'bi bi-x-lg';
button.appendChild(icon);
indicator.appendChild(button);
}
function hideIndicator() {
clearElement(indicator);
}
// ── Dropdown helpers ───────────────────────────────────────────────
function openDropdown() {
dropdown.hidden = false;
input.setAttribute('aria-expanded', 'true');
}
function closeDropdown() {
dropdown.hidden = true;
input.setAttribute('aria-expanded', 'false');
activeIndex = -1;
}
function clearElement(el) {
while (el.firstChild) el.removeChild(el.firstChild);
}
function renderResults(items) {
results = items;
activeIndex = -1;
clearElement(dropdown);
if (items.length === 0) {
const msg = document.createElement('li');
msg.className = 'app-lookup-field-message';
msg.textContent = container.dataset.lookupEmptyText || 'No results';
dropdown.appendChild(msg);
openDropdown();
return;
}
items.forEach((item, index) => {
const li = document.createElement('li');
li.className = 'app-lookup-field-option';
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', 'false');
li.dataset.index = index;
const label = document.createElement('span');
label.className = 'app-lookup-field-option-label';
label.textContent = item[displayKey] || '';
li.appendChild(label);
if (item.meta) {
const meta = document.createElement('span');
meta.className = 'app-lookup-field-option-meta';
meta.textContent = item.meta;
li.appendChild(meta);
}
li.addEventListener('mousedown', (e) => {
e.preventDefault();
selectItem(index);
});
li.addEventListener('mouseenter', () => {
setActive(index);
});
dropdown.appendChild(li);
});
openDropdown();
}
function renderError() {
clearElement(dropdown);
const msg = document.createElement('li');
msg.className = 'app-lookup-field-message';
msg.textContent = container.dataset.lookupErrorText || 'Search failed';
dropdown.appendChild(msg);
openDropdown();
}
function setActive(index) {
const options = dropdown.querySelectorAll('.app-lookup-field-option');
options.forEach((opt, i) => {
opt.setAttribute('aria-selected', i === index ? 'true' : 'false');
});
activeIndex = index;
// Scroll active into view
const active = options[index];
if (active) {
active.scrollIntoView({ block: 'nearest' });
}
}
// ── Selection ──────────────────────────────────────────────────────
function selectItem(index) {
const item = results[index];
if (!item) return;
input.value = item[displayKey] || '';
input.dataset.lookupSelected = '';
selected = true;
if (hiddenValue) hiddenValue.value = item[valueKey] || '';
if (hiddenDisplay) hiddenDisplay.value = item[displayKey] || '';
closeDropdown();
showClear();
container.dispatchEvent(new CustomEvent('lookup:select', {
detail: item,
bubbles: true,
}));
}
function clearSelection() {
input.value = '';
delete input.dataset.lookupSelected;
selected = false;
if (hiddenValue) hiddenValue.value = '';
if (hiddenDisplay) hiddenDisplay.value = '';
hideIndicator();
input.focus();
container.dispatchEvent(new CustomEvent('lookup:clear', { bubbles: true }));
}
// ── Fetch ──────────────────────────────────────────────────────────
function fetchResults(query) {
if (abortController) abortController.abort();
abortController = new AbortController();
showSpinner();
const separator = url.includes('?') ? '&' : '?';
fetch(url + separator + 'q=' + encodeURIComponent(query), {
signal: abortController.signal,
headers: { 'Accept': 'application/json' },
})
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
return res.json();
})
.then((data) => {
hideIndicator();
const items = Array.isArray(data) ? data : (data.data || []);
renderResults(items);
})
.catch((err) => {
if (err.name === 'AbortError') return;
hideIndicator();
renderError();
});
}
// ── Input events ───────────────────────────────────────────────────
input.addEventListener('input', () => {
if (selected) {
selected = false;
delete input.dataset.lookupSelected;
if (hiddenValue) hiddenValue.value = '';
if (hiddenDisplay) hiddenDisplay.value = '';
}
const query = input.value.trim();
if (query.length < minChars) {
closeDropdown();
hideIndicator();
return;
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => fetchResults(query), debounceMs);
});
input.addEventListener('keydown', (e) => {
if (dropdown.hidden) return;
const options = dropdown.querySelectorAll('.app-lookup-field-option');
if (!options.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive(activeIndex < options.length - 1 ? activeIndex + 1 : 0);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive(activeIndex > 0 ? activeIndex - 1 : options.length - 1);
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0) {
selectItem(activeIndex);
}
} else if (e.key === 'Escape') {
e.preventDefault();
closeDropdown();
}
});
input.addEventListener('focus', () => {
if (input.value.trim().length >= minChars && !selected && results.length > 0) {
openDropdown();
}
});
input.addEventListener('blur', () => {
// Delay to allow mousedown on option to fire first
setTimeout(() => closeDropdown(), 150);
});
return { input, clearSelection };
}
// ── Auto-init ──────────────────────────────────────────────────────
function initAll() {
document.querySelectorAll('[data-app-lookup]').forEach((el) => {
if (el.dataset.lookupInitialized) return;
el.dataset.lookupInitialized = '1';
initLookupField(el);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initAll);
} else {
initAll();
}