Add operator panel valet park (#7823)

* Add parked calls monitoring

* Introduce a transfer mode

* Fix extension color on page load
This commit is contained in:
frytimo
2026-03-31 15:02:02 -03:00
committed by GitHub
parent 83d5faa1af
commit 0ecd34c4ab
7 changed files with 1751 additions and 86 deletions
+40 -11
View File
@@ -56,19 +56,48 @@
$apps[$x]['description']['sv-se'] = "";
$apps[$x]['description']['uk-ua'] = "";
// Permissions are inherited from the basic_operator_panel app (uuid: dd3d173a-5d51-4231-ab22-b18c5b712bb2).
// The following permissions are reused:
// operator_panel_view - view the panel
// operator_panel_manage - supervisor: transfer, agent management
// operator_panel_originate - originate calls via drag-and-drop
// operator_panel_eavesdrop - listen to calls
// operator_panel_hangup - disconnect calls
// operator_panel_record - record calls
// operator_panel_call_details - view caller/callee details
// operator_panel_on_demand - on-demand availability status
// Core permissions (migrated from basic_operator_panel uuid: dd3d173a-5d51-4231-ab22-b18c5b712bb2)
$y = 0;
$apps[$x]['permissions'][$y]['name'] = "operator_panel_view";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "operator_panel_manage";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "operator_panel_originate";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "operator_panel_hangup";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "operator_panel_eavesdrop";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "operator_panel_coach";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "operator_panel_record";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "operator_panel_call_details";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
$apps[$x]['permissions'][$y]['name'] = "operator_panel_on_demand";
$y++;
$apps[$x]['permissions'][$y]['name'] = "operator_panel_transfer_attended";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
$y++;
// Tab visibility permissions
$y = 0;
$apps[$x]['permissions'][$y]['name'] = "operator_panel_extensions";
$apps[$x]['permissions'][$y]['groups'][] = "superadmin";
$apps[$x]['permissions'][$y]['groups'][] = "admin";
+51
View File
@@ -485,6 +485,15 @@ $text['label-no_parked_calls']['zh-cn'] = "没有已停泊通话";
$text['label-no_parked_calls']['ja-jp'] = "保留中の通話はありません";
$text['label-no_parked_calls']['ko-kr'] = "주차된 통화 없음";
$text['label-parking_lot']['en-us'] = "Parking Lot";
$text['label-parking_lot']['en-gb'] = "Parking Lot";
$text['label-parked_by']['en-us'] = "Parked By";
$text['label-parked_by']['en-gb'] = "Parked By";
$text['label-original_destination']['en-us'] = "Original Destination";
$text['label-original_destination']['en-gb'] = "Original Destination";
$text['label-recording']['en-us'] = "Recording";
$text['label-recording']['en-gb'] = "Recording";
$text['label-recording']['ar-eg'] = "تسجيل";
@@ -566,6 +575,45 @@ $text['label-eavesdrop']['zh-cn'] = "监听";
$text['label-eavesdrop']['ja-jp'] = "聞く";
$text['label-eavesdrop']['ko-kr'] = "듣기";
$text['label-choose_action']['en-us'] = "Choose Action";
$text['label-choose_action']['en-gb'] = "Choose Action";
$text['label-ringing_action_desc']['en-us'] = "Extension {target} is ringing. What would you like to do from {source}?";
$text['label-ringing_action_desc']['en-gb'] = "Extension {target} is ringing. What would you like to do from {source}?";
$text['button-intercept']['en-us'] = "Intercept";
$text['button-intercept']['en-gb'] = "Intercept";
$text['button-call']['en-us'] = "Call";
$text['button-call']['en-gb'] = "Call";
$text['button-reject']['en-us'] = "Reject";
$text['button-reject']['en-gb'] = "Reject";
$text['button-hangup_caller']['en-us'] = "Hangup Caller";
$text['button-hangup_caller']['en-gb'] = "Hangup Caller";
$text['label-confirm_hangup_caller']['en-us'] = "Hang up the caller?";
$text['label-confirm_hangup_caller']['en-gb'] = "Hang up the caller?";
$text['label-transfer_mode']['en-us'] = "Transfer";
$text['label-transfer_mode']['en-gb'] = "Transfer";
$text['label-transfer_mode_title']['en-us'] = "Default transfer type used when transferring calls";
$text['label-transfer_mode_title']['en-gb'] = "Default transfer type used when transferring calls";
$text['label-blind_transfer']['en-us'] = "Blind";
$text['label-blind_transfer']['en-gb'] = "Blind";
$text['label-blind_transfer_title']['en-us'] = "Blind transfer: immediately connect the call to the destination";
$text['label-blind_transfer_title']['en-gb'] = "Blind transfer: immediately connect the call to the destination";
$text['label-attended_transfer']['en-us'] = "Conference";
$text['label-attended_transfer']['en-gb'] = "Conference";
$text['label-attended_transfer_title']['en-us'] = "Conference transfer: destination is called while the caller is still connected; also called a warm transfer";
$text['label-attended_transfer_title']['en-gb'] = "Conference transfer: destination is called while the caller is still connected; also called a warm transfer";
$text['label-consulting_with']['en-us'] = "Consulting with";
$text['label-consulting_with']['en-gb'] = "Consulting with";
$text['message-transfer_completed']['en-us'] = "Transfer completed";
$text['message-transfer_completed']['en-gb'] = "Transfer completed";
$text['message-transfer_cancelled']['en-us'] = "Transfer cancelled";
$text['message-transfer_cancelled']['en-gb'] = "Transfer cancelled";
$text['button-complete_transfer']['en-us'] = "Complete Transfer";
$text['button-complete_transfer']['en-gb'] = "Complete Transfer";
$text['button-cancel_transfer']['en-us'] = "Cancel";
$text['button-cancel_transfer']['en-gb'] = "Cancel";
$text['label-hangup']['en-us'] = "Hangup";
$text['label-hangup']['en-gb'] = "Hangup";
$text['label-hangup']['ar-eg'] = "إنهاء";
@@ -1756,6 +1804,9 @@ $text['message-action_failed']['zh-cn'] = "操作失败";
$text['message-action_failed']['ja-jp'] = "操作に失敗しました";
$text['message-action_failed']['ko-kr'] = "작업에 실패했습니다";
$text['label-dial_number']['en-us'] = "Dial a Number";
$text['label-dial_number']['en-gb'] = "Dial a Number";
$text['message-permission_denied']['en-us'] = "Permission denied";
$text['message-permission_denied']['en-gb'] = "Permission denied";
$text['message-permission_denied']['ar-eg'] = "تم رفض الإذن";
+401 -27
View File
@@ -43,6 +43,11 @@
$token = (new token())->create($_SERVER['PHP_SELF']);
subscriber::save_token($token, ['active.operator.panel']);
// Get the status for the current user
$sql = 'select user_status from v_users where user_uuid = :user_uuid';
$parameters = ['user_uuid' => $_SESSION['user_uuid'] ?? ''];
$user_status = $database->select($sql, $parameters, 'column') ?? '';
// Gather user permissions for the JS side
$perm = [
'operator_panel_view' => permission_exists('operator_panel_view'),
@@ -51,6 +56,10 @@
'operator_panel_eavesdrop' => permission_exists('operator_panel_eavesdrop'),
'operator_panel_record' => permission_exists('operator_panel_record'),
'operator_panel_originate' => permission_exists('operator_panel_originate'),
'operator_panel_coach' => permission_exists('operator_panel_coach'),
'operator_panel_call_details' => permission_exists('operator_panel_call_details'),
'operator_panel_on_demand' => permission_exists('operator_panel_on_demand'),
'operator_panel_transfer_attended' => permission_exists('operator_panel_transfer_attended'),
'operator_panel_extensions' => permission_exists('operator_panel_extensions'),
'operator_panel_calls' => permission_exists('operator_panel_calls'),
'operator_panel_conferences' => permission_exists('operator_panel_conferences'),
@@ -108,6 +117,12 @@
// Optional polling reconciliation of registration state (can be disabled).
$registrations_reconcile_enabled = $settings->get('operator_panel', 'registrations_reconcile_enabled', 'false') === 'true';
// Default auto-park destination for drag/drop parking.
$park_destination = (string)$settings->get('operator_panel', 'park_destination', '*5900');
if (!preg_match('/^[0-9*#+]+$/', $park_destination)) {
$park_destination = '*5900';
}
// Get the logged-in user's own extension numbers (shown at top of Extensions panel)
// and primary eavesdrop destination extension
$user_own_extensions = [];
@@ -162,7 +177,7 @@
const user_uuid = <?= json_encode($_SESSION['user_uuid'] ?? '') ?>;
// User status options
const user_statuses = <?= json_encode($user_statuses) ?>;
const user_status = <?= json_encode($user_status) ?>;
// The logged-in user's own extension numbers — shown first / highlighted in the Extensions panel
const user_own_extensions = <?= json_encode($user_own_extensions, JSON_UNESCAPED_SLASHES) ?>;
@@ -176,6 +191,9 @@
// Optional registrations-state reconciliation polling
const registrations_reconcile_enabled = <?= json_encode($registrations_reconcile_enabled) ?>;
// Default auto-park destination for drag/drop parking
const park_destination = <?= json_encode($park_destination) ?>;
</script>
<script src="resources/javascript/websocket_client.js?v=<?= $ws_client_hash ?>"></script>
@@ -318,6 +336,116 @@
}
.op-edit-btn:hover { background: #e9ecef; }
.op-edit-btn.active { background: #0d6efd; color: #fff; border-color: #0d6efd; }
/* Transfer mode toggle */
.op-transfer-mode {
display: inline-flex;
align-items: center;
gap: 5px;
margin-left: auto;
font-size: 12px;
color: #555;
white-space: nowrap;
}
.op-transfer-mode-label {
font-weight: 600;
color: #444;
}
.op-transfer-mode-btn {
padding: 2px 10px;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
font-size: 11px;
font-weight: 600;
cursor: pointer;
color: #555;
transition: background .15s, color .15s, border-color .15s;
}
.op-transfer-mode-btn:hover { background: #e9ecef; }
.op-transfer-mode-btn.active { background: #0d6efd; color: #fff; border-color: #0d6efd; }
/* Context menu */
.op-ctx-menu {
position: fixed;
z-index: 9990;
background: #fff;
border: 1px solid #d0d7de;
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0,0,0,.18);
min-width: 165px;
padding: 4px 0;
display: none;
margin: 0;
}
.op-ctx-header {
font-size: 11px;
color: #888;
padding: 3px 14px 2px;
text-transform: uppercase;
letter-spacing: .5px;
font-weight: 600;
user-select: none;
}
.op-ctx-separator {
height: 1px;
background: #e0e0e0;
margin: 4px 0;
}
.op-ctx-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
font-size: 13px;
cursor: pointer;
color: #24292f;
white-space: nowrap;
border: none;
background: none;
width: 100%;
text-align: left;
line-height: 1.3;
}
.op-ctx-item:hover { background: #f0f6ff; }
.op-ctx-item .op-ctx-icon { font-size: 12px; opacity: .7; flex-shrink: 0; }
.op-ctx-danger { color: #c9242d; }
.op-ctx-danger:hover { background: #fff1f0; }
/* Attended transfer consultation bar */
.op-att-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9980;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
background: #198754;
color: #fff;
font-size: 14px;
font-weight: 600;
box-shadow: 0 -2px 8px rgba(0,0,0,.2);
}
.op-att-icon { font-size: 18px; }
.op-att-label { flex: 1; }
.op-att-btn {
padding: 6px 16px;
border: none;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.op-att-complete {
background: #fff;
color: #198754;
}
.op-att-complete:hover { background: #e9ecef; }
.op-att-cancel {
background: rgba(255,255,255,.2);
color: #fff;
}
.op-att-cancel:hover { background: rgba(255,255,255,.35); }
.op-ext-block {
display: flex;
width: 235px;
@@ -602,6 +730,174 @@ body.op-dragging, body.op-dragging * {
padding: 10px 8px 4px;
flex: 1;
}
/* Top row in Extensions tab: My Extensions + Parked Calls */
.op-top-row {
display: flex;
align-items: stretch;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 14px;
}
#my_extensions_container,
#parked_side_container {
flex: 1 1 420px;
min-width: 320px;
}
#my_extensions_container:not(:empty),
#parked_side_container:not(:empty) {
margin-bottom: 0;
padding-bottom: 0;
}
/* Parked calls side panel */
.op-parked-card {
border: 1px solid #d0d8e5;
border-radius: 5px;
background-color: #fff;
box-shadow: 0 1px 3px #d0d8e5;
overflow: hidden;
}
.op-parked-header {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #e5e9f0;
border-bottom: 1px solid #d0d8e5;
padding: 6px 10px;
font-size: 12px;
font-weight: 700;
color: #444;
text-transform: uppercase;
letter-spacing: .4px;
}
.op-parked-badge {
font-size: 11px;
font-weight: 600;
background: #6c757d;
color: #fff;
padding: 2px 6px;
border-radius: 10px;
}
.op-parked-list {
padding: 8px;
max-height: 280px;
overflow: auto;
}
.op-parked-item {
border: 1px solid #d0d8e5;
border-radius: 4px;
background: #f8fbff;
padding: 6px 8px;
margin-bottom: 7px;
cursor: grab;
user-select: none;
line-height: 1.25;
}
.op-parked-item:last-child { margin-bottom: 0; }
.op-parked-item:hover { background: #eef5ff; border-color: #80bdff; }
.op-parked-main { font-size: 12px; font-weight: 600; color: #2c3e50; }
.op-parked-sub { font-size: 11px; color: #555; margin-top: 2px; }
.op-parked-drop-over {
box-shadow: 0 0 0 3px #0d6efd;
border-color: #0d6efd;
}
.op-parked-empty {
padding: 10px;
font-size: 12px;
color: #6c757d;
font-style: italic;
}
@media (max-width: 980px) {
#my_extensions_container,
#parked_side_container {
flex-basis: 100%;
min-width: 0;
}
}
/* HTML5 dialog styles */
.op-dialog {
border: 1px solid #ccc;
border-radius: 8px;
padding: 0;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.op-dialog::backdrop {
background: rgba(0,0,0,0.4);
}
.op-dialog-sm {
max-width: 320px;
}
.op-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #ddd;
}
.op-dialog-header h5 {
margin: 0;
font-size: 1.1rem;
}
.op-dialog-close {
background: none;
border: none;
font-size: 1.4rem;
cursor: pointer;
line-height: 1;
padding: 0 4px;
opacity: 0.6;
}
.op-dialog-close:hover { opacity: 1; }
.op-dialog-body {
padding: 16px;
}
.op-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid #ddd;
}
.op-dialog-input {
width: 100%;
padding: 6px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.95rem;
box-sizing: border-box;
}
.op-dialog-input:focus {
outline: 2px solid #0d6efd;
outline-offset: -1px;
}
.op-dialog-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.op-dialog-btn {
display: block;
width: 100%;
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 0.95rem;
cursor: pointer;
color: #fff;
}
.op-btn-primary { background: #0d6efd; }
.op-btn-primary:hover { background: #0b5ed7; }
.op-btn-success { background: #198754; }
.op-btn-success:hover { background: #157347; }
.op-btn-info { background: #0dcaf0; color: #000; }
.op-btn-info:hover { background: #31d2f2; }
.op-btn-secondary { background: #6c757d; }
.op-btn-secondary:hover { background: #5c636a; }
</style>
<!-- Bootstrap tabs: Extensions | Calls | Conferences | Agents -->
@@ -624,6 +920,15 @@ body.op-dragging, body.op-dragging * {
</button>
</li>
<?php endif; ?>
<?php if ($perm['operator_panel_extensions']): ?>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-parked" data-bs-toggle="tab" data-bs-target="#panel-parked"
type="button" role="tab" aria-controls="panel-parked" aria-selected="false">
<?= htmlspecialchars($text['label-parked_calls'] ?? 'Parked Calls') ?>
<span id="parked_count" class="badge ms-1" style="background:#6c757d;color:#fff;">0</span>
</button>
</li>
<?php endif; ?>
<?php if ($perm['operator_panel_conferences']): ?>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-conferences" data-bs-toggle="tab" data-bs-target="#panel-conferences"
@@ -656,8 +961,28 @@ body.op-dragging, body.op-dragging * {
</button>
<div id="group_filter_buttons" class="op-group-filters"></div>
<input type="text" id="extensions_text_filter" class="op-text-filter" placeholder="<?= htmlspecialchars($text['label-filter'] ?? 'Filter...') ?>" oninput="apply_extension_filters()">
<?php if ($perm['operator_panel_manage']): ?>
<!-- <div class="op-transfer-mode" id="transfer_mode_control"> -->
<!-- <span class="op-transfer-mode-label"><?= htmlspecialchars($text['label-transfer_mode'] ?? 'Transfer') ?>:</span> -->
<?php if ($perm['operator_panel_transfer_attended']): ?>
<!-- <button type="button" class="op-transfer-mode-btn active" id="btn_transfer_mode_toggle" onclick="toggle_transfer_mode()" -->
<!-- title="<?= htmlspecialchars($text['label-blind_transfer_title'] ?? 'Blind transfer: immediately connect the call to the destination') ?>"> -->
<!-- <?= htmlspecialchars($text['label-blind_transfer'] ?? 'Blind') ?> -->
<!-- </button> -->
<?php else: ?>
<!-- <span class="op-transfer-mode-btn active" style="cursor:default;" title="<?= htmlspecialchars($text['label-blind_transfer_title'] ?? 'Blind transfer: immediately connect the call to the destination') ?>"> -->
<!-- <?= htmlspecialchars($text['label-blind_transfer'] ?? 'Blind') ?> -->
<!-- </span> -->
<?php endif; ?>
<!-- </div> -->
<?php endif; ?>
</div>
<div class="op-top-row" id="extensions_top_row">
<div id="my_extensions_container"></div>
<div id="parked_side_container">
<p class="text-muted"><?= htmlspecialchars($text['label-connecting'] ?? 'Connecting...') ?></p>
</div>
</div>
<div id="my_extensions_container"></div>
<div id="extensions_container">
<p class="text-muted"><?= htmlspecialchars($text['label-connecting'] ?? 'Connecting...') ?></p>
</div>
@@ -677,6 +1002,19 @@ body.op-dragging, body.op-dragging * {
</div>
<?php endif; ?>
<!-- PARKED CALLS TAB -->
<?php if ($perm['operator_panel_extensions']): ?>
<div class="tab-pane fade" id="panel-parked" role="tabpanel" aria-labelledby="tab-parked">
<div id="parked_filter_bar" class="op-filter-bar" style="display:none;">
<div id="group_filter_buttons_parked" class="op-group-filters"></div>
<input type="text" id="parked_text_filter" class="op-text-filter" placeholder="<?= htmlspecialchars($text['label-filter'] ?? 'Filter...') ?>" oninput="apply_parked_filters()">
</div>
<div id="parked_container">
<p class="text-muted"><?= htmlspecialchars($text['label-connecting'] ?? 'Connecting...') ?></p>
</div>
</div>
<?php endif; ?>
<!-- CONFERENCES TAB -->
<?php if ($perm['operator_panel_conferences']): ?>
<div class="tab-pane fade" id="panel-conferences" role="tabpanel" aria-labelledby="tab-conferences">
@@ -705,33 +1043,69 @@ body.op-dragging, body.op-dragging * {
</div>
<!-- Transfer modal -->
<div class="modal fade" id="transfer_modal" tabindex="-1" aria-labelledby="transfer_modal_label" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content" style="background:var(--bs-body-bg);">
<div class="modal-header">
<h5 class="modal-title" id="transfer_modal_label"><?= htmlspecialchars($text['label-transfer'] ?? 'Transfer Call') ?></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<label for="transfer_destination" class="form-label" style="font-weight:600;">
<?= htmlspecialchars($text['label-destination'] ?? 'Destination') ?>
</label>
<input type="text" id="transfer_destination" class="form-control" placeholder="1001"
autocomplete="off" autofocus>
<input type="hidden" id="transfer_uuid">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<?= htmlspecialchars($text['button-cancel'] ?? 'Cancel') ?>
</button>
<button type="button" class="btn btn-primary" onclick="confirm_transfer()">
<?= htmlspecialchars($text['button-transfer'] ?? 'Transfer') ?>
</button>
</div>
<!-- Right-click context menu -->
<div id="op_context_menu" class="op-ctx-menu" role="menu" aria-label="<?= htmlspecialchars($text['label-actions'] ?? 'Actions') ?>"></div>
<!-- Attended transfer consultation bar -->
<div id="attended_transfer_bar" class="op-att-bar" style="display:none;">
<i class="fa-solid fa-phone-volume op-att-icon"></i>
<span class="op-att-label"></span>
<button type="button" class="op-att-btn op-att-complete" onclick="complete_attended_transfer()">
<i class="fa-solid fa-check"></i> <?= htmlspecialchars($text['button-complete_transfer'] ?? 'Complete Transfer') ?>
</button>
<button type="button" class="op-att-btn op-att-cancel" onclick="cancel_attended_transfer()">
<i class="fa-solid fa-xmark"></i> <?= htmlspecialchars($text['button-cancel_transfer'] ?? 'Cancel') ?>
</button>
</div>
<!-- Transfer dialog -->
<dialog id="transfer_dialog" class="op-dialog">
<div class="op-dialog-header">
<h5><?= htmlspecialchars($text['label-transfer'] ?? 'Transfer Call') ?></h5>
<button type="button" class="op-dialog-close" onclick="document.getElementById('transfer_dialog').close()" aria-label="Close">&times;</button>
</div>
<div class="op-dialog-body">
<label for="transfer_destination" style="font-weight:600; display:block; margin-bottom:4px;">
<?= htmlspecialchars($text['label-destination'] ?? 'Destination') ?>
</label>
<input type="text" id="transfer_destination" class="op-dialog-input" placeholder="1001" autocomplete="off">
<input type="hidden" id="transfer_uuid">
<input type="hidden" id="transfer_source_extension">
</div>
<div class="op-dialog-footer">
<button type="button" class="op-dialog-btn op-btn-secondary" onclick="document.getElementById('transfer_dialog').close()">
<?= htmlspecialchars($text['button-cancel'] ?? 'Cancel') ?>
</button>
<button type="button" class="op-dialog-btn op-btn-primary" onclick="confirm_transfer()">
<?= htmlspecialchars($text['button-transfer'] ?? 'Transfer') ?>
</button>
</div>
</dialog>
<!-- Ringing Action dialog -->
<dialog id="ringing_action_dialog" class="op-dialog op-dialog-sm">
<div class="op-dialog-header">
<h5><?= htmlspecialchars($text['label-choose_action'] ?? 'Choose Action') ?></h5>
<button type="button" class="op-dialog-close" onclick="document.getElementById('ringing_action_dialog').close()" aria-label="Close">&times;</button>
</div>
<div class="op-dialog-body" style="text-align:center;">
<p id="ringing_action_description" style="margin-bottom:12px;"></p>
<div class="op-dialog-actions">
<button type="button" class="op-dialog-btn op-btn-success" id="ringing_action_intercept">
<?= htmlspecialchars($text['button-intercept'] ?? 'Intercept') ?>
</button>
<button type="button" class="op-dialog-btn op-btn-primary" id="ringing_action_call">
<?= htmlspecialchars($text['button-call'] ?? 'Call') ?>
</button>
<button type="button" class="op-dialog-btn op-btn-info" id="ringing_action_eavesdrop">
<?= htmlspecialchars($text['label-eavesdrop'] ?? 'Eavesdrop') ?>
</button>
<button type="button" class="op-dialog-btn op-btn-secondary" onclick="document.getElementById('ringing_action_dialog').close()">
<?= htmlspecialchars($text['button-cancel'] ?? 'Cancel') ?>
</button>
</div>
</div>
</div>
</dialog>
<br><br>
@@ -172,7 +172,11 @@ class operator_panel_service extends base_websocket_system_service implements we
const permission_map = [
// Call actions
'hangup' => 'operator_panel_hangup',
'hangup_caller' => 'operator_panel_hangup',
'transfer' => 'operator_panel_manage',
'transfer_attended' => 'operator_panel_transfer_attended',
'transfer_attended_complete' => 'operator_panel_transfer_attended',
'transfer_attended_cancel' => 'operator_panel_transfer_attended',
'eavesdrop' => 'operator_panel_eavesdrop',
'whisper' => 'operator_panel_coach',
'barge' => 'operator_panel_coach',
@@ -180,6 +184,7 @@ class operator_panel_service extends base_websocket_system_service implements we
'recording_state' => 'operator_panel_record',
'registrations_state' => 'operator_panel_view',
'originate' => 'operator_panel_originate',
'intercept' => 'operator_panel_manage',
// Conference member actions
'mute' => 'operator_panel_manage',
'unmute' => 'operator_panel_manage',
@@ -262,6 +267,7 @@ class operator_panel_service extends base_websocket_system_service implements we
$this->on_topic('calls_active', [$this, 'request_calls_active']);
$this->on_topic('conferences_active', [$this, 'request_conferences_active']);
$this->on_topic('agents_active', [$this, 'request_agents_active']);
$this->on_topic('parked_active', [$this, 'request_parked_active']);
// Action handler (all mutations)
$this->on_topic('action', [$this, 'handle_action']);
// Keep-alive
@@ -443,7 +449,7 @@ class operator_panel_service extends base_websocket_system_service implements we
$this->broadcast_call_event($event_message, $topic);
break;
case 'valet_parking':
case 'valet_parking::info':
$this->broadcast_call_event($event_message, 'valet_info');
break;
@@ -528,7 +534,7 @@ class operator_panel_service extends base_websocket_system_service implements we
$user_status_map = [];
try {
$t_us0 = microtime(true);
$sql = "SELECT e.extension, eu.user_uuid, COALESCE(u.user_status, 'Logged Out') AS user_status "
$sql = "SELECT e.extension, eu.user_uuid, COALESCE(u.user_status, '') AS user_status "
. "FROM v_extensions AS e "
. "LEFT JOIN v_domains AS d ON e.domain_uuid = d.domain_uuid "
. "LEFT JOIN ("
@@ -552,7 +558,7 @@ class operator_panel_service extends base_websocket_system_service implements we
if ($ext_num === '') continue;
$user_status_map[$ext_num] = [
'user_uuid' => $row['user_uuid'] ?? null,
'user_status' => $row['user_status'] ?? 'Logged Out',
'user_status' => $row['user_status'] ?? '',
];
}
@@ -568,6 +574,7 @@ class operator_panel_service extends base_websocket_system_service implements we
$registered_map = [];
try {
$t_reg0 = microtime(true);
$normalized_domain_name = preg_replace('/:\d+$/', '', (string) $domain_name);
$this->debug('extensions_active trace [step4] fetching registrations via show registrations as json');
$reg_json = trim(event_socket::api('show registrations as json'));
$this->debug('extensions_active trace [step4] registrations api returned: bytes=' . strlen($reg_json)
@@ -576,9 +583,16 @@ class operator_panel_service extends base_websocket_system_service implements we
$reg_data = json_decode($reg_json, true);
if (is_array($reg_data) && !empty($reg_data['rows'])) {
foreach ($reg_data['rows'] as $row) {
$ext_num = $row['reg_user'] ?? '';
$reg_domain = $row['realm'] ?? '';
if (!empty($ext_num) && $reg_domain === $domain_name) {
$ext_num = trim((string) ($row['reg_user'] ?? ''));
$reg_domain = preg_replace('/:\d+$/', '', trim((string) ($row['realm'] ?? '')));
if (strpos($ext_num, '@') !== false) {
[$ext_num, $parsed_domain] = array_pad(explode('@', $ext_num, 2), 2, '');
$ext_num = trim((string) $ext_num);
if ($reg_domain === '' && $parsed_domain !== '') {
$reg_domain = preg_replace('/:\d+$/', '', trim((string) $parsed_domain));
}
}
if ($ext_num !== '' && $reg_domain === $normalized_domain_name) {
$registered_map[$ext_num] = ($registered_map[$ext_num] ?? 0) + 1;
}
}
@@ -717,6 +731,83 @@ class operator_panel_service extends base_websocket_system_service implements we
websocket_client::send($this->ws_client->socket(), $response);
}
/**
* Respond to a parked_active snapshot request.
*
* Uses valet_info to enumerate current parked slots for the subscriber's domain
* and enriches each parked UUID with caller details.
*
* @param websocket_message $message
*
* @return void
*/
protected function request_parked_active(websocket_message $message): void {
$this->debug('parked_active snapshot requested');
$payload = $message->payload();
$domain_name = $payload['domain_name'] ?? '';
$parked = [];
if ($domain_name !== '') {
$valet_info = event_socket::api('valet_info park@' . $domain_name);
if ($valet_info !== false && is_string($valet_info)) {
$matches = [];
preg_match_all('/<extension uuid="(.*?)">(.*?)<\/extension>/s', $valet_info, $matches, PREG_SET_ORDER);
foreach ($matches as $row) {
$uuid = $row[1] ?? '';
$extension = trim((string)($row[2] ?? ''));
if ($uuid === '' || $extension === '') continue;
$caller_name = trim((string)event_socket::api('uuid_getvar ' . $uuid . ' caller_id_name'));
if ($caller_name === '_undef_') $caller_name = '';
$caller_number = trim((string)event_socket::api('uuid_getvar ' . $uuid . ' caller_id_number'));
if ($caller_number === '_undef_') $caller_number = '';
$parked_by = trim((string)event_socket::api('uuid_getvar ' . $uuid . ' referred_by_user'));
if ($parked_by === '' || $parked_by === '_undef_') {
$parked_by = trim((string)event_socket::api('uuid_getvar ' . $uuid . ' valet_parking_orbit_exten'));
}
if ($parked_by === '_undef_') $parked_by = '';
$original_destination = trim((string)event_socket::api('uuid_getvar ' . $uuid . ' destination_number'));
if ($original_destination === '_undef_') $original_destination = '';
$created_epoch = trim((string)event_socket::api('uuid_getvar ' . $uuid . ' caller_channel_created_time'));
if ($created_epoch === '' || $created_epoch === '_undef_') {
// Fall back to start_uepoch (microseconds) or start_epoch (seconds)
$created_epoch = trim((string)event_socket::api('uuid_getvar ' . $uuid . ' start_uepoch'));
if ($created_epoch === '' || $created_epoch === '_undef_') {
$created_epoch = trim((string)event_socket::api('uuid_getvar ' . $uuid . ' start_epoch'));
if ($created_epoch === '_undef_') $created_epoch = '';
}
}
$parked[] = [
'unique_id' => $uuid,
'event_name' => 'valet_parking::snapshot',
'action' => 'hold',
'valet_extension' => $extension,
'caller_caller_id_name' => $caller_name,
'caller_caller_id_number' => $caller_number,
'caller_destination_number' => $original_destination,
'variable_referred_by_user' => $parked_by,
'caller_channel_created_time' => $created_epoch,
];
}
}
}
$response = new websocket_message();
$response
->payload($parked)
->service_name(self::get_service_name())
->topic('parked_active')
->status_string('ok')
->status_code(200)
->request_id($message->request_id())
->resource_id($message->resource_id())
;
websocket_client::send($this->ws_client->socket(), $response);
}
/**
* Respond to a conferences_active snapshot request.
*
@@ -842,7 +933,7 @@ class operator_panel_service extends base_websocket_system_service implements we
return;
}
$result = $this->execute_action($action, $payload, $permissions);
$result = $this->execute_action($action, $payload);
$status_message = isset($result['message']) ? (string)$result['message'] : '';
$success = (bool)($result['success'] ?? false);
$extra_payload = $result;
@@ -867,6 +958,9 @@ class operator_panel_service extends base_websocket_system_service implements we
$member_id = $payload['member_id'] ?? '';
$direction = $payload['direction'] ?? '';
// Debug action execution attempt with all relevant parameters
$this->debug("Executing action: $action, uuid: $uuid, destination: $destination, context: $context, conference_name: $conference_name, member_id: $member_id, direction: $direction");
try {
switch ($action) {
case 'hangup':
@@ -876,11 +970,28 @@ class operator_panel_service extends base_websocket_system_service implements we
event_socket::api("uuid_kill $uuid");
return ['success' => true, 'message' => 'Call terminated'];
case 'hangup_caller':
if (empty($uuid)) {
return ['success' => false, 'message' => 'UUID required'];
}
// Find the A-leg (caller) from the B-leg UUID
$a_leg = trim((string)event_socket::api("uuid_getvar $uuid other_leg_unique_id"));
if (empty($a_leg) || stripos($a_leg, '-ERR') !== false || $a_leg === '_undef_') {
$a_leg = trim((string)event_socket::api("uuid_getvar $uuid signal_bond"));
}
if (!empty($a_leg) && stripos($a_leg, '-ERR') === false && $a_leg !== '_undef_') {
event_socket::api("uuid_kill $a_leg");
$this->info("Hangup caller: killed A-leg $a_leg (B-leg was $uuid)");
return ['success' => true, 'message' => 'Caller terminated'];
}
// Fallback: kill the provided UUID
event_socket::api("uuid_kill $uuid");
return ['success' => true, 'message' => 'Call terminated'];
case 'transfer':
if (empty($uuid) || empty($destination)) {
return ['success' => false, 'message' => 'UUID and destination required'];
}
// Sanitize destination: digits, *, #, + only
if (!preg_match('/^[0-9*#+]+$/', $destination)) {
return ['success' => false, 'message' => 'Invalid destination'];
}
@@ -888,6 +999,85 @@ class operator_panel_service extends base_websocket_system_service implements we
event_socket::api("uuid_transfer $uuid {$bleg}$destination XML $context");
return ['success' => true, 'message' => 'Call transferred'];
case 'transfer_attended':
if (empty($uuid) || empty($destination)) {
return ['success' => false, 'message' => 'UUID and destination required'];
}
if (!preg_match('/^[0-9*#+]+$/', $destination)) {
return ['success' => false, 'message' => 'Invalid destination'];
}
$xfer_domain = $domain_name !== '' ? $domain_name : $context;
$reply = trim((string)event_socket::api("uuid_broadcast $uuid att_xfer::user/$destination@$xfer_domain both"));
$this->debug("transfer_attended reply: $reply");
if (stripos($reply, '-ERR') !== false) {
return ['success' => false, 'message' => $reply];
}
return ['success' => true, 'message' => 'Attended transfer started'];
case 'transfer_attended_complete':
// Attended transfer — Step 2: Complete the transfer
// Bridge the parked caller to the destination (other side of operator's current call).
$parked_uuid = $payload['parked_uuid'] ?? '';
$operator_uuid_val = $payload['operator_uuid'] ?? '';
if (empty($parked_uuid) || empty($operator_uuid_val)) {
return ['success' => false, 'message' => 'parked_uuid and operator_uuid required'];
}
// Find the destination's channel (other side of operator's consultation call)
$dest_uuid = trim((string)event_socket::api("uuid_getvar $operator_uuid_val other_leg_unique_id"));
if (empty($dest_uuid) || stripos($dest_uuid, '-ERR') !== false || $dest_uuid === '_undef_') {
$dest_uuid = trim((string)event_socket::api("uuid_getvar $operator_uuid_val signal_bond"));
}
if (!empty($dest_uuid) && stripos($dest_uuid, '-ERR') === false && $dest_uuid !== '_undef_') {
// Destination answered — bridge parked caller to destination
event_socket::api("uuid_bridge $parked_uuid $dest_uuid");
// Disconnect operator cleanly
event_socket::api("uuid_kill $operator_uuid_val");
$this->info("Attended transfer complete: bridged $parked_uuid to $dest_uuid");
return ['success' => true, 'message' => 'Transfer completed'];
}
// Destination not yet answered — blind-transfer the parked caller instead
$dest_ext = $payload['destination'] ?? '';
if (!empty($dest_ext) && preg_match('/^[0-9*#+]+$/', $dest_ext)) {
event_socket::api("uuid_kill $operator_uuid_val");
$xfer_context = $payload['context'] ?? ($domain_name !== '' ? $domain_name : 'default');
event_socket::api("uuid_transfer $parked_uuid $dest_ext XML $xfer_context");
$this->info("Attended transfer complete (dest not answered): blind-transferred $parked_uuid to $dest_ext");
return ['success' => true, 'message' => 'Transfer completed (destination still ringing)'];
}
return ['success' => false, 'message' => 'Cannot find destination channel'];
case 'transfer_attended_cancel':
// Attended transfer — Cancel: hang up consultation, reconnect caller to operator
$parked_uuid = $payload['parked_uuid'] ?? '';
$operator_uuid_val = $payload['operator_uuid'] ?? '';
$source_ext = $payload['source_extension'] ?? '';
if (empty($parked_uuid)) {
return ['success' => false, 'message' => 'parked_uuid required'];
}
// Kill the operator's consultation call (also terminates the destination leg)
if (!empty($operator_uuid_val)) {
event_socket::api("uuid_kill $operator_uuid_val");
}
// Reconnect the parked caller back to the operator's extension
if (!empty($source_ext) && !empty($domain_name)) {
$originate_cmd = "bgapi originate user/{$source_ext}@{$domain_name} &bridge({$parked_uuid})";
$reply = trim((string)event_socket::api($originate_cmd));
if (stripos($reply, '-ERR') === false) {
$this->info("Attended transfer cancelled: reconnecting $parked_uuid via $source_ext");
return ['success' => true, 'message' => 'Transfer cancelled, reconnecting'];
}
}
// Fallback: could not reconnect — just kill the parked call
event_socket::api("uuid_kill $parked_uuid");
$this->warning("Attended transfer cancelled: could not reconnect, killed parked call $parked_uuid");
return ['success' => true, 'message' => 'Transfer cancelled'];
case 'eavesdrop':
if (empty($uuid) || empty($destination)) {
return ['success' => false, 'message' => 'UUID and destination extension required'];
@@ -992,14 +1182,22 @@ class operator_panel_service extends base_websocket_system_service implements we
if (empty($domain_name)) {
return ['success' => false, 'message' => 'domain_name required'];
}
$normalized_domain_name = preg_replace('/:\d+$/', '', (string) $domain_name);
$states = [];
$reg_json = trim((string) event_socket::api('show registrations as json'));
$reg_data = json_decode($reg_json, true);
if (is_array($reg_data) && !empty($reg_data['rows']) && is_array($reg_data['rows'])) {
foreach ($reg_data['rows'] as $row) {
$ext_num = trim((string)($row['reg_user'] ?? ''));
$reg_domain = trim((string)($row['realm'] ?? ''));
if ($ext_num === '' || $reg_domain !== $domain_name) continue;
$ext_num = trim((string) ($row['reg_user'] ?? ''));
$reg_domain = preg_replace('/:\d+$/', '', trim((string) ($row['realm'] ?? '')));
if (strpos($ext_num, '@') !== false) {
[$ext_num, $parsed_domain] = array_pad(explode('@', $ext_num, 2), 2, '');
$ext_num = trim((string) $ext_num);
if ($reg_domain === '' && $parsed_domain !== '') {
$reg_domain = preg_replace('/:\d+$/', '', trim((string) $parsed_domain));
}
}
if ($ext_num === '' || $reg_domain !== $normalized_domain_name) continue;
$states[$ext_num] = ($states[$ext_num] ?? 0) + 1;
}
}
@@ -1068,6 +1266,40 @@ class operator_panel_service extends base_websocket_system_service implements we
}
return ['success' => true, 'message' => 'Volume updated'];
case 'intercept':
if (empty($uuid) || empty($destination)) {
return ['success' => false, 'message' => 'UUID and destination extension required'];
}
$dest_ext = $payload['destination_extension'] ?? $destination;
if (!preg_match('/^[0-9*#+]+$/', $dest_ext)) {
return ['success' => false, 'message' => 'Invalid destination extension'];
}
if (empty($domain_name)) {
return ['success' => false, 'message' => 'domain_name required'];
}
// Find the A-leg (caller) by querying the B-leg's other_leg_unique_id
$a_leg = trim((string)event_socket::api("uuid_getvar $uuid other_leg_unique_id"));
if (empty($a_leg) || stripos($a_leg, '-ERR') !== false || $a_leg === '_undef_') {
$a_leg = trim((string)event_socket::api("uuid_getvar $uuid bridge_uuid"));
}
if (empty($a_leg) || stripos($a_leg, '-ERR') !== false || $a_leg === '_undef_') {
$a_leg = trim((string)event_socket::api("uuid_getvar $uuid signal_bond"));
}
if (!empty($a_leg) && stripos($a_leg, '-ERR') === false && $a_leg !== '_undef_') {
// Transfer the A-leg to the interceptor's extension
$reply = trim((string)event_socket::api("uuid_transfer $a_leg $dest_ext XML $context"));
$this->info("Intercept via A-leg transfer: a_leg=$a_leg dest=$dest_ext@$domain_name");
} else {
// Last resort: transfer the B-leg itself to the interceptor
$reply = trim((string)event_socket::api("uuid_transfer $uuid $dest_ext XML $context"));
$this->info("Intercept via B-leg transfer: uuid=$uuid dest=$dest_ext@$domain_name");
}
$this->debug("Intercept command reply: $reply");
if (stripos($reply, '-ERR') !== false) {
return ['success' => false, 'message' => $reply];
}
return ['success' => true, 'message' => 'Call intercepted'];
case 'originate':
$source = $payload['source'] ?? '';
$dest = $payload['destination'] ?? '';
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Intercept">
<defs>
<linearGradient id="ig1" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#68d391"/>
<stop offset="100%" stop-color="#38a169"/>
</linearGradient>
</defs>
<!-- Phone handset -->
<path d="M18 46 C10 42, 8 34, 12 26 L16 28 C14 34, 16 38, 20 42 Z" fill="url(#ig1)"/>
<path d="M46 18 C42 10, 34 8, 26 12 L28 16 C34 14, 38 16, 42 20 Z" fill="url(#ig1)"/>
<line x1="18" y1="46" x2="46" y2="18" stroke="url(#ig1)" stroke-width="5" stroke-linecap="round"/>
<!-- Arrow pointing down-left indicating "grab" -->
<polyline points="28,50 18,50 18,40" fill="none" stroke="#2d3748" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="18" y1="50" x2="34" y2="34" stroke="#2d3748" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 925 B

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Reject">
<defs>
<linearGradient id="rg1" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#fc8181"/>
<stop offset="100%" stop-color="#e53e3e"/>
</linearGradient>
</defs>
<!-- Phone handset rotated to "hang up" position -->
<path d="M12 38 C12 28, 18 22, 32 20 C46 22, 52 28, 52 38 L48 40 C46 34, 40 30, 32 28 C24 30, 18 34, 16 40 Z" fill="url(#rg1)"/>
<!-- X mark overlay -->
<line x1="22" y1="42" x2="42" y2="54" stroke="#c53030" stroke-width="4" stroke-linecap="round"/>
<line x1="42" y1="42" x2="22" y2="54" stroke="#c53030" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 743 B

File diff suppressed because it is too large Load Diff