More operator panel fixes (#7917)

* Fix right-click options for unregistered extensions

* Show only "Call Voicemail" option

* Fix caller id number cut-off too early

* Fix drag-and-drop request rejected

* Always show "My Extensions"

* Fix showing caller id on unregistered called extension

* The voicemail icon and caller ID information now shows for a call routed to the unregistered extension.
Also, hide the intercept option if the caller is the current operator extension because the operator can't intercept their own call.

* Fix: extension has multiple call groups
This commit is contained in:
frytimo
2026-04-24 03:36:17 +00:00
committed by GitHub
parent 026f142b88
commit 28c2c90738
5 changed files with 190 additions and 26 deletions
+8
View File
@@ -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";
+7
View File
@@ -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) ?>;
@@ -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;
@@ -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 {
@@ -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) {
: '') +
`</div>`;
} 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 = `<div class="op-ext-call-actions">` +
(permissions.operator_panel_manage
(permissions.operator_panel_manage && can_intercept
? `<img class="op-ext-action-icon" src="../operator_panel/resources/images/intercept.svg" alt="${esc(text['button-intercept'] || 'Intercept')}" title="${esc(text['button-intercept'] || 'Intercept')}" onclick="action_intercept_icon('${call_uuid_js}', '${esc(num)}')">`
: '') +
(permissions.operator_panel_hangup
@@ -2906,7 +3022,7 @@ function render_ext_block(ext, is_mine) {
`</div>`;
}
return `<div class="op-ext-block ${css_state}${mine_cls}" id="ext_block_${esc(num)}"` +
return `<div class="op-ext-block ${css_state}${mine_cls}"` +
` data-extension="${esc(num)}"${data_uuid}` +
` data-can-receive-originate="${can_receive_originate ? 'true' : 'false'}"` +
drag_attrs +
@@ -2921,13 +3037,31 @@ function render_ext_block(ext, is_mine) {
`<div class="op-ext-number">${esc(num)}</div>` +
dialpad_html +
(show_name ? `<div class="op-ext-name" title="${esc(raw_name)}">${esc(raw_name)}</div>` : '') +
(state_label ? `<div class="op-ext-state-info">${state_label}</div>` : '') +
(state_label ? `<div class="op-ext-state-info">${state_label_icon ? `<i class="${esc(state_label_icon)} op-ext-state-icon" aria-hidden="true"></i> ` : ''}${state_label}</div>` : '') +
live_call_meta_html +
live_actions_html +
`</div>` +
`</div>`;
}
function get_extension_block(ext_number, event) {
const target_num = ((ext_number || '') + '').trim();
if (!target_num) return null;
if (event && event.currentTarget && event.currentTarget.classList && event.currentTarget.classList.contains('op-ext-block')) {
const current_num = (event.currentTarget.getAttribute('data-extension') || '').trim();
if (current_num === target_num) return event.currentTarget;
}
const blocks = document.querySelectorAll('.op-ext-block[data-extension]');
for (const block of blocks) {
const candidate = (block.getAttribute('data-extension') || '').trim();
if (candidate === target_num) return block;
}
return null;
}
/**
* Convert a string to Title Case.
*/
@@ -3178,7 +3312,8 @@ function apply_extension_filters() {
document.querySelectorAll('.op-group-card').forEach(card => {
const key = card.getAttribute('data-group-key') || '';
// Group filter
const group_visible = active_group_filters.size === 0 || active_group_filters.has(key);
const my_extensions_always_visible = key === '__my__' && !my_extensions_button_visible;
const group_visible = my_extensions_always_visible || active_group_filters.size === 0 || active_group_filters.has(key);
card.classList.toggle('op-hidden', !group_visible);
if (group_visible && filter_text) {
@@ -3297,13 +3432,15 @@ function render_extensions_tab() {
// Group remaining extensions by call_group (case-insensitive)
const groups = new Map();
others.forEach(ext => {
const raw_group = (ext.call_group || '').trim();
const key = raw_group.toLowerCase() || '';
const group_entries = parse_call_group_entries(ext.call_group || '');
group_entries.forEach(group_entry => {
const key = group_entry.key;
if (!groups.has(key)) {
groups.set(key, { display: raw_group ? to_title_case(raw_group) : '', exts: [] });
groups.set(key, { display: group_entry.display, exts: [] });
}
groups.get(key).exts.push(ext);
});
});
// Sort groups: named groups alphabetically, ungrouped last
let sorted_keys = Array.from(groups.keys()).sort((a, b) => {
@@ -3314,7 +3451,7 @@ function render_extensions_tab() {
// Build the list of all group keys for filters (including "my_extensions")
const filter_keys = [];
if (own.length > 0) {
if (own.length > 0 && my_extensions_button_visible) {
filter_keys.push({ key: '__my__', display: text['label-my_extensions'] || 'My Extensions' });
}
sorted_keys.forEach(key => {
@@ -3624,7 +3761,7 @@ function toggle_ext_dialpad(ext_number, event) {
event.stopPropagation();
}
const row = document.getElementById(`ext_block_${ext_number}`);
const row = get_extension_block(ext_number, event);
if (!row) return;
const input = row.querySelector('.op-ext-dial-input');
@@ -3648,7 +3785,7 @@ function submit_ext_dial(ext_number, event) {
event.stopPropagation();
}
const row = document.getElementById(`ext_block_${ext_number}`);
const row = get_extension_block(ext_number, event);
if (!row) return;
const input = row.querySelector('.op-ext-dial-input');