|
|
|
@@ -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');
|
|
|
|
|