diff --git a/app/operator_panel/app_config.php b/app/operator_panel/app_config.php index 816f64c57..1007c9f3c 100644 --- a/app/operator_panel/app_config.php +++ b/app/operator_panel/app_config.php @@ -186,6 +186,14 @@ $apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true"; $apps[$x]['default_settings'][$y]['default_setting_description'] = "Position of extension group card labels. Valid values: top, left, right, bottom, hidden."; $y++; + $apps[$x]['default_settings'][$y]['default_setting_uuid'] = "9a25fc04-4fc8-4027-97fe-9e4723346892"; + $apps[$x]['default_settings'][$y]['default_setting_category'] = "operator_panel"; + $apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "my_extensions_button_visible"; + $apps[$x]['default_settings'][$y]['default_setting_name'] = "boolean"; + $apps[$x]['default_settings'][$y]['default_setting_value'] = "false"; + $apps[$x]['default_settings'][$y]['default_setting_enabled'] = "true"; + $apps[$x]['default_settings'][$y]['default_setting_description'] = "Show the 'My Extensions' filter button. False hides the button and keeps the logged-in user's extensions visible in the Extensions tab."; + $y++; $apps[$x]['default_settings'][$y]['default_setting_uuid'] = "c4a7db2a-ec69-4aef-a95b-1a8f2d7d2de1"; $apps[$x]['default_settings'][$y]['default_setting_category'] = "operator_panel"; $apps[$x]['default_settings'][$y]['default_setting_subcategory'] = "registrations_reconcile_enabled"; diff --git a/app/operator_panel/index.php b/app/operator_panel/index.php index de22e0d10..3aaf56e51 100644 --- a/app/operator_panel/index.php +++ b/app/operator_panel/index.php @@ -114,6 +114,10 @@ $card_label_position = 'left'; } +// Show/hide the "My Extensions" filter button. +// Default is hidden when unset so the user's own extensions remain shown. + $my_extensions_button_visible = $settings->get('operator_panel', 'my_extensions_button_visible', 'false') === 'true'; + // Optional polling reconciliation of registration state (can be disabled). $registrations_reconcile_enabled = $settings->get('operator_panel', 'registrations_reconcile_enabled', 'false') === 'true'; @@ -195,6 +199,9 @@ // Group card label position (top, left, right, bottom, hidden) const card_label_position = = json_encode($card_label_position) ?>; + // Show/hide the "My Extensions" filter button + const my_extensions_button_visible = = json_encode($my_extensions_button_visible) ?>; + // Optional registrations-state reconciliation polling const registrations_reconcile_enabled = = json_encode($registrations_reconcile_enabled) ?>; diff --git a/app/operator_panel/resources/classes/operator_panel_service.php b/app/operator_panel/resources/classes/operator_panel_service.php index fa43977c7..53ec8a399 100644 --- a/app/operator_panel/resources/classes/operator_panel_service.php +++ b/app/operator_panel/resources/classes/operator_panel_service.php @@ -708,7 +708,14 @@ class operator_panel_service extends base_websocket_system_service implements we $mapped['channel_presence_id'] = $call['presence_id'] ?? ''; $mapped['caller_caller_id_name'] = $call['initial_cid_name'] ?? ($call['cid_name'] ?? ''); $mapped['caller_caller_id_number'] = $call['initial_cid_num'] ?? ($call['cid_num'] ?? ''); - $mapped['caller_destination_number'] = $call['initial_dest'] ?? ($call['dest'] ?? ''); + // Prefer the current destination (e.g. 104 for a voicemail leg) over + // the initial dialed number (e.g. 100 for a phone-originated leg). + // This matches what live CHANNEL_* events publish in + // caller_destination_number and lets the panel show calls routed to + // voicemail (or other redirects) going to the correct extension. + $current_dest = $call['dest'] ?? ''; + $initial_dest = $call['initial_dest'] ?? ''; + $mapped['caller_destination_number'] = ($current_dest !== '' ? $current_dest : $initial_dest); $mapped['application'] = $call['application'] ?? ''; $mapped['secure'] = $call['secure'] ?? ''; $channels[] = $mapped; diff --git a/app/operator_panel/resources/css/operator_panel.css b/app/operator_panel/resources/css/operator_panel.css index c02b30df4..b4031afad 100644 --- a/app/operator_panel/resources/css/operator_panel.css +++ b/app/operator_panel/resources/css/operator_panel.css @@ -240,8 +240,13 @@ .op-ext-number { font-size: 12px; font-weight: bold; color: #3164AD; line-height: 1.4; } .op-ext-name { font-size: 10px; color: #444; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .op-ext-state-info { font-size: 10px; color: #555; margin-top: 3px; } -.op-ext-info.op-has-live-call { padding-right: 78px; padding-bottom: 15px; box-sizing: border-box; } -.op-ext-info.op-has-live-call .op-ext-state-info { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.op-ext-info.op-has-live-call { padding-right: 18px; padding-bottom: 15px; box-sizing: border-box; } +.op-ext-info.op-has-live-call .op-ext-state-info { + padding-right: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} .op-ext-mine-label { position: absolute; top: 2px; right: 4px; font-size: 9px; color: #0d6efd; font-weight: 600; } .op-ext-dial-wrap { position: absolute; top: 22px; right: 3px; } .op-ext-dial-toggle { diff --git a/app/operator_panel/resources/javascript/operator_panel.js b/app/operator_panel/resources/javascript/operator_panel.js index 1ef2cb5d6..23a48068a 100644 --- a/app/operator_panel/resources/javascript/operator_panel.js +++ b/app/operator_panel/resources/javascript/operator_panel.js @@ -247,10 +247,34 @@ function normalize_group_key(raw_group) { return key || ''; } -function get_extension_group_key(ext_number) { +function parse_call_group_entries(raw_groups) { + const entries = []; + const seen = new Set(); + + ((raw_groups || '') + '').split(',').forEach(part => { + const raw = (part + '').trim(); + const key = normalize_group_key(raw); + if (!key || seen.has(key)) return; + seen.add(key); + entries.push({ key: key, display: to_title_case(raw) }); + }); + + if (entries.length === 0) { + entries.push({ key: '', display: '' }); + } + + return entries; +} + +function get_extension_group_entries(ext_number) { const ext = extensions_map.get((ext_number || '').toString()); - if (!ext) return ''; - return normalize_group_key(ext.call_group || ''); + if (!ext) return [{ key: '', display: '' }]; + return parse_call_group_entries(ext.call_group || ''); +} + +function get_extension_group_key(ext_number) { + const groups = get_extension_group_entries(ext_number); + return groups.length ? (groups[0].key || '') : ''; } function get_extension_display_name(ext_number) { @@ -653,6 +677,46 @@ function get_call_uuid(ch) { return ch.unique_id || ch.uuid || ch.channel_uuid || ''; } +function is_ringing_like_call(ch) { + if (!ch || typeof ch !== 'object') return false; + const channel_state = ((ch.channel_call_state || '') + '').toUpperCase(); + const answer_state = ((ch.answer_state || '') + '').toUpperCase(); + return channel_state.indexOf('RING') !== -1 || answer_state.indexOf('RING') !== -1 || answer_state === 'EARLY'; +} + +function remove_stale_linked_ringing_legs(destroyed_uuid, destroyed_event) { + if (!destroyed_uuid) return; + + const destroyed_refs = [ + destroyed_event && destroyed_event.other_leg_unique_id, + destroyed_event && destroyed_event.variable_bridge_uuid, + destroyed_event && destroyed_event.bridge_uuid, + ] + .map(v => ((v || '') + '').trim()) + .filter(Boolean); + + for (const linked_uuid of get_linked_call_uuids(destroyed_uuid)) { + if (!linked_uuid || linked_uuid === destroyed_uuid) continue; + + const linked_call = calls_map.get(linked_uuid); + if (!linked_call || !is_ringing_like_call(linked_call)) continue; + + const linked_refs = [ + linked_call.other_leg_unique_id, + linked_call.variable_bridge_uuid, + linked_call.bridge_uuid, + ] + .map(v => ((v || '') + '').trim()) + .filter(Boolean); + + const directly_linked = linked_refs.includes(destroyed_uuid) || destroyed_refs.includes(linked_uuid); + if (directly_linked) { + recording_call_uuids.delete(linked_uuid); + calls_map.delete(linked_uuid); + } + } +} + function is_parked_call(ch) { if (!ch || typeof ch !== 'object') return false; const callstate = ((ch.channel_call_state || ch.answer_state || '') + '').toLowerCase(); @@ -895,6 +959,7 @@ function on_call_event(event) { case 'channel_destroy': recording_call_uuids.delete(uuid); calls_map.delete(uuid); + remove_stale_linked_ringing_legs(uuid, event); break; default: @@ -1919,18 +1984,31 @@ document.addEventListener('keydown', function (e) { if (e.key === 'Escape') hide_context_menu(); }); +/** + * True when the originating caller of the given call is one of the logged-in + * user's own extensions. Used to suppress Intercept UI when the operator is + * the one placing the call (you cannot intercept your own outgoing call). + */ +function is_call_originated_by_me(call_info) { + if (!call_info || !Array.isArray(user_own_extensions) || user_own_extensions.length === 0) return false; + const cid = ((call_info.caller_caller_id_number || call_info.caller_id_number || '') + '').trim(); + if (!cid) return false; + return user_own_extensions.includes(cid); +} + /** * Right-click handler for extension blocks. * @param {MouseEvent} event * @param {string} ext_num */ function on_ext_contextmenu(event, ext_num) { - const block = document.getElementById('ext_block_' + ext_num); + const block = get_extension_block(ext_num, event); const uuid = block ? (block.getAttribute('data-call-uuid') || '') : ''; const is_mine = !!(block && block.classList.contains('op-ext-mine')); const { state, call_info } = get_extension_call_state(ext_num); const has_call = !!uuid; const ext_data = extensions_map.get(ext_num) || {}; + const is_registered = ext_data.registered === true; const voicemail_enabled = (ext_data.voicemail_enabled || '') === 'true'; // Derive call direction for the ringing extension to suppress Reject on outbound calls. @@ -1945,6 +2023,17 @@ function on_ext_contextmenu(event, ext_num) { const items = []; items.push({ header: ext_num }); + if (!is_registered) { + if (permissions.operator_panel_originate) { + items.push({ label: text['button-call_voicemail'] || 'Call Voicemail', icon_class: 'fa-solid fa-voicemail', + fn: function () { action_call_voicemail(ext_num); } + }); + } + if (items.length <= 1) return; + show_context_menu(event, items); + return; + } + if (!has_call) { if (is_mine) { // Own idle extension: open dialpad to originate from this extension @@ -1982,7 +2071,7 @@ function on_ext_contextmenu(event, ext_num) { fn: function () { action_hangup_caller(uuid); }, danger: true }); } } else { - if (permissions.operator_panel_manage) { + if (permissions.operator_panel_manage && !is_call_originated_by_me(call_info)) { items.push({ label: text['button-intercept'] || 'Intercept', icon_class: 'fa-solid fa-phone-volume', fn: function () { action_intercept_icon(uuid, ext_num); } }); } @@ -2096,7 +2185,7 @@ function on_call_contextmenu(event, uuid) { fn: function () { action_hangup_caller(uuid); }, danger: true }); } } else { - if (permissions.operator_panel_manage) { + if (permissions.operator_panel_manage && !is_call_originated_by_me(call_info)) { items.push({ label: text['button-intercept'] || 'Intercept', icon_class: 'fa-solid fa-phone-volume', fn: function () { action_intercept_icon(uuid, source_ext); } }); } @@ -2696,6 +2785,9 @@ function render_ext_block(ext, is_mine) { ? current_user_status : (ext.user_status || '').trim(); + // Unregistered extensions always render as unregistered (grey). When they + // have an active call it is running on the voicemail leg; that is surfaced + // to the user via the state label + voicemail icon below, not a colour change. let css_state; if (!reg) { css_state = 'op-ext-unregistered'; @@ -2734,8 +2826,22 @@ function render_ext_block(ext, is_mine) { // Only show a state label when something notable is happening let state_label = ''; + let state_label_icon = ''; // optional leading Font Awesome icon class if (dnd && reg) { state_label = text['label-do_not_disturb'] || 'Do Not Disturb'; + } else if (!reg && state !== 'idle' && call_info) { + // Unregistered extension with a live call: the call is on the voicemail leg. + const call_name = ((call_info.caller_caller_id_name || call_info.caller_id_name || '') + '').trim(); + const call_cid = ((call_info.caller_caller_id_number || call_info.caller_id_number || '') + '').trim(); + const from_parts = []; + if (call_name && call_name.toLowerCase() !== 'outbound call' && call_name.toLowerCase() !== 'inbound call' && call_name !== call_cid) { + from_parts.push(call_name); + } + if (call_cid) from_parts.push(call_cid); + const from_text = from_parts.join(' '); + state_label_icon = 'fa-solid fa-voicemail'; + state_label = (text['label-voicemail'] || 'Voicemail') + + (from_text ? ' \u2014 ' + (text['label-from'] || 'From') + ': ' + esc(from_text) : ''); } else if (reg && state !== 'idle') { switch (state) { case 'ringing': state_label = text['label-ringing'] || 'Ringing\u2026'; break; @@ -2776,13 +2882,21 @@ function render_ext_block(ext, is_mine) { const call_uuid_js = (call_uuid || '').replace(/'/g, "\\'"); const call_dest = ((call_info || {}).caller_destination_number || '').trim(); const call_cid = ((call_info || {}).caller_caller_id_number || '').trim(); + const call_presence = (((call_info || {}).channel_presence_id || '').split('@')[0] || '').trim(); + const fs_direction = (((call_info || {}).call_direction || (call_info || {}).variable_call_direction || '') + '').toLowerCase(); let direction_raw = ''; - if (call_dest === num && call_cid !== num) { + if (call_dest === num && call_cid !== num && call_cid) { direction_raw = 'inbound'; - } else if (call_cid === num && call_dest !== num) { + } else if (call_cid === num && call_dest !== num && call_dest) { direction_raw = 'outbound'; + } else if (call_presence === num && fs_direction) { + // FS leg that belongs to this extension. FS direction is relative to the + // switch, not the extension, so invert it for the panel's viewpoint: + // FS outbound => switch is dialing this phone => panel inbound + // FS inbound => phone dialed the switch => panel outbound + direction_raw = (fs_direction === 'outbound') ? 'inbound' : 'outbound'; } else { - direction_raw = (((call_info || {}).call_direction || (call_info || {}).variable_call_direction || '') + '').toLowerCase(); + direction_raw = fs_direction; } const direction_icon = direction_raw === 'inbound' ? '../operator_panel/resources/images/inbound.png' @@ -2876,9 +2990,11 @@ function render_ext_block(ext, is_mine) { : '') + ``; } else if (has_live_call && is_ringing && !is_mine) { - // Ringing on another user's extension: Intercept icon + // Ringing on another user's extension: Intercept icon (hidden when the + // operator is the one placing the call). + const can_intercept = !is_call_originated_by_me(call_info); live_actions_html = `