botui/ui/suite/attendant/index.html

2935 lines
108 KiB
HTML
Raw Permalink Normal View History

<!doctype html>
2025-12-03 18:42:22 -03:00
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Attendant Console - General Bots</title>
<style>
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--bg-quaternary: #475569;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--border-color: #334155;
--accent-color: #3b82f6;
--accent-hover: #2563eb;
--success-color: #10b981;
--warning-color: #f59e0b;
--error-color: #ef4444;
--info-color: #06b6d4;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
}
/* Main Layout */
.attendant-layout {
display: grid;
grid-template-columns: 320px 1fr 380px;
height: 100vh;
}
/* CRM Disabled State */
.crm-disabled {
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
text-align: center;
padding: 40px;
}
.crm-disabled.active {
display: flex;
}
.crm-disabled-icon {
font-size: 64px;
margin-bottom: 24px;
opacity: 0.5;
}
.crm-disabled h2 {
font-size: 24px;
margin-bottom: 12px;
}
.crm-disabled p {
color: var(--text-secondary);
max-width: 500px;
line-height: 1.6;
margin-bottom: 24px;
}
.crm-disabled code {
display: block;
background: var(--bg-tertiary);
padding: 16px 24px;
border-radius: 8px;
font-family: "Monaco", "Menlo", monospace;
font-size: 14px;
margin-top: 16px;
}
/* Left Sidebar - Queue */
.queue-sidebar {
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden;
}
.queue-header {
padding: 20px;
border-bottom: 1px solid var(--border-color);
}
.queue-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.queue-title svg {
width: 24px;
height: 24px;
}
/* Attendant Status Selector */
.attendant-status {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg-tertiary);
border-radius: 8px;
cursor: pointer;
position: relative;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--success-color);
transition: background 0.3s;
}
.status-indicator.online {
background: var(--success-color);
}
.status-indicator.busy {
background: var(--warning-color);
}
.status-indicator.away {
background: var(--text-muted);
}
.status-indicator.offline {
background: var(--error-color);
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.1);
}
}
.status-indicator.online {
animation: pulse 2s infinite;
}
.status-info {
flex: 1;
}
.status-name {
font-weight: 500;
font-size: 14px;
}
.status-text {
font-size: 12px;
color: var(--text-secondary);
}
.status-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-tertiary);
border-radius: 8px;
margin-top: 4px;
overflow: hidden;
display: none;
z-index: 100;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.status-dropdown.show {
display: block;
}
.status-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
cursor: pointer;
transition: background 0.2s;
}
.status-option:hover {
background: var(--bg-quaternary);
}
/* Queue Stats */
.queue-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.stat-item {
text-align: center;
padding: 8px;
background: var(--bg-tertiary);
border-radius: 6px;
}
.stat-value {
font-size: 20px;
font-weight: 600;
color: var(--accent-color);
}
.stat-label {
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Queue Filters */
.queue-filters {
padding: 12px 20px;
display: flex;
gap: 8px;
border-bottom: 1px solid var(--border-color);
flex-wrap: wrap;
}
.filter-btn {
padding: 6px 12px;
border: none;
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.filter-btn:hover {
background: var(--bg-quaternary);
}
.filter-btn.active {
background: var(--accent-color);
color: white;
}
.filter-btn .badge {
background: rgba(255, 255, 255, 0.2);
padding: 2px 6px;
border-radius: 10px;
font-size: 11px;
}
/* Conversation List */
.conversation-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.conversation-item {
padding: 14px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 4px;
position: relative;
}
.conversation-item:hover {
background: var(--bg-tertiary);
}
.conversation-item.active {
background: var(--accent-color);
}
.conversation-item.active .conversation-time,
.conversation-item.active .conversation-preview,
.conversation-item.active .channel-tag {
color: rgba(255, 255, 255, 0.8);
}
.conversation-item.unread::before {
content: "";
position: absolute;
left: 4px;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
background: var(--accent-color);
border-radius: 50%;
}
.conversation-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 6px;
}
.customer-name {
font-weight: 500;
font-size: 14px;
}
.conversation-time {
font-size: 11px;
color: var(--text-muted);
}
.conversation-preview {
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 8px;
}
.conversation-meta {
display: flex;
gap: 8px;
align-items: center;
}
.channel-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
background: var(--bg-quaternary);
color: var(--text-secondary);
}
.channel-whatsapp {
background: #25d366;
color: white;
}
.channel-teams {
background: #6264a7;
color: white;
}
.channel-instagram {
background: linear-gradient(
45deg,
#f09433,
#e6683c,
#dc2743,
#cc2366,
#bc1888
);
color: white;
}
.channel-web {
background: var(--accent-color);
color: white;
}
.channel-telegram {
background: #0088cc;
color: white;
}
.channel-email {
background: #ea4335;
color: white;
}
.priority-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
}
.priority-high {
background: var(--error-color);
color: white;
}
.priority-urgent {
background: #dc2626;
color: white;
animation: pulse 1s infinite;
}
.priority-normal {
background: var(--bg-quaternary);
color: var(--text-secondary);
}
.waiting-time {
font-size: 11px;
color: var(--text-muted);
}
.waiting-time.long {
color: var(--warning-color);
}
/* Empty Queue State */
.empty-queue {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
color: var(--text-secondary);
}
.empty-queue svg {
width: 64px;
height: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
/* Chat Area */
.chat-area {
display: flex;
flex-direction: column;
background: var(--bg-primary);
position: relative;
}
.no-conversation {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
text-align: center;
}
.no-conversation svg {
width: 80px;
height: 80px;
margin-bottom: 16px;
opacity: 0.3;
}
.chat-header {
padding: 16px 20px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.customer-info {
display: flex;
align-items: center;
gap: 12px;
}
.customer-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent-color);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 16px;
}
.customer-details h3 {
font-size: 16px;
font-weight: 500;
}
.customer-status-line {
font-size: 12px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
}
.typing-indicator {
display: inline-flex;
gap: 3px;
}
.typing-indicator span {
width: 6px;
height: 6px;
background: var(--text-secondary);
border-radius: 50%;
animation: typing 1.4s infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%,
60%,
100% {
transform: translateY(0);
}
30% {
transform: translateY(-4px);
}
}
.chat-actions {
display: flex;
gap: 8px;
}
.action-btn {
padding: 8px 12px;
border: none;
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.action-btn:hover {
background: var(--bg-quaternary);
}
.action-btn.primary {
background: var(--success-color);
}
.action-btn.primary:hover {
background: #059669;
}
.action-btn.danger {
background: var(--error-color);
}
.action-btn.danger:hover {
background: #dc2626;
}
/* Chat Messages */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
display: flex;
gap: 12px;
max-width: 80%;
}
.message.customer {
align-self: flex-start;
}
.message.attendant {
align-self: flex-end;
flex-direction: row-reverse;
}
.message.bot {
align-self: flex-start;
}
.message.system {
align-self: center;
max-width: 100%;
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
}
.message.bot .message-avatar {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.message-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.message-bubble {
padding: 12px 16px;
border-radius: 12px;
background: var(--bg-tertiary);
line-height: 1.5;
font-size: 14px;
}
.message.customer .message-bubble {
border-bottom-left-radius: 4px;
}
.message.attendant .message-bubble {
background: var(--accent-color);
border-bottom-right-radius: 4px;
}
.message.bot .message-bubble {
background: linear-gradient(
135deg,
rgba(102, 126, 234, 0.2),
rgba(118, 75, 162, 0.2)
);
border: 1px solid rgba(102, 126, 234, 0.3);
}
.message.system .message-bubble {
background: var(--bg-secondary);
font-size: 12px;
color: var(--text-secondary);
padding: 8px 16px;
}
.message-meta {
display: flex;
gap: 8px;
font-size: 11px;
color: var(--text-muted);
}
.message.attendant .message-meta {
justify-content: flex-end;
}
.bot-badge {
font-size: 10px;
padding: 2px 6px;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
border-radius: 4px;
}
/* Chat Input */
.chat-input-area {
padding: 16px 20px;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
}
.quick-responses {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.quick-response-btn {
padding: 6px 12px;
border: 1px solid var(--border-color);
border-radius: 16px;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.quick-response-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.input-wrapper {
display: flex;
gap: 12px;
align-items: flex-end;
}
.input-actions {
display: flex;
gap: 4px;
}
.input-action-btn {
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: var(--bg-tertiary);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.input-action-btn:hover {
background: var(--bg-quaternary);
color: var(--text-primary);
}
.chat-input {
flex: 1;
padding: 12px 16px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 14px;
resize: none;
min-height: 44px;
max-height: 120px;
font-family: inherit;
}
.chat-input:focus {
outline: none;
border-color: var(--accent-color);
}
.chat-input::placeholder {
color: var(--text-muted);
}
.send-btn {
width: 44px;
height: 44px;
border: none;
border-radius: 12px;
background: var(--accent-color);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.send-btn:hover {
background: var(--accent-hover);
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Right Sidebar - Insights */
.insights-sidebar {
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.sidebar-section {
padding: 20px;
border-bottom: 1px solid var(--border-color);
}
.section-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
/* AI Insights */
.ai-insight {
background: linear-gradient(
135deg,
rgba(102, 126, 234, 0.1),
rgba(118, 75, 162, 0.1)
);
border: 1px solid rgba(102, 126, 234, 0.2);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.insight-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.insight-icon {
width: 20px;
height: 20px;
}
.insight-label {
font-size: 12px;
color: var(--text-secondary);
}
.insight-value {
font-size: 14px;
color: var(--text-primary);
}
/* Suggested Replies */
.suggested-reply {
background: var(--bg-tertiary);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s;
}
.suggested-reply:hover {
background: var(--bg-quaternary);
}
.suggested-reply-text {
font-size: 13px;
line-height: 1.4;
margin-bottom: 6px;
}
.suggestion-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.suggestion-confidence {
font-size: 11px;
color: var(--success-color);
}
.suggestion-source {
font-size: 11px;
color: var(--text-muted);
}
/* Customer Details */
.customer-detail-item {
margin-bottom: 16px;
}
.detail-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.detail-value {
font-size: 14px;
color: var(--text-primary);
}
.detail-value a {
color: var(--accent-color);
text-decoration: none;
}
.detail-value a:hover {
text-decoration: underline;
}
/* Conversation History */
.history-item {
padding: 12px;
background: var(--bg-tertiary);
border-radius: 8px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s;
}
.history-item:hover {
background: var(--bg-quaternary);
}
.history-header {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
}
.history-date {
font-size: 12px;
color: var(--text-secondary);
}
.history-status {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: var(--success-color);
color: white;
}
.history-summary {
font-size: 13px;
color: var(--text-primary);
}
/* Sentiment */
.sentiment-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
}
.sentiment-positive {
background: rgba(16, 185, 129, 0.2);
color: var(--success-color);
}
.sentiment-neutral {
background: rgba(148, 163, 184, 0.2);
color: var(--text-secondary);
}
.sentiment-negative {
background: rgba(239, 68, 68, 0.2);
color: var(--error-color);
}
/* Tags */
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
padding: 4px 10px;
background: var(--bg-tertiary);
border-radius: 12px;
font-size: 12px;
color: var(--text-secondary);
}
/* Transfer Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-overlay.show {
display: flex;
}
.modal {
background: var(--bg-secondary);
border-radius: 12px;
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
padding: 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
font-size: 18px;
font-weight: 600;
}
.modal-close {
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: var(--bg-tertiary);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.modal-body {
padding: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.form-input,
.form-select {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 14px;
}
.form-input:focus,
.form-select:focus {
outline: none;
border-color: var(--accent-color);
}
2025-12-03 18:42:22 -03:00
.attendant-list {
max-height: 200px;
overflow-y: auto;
}
2025-12-03 18:42:22 -03:00
.attendant-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
2025-12-03 18:42:22 -03:00
.attendant-option:hover {
background: var(--bg-tertiary);
}
.attendant-option.selected {
background: var(--accent-color);
}
.attendant-option .status-indicator {
width: 10px;
height: 10px;
}
.modal-footer {
padding: 16px 20px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* Toast Notifications */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
padding: 14px 20px;
background: var(--bg-secondary);
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
gap: 12px;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.toast.success {
border-left: 4px solid var(--success-color);
}
.toast.error {
border-left: 4px solid var(--error-color);
}
.toast.warning {
border-left: 4px solid var(--warning-color);
}
.toast.info {
border-left: 4px solid var(--info-color);
}
/* Loading State */
.loading-spinner {
width: 24px;
height: 24px;
border: 3px solid var(--bg-tertiary);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Responsive */
@media (max-width: 1200px) {
.attendant-layout {
grid-template-columns: 280px 1fr 320px;
}
}
@media (max-width: 992px) {
.attendant-layout {
grid-template-columns: 1fr;
}
.queue-sidebar,
.insights-sidebar {
display: none;
}
.queue-sidebar.mobile-show,
.insights-sidebar.mobile-show {
display: flex;
position: fixed;
top: 0;
bottom: 0;
width: 320px;
z-index: 500;
}
.queue-sidebar.mobile-show {
left: 0;
}
.insights-sidebar.mobile-show {
right: 0;
}
}
</style>
</head>
<body>
<!-- CRM Disabled State -->
<div class="crm-disabled" id="crmDisabled">
<div class="crm-disabled-icon">🚫</div>
<h2>CRM Features Not Enabled</h2>
<p>
The Attendant Console requires CRM features to be enabled for
this bot. This allows human agents to receive and respond to
conversations transferred from the bot.
</p>
<p>
To enable CRM features, add this line to your bot's
<strong>config.csv</strong>:
</p>
<code>crm-enabled,true</code>
<p style="margin-top: 24px">
Then create an <strong>attendant.csv</strong> file to configure
your team:
</p>
<code
>id,name,channel,preferences,department,aliases att-001,John
Smith,all,sales,commercial,john;johnny att-002,Jane
Doe,web,support,customer-service,jane</code
>
</div>
<!-- Main Layout -->
<div class="attendant-layout" id="mainLayout" style="display: none">
<!-- Left Sidebar - Queue -->
<aside class="queue-sidebar">
<div class="queue-header">
<div class="queue-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"
/>
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Attendant Console
2025-12-03 18:42:22 -03:00
</div>
<div
class="attendant-status"
id="attendantStatus"
onclick="toggleStatusDropdown()"
>
<div
class="status-indicator online"
id="statusIndicator"
></div>
<div class="status-info">
<div class="status-name" id="attendantName">
Loading...
</div>
<div class="status-text" id="statusText">
Online - Ready for conversations
</div>
</div>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
<div class="status-dropdown" id="statusDropdown">
<div
class="status-option"
onclick="setStatus('online')"
>
<div class="status-indicator online"></div>
<span>Online</span>
</div>
<div
class="status-option"
onclick="setStatus('busy')"
>
<div class="status-indicator busy"></div>
<span>Busy</span>
</div>
<div
class="status-option"
onclick="setStatus('away')"
>
<div class="status-indicator away"></div>
<span>Away</span>
</div>
<div
class="status-option"
onclick="setStatus('offline')"
>
<div class="status-indicator offline"></div>
<span>Offline</span>
</div>
</div>
2025-12-03 18:42:22 -03:00
</div>
</div>
<div class="queue-stats">
<div class="stat-item">
<div class="stat-value" id="waitingCount">0</div>
<div class="stat-label">Waiting</div>
2025-12-03 18:42:22 -03:00
</div>
<div class="stat-item">
<div class="stat-value" id="activeCount">0</div>
<div class="stat-label">Active</div>
2025-12-03 18:42:22 -03:00
</div>
<div class="stat-item">
<div class="stat-value" id="resolvedCount">0</div>
<div class="stat-label">Resolved</div>
2025-12-03 18:42:22 -03:00
</div>
</div>
<div class="queue-filters">
<button
class="filter-btn active"
data-filter="all"
onclick="filterQueue('all')"
>
All <span class="badge" id="allBadge">0</span>
</button>
<button
class="filter-btn"
data-filter="waiting"
onclick="filterQueue('waiting')"
>
Waiting <span class="badge" id="waitingBadge">0</span>
</button>
<button
class="filter-btn"
data-filter="mine"
onclick="filterQueue('mine')"
>
Mine <span class="badge" id="mineBadge">0</span>
</button>
<button
class="filter-btn"
data-filter="high"
onclick="filterQueue('high')"
>
🔥 Priority
</button>
2025-12-03 18:42:22 -03:00
</div>
<div class="conversation-list" id="conversationList">
<div class="empty-queue" id="emptyQueue">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M20 6L9 17l-5-5" />
</svg>
<p>No conversations in queue</p>
<small>New conversations will appear here</small>
2025-12-03 18:42:22 -03:00
</div>
</div>
</aside>
<!-- Chat Area -->
<main class="chat-area" id="chatArea">
<div class="no-conversation" id="noConversation">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/>
</svg>
<h3>Select a conversation</h3>
<p>
Choose a conversation from the queue to start responding
</p>
2025-12-03 18:42:22 -03:00
</div>
<div
id="activeChat"
style="display: none; flex-direction: column; height: 100%"
>
<div class="chat-header">
<div class="customer-info">
<div class="customer-avatar" id="customerAvatar">
?
</div>
<div class="customer-details">
<h3 id="customerName">Customer Name</h3>
<div class="customer-status-line">
<span
class="channel-tag"
id="customerChannel"
>web</span
>
<span id="customerStatusText"
>Active now</span
>
</div>
</div>
2025-12-03 18:42:22 -03:00
</div>
<div class="chat-actions">
<button
class="action-btn"
onclick="showTransferModal()"
title="Transfer"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 1l4 4-4 4" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<path d="M7 23l-4-4 4-4" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
Transfer
</button>
<button
class="action-btn primary"
onclick="resolveConversation()"
title="Resolve"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M20 6L9 17l-5-5" />
</svg>
Resolve
</button>
2025-12-03 18:42:22 -03:00
</div>
</div>
<div class="chat-messages" id="chatMessages">
<!-- Messages loaded dynamically -->
</div>
<div class="chat-input-area">
<div class="quick-responses" id="quickResponses">
<button
class="quick-response-btn"
onclick="useQuickResponse('Hello! How can I help you today?')"
>
👋 Greeting
</button>
<button
class="quick-response-btn"
onclick="useQuickResponse('Thank you for your patience.')"
>
🙏 Thanks
</button>
<button
class="quick-response-btn"
onclick="useQuickResponse('Let me look into that for you.')"
>
🔍 Looking into it
</button>
<button
class="quick-response-btn"
onclick="useQuickResponse('Is there anything else I can help you with?')"
>
❓ Anything else
</button>
2025-12-03 18:42:22 -03:00
</div>
<div class="input-wrapper">
<div class="input-actions">
<button
class="input-action-btn"
onclick="attachFile()"
title="Attach file"
>
<button class="input-action-btn" onclick="attachFile()" title="Attach file">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"
/>
</svg>
</button>
<button
class="input-action-btn"
onclick="polishMessage()"
title="✨ Polish message with AI"
style="color: var(--primary);"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3z"/>
<path d="M19 13l1 3 3 1-3 1-1 3-1-3-3-1 3-1 1-3z"/>
</svg>
</button>
<button
class="input-action-btn"
onclick="insertEmoji()"
title="Insert emoji"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
</button>
</div>
<textarea
class="chat-input"
id="chatInput"
placeholder="Type your message..."
rows="1"
></textarea>
<button
class="send-btn"
id="sendBtn"
onclick="sendMessage()"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="22" y1="2" x2="11" y2="13" />
<polygon
points="22 2 15 22 11 13 2 9 22 2"
/>
</svg>
</button>
2025-12-03 18:42:22 -03:00
</div>
</div>
</div>
</main>
<!-- Right Sidebar - Insights -->
<aside class="insights-sidebar">
<div class="sidebar-section">
<div class="section-title">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z"
/>
<path d="M12 6v6l4 2" />
</svg>
AI Insights
</div>
<div class="ai-insight" id="sentimentInsight">
<div class="insight-header">
<span class="insight-label"
>Customer Sentiment</span
>
2025-12-03 18:42:22 -03:00
</div>
<div class="insight-value">
<span
class="sentiment-indicator sentiment-neutral"
id="sentimentValue"
>😐 Neutral</span
>
</div>
</div>
<div class="ai-insight" id="intentInsight">
<div class="insight-header">
<span class="insight-label">Detected Intent</span>
</div>
<div class="insight-value" id="intentValue">
Awaiting messages...
</div>
</div>
<div class="ai-insight" id="summaryInsight">
<div class="insight-header">
<span class="insight-label"
>Conversation Summary</span
>
</div>
<div class="insight-value" id="summaryValue">
No conversation selected
2025-12-03 18:42:22 -03:00
</div>
</div>
</div>
<div class="sidebar-section">
<div class="section-title">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/>
</svg>
Suggested Replies
</div>
<div id="suggestedReplies">
<div
class="suggested-reply"
onclick="useSuggestion(this)"
>
<div class="suggested-reply-text">
Select a conversation to see AI-suggested
replies
</div>
<div class="suggestion-meta">
<span class="suggestion-source"
>AI Assistant</span
>
</div>
2025-12-03 18:42:22 -03:00
</div>
</div>
</div>
<div
class="sidebar-section"
id="customerDetails"
style="display: none"
>
<div class="section-title">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
/>
<circle cx="12" cy="7" r="4" />
</svg>
Customer Details
</div>
<div class="customer-detail-item">
<div class="detail-label">Email</div>
<div class="detail-value" id="detailEmail">-</div>
</div>
<div class="customer-detail-item">
<div class="detail-label">Phone</div>
<div class="detail-value" id="detailPhone">-</div>
</div>
<div class="customer-detail-item">
<div class="detail-label">Location</div>
<div class="detail-value" id="detailLocation">-</div>
</div>
<div class="customer-detail-item">
<div class="detail-label">Tags</div>
<div class="tags-container" id="detailTags">
<span class="tag">new-customer</span>
</div>
</div>
2025-12-03 18:42:22 -03:00
</div>
<div class="sidebar-section">
<div class="section-title">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
Previous Conversations
</div>
<div id="conversationHistory">
<div
class="history-item"
onclick="loadHistoricalConversation('123')"
>
<div class="history-header">
<span class="history-date"
>No history available</span
>
</div>
<div class="history-summary">
Select a customer to view their history
</div>
</div>
</div>
2025-12-03 18:42:22 -03:00
</div>
</aside>
2025-12-03 18:42:22 -03:00
</div>
<!-- Transfer Modal -->
<div class="modal-overlay" id="transferModal">
<div class="modal">
<div class="modal-header">
<h3>Transfer Conversation</h3>
<button class="modal-close" onclick="closeTransferModal()">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
2025-12-03 18:42:22 -03:00
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Transfer to</label>
<div class="attendant-list" id="attendantList">
<!-- Loaded dynamically -->
</div>
</div>
<div class="form-group">
<label class="form-label">Reason (optional)</label>
<input
type="text"
class="form-input"
id="transferReason"
placeholder="e.g., Technical issue, needs specialist"
/>
2025-12-03 18:42:22 -03:00
</div>
</div>
<div class="modal-footer">
<button class="action-btn" onclick="closeTransferModal()">
Cancel
</button>
<button
class="action-btn primary"
onclick="confirmTransfer()"
>
Transfer
</button>
2025-12-03 18:42:22 -03:00
</div>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
<script>
// =====================================================================
// Configuration
// =====================================================================
const API_BASE = window.location.origin;
let currentSessionId = null;
let currentAttendantId = null;
let currentAttendantStatus = "online";
let conversations = [];
let attendants = [];
let ws = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;
// LLM Assist configuration
let llmAssistConfig = {
tips_enabled: false,
polish_enabled: false,
smart_replies_enabled: false,
auto_summary_enabled: false,
sentiment_enabled: false
};
let conversationHistory = [];
// =====================================================================
// Initialization
// =====================================================================
document.addEventListener("DOMContentLoaded", async () => {
await checkCRMEnabled();
setupEventListeners();
});
async function checkCRMEnabled() {
try {
const response = await fetch(
`${API_BASE}/api/attendance/attendants`,
);
const data = await response.json();
if (response.ok && Array.isArray(data)) {
attendants = data;
if (attendants.length > 0) {
// CRM is enabled
document.getElementById(
"crmDisabled",
).style.display = "none";
document.getElementById(
"mainLayout",
).style.display = "grid";
// Set current attendant (first one for now, should come from auth)
currentAttendantId = attendants[0].attendant_id;
document.getElementById(
"attendantName",
).textContent = attendants[0].attendant_name;
await loadQueue();
connectWebSocket();
} else {
showCRMDisabled();
}
} else {
showCRMDisabled();
}
} catch (error) {
console.error("Failed to check CRM status:", error);
showCRMDisabled();
}
}
function showCRMDisabled() {
document.getElementById("crmDisabled").classList.add("active");
document.getElementById("mainLayout").style.display = "none";
}
function setupEventListeners() {
// Chat input auto-resize
const chatInput = document.getElementById("chatInput");
chatInput.addEventListener("input", function () {
this.style.height = "auto";
this.style.height = Math.min(this.scrollHeight, 120) + "px";
});
// Send on Enter (without Shift)
chatInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Close dropdown on outside click
document.addEventListener("click", (e) => {
if (!e.target.closest("#attendantStatus")) {
document
.getElementById("statusDropdown")
.classList.remove("show");
}
});
}
// =====================================================================
// Queue Management
// =====================================================================
async function loadQueue() {
try {
const response = await fetch(
`${API_BASE}/api/attendance/queue`,
);
if (response.ok) {
conversations = await response.json();
renderConversations();
updateStats();
}
} catch (error) {
console.error("Failed to load queue:", error);
showToast("Failed to load queue", "error");
}
}
2025-12-03 18:42:22 -03:00
function renderConversations() {
const list = document.getElementById("conversationList");
const emptyState = document.getElementById("emptyQueue");
if (conversations.length === 0) {
emptyState.style.display = "flex";
return;
}
2025-12-03 18:42:22 -03:00
emptyState.style.display = "none";
// Sort by priority and waiting time
conversations.sort((a, b) => {
if (b.priority !== a.priority)
return b.priority - a.priority;
return b.waiting_time_seconds - a.waiting_time_seconds;
});
list.innerHTML =
conversations
.map(
(conv) => `
<div class="conversation-item ${conv.session_id === currentSessionId ? "active" : ""} ${conv.status === "waiting" ? "unread" : ""}"
onclick="selectConversation('${conv.session_id}')"
data-session-id="${conv.session_id}">
<div class="conversation-header">
<span class="customer-name">${escapeHtml(conv.user_name || "Anonymous")}</span>
<span class="conversation-time">${formatTime(conv.last_message_time)}</span>
</div>
<div class="conversation-preview">${escapeHtml(conv.last_message || "No messages")}</div>
<div class="conversation-meta">
<span class="channel-tag channel-${conv.channel.toLowerCase()}">${conv.channel}</span>
${conv.priority >= 2 ? `<span class="priority-tag priority-${conv.priority >= 3 ? "urgent" : "high"}">🔥 ${conv.priority >= 3 ? "Urgent" : "High"}</span>` : ""}
<span class="waiting-time ${conv.waiting_time_seconds > 300 ? "long" : ""}">${formatWaitTime(conv.waiting_time_seconds)}</span>
2025-12-03 18:42:22 -03:00
</div>
</div>
`,
)
.join("") +
`<div class="empty-queue" id="emptyQueue" style="display: none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M20 6L9 17l-5-5"/>
</svg>
<p>No conversations in queue</p>
<small>New conversations will appear here</small>
</div>`;
}
function updateStats() {
const waiting = conversations.filter(
(c) => c.status === "waiting",
).length;
const active = conversations.filter(
(c) => c.status === "active",
).length;
const resolved = conversations.filter(
(c) => c.status === "resolved",
).length;
const mine = conversations.filter(
(c) => c.assigned_to === currentAttendantId,
).length;
document.getElementById("waitingCount").textContent = waiting;
document.getElementById("activeCount").textContent = active;
document.getElementById("resolvedCount").textContent = resolved;
document.getElementById("allBadge").textContent =
conversations.length;
document.getElementById("waitingBadge").textContent = waiting;
document.getElementById("mineBadge").textContent = mine;
}
function filterQueue(filter) {
document.querySelectorAll(".filter-btn").forEach((btn) => {
btn.classList.toggle(
"active",
btn.dataset.filter === filter,
);
});
const items = document.querySelectorAll(".conversation-item");
items.forEach((item) => {
const sessionId = item.dataset.sessionId;
const conv = conversations.find(
(c) => c.session_id === sessionId,
);
if (!conv) return;
let show = true;
switch (filter) {
case "waiting":
show = conv.status === "waiting";
break;
case "mine":
show = conv.assigned_to === currentAttendantId;
break;
case "high":
show = conv.priority >= 2;
break;
}
item.style.display = show ? "block" : "none";
});
}
2025-12-03 18:42:22 -03:00
// =====================================================================
// Conversation Selection & Chat
// =====================================================================
async function selectConversation(sessionId) {
currentSessionId = sessionId;
conversationHistory = []; // Reset history for new conversation
const conv = conversations.find(
(c) => c.session_id === sessionId,
);
if (!conv) return;
// Update UI
document
.querySelectorAll(".conversation-item")
.forEach((item) => {
item.classList.toggle(
"active",
item.dataset.sessionId === sessionId,
);
if (item.dataset.sessionId === sessionId) {
item.classList.remove("unread");
}
});
document.getElementById("noConversation").style.display =
"none";
document.getElementById("activeChat").style.display = "flex";
// Update header
document.getElementById("customerAvatar").textContent =
(conv.user_name || "A")[0].toUpperCase();
document.getElementById("customerName").textContent =
conv.user_name || "Anonymous";
document.getElementById("customerChannel").textContent =
conv.channel;
document.getElementById("customerChannel").className =
`channel-tag channel-${conv.channel.toLowerCase()}`;
// Show customer details
document.getElementById("customerDetails").style.display =
"block";
document.getElementById("detailEmail").textContent =
conv.user_email || "-";
// Load messages
await loadMessages(sessionId);
// Load AI insights
await loadInsights(sessionId);
// Assign to self if unassigned
if (!conv.assigned_to && currentAttendantId) {
await assignConversation(sessionId, currentAttendantId);
}
}
async function loadMessages(sessionId) {
const container = document.getElementById("chatMessages");
container.innerHTML = '<div class="loading-spinner"></div>';
try {
// For now, show the last message from queue data
const conv = conversations.find(
(c) => c.session_id === sessionId,
);
// In real implementation, fetch from /api/sessions/{id}/messages
container.innerHTML = "";
if (conv && conv.last_message) {
addMessage(
"customer",
conv.last_message,
conv.last_message_time,
);
}
// Add system message for transfer
if (conv && conv.assigned_to_name) {
addSystemMessage(
`Assigned to ${conv.assigned_to_name}`,
);
}
} catch (error) {
console.error("Failed to load messages:", error);
container.innerHTML =
'<p style="text-align: center; color: var(--text-muted);">Failed to load messages</p>';
}
}
function addMessage(type, content, time = null) {
const container = document.getElementById("chatMessages");
const timeStr = time
? formatTime(time)
: new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
const avatarContent =
type === "customer" ? "C" : type === "bot" ? "🤖" : "You";
const avatarClass = type === "bot" ? "bot" : "";
const messageHtml = `
<div class="message ${type}">
<div class="message-avatar ${avatarClass}">${avatarContent}</div>
<div class="message-content">
<div class="message-bubble">${escapeHtml(content)}</div>
<div class="message-meta">
<span>${timeStr}</span>
${type === "bot" ? '<span class="bot-badge">Bot</span>' : ""}
</div>
2025-12-03 18:42:22 -03:00
</div>
</div>
`;
2025-12-03 18:42:22 -03:00
container.insertAdjacentHTML("beforeend", messageHtml);
container.scrollTop = container.scrollHeight;
}
2025-12-03 18:42:22 -03:00
function addSystemMessage(content) {
const container = document.getElementById("chatMessages");
const messageHtml = `
<div class="message system">
<div class="message-content">
<div class="message-bubble">${escapeHtml(content)}</div>
</div>
2025-12-03 18:42:22 -03:00
</div>
`;
container.insertAdjacentHTML("beforeend", messageHtml);
}
2025-12-03 18:42:22 -03:00
async function sendMessage() {
const input = document.getElementById("chatInput");
const message = input.value.trim();
if (!message || !currentSessionId) return;
input.value = "";
input.style.height = "auto";
// Add to UI immediately
addMessage("attendant", message);
// Add to conversation history
conversationHistory.push({
role: "attendant",
content: message,
timestamp: new Date().toISOString()
});
try {
// Send to attendance respond API
const response = await fetch(
`${API_BASE}/api/attendance/respond`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: currentSessionId,
message: message,
attendant_id: currentAttendantId,
}),
},
);
const result = await response.json();
if (!result.success) {
throw new Error(
result.error || "Failed to send message",
);
}
showToast(result.message, "success");
// Refresh smart replies after sending
if (llmAssistConfig.smart_replies_enabled) {
loadSmartReplies(currentSessionId);
}
} catch (error) {
console.error("Failed to send message:", error);
showToast(
"Failed to send message: " + error.message,
"error",
);
}
}
2025-12-03 18:42:22 -03:00
function useQuickResponse(text) {
document.getElementById("chatInput").value = text;
document.getElementById("chatInput").focus();
}
2025-12-03 18:42:22 -03:00
function useSuggestion(element) {
const text = element
.querySelector(".suggested-reply-text")
.textContent.trim();
document.getElementById("chatInput").value = text;
document.getElementById("chatInput").focus();
}
// =====================================================================
// Transfer & Assignment
// =====================================================================
async function assignConversation(sessionId, attendantId) {
try {
const response = await fetch(
`${API_BASE}/api/attendance/assign`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: sessionId,
attendant_id: attendantId,
}),
},
);
if (response.ok) {
showToast("Conversation assigned", "success");
await loadQueue();
}
} catch (error) {
console.error("Failed to assign conversation:", error);
}
}
2025-12-03 18:42:22 -03:00
function showTransferModal() {
if (!currentSessionId) return;
const list = document.getElementById("attendantList");
list.innerHTML = attendants
.filter((a) => a.attendant_id !== currentAttendantId)
.map(
(a) => `
<div class="attendant-option" onclick="selectTransferTarget(this, '${a.attendant_id}')">
<div class="status-indicator ${a.status.toLowerCase()}"></div>
<div>
<div style="font-weight: 500;">${escapeHtml(a.attendant_name)}</div>
<div style="font-size: 12px; color: var(--text-secondary);">${a.preferences} • ${a.channel}</div>
</div>
2025-12-03 18:42:22 -03:00
</div>
`,
)
.join("");
2025-12-03 18:42:22 -03:00
document.getElementById("transferModal").classList.add("show");
}
2025-12-03 18:42:22 -03:00
function closeTransferModal() {
document
.getElementById("transferModal")
.classList.remove("show");
document.getElementById("transferReason").value = "";
}
2025-12-03 18:42:22 -03:00
let selectedTransferTarget = null;
2025-12-03 18:42:22 -03:00
function selectTransferTarget(element, attendantId) {
document
.querySelectorAll(".attendant-option")
.forEach((el) => el.classList.remove("selected"));
element.classList.add("selected");
selectedTransferTarget = attendantId;
}
2025-12-03 18:42:22 -03:00
async function confirmTransfer() {
if (!selectedTransferTarget || !currentSessionId) {
showToast("Please select an attendant", "warning");
return;
}
const reason = document.getElementById("transferReason").value;
try {
const response = await fetch(
`${API_BASE}/api/attendance/transfer`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: currentSessionId,
from_attendant_id: currentAttendantId,
to_attendant_id: selectedTransferTarget,
reason: reason,
}),
},
);
if (response.ok) {
showToast("Conversation transferred", "success");
closeTransferModal();
currentSessionId = null;
document.getElementById(
"noConversation",
).style.display = "flex";
document.getElementById("activeChat").style.display =
"none";
await loadQueue();
} else {
throw new Error("Transfer failed");
}
} catch (error) {
console.error("Failed to transfer:", error);
showToast("Failed to transfer conversation", "error");
}
}
async function resolveConversation() {
if (!currentSessionId) return;
try {
const response = await fetch(
`${API_BASE}/api/attendance/resolve/${currentSessionId}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
},
);
if (response.ok) {
showToast("Conversation resolved", "success");
currentSessionId = null;
document.getElementById(
"noConversation",
).style.display = "flex";
document.getElementById("activeChat").style.display =
"none";
await loadQueue();
} else {
throw new Error("Failed to resolve");
}
} catch (error) {
console.error("Failed to resolve:", error);
showToast("Failed to resolve conversation", "error");
}
}
// =====================================================================
// Status Management
// =====================================================================
function toggleStatusDropdown() {
document
.getElementById("statusDropdown")
.classList.toggle("show");
}
async function setStatus(status) {
currentAttendantStatus = status;
document.getElementById("statusIndicator").className =
`status-indicator ${status}`;
document
.getElementById("statusDropdown")
.classList.remove("show");
const statusTexts = {
online: "Online - Ready for conversations",
busy: "Busy - Handling conversations",
away: "Away - Temporarily unavailable",
offline: "Offline - Not accepting conversations",
};
document.getElementById("statusText").textContent =
statusTexts[status];
try {
await fetch(
`${API_BASE}/api/attendance/attendants/${currentAttendantId}/status`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: status }),
},
);
} catch (error) {
console.error("Failed to update status:", error);
}
}
// =====================================================================
// AI Insights
// =====================================================================
async function loadInsights(sessionId) {
// Update sentiment (loading state)
document.getElementById("sentimentValue").innerHTML =
"😐 Analyzing...";
document.getElementById("intentValue").textContent =
"Analyzing conversation...";
document.getElementById("summaryValue").textContent =
"Loading summary...";
const conv = conversations.find(c => c.session_id === sessionId);
// Load LLM Assist config for this bot
try {
const configResponse = await fetch(`${API_BASE}/api/attendance/llm/config/${conv?.bot_id || 'default'}`);
if (configResponse.ok) {
llmAssistConfig = await configResponse.json();
}
} catch (e) {
console.log("LLM config not available, using defaults");
}
// Load real insights using LLM Assist APIs
try {
// Generate summary if enabled
if (llmAssistConfig.auto_summary_enabled) {
const summaryResponse = await fetch(`${API_BASE}/api/attendance/llm/summary/${sessionId}`);
if (summaryResponse.ok) {
const summaryData = await summaryResponse.json();
if (summaryData.success) {
document.getElementById("summaryValue").textContent = summaryData.summary.brief || "No summary available";
document.getElementById("intentValue").textContent =
summaryData.summary.customer_needs?.join(", ") || "General inquiry";
}
}
} else {
document.getElementById("summaryValue").textContent =
`Customer ${conv?.user_name || "Anonymous"} via ${conv?.channel || "web"}`;
document.getElementById("intentValue").textContent = "General inquiry";
}
// Analyze sentiment if we have the last message
if (llmAssistConfig.sentiment_enabled && conv?.last_message) {
const sentimentResponse = await fetch(`${API_BASE}/api/attendance/llm/sentiment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
message: conv.last_message,
history: conversationHistory
})
});
if (sentimentResponse.ok) {
const sentimentData = await sentimentResponse.json();
if (sentimentData.success) {
const s = sentimentData.sentiment;
const sentimentClass = s.overall === 'positive' ? 'sentiment-positive' :
s.overall === 'negative' ? 'sentiment-negative' : 'sentiment-neutral';
document.getElementById("sentimentValue").innerHTML =
`<span class="sentiment-indicator ${sentimentClass}">${s.emoji} ${s.overall.charAt(0).toUpperCase() + s.overall.slice(1)}</span>`;
// Show warning for high escalation risk
if (s.escalation_risk === 'high') {
showToast("⚠️ High escalation risk detected", "warning");
}
}
}
} else {
document.getElementById("sentimentValue").innerHTML =
`<span class="sentiment-indicator sentiment-neutral">😐 Neutral</span>`;
}
// Generate smart replies if enabled
if (llmAssistConfig.smart_replies_enabled) {
await loadSmartReplies(sessionId);
} else {
loadDefaultReplies();
}
} catch (error) {
console.error("Failed to load insights:", error);
// Show fallback data
document.getElementById("sentimentValue").innerHTML =
`<span class="sentiment-indicator sentiment-neutral">😐 Neutral</span>`;
document.getElementById("summaryValue").textContent =
`Customer ${conv?.user_name || "Anonymous"} via ${conv?.channel || "web"}`;
loadDefaultReplies();
}
}
// Load smart replies from LLM
async function loadSmartReplies(sessionId) {
try {
const response = await fetch(`${API_BASE}/api/attendance/llm/smart-replies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
history: conversationHistory
})
});
if (response.ok) {
const data = await response.json();
if (data.success && data.replies.length > 0) {
const repliesHtml = data.replies.map(reply => `
<div class="suggested-reply" onclick="useSuggestion(this)">
<div class="suggested-reply-text">${escapeHtml(reply.text)}</div>
<div class="suggestion-meta">
<span class="suggestion-confidence">${Math.round(reply.confidence * 100)}% match</span>
<span class="suggestion-source">${reply.tone} • AI</span>
</div>
</div>
`).join('');
document.getElementById("suggestedReplies").innerHTML = repliesHtml;
return;
}
}
} catch (e) {
console.error("Failed to load smart replies:", e);
}
loadDefaultReplies();
}
// Load default replies when LLM is unavailable
function loadDefaultReplies() {
document.getElementById("suggestedReplies").innerHTML = `
<div class="suggested-reply" onclick="useSuggestion(this)">
<div class="suggested-reply-text">Hello! Thank you for reaching out. How can I assist you today?</div>
<div class="suggestion-meta">
<span class="suggestion-confidence">Template</span>
<span class="suggestion-source">Quick Reply</span>
</div>
2025-12-03 18:42:22 -03:00
</div>
<div class="suggested-reply" onclick="useSuggestion(this)">
<div class="suggested-reply-text">I'd be happy to help you with that. Let me look into it.</div>
<div class="suggestion-meta">
<span class="suggestion-confidence">Template</span>
<span class="suggestion-source">Quick Reply</span>
</div>
</div>
<div class="suggested-reply" onclick="useSuggestion(this)">
<div class="suggested-reply-text">Is there anything else I can help you with?</div>
<div class="suggestion-meta">
<span class="suggestion-confidence">Template</span>
<span class="suggestion-source">Quick Reply</span>
</div>
</div>
`;
}
// Generate tips when customer message arrives
async function generateTips(sessionId, customerMessage) {
if (!llmAssistConfig.tips_enabled) return;
try {
const response = await fetch(`${API_BASE}/api/attendance/llm/tips`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
customer_message: customerMessage,
history: conversationHistory
})
});
if (response.ok) {
const data = await response.json();
if (data.success && data.tips.length > 0) {
displayTips(data.tips);
}
}
} catch (e) {
console.error("Failed to generate tips:", e);
}
}
// Display tips in the UI
function displayTips(tips) {
const tipsContainer = document.getElementById("tipsContainer");
if (!tipsContainer) {
// Create tips container if it doesn't exist
const insightsSection = document.querySelector(".insights-sidebar .sidebar-section");
if (insightsSection) {
const tipsDiv = document.createElement("div");
tipsDiv.id = "tipsContainer";
tipsDiv.className = "ai-insight";
tipsDiv.innerHTML = `
<div class="insight-header">
<span class="insight-icon">💡</span>
<span class="insight-label">Tips</span>
</div>
<div class="insight-value" id="tipsValue"></div>
`;
insightsSection.insertBefore(tipsDiv, insightsSection.firstChild);
}
}
const tipsValue = document.getElementById("tipsValue");
if (tipsValue) {
const tipsHtml = tips.map(tip => {
const emoji = tip.tip_type === 'warning' ? '⚠️' :
tip.tip_type === 'intent' ? '🎯' :
tip.tip_type === 'action' ? '✅' : '💡';
return `<div style="margin-bottom: 8px;">${emoji} ${escapeHtml(tip.content)}</div>`;
}).join('');
tipsValue.innerHTML = tipsHtml;
// Show toast for high priority tips
const highPriorityTip = tips.find(t => t.priority === 1);
if (highPriorityTip) {
showToast(`💡 ${highPriorityTip.content}`, "info");
}
}
}
// Polish message before sending
async function polishMessage() {
if (!llmAssistConfig.polish_enabled) {
showToast("Message polish feature is disabled", "info");
return;
}
const input = document.getElementById("chatInput");
const message = input.value.trim();
if (!message || !currentSessionId) {
showToast("Enter a message first", "info");
return;
}
showToast("✨ Polishing message...", "info");
try {
const response = await fetch(`${API_BASE}/api/attendance/llm/polish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: currentSessionId,
message: message,
tone: "professional"
})
});
if (response.ok) {
const data = await response.json();
if (data.success && data.polished !== message) {
input.value = data.polished;
input.style.height = "auto";
input.style.height = Math.min(input.scrollHeight, 120) + "px";
if (data.changes.length > 0) {
showToast(`✨ Message polished: ${data.changes.join(", ")}`, "success");
} else {
showToast("✨ Message polished!", "success");
}
} else {
showToast("Message looks good already!", "success");
}
}
} catch (e) {
console.error("Failed to polish message:", e);
showToast("Failed to polish message", "error");
}
}
// =====================================================================
// WebSocket
// =====================================================================
function connectWebSocket() {
if (!currentAttendantId) {
console.warn(
"No attendant ID, skipping WebSocket connection",
);
return;
}
try {
const protocol =
window.location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(
`${protocol}//${window.location.host}/ws/attendant?attendant_id=${encodeURIComponent(currentAttendantId)}`,
);
ws.onopen = () => {
console.log(
"WebSocket connected for attendant:",
currentAttendantId,
);
reconnectAttempts = 0;
showToast(
"Connected to notification service",
"success",
);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("WebSocket message received:", data);
handleWebSocketMessage(data);
};
ws.onclose = () => {
console.log("WebSocket disconnected");
attemptReconnect();
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
} catch (error) {
console.error("Failed to connect WebSocket:", error);
attemptReconnect();
}
}
function attemptReconnect() {
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
reconnectAttempts++;
setTimeout(() => {
console.log(
`Reconnecting... attempt ${reconnectAttempts}`,
);
connectWebSocket();
}, 2000 * reconnectAttempts);
}
}
function handleWebSocketMessage(data) {
const msgType = data.type || data.notification_type;
switch (msgType) {
case "connected":
console.log("WebSocket connected:", data.message);
break;
case "new_conversation":
showToast("New conversation in queue", "info");
loadQueue();
// Play notification sound
playNotificationSound();
2025-12-03 18:42:22 -03:00
break;
case "new_message":
// Message from customer
showToast(
`New message from ${data.user_name || "Customer"}`,
"info",
);
if (data.session_id === currentSessionId) {
addMessage(
"customer",
data.content,
data.timestamp,
);
// Add to conversation history for context
conversationHistory.push({
role: "customer",
content: data.content,
timestamp: data.timestamp || new Date().toISOString()
});
// Generate tips for this new message
generateTips(data.session_id, data.content);
// Refresh sentiment analysis
if (llmAssistConfig.sentiment_enabled) {
loadInsights(data.session_id);
}
}
loadQueue();
playNotificationSound();
2025-12-03 18:42:22 -03:00
break;
case "attendant_response":
// Response from another attendant
if (
data.session_id === currentSessionId &&
data.assigned_to !== currentAttendantId
) {
addMessage(
"attendant",
data.content,
data.timestamp,
);
}
2025-12-03 18:42:22 -03:00
break;
case "queue_update":
loadQueue();
break;
case "transfer":
if (data.assigned_to === currentAttendantId) {
showToast(
`Conversation transferred to you`,
"info",
);
loadQueue();
playNotificationSound();
}
break;
default:
console.log(
"Unknown WebSocket message type:",
msgType,
data,
);
2025-12-03 18:42:22 -03:00
}
}
function playNotificationSound() {
// Create a simple beep sound
try {
const audioContext = new (window.AudioContext ||
window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800;
oscillator.type = "sine";
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(
0.01,
audioContext.currentTime + 0.3,
);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.3);
} catch (e) {
// Audio not available
console.log("Could not play notification sound");
}
}
// =====================================================================
// Utility Functions
// =====================================================================
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text || "";
return div.innerHTML;
}
function formatTime(timestamp) {
if (!timestamp) return "";
const date = new Date(timestamp);
const now = new Date();
const diff = (now - date) / 1000;
if (diff < 60) return "Just now";
if (diff < 3600) return `${Math.floor(diff / 60)} min`;
if (diff < 86400)
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
return date.toLocaleDateString();
}
function formatWaitTime(seconds) {
if (!seconds || seconds < 0) return "";
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}
function showToast(message, type = "info") {
const container = document.getElementById("toastContainer");
const toast = document.createElement("div");
toast.className = `toast ${type}`;
toast.innerHTML = `
<span>${escapeHtml(message)}</span>
`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = "0";
setTimeout(() => toast.remove(), 300);
}, 3000);
}
function attachFile() {
showToast("File attachment coming soon", "info");
}
function insertEmoji() {
showToast("Emoji picker coming soon", "info");
}
function loadHistoricalConversation(id) {
showToast("Loading conversation history...", "info");
}
// Periodic refresh (every 30 seconds if WebSocket not connected)
setInterval(() => {
if (currentAttendantStatus === "online") {
// Only refresh if WebSocket is not connected
if (!ws || ws.readyState !== WebSocket.OPEN) {
loadQueue();
}
}
}, 30000);
// Send status updates via WebSocket
function sendWebSocketMessage(data) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
}
}
2025-12-03 18:42:22 -03:00
// Send typing indicator
function sendTypingIndicator() {
if (currentSessionId) {
sendWebSocketMessage({
type: "typing",
session_id: currentSessionId,
});
}
}
// Mark messages as read
function markAsRead(sessionId) {
sendWebSocketMessage({
type: "read",
session_id: sessionId,
});
}
</script>
</body>
2025-12-03 18:42:22 -03:00
</html>