diff --git a/app/operator_panel/index.php b/app/operator_panel/index.php index debacb0fe0..384ea3ed74 100644 --- a/app/operator_panel/index.php +++ b/app/operator_panel/index.php @@ -436,7 +436,7 @@ body.op-dragging, body.op-dragging * { /* user status: logged out — grey */ .op-ext-logged-out { border-color: #9da5ae; background-color: #e2e3e5; } .op-ext-logged-out .op-ext-icon { background-color: #d6d8db; } -.op-ext-logged-out .op-ext-icon .op-ext-status-icon { color: #6c757d; } +.op-ext-logged-out .op-ext-icon .op-ext-status-icon { color: #1e7e34; } .op-ext-logged-out .op-ext-info { background-color: #f0f1f2; } .op-ext-logged-out .op-ext-number { color: #888; } .op-ext-logged-out .op-ext-name { color: #999; } diff --git a/app/operator_panel/resources/classes/operator_panel_service.php b/app/operator_panel/resources/classes/operator_panel_service.php index f9a7166637..dee38b3434 100644 --- a/app/operator_panel/resources/classes/operator_panel_service.php +++ b/app/operator_panel/resources/classes/operator_panel_service.php @@ -872,7 +872,8 @@ class operator_panel_service extends base_websocket_system_service implements we if (!preg_match('/^[0-9*#+]+$/', $destination)) { return ['success' => false, 'message' => 'Invalid destination']; } - event_socket::api("uuid_transfer $uuid $destination XML $context"); + $bleg = !empty($payload['bleg']) ? '-bleg ' : ''; + event_socket::api("uuid_transfer $uuid {$bleg}$destination XML $context"); return ['success' => true, 'message' => 'Call transferred']; case 'eavesdrop': @@ -1007,11 +1008,29 @@ class operator_panel_service extends base_websocket_system_service implements we return ['success' => false, 'message' => 'Cannot call self']; } - // The destination gets routed through the domain's dialplan which handles bridging - $originate_cmd = "originate {sip_auto_answer=true,origination_caller_id_number=$source,sip_h_Call-Info=_undef_}user/$source@$domain_name $dest XML $context"; + // Look up the source extension's user_context for correct dialplan routing + $originate_context = $context; + try { + $database = database::new(['config' => parent::$config]); + $rows = $database->select( + "SELECT e.user_context FROM v_extensions AS e " + . "LEFT JOIN v_domains AS d ON e.domain_uuid = d.domain_uuid " + . "WHERE d.domain_name = :domain_name AND e.extension = :extension AND e.enabled = 'true' LIMIT 1", + [':domain_name' => $domain_name, ':extension' => $source], + 'all' + ); + if (!empty($rows[0]['user_context'])) { + $originate_context = $rows[0]['user_context']; + } + } catch (\Exception $e) { + $this->debug('Could not look up user_context for originate: ' . $e->getMessage()); + } + + // The destination gets routed through the extension's dialplan context + $originate_cmd = "originate {sip_auto_answer=true,origination_caller_id_number=$source}user/$source@$domain_name $dest XML $originate_context"; // Log the originate command attempt - $this->debug("Originate: from=$source to=$dest domain=$domain_name context=$context cmd=$originate_cmd"); + $this->debug("Originate: from=$source to=$dest domain=$domain_name context=$originate_context cmd=$originate_cmd"); $fs_response = event_socket::api($originate_cmd); diff --git a/app/operator_panel/resources/javascript/operator_panel.js b/app/operator_panel/resources/javascript/operator_panel.js index 499db1f972..59b176f443 100644 --- a/app/operator_panel/resources/javascript/operator_panel.js +++ b/app/operator_panel/resources/javascript/operator_panel.js @@ -1330,7 +1330,21 @@ function action_record(uuid) { * @param {string} status */ function send_user_status(status) { - send_action('user_status', { status, user_uuid }).catch(console.error); + send_action('user_status', { status, user_uuid }) + .then(() => { + // Update local extensions_map so the UI reflects the new status immediately + if (Array.isArray(user_own_extensions)) { + user_own_extensions.forEach(ext_num => { + const ext = extensions_map.get(ext_num); + if (ext) { + ext.user_status = status; + ext.do_not_disturb = (status === 'Do Not Disturb') ? 'true' : 'false'; + } + }); + } + render_extensions_tab(); + }) + .catch(console.error); } function action_agent_status(agent_name, status) { @@ -1381,10 +1395,30 @@ function load_extensions_snapshot() { if (ext.extension) extensions_map.set(ext.extension, ext); }); render_extensions_tab(); + sync_status_buttons(); }) .catch(console.error); } +/** + * Highlight the status button that matches the logged-in user's current + * status (read from extensions_map for the user's own extension). + */ +function sync_status_buttons() { + if (!Array.isArray(user_own_extensions) || user_own_extensions.length === 0) return; + const ext = extensions_map.get(user_own_extensions[0]); + if (!ext) return; + const current = (ext.user_status || '').trim(); + if (!current) return; + document.querySelectorAll('.op-status-btn').forEach(b => { + if (b.getAttribute('data-status') === current) { + b.classList.add('active'); + } else { + b.classList.remove('active'); + } + }); +} + /** * Derive call state for a given extension number by scanning calls_map. * @param {string} ext_number @@ -1474,6 +1508,8 @@ function render_ext_block(ext, is_mine) { css_state = 'op-ext-on-break'; } else if (user_status_raw === 'Available' || user_status_raw === 'Available (On Demand)') { css_state = 'op-ext-available'; + } else if (user_status_raw === 'Logged Out') { + css_state = 'op-ext-logged-out'; } else { // Registered with no explicit/active status — blue css_state = 'op-ext-registered'; @@ -1485,7 +1521,7 @@ function render_ext_block(ext, is_mine) { 'op-ext-on-break': '#8a6508', 'op-ext-dnd': '#a71d2a', 'op-ext-registered': '#2b6cb0', - 'op-ext-logged-out': '#6c757d', + 'op-ext-logged-out': '#1e7e34', 'op-ext-unregistered': '#6c757d', 'op-ext-ringing': '#0e6882', 'op-ext-active': '#2a7a2b', @@ -1574,9 +1610,17 @@ function render_ext_block(ext, is_mine) { status_icon = 'status_do_not_disturb'; status_hover = text['label-status_do_not_disturb'] || 'Do Not Disturb'; break; + case 'Logged Out': + // Use green icon when still registered to indicate the phone is online + if (reg) { + status_icon = 'status_available'; + } else { + status_icon = 'status_logged_out'; + } + status_hover = text['label-status_logged_out_or_unknown'] || text['label-status_logged_out'] || 'Logged Out'; + break; default: if (reg) { - // In this panel, registered-without-explicit-status uses the same icon as Available. status_icon = 'status_available'; status_hover = text['label-status_available'] || 'Available'; } else { @@ -2228,7 +2272,11 @@ function on_ext_drop(ext_number, event) { dragged_extension = null; if (!uuid || !ext_number) return; if (source_ext && source_ext === ext_number) return; - send_action('transfer', { uuid, destination: ext_number, context: domain_name }) + // When dragged from an extension block the UUID is the extension's own + // leg; use -bleg so FreeSWITCH transfers the *other* leg (the caller). + const payload = { uuid, destination: ext_number, context: domain_name }; + if (source_ext) payload.bleg = true; + send_action('transfer', payload) .catch(console.error); } else if (dragged_eavesdrop_uuid) { // Eavesdrop an existing call using dropped extension as destination