diff --git a/app/operator_panel/autocomplete.php b/app/operator_panel/autocomplete.php new file mode 100644 index 000000000..68c3d6a3c --- /dev/null +++ b/app/operator_panel/autocomplete.php @@ -0,0 +1,183 @@ + + Portions created by the Initial Developer are Copyright (C) 2025 + the Initial Developer. All Rights Reserved. + + Contributor(s): + Mark J Crane +*/ + +// Includes files + require_once dirname(__DIR__, 2) . "/resources/require.php"; + require_once "resources/check_auth.php"; + +// Check permissions + if (!permission_exists('operator_panel_view')) { + exit; + } + +// Global variables + global $database; + +// Search term + $term = $_GET['term'] ?? ''; + +// If term contains spaces, break into array + if (substr_count($term, ' ') > 0) { + $terms = explode(' ', $term); + } + else { + $terms[] = $term; + } + +// Add multi-lingual support + $language = new text; + $text = $language->get(); + +// Retrieve current user's assigned groups (uuids) + $user_group_uuids = []; + foreach ($_SESSION['groups'] as $group_data) { + $user_group_uuids[] = $group_data['group_uuid']; + } + // Add user's uuid to group uuid list to include private (non-shared) contacts + $user_group_uuids[] = $_SESSION["user_uuid"]; + +// Get extensions list + $sql = "select \n"; + $sql .= "e.extension, \n"; + $sql .= "e.effective_caller_id_name, \n"; + $sql .= "concat(e.directory_first_name, ' ', e.directory_last_name) as directory_full_name \n"; + $sql .= "from \n"; + $sql .= "v_extensions e \n"; + $sql .= "where \n"; + foreach ($terms as $index => $term) { + $sql .= "( \n"; + $sql .= " lower(e.effective_caller_id_name) like lower(:term) or \n"; + $sql .= " lower(e.outbound_caller_id_name) like lower(:term) or \n"; + $sql .= " lower(concat(e.directory_first_name, ' ', e.directory_last_name)) like lower(:term) or \n"; + $sql .= " lower(e.description) like lower(:term) or \n"; + $sql .= " lower(e.call_group) like lower(:term) or \n"; + $sql .= " e.extension like :term \n"; + $sql .= ") \n"; + if ($index + 1 < sizeof($terms)) { + $sql .= " and \n"; + } + } + $sql .= "and e.domain_uuid = :domain_uuid \n"; + $sql .= "and e.enabled = 'true' \n"; + $sql .= "order by \n"; + $sql .= "directory_full_name asc, \n"; + $sql .= "e.effective_caller_id_name asc \n"; + $parameters['term'] = '%'.$term.'%'; + $parameters['domain_uuid'] = $_SESSION['domain_uuid']; + $result = $database->select($sql, $parameters, 'all'); + unset ($parameters, $sql); + + $suggestions = []; + if (is_array($result)) { + foreach($result as $row) { + $values = []; + $dir_name = trim($row['directory_full_name'] ?? ''); + $cid_name = trim($row['effective_caller_id_name'] ?? ''); + if ($dir_name !== '') { $values[] = $dir_name; } + if ($cid_name !== '') { $values[] = $cid_name; } + + $label = implode(', ', $values)." @ ".$row['extension']; + $suggestions[] = [ "label" => $label, "value" => $row['extension'] ]; + } + } + +// Get contacts list + $sql = "select \n"; + $sql .= "c.contact_organization, \n"; + $sql .= "c.contact_name_given, \n"; + $sql .= "c.contact_name_middle, \n"; + $sql .= "c.contact_name_family, \n"; + $sql .= "c.contact_nickname, \n"; + $sql .= "p.phone_number, \n"; + $sql .= "p.phone_label \n"; + $sql .= "from \n"; + $sql .= "v_contacts as c, \n"; + $sql .= "v_contact_phones as p \n"; + $sql .= "where \n"; + foreach ($terms as $index => $term) { + $sql .= "( \n"; + $sql .= " lower(c.contact_organization) like lower(:term) or \n"; + $sql .= " lower(c.contact_name_given) like lower(:term) or \n"; + $sql .= " lower(c.contact_name_middle) like lower(:term) or \n"; + $sql .= " lower(c.contact_name_family) like lower(:term) or \n"; + $sql .= " lower(c.contact_nickname) like lower(:term) or \n"; + $sql .= " p.phone_number like :term \n"; + $sql .= ") \n"; + if ($index + 1 < sizeof($terms)) { + $sql .= " and \n"; + } + } + $sql .= "and c.contact_uuid = p.contact_uuid \n"; + $sql .= "and c.domain_uuid = :domain_uuid \n"; + if (sizeof($user_group_uuids) > 0) { + $sql .= "and ( \n"; // Only contacts assigned to current user's group(s) and those not assigned to any group + $sql .= " c.contact_uuid in ( \n"; + $sql .= " select contact_uuid from v_contact_groups \n"; + $sql .= " where group_uuid in ('".implode("','", $user_group_uuids)."') \n"; + $sql .= " and domain_uuid = :domain_uuid \n"; + $sql .= " ) \n"; + $sql .= " or \n"; + $sql .= " c.contact_uuid not in ( \n"; + $sql .= " select contact_uuid from v_contact_groups \n"; + $sql .= " where domain_uuid = :domain_uuid \n"; + $sql .= " ) \n"; + $sql .= ") \n"; + } + $sql .= "and p.phone_type_voice = 1 \n"; + $sql .= "order by \n"; + $sql .= "contact_organization desc, \n"; + $sql .= "contact_name_given asc, \n"; + $sql .= "contact_name_family asc \n"; + $parameters['term'] = '%'.$term.'%'; + $parameters['domain_uuid'] = $_SESSION['domain_uuid']; + $result = $database->select($sql, $parameters, 'all'); + unset ($parameters, $sql); + + if (is_array($result)) { + foreach($result as $row) { + $values = []; + $org = trim($row['contact_organization'] ?? ''); + if ($org !== '') { $values[] = $org; } + + $names = ''; + if (trim($row['contact_name_given'] ?? '') !== '') { $names = trim($row['contact_name_given']); } + if (trim($row['contact_name_middle'] ?? '') !== '') { $names .= ($names !== '' ? ' ' : '').trim($row['contact_name_middle']); } + if (trim($row['contact_name_family'] ?? '') !== '') { $names .= ($names !== '' ? ' ' : '').trim($row['contact_name_family']); } + if ($names !== '') { $values[] = $names; } + + $nickname = trim($row['contact_nickname'] ?? ''); + if ($nickname !== '') { $values[] = $nickname; } + + $phone_label = (trim($row['phone_label'] ?? '') !== '') ? " (".trim($row['phone_label']).")" : ''; + $prefix = !empty($values) ? implode(', ', $values).' ' : ''; + $label = $prefix."@ ".$row['phone_number'].$phone_label; + $suggestions[] = [ "label" => $label, "value" => $row['phone_number'] ]; + } + } + +// Output suggestions as JSON + header('Content-Type: application/json'); + echo json_encode($suggestions); diff --git a/app/operator_panel/resources/css/operator_panel.css b/app/operator_panel/resources/css/operator_panel.css index b4031afad..f0de38041 100644 --- a/app/operator_panel/resources/css/operator_panel.css +++ b/app/operator_panel/resources/css/operator_panel.css @@ -1,3 +1,49 @@ +/* Contact Autocomplete Dropdown */ +.op-autocomplete-list { + position: fixed; + margin: 0; + padding: 0; + background: #fff; + border: 1px solid #d0d7de; + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0,0,0,.15); + list-style: none; + z-index: 9995; + max-height: 300px; + overflow-y: auto; + display: none; +} + +.op-autocomplete-item { + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid #f0f0f0; + transition: background-color .15s; +} + +.op-autocomplete-item:last-child { + border-bottom: none; +} + +.op-autocomplete-item:hover { + background-color: #f6f8fa; +} + +.op-autocomplete-item.op-autocomplete-selected { + background-color: #e7f1ff; + color: #0d6efd; +} + +.op-autocomplete-label { + font-size: 13px; + color: #24292f; + word-break: break-word; +} + +.op-autocomplete-item.op-autocomplete-selected .op-autocomplete-label { + color: #0d6efd; + font-weight: 500; +} /* Active Operator Panel — extension blocks */ .op-ext-grid { display: flex; diff --git a/app/operator_panel/resources/javascript/operator_panel.js b/app/operator_panel/resources/javascript/operator_panel.js index d8b51878e..e11bd92d5 100644 --- a/app/operator_panel/resources/javascript/operator_panel.js +++ b/app/operator_panel/resources/javascript/operator_panel.js @@ -2299,9 +2299,209 @@ function open_transfer_modal(uuid, source_ext) { if (!dlg) return; dlg.showModal(); + init_contact_autocomplete(dest_field); setTimeout(() => dest_field.focus(), 100); } +/** + * Initialize contact autocomplete for an input field. + * @param {HTMLInputElement} input_field The input element to attach autocomplete to + */ +function init_contact_autocomplete(input_field) { + if (!input_field) return; + + // Assign a stable ID so we can scope cleanup to this specific input + if (!input_field.dataset.acId) input_field.dataset.acId = 'ac_' + Math.random().toString(36).slice(2); + const ac_id = input_field.dataset.acId; + + // Remove any existing autocomplete list tied to this input + document.querySelectorAll('.op-autocomplete-list[data-for="' + ac_id + '"]').forEach(el => el.remove()); + + let autocomplete_list = null; + let autocomplete_items = []; + let selected_index = -1; + + /** + * Fetch contact/extension suggestions from the server. + */ + function fetch_suggestions(term) { + if (term.length === 0) { + hide_autocomplete(); + return; + } + + fetch('autocomplete.php?term=' + encodeURIComponent(term)) + .then(response => response.json()) + .then(data => { + autocomplete_items = data || []; + display_autocomplete_suggestions(autocomplete_items); + }) + .catch(err => { + console.error('[OP] Autocomplete error:', err); + hide_autocomplete(); + }); + } + + /** + * Display the autocomplete dropdown with suggestions. + */ + function display_autocomplete_suggestions(items) { + if (!autocomplete_list) { + autocomplete_list = document.createElement('ul'); + autocomplete_list.className = 'op-autocomplete-list'; + autocomplete_list.dataset.for = ac_id; + document.body.appendChild(autocomplete_list); + } + + if (items.length === 0) { + hide_autocomplete(); + return; + } + + autocomplete_list.innerHTML = ''; + selected_index = -1; + + items.forEach((item, index) => { + const li = document.createElement('li'); + li.className = 'op-autocomplete-item'; + li.innerHTML = `
${esc(item.label)}
`; + li.dataset.value = item.value; + li.dataset.index = index; + + li.addEventListener('click', function() { + select_autocomplete_item(item); + }); + + li.addEventListener('mouseenter', function() { + set_autocomplete_selected(index); + }); + + autocomplete_list.appendChild(li); + }); + + autocomplete_list.style.display = 'block'; + reposition_dropdown(); + } + + /** + * Position the dropdown directly below the input using fixed coordinates. + */ + function reposition_dropdown() { + if (!autocomplete_list) return; + const rect = input_field.getBoundingClientRect(); + const min_w = 420; + const max_w = window.innerWidth - rect.left - 8; + const width = Math.min(Math.max(rect.width, min_w), max_w); + autocomplete_list.style.top = (rect.bottom + 2) + 'px'; + autocomplete_list.style.left = rect.left + 'px'; + autocomplete_list.style.width = width + 'px'; + } + + /** + * Select an autocomplete item and fill the input field. + */ + function select_autocomplete_item(item) { + input_field.value = item.value; + hide_autocomplete(); + // Trigger change event in case there are listeners + input_field.dispatchEvent(new Event('change')); + } + + /** + * Set the selected/highlighted item in the autocomplete list. + */ + function set_autocomplete_selected(index) { + if (!autocomplete_list) return; + + const items = autocomplete_list.querySelectorAll('.op-autocomplete-item'); + items.forEach((item, i) => { + item.classList.toggle('op-autocomplete-selected', i === index); + }); + + selected_index = index; + } + + /** + * Hide the autocomplete dropdown. + */ + function hide_autocomplete() { + if (autocomplete_list) { + autocomplete_list.style.display = 'none'; + } + } + + /** + * Handle arrow keys and Enter in the input field. + */ + function handle_keyboard(event) { + if (!autocomplete_list || autocomplete_list.style.display === 'none') { + if (event.key === 'Enter') { + event.preventDefault(); + return; + } + return; + } + + const items = autocomplete_list.querySelectorAll('.op-autocomplete-item'); + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + selected_index = Math.min(selected_index + 1, items.length - 1); + set_autocomplete_selected(selected_index); + break; + case 'ArrowUp': + event.preventDefault(); + selected_index = Math.max(selected_index - 1, -1); + if (selected_index >= 0) { + set_autocomplete_selected(selected_index); + } else { + items.forEach(item => item.classList.remove('op-autocomplete-selected')); + } + break; + case 'Enter': + event.preventDefault(); + if (selected_index >= 0 && autocomplete_items[selected_index]) { + select_autocomplete_item(autocomplete_items[selected_index]); + } + break; + case 'Escape': + event.preventDefault(); + hide_autocomplete(); + break; + } + } + + // Debounce timer for input + let autocomplete_timer = null; + + // Input event listener with debouncing + input_field.addEventListener('input', function(e) { + if (autocomplete_timer) clearTimeout(autocomplete_timer); + + const term = (e.target.value || '').trim(); + + if (term.length < 1) { + hide_autocomplete(); + } else { + // Debounce the API call + autocomplete_timer = setTimeout(() => { + fetch_suggestions(term); + }, 300); + } + }); + + // Keyboard event listener + input_field.addEventListener('keydown', handle_keyboard); + + // Hide autocomplete when input loses focus + input_field.addEventListener('blur', function() { + setTimeout(() => { + hide_autocomplete(); + }, 150); + }); +} + /** Called by the Transfer button inside the dialog. */ function confirm_transfer() { const uuid = (document.getElementById('transfer_uuid') || {}).value || ''; @@ -3871,6 +4071,7 @@ function toggle_ext_dialpad(ext_number, event) { } input.classList.remove('d-none'); + init_contact_autocomplete(input); setTimeout(() => input.focus(), 10); }