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:
329
web/js/components/app-lookup-field.js
Normal file
329
web/js/components/app-lookup-field.js
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user