diff --git a/app/operator_panel/index.php b/app/operator_panel/index.php index 490d7e245..71cda054d 100644 --- a/app/operator_panel/index.php +++ b/app/operator_panel/index.php @@ -783,21 +783,52 @@ body.op-dragging, body.op-dragging * { padding: 8px; max-height: 280px; overflow: auto; + display: flex; + flex-wrap: wrap; + gap: 8px; } .op-parked-item { - border: 1px solid #d0d8e5; - border-radius: 4px; - background: #f8fbff; - padding: 6px 8px; - margin-bottom: 7px; + width: 235px; + min-height: 50px; + border-style: solid; + border-width: 1px 3px; + border-color: #77d779; + border-radius: 5px; + background: #baf4bb; + box-shadow: 0 0 3px #c8cdd9; + padding: 5px 8px; cursor: grab; user-select: none; - line-height: 1.25; + line-height: 1.2; + position: relative; + overflow: hidden; +} +.op-parked-item:hover { background: #c8f6c9; border-color: #4fc453; } +.op-parked-main { + font-size: 12px; + font-weight: 700; + color: #3164AD; + padding-right: 74px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.op-parked-sub { + font-size: 10px; + color: #444; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.op-parked-duration { + position: absolute; + top: 6px; + right: 8px; + font-size: 12px; + color: #4a4a4a; + line-height: 1; } -.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; diff --git a/app/operator_panel/resources/javascript/operator_panel.js b/app/operator_panel/resources/javascript/operator_panel.js index 3320ffacf..e31776526 100644 --- a/app/operator_panel/resources/javascript/operator_panel.js +++ b/app/operator_panel/resources/javascript/operator_panel.js @@ -91,6 +91,10 @@ let dragged_parked_uuid = null; /** UUIDs recently removed from parked state; suppressed from snapshots briefly. */ const parked_suppress_map = new Map(); +/** Persistent store of the first valid parked_since_us seen per UUID. + * Survives parked_calls_map.clear() so duration never resets on snapshot refresh. */ +const parked_since_known = new Map(); + /** Current user's status; this is the only status source for My Extensions cards. */ let current_user_status = user_status.trim(); @@ -530,10 +534,44 @@ function get_conference_action_icon(name, fallback) { return fallback; } +/** Normalize epoch-like timestamps to Unix microseconds as a numeric string. */ +function normalize_epoch_us(raw_value) { + if (raw_value === null || raw_value === undefined) return '0'; + const value = String(raw_value).trim(); + if (!value) return '0'; + + // Fast path: pure integer string (seconds/ms/us/ns). + if (/^\d+$/.test(value)) { + if (value.length <= 10) return `${value}000000`; // seconds -> us + if (value.length <= 13) return `${value}000`; // ms -> us + if (value.length <= 16) return value; // already us + if (value.length <= 19) return String(Math.floor(Number(value) / 1000)); // ns -> us + return value.slice(0, 16); + } + + // Common case from some APIs: decimal seconds (e.g. 1711864980.123456). + if (/^\d+\.\d+$/.test(value)) { + const float_val = Number(value); + if (Number.isFinite(float_val) && float_val > 0) { + return String(Math.floor(float_val * 1000000)); + } + } + + // Fallback: pull a 10-19 digit run from mixed strings. + const match = value.match(/(\d{10,19})/); + if (match && match[1]) { + return normalize_epoch_us(match[1]); + } + + return '0'; +} + /** Format a Unix microsecond timestamp as elapsed time hh:mm:ss */ function format_elapsed(us_timestamp) { - if (!us_timestamp || us_timestamp === '0') return '--:--:--'; - const start = Math.floor(Number(us_timestamp) / 1000000); + const normalized_us = normalize_epoch_us(us_timestamp); + if (!normalized_us || normalized_us === '0') return '--:--:--'; + const start = Math.floor(Number(normalized_us) / 1000000); + if (!Number.isFinite(start) || start <= 0) return '--:--:--'; const now = Math.floor(Date.now() / 1000); let sec = Math.max(0, now - start); const h = Math.floor(sec / 3600); sec -= h * 3600; @@ -545,7 +583,7 @@ function format_elapsed(us_timestamp) { function tick_durations() { document.querySelectorAll('[data-created]').forEach(el => { const ts = el.getAttribute('data-created'); - if (ts && ts !== '0') { + if (normalize_epoch_us(ts) !== '0') { el.textContent = format_elapsed(ts); } }); @@ -584,18 +622,16 @@ function get_parked_since_us(ch) { ch.parked_epoch, ch.variable_parked_epoch, ch.variable_park_epoch, - ch.event_date_timestamp, ch.caller_channel_created_time, + ch.variable_start_uepoch, + ch.start_uepoch, + ch.variable_start_epoch, + ch.start_epoch, + ch.event_date_timestamp, ]; for (const value of candidates) { - if (!value) continue; - const raw = String(value).trim(); - if (!raw) continue; - if (/^\d+$/.test(raw)) { - if (raw.length <= 10) return `${raw}000000`; - if (raw.length <= 13) return `${raw}000`; - return raw; - } + const normalized = normalize_epoch_us(value); + if (normalized !== '0') return normalized; } return '0'; } @@ -690,12 +726,23 @@ function upsert_parked_call(ch) { parked_suppress_map.delete(normalized.uuid); } const current = parked_calls_map.get(normalized.uuid) || {}; + + // Keep the first valid parked_since_us we ever saw for this UUID. + // parked_since_known persists across parked_calls_map.clear() so snapshot + // refreshes and rebuild_parked_calls_map cannot wipe the live duration. + if (normalized.parked_since_us && normalized.parked_since_us !== '0') { + parked_since_known.set(normalized.uuid, normalized.parked_since_us); + } else if (parked_since_known.has(normalized.uuid)) { + normalized.parked_since_us = parked_since_known.get(normalized.uuid); + } + parked_calls_map.set(normalized.uuid, Object.assign(current, normalized)); } function remove_parked_call_by_uuid(uuid) { if (!uuid) return; parked_calls_map.delete(uuid); + parked_since_known.delete(uuid); // Suppress this UUID from being re-added by snapshots for a short window parked_suppress_map.set(uuid, Date.now() + 6000); } @@ -862,18 +909,17 @@ function render_parked_side_card() { parked_calls.forEach(p => { const uuid = esc(p.uuid); const caller = esc((p.caller_id_name || '').trim() || (p.caller_id_number || 'Unknown')); - const caller_num = esc(p.caller_id_number || '-'); const lot = esc(p.parking_lot || '-'); + const dest = esc(p.original_destination || p.caller_id_number || '-'); const parked_by = esc(p.parked_by || '-'); - const dest = esc(p.original_destination || '-'); + const parked_since = esc(p.parked_since_us || '0'); html += `
`; - html += `
${caller}
`; - html += `
${esc(text['label-caller_id'] || 'Caller ID')}: ${caller_num}
`; - html += `
${esc(text['label-parking_lot'] || 'Parking Lot')}: ${lot}
`; - html += `
${esc(text['label-duration'] || 'Duration')}: ${esc(format_elapsed(p.parked_since_us || '0'))}
`; + html += `${esc(format_elapsed(parked_since))}`; + html += `
${lot}
`; + html += `
${caller}
`; + html += `
${esc(text['label-on_call'] || 'On Call')}: ${dest}
`; html += `
${esc(text['label-parked_by'] || 'Parked By')}: ${parked_by}
`; - html += `
${esc(text['label-original_destination'] || 'Original Destination')}: ${dest}
`; html += `
`; }); html += ``;