<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Playlist — Control Panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f7f7f5;
--surface: #ffffff;
--border: #e8e6e1;
--border-strong: #d0cdc8;
--text: #1a1a1a;
--text-secondary: #6b6860;
--text-muted: #a09d98;
--accent: #1a1a1a;
--accent-hover: #333;
--danger: #c53030;
--danger-bg: #fff5f5;
--danger-border: #fed7d7;
--success-bg: #f0faf4;
--success-border: #a8dbb8;
--success-text: #2d6e46;
--warning-bg: #fffbeb;
--warning-border: #fde68a;
--warning-text: #92400e;
--tag-bg: #f0ede8;
--tag-text: #4a4845;
--radius: 8px;
--radius-sm: 5px;
}
body {
font-family: 'DM Sans', -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
/* ── HEADER ── */
.header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 32px;
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
position: sticky;
top: 0;
z-index: 100;
}
.header-brand { display: flex; align-items: center; gap: 10px; }
.brand-icon {
width: 30px;
height: 30px;
background: var(--accent);
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.brand-name {
font-size: 14px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.header-subtitle {
font-size: 12px;
color: var(--text-muted);
font-family: 'DM Mono', monospace;
}
/* ── TABS ── */
.tabs {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 32px;
display: flex;
gap: 0;
}
.tab {
padding: 12px 18px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
user-select: none;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--text); border-bottom-color: var(--text); }
/* ── MAIN ── */
.main { padding: 32px; max-width: 900px; margin: 0 auto; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* ── SECTION HEADER ── */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.section-title { font-size: 16px; font-weight: 600; color: var(--text); }
.section-desc { font-size: 13px; color: var(--text-secondary); margin-top: 2px; }
/* ── BUTTONS ── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s;
}
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-primary:hover { background: var(--accent-hover); }
.btn-outline { background: var(--surface); color: var(--text); border-color: var(--border-strong); }
.btn-outline:hover { background: var(--bg); }
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger-border); }
.btn-danger:hover { background: var(--danger-bg); }
.btn-success { background: var(--success-bg); color: var(--success-text); border-color: var(--success-border); }
.btn-success:hover { filter: brightness(0.97); }
.btn-sm { padding: 5px 10px; font-size: 12px; }
/* ── CARDS ── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 16px;
}
.card-header {
padding: 14px 18px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title { font-size: 14px; font-weight: 600; }
.card-body { padding: 18px; }
/* ── PROFILE BADGE ── */
.profile-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--tag-bg);
color: var(--tag-text);
padding: 3px 10px;
border-radius: 100px;
font-size: 12px;
font-weight: 500;
}
.badge-warning {
background: var(--warning-bg);
color: var(--warning-text);
border: 1px solid var(--warning-border);
}
/* ── USER CHIPS ── */
.user-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
min-height: 32px;
}
.user-chip {
display: inline-flex;
align-items: center;
gap: 5px;
background: var(--tag-bg);
border: 1px solid var(--border);
border-radius: 100px;
padding: 4px 10px 4px 8px;
font-size: 12px;
color: var(--tag-text);
}
.user-chip button {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0;
line-height: 1;
font-size: 14px;
display: flex;
align-items: center;
}
.user-chip button:hover { color: var(--danger); }
.empty-chips {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
padding: 6px 0;
}
/* ── ADD USER ROW ── */
.add-user-row {
display: flex;
gap: 8px;
align-items: center;
}
.add-user-row input {
flex: 1;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 13px;
color: var(--text);
background: var(--bg);
outline: none;
transition: border-color 0.15s;
}
.add-user-row input:focus { border-color: var(--accent); background: #fff; }
/* ── ADD PROFILE FORM ── */
.add-profile-form {
background: var(--surface);
border: 1px dashed var(--border-strong);
border-radius: var(--radius);
padding: 16px 18px;
margin-bottom: 16px;
display: flex;
gap: 10px;
align-items: center;
}
.add-profile-form input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 13px;
color: var(--text);
background: var(--bg);
outline: none;
transition: border-color 0.15s;
}
.add-profile-form input:focus { border-color: var(--accent); background: #fff; }
/* ── PROFILE SETTINGS TABLE ── */
.search-table { width: 100%; border-collapse: collapse; }
.search-table th {
text-align: left;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0 10px 10px;
}
.search-table th:first-child { padding-left: 0; width: 36px; }
.search-table th:last-child { width: 60px; text-align: center; }
.search-table td { padding: 5px 10px; vertical-align: middle; }
.search-table td:first-child { padding-left: 0; }
.search-table td:last-child { text-align: center; }
.search-table tr.search-row td { border-top: 1px solid var(--border); }
.search-table tr.search-row:first-of-type td { border-top: none; }
.priority-badge {
width: 24px;
height: 24px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
font-family: 'DM Mono', monospace;
}
.search-table select,
.search-table input[type="number"] {
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 13px;
color: var(--text);
background: var(--bg);
outline: none;
transition: border-color 0.15s;
-webkit-appearance: none;
}
.search-table select:focus,
.search-table input[type="number"]:focus { border-color: var(--accent); background: #fff; }
.search-table select { width: 100%; cursor: pointer; }
.search-table input[type="number"] { width: 60px; text-align: center; }
.add-search-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed var(--border-strong);
}
/* ── CAPACITY CONTROL ── */
.capacity-add-form {
background: var(--surface);
border: 1px dashed var(--border-strong);
border-radius: var(--radius);
padding: 16px 18px;
margin-bottom: 16px;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.capacity-add-form input[type="text"],
.capacity-add-form input[type="number"] {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 13px;
color: var(--text);
background: var(--bg);
outline: none;
transition: border-color 0.15s;
}
.capacity-add-form input[type="text"] { flex: 1; min-width: 180px; }
.capacity-add-form input[type="number"] { width: 80px; text-align: center; }
.capacity-add-form input:focus { border-color: var(--accent); background: #fff; }
.capacity-status-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.capacity-status-table th {
text-align: left;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0 12px 10px;
border-bottom: 1px solid var(--border);
}
.capacity-status-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.capacity-status-table tr:last-child td { border-bottom: none; }
.lock-indicator {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-weight: 500;
padding: 3px 8px;
border-radius: 100px;
}
.lock-indicator.locked {
background: var(--warning-bg);
color: var(--warning-text);
border: 1px solid var(--warning-border);
}
.lock-indicator.clear {
background: var(--success-bg);
color: var(--success-text);
border: 1px solid var(--success-border);
}
.threshold-input {
width: 56px;
text-align: center;
padding: 4px 6px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: 'DM Mono', monospace;
font-size: 13px;
background: var(--bg);
outline: none;
}
.threshold-input:focus { border-color: var(--accent); background: #fff; }
.cap-section-label {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 24px 0 10px;
}
.status-refresh-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.last-refreshed {
font-size: 12px;
color: var(--text-muted);
font-family: 'DM Mono', monospace;
}
/* ── TOAST ── */
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 1000;
}
.toast-msg {
background: var(--text);
color: #fff;
padding: 10px 16px;
border-radius: var(--radius);
font-size: 13px;
animation: slideIn 0.2s ease;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── EMPTY STATE ── */
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
font-size: 13px;
}
.divider { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
.inline-label {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
display: block;
margin-bottom: 6px;
}
.form-label-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
}
</style>
</head>
<body>
<header class="header">
<div class="header-brand">
<div class="brand-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
<circle cx="3" cy="6" r="1" fill="#fff" stroke="none"/><circle cx="3" cy="12" r="1" fill="#fff" stroke="none"/><circle cx="3" cy="18" r="1" fill="#fff" stroke="none"/>
</svg>
</div>
<span class="brand-name">Playlist</span>
</div>
<span class="header-subtitle">Control Panel</span>
</header>
<nav class="tabs">
<div class="tab active" onclick="switchTab('users')">Users & Profiles</div>
<div class="tab" onclick="switchTab('settings')">Profile Settings</div>
<div class="tab" onclick="switchTab('capacity')">Capacity Control</div>
</nav>
<main class="main">
<!-- ══ TAB: USERS & PROFILES ══ -->
<div id="tab-users" class="tab-panel active">
<div class="section-header">
<div>
<div class="section-title">Users & Profiles</div>
<div class="section-desc">Assign agents to a playlist profile. Each agent can belong to only one profile.</div>
</div>
</div>
<div class="add-profile-form">
<input type="text" id="newProfileName" placeholder="New profile name (e.g. Tier 1, VIP, Returns…)" />
<button class="btn btn-primary" onclick="addProfile()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Profile
</button>
</div>
<div id="profiles-list"></div>
</div>
<!-- ══ TAB: PROFILE SETTINGS ══ -->
<div id="tab-settings" class="tab-panel">
<div class="section-header">
<div>
<div class="section-title">Profile Settings</div>
<div class="section-desc">Configure which saved searches each profile pulls from, and how many conversations to assign per search.</div>
</div>
<button class="btn btn-outline" onclick="saveAllSettings()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
Save All
</button>
</div>
<div id="settings-list"></div>
</div>
<!-- ══ TAB: CAPACITY CONTROL ══ -->
<div id="tab-capacity" class="tab-panel">
<div class="section-header">
<div>
<div class="section-title">Capacity Control</div>
<div class="section-desc">Automatically set agents to Away when they reach their open chat limit. Checked every minute across T1 Chat, T2 Chat, and Virtual Sales Chat.</div>
</div>
</div>
<!-- Capacity Profiles -->
<div class="cap-section-label">Capacity Profiles</div>
<div class="capacity-add-form">
<input type="text" id="newCapProfileName" placeholder="Profile name (e.g. 2 Chat Max)" />
<div class="form-label-row">
<label for="newCapThreshold">Max open chats:</label>
<input type="number" id="newCapThreshold" min="1" max="20" value="3" />
</div>
<button class="btn btn-primary" onclick="addCapacityProfile()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Profile
</button>
</div>
<div id="cap-profiles-list"></div>
<hr class="divider">
<!-- Live Status -->
<div class="status-refresh-row">
<div class="cap-section-label" style="margin:0;">Live Agent Status</div>
<div style="display:flex;align-items:center;gap:10px;">
<span class="last-refreshed" id="statusRefreshedAt">—</span>
<button class="btn btn-outline btn-sm" onclick="refreshCapacityStatus()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-3.34"/></svg>
Refresh
</button>
</div>
</div>
<div class="card">
<div id="cap-status-body" class="card-body">
<div class="empty-state">Loading status…</div>
</div>
</div>
</div>
</main>
<div class="toast-container" id="toastContainer"></div>
<script>
// ── CONFIG ──────────────────────────────────────────────
const API_BASE = 'https://playlist-backend.prprkust.workers.dev';
// ── STATE ────────────────────────────────────────────────
let profiles = [];
let savedSearches = [];
let capProfiles = []; // [{ id, name, threshold }]
let capAgents = []; // [{ agentId, agentEmail, profileId }]
// ── INIT ─────────────────────────────────────────────────
async function init() {
try {
const [profilesRes, searchesRes, capProfilesRes, capAgentsRes] = await Promise.all([
fetch(API_BASE + '/api/profiles'),
fetch(API_BASE + '/api/saved-searches'),
fetch(API_BASE + '/api/capacity-profiles'),
fetch(API_BASE + '/api/capacity-agents'),
]);
profiles = await profilesRes.json();
savedSearches = await searchesRes.json();
capProfiles = await capProfilesRes.json();
capAgents = await capAgentsRes.json();
} catch (e) {
// Dev fallback
profiles = [
{
id: 'p1', name: 'Tier 1',
users: ['alex@propercloth.com', 'jordan@propercloth.com'],
searches: [
{ searchId: 's1', searchName: 'Time Sensitive Issues', quantity: 2 },
{ searchId: 's2', searchName: 'Unassigned Tier 1 Emails', quantity: 4 },
{ searchId: 's3', searchName: 'Unassigned Tier 1 Social', quantity: 2 },
{ searchId: 's4', searchName: 'Unassigned Tasks', quantity: 1 }
]
},
{
id: 'p2', name: 'Tier 2',
users: ['morgan@propercloth.com'],
searches: [
{ searchId: 's1', searchName: 'Time Sensitive Issues', quantity: 1 },
{ searchId: 's5', searchName: 'Unassigned Tier 2 Emails', quantity: 4 },
{ searchId: 's2', searchName: 'Unassigned Tier 1 Emails', quantity: 2 },
{ searchId: 's6', searchName: 'Unassigned Tier 2 Social', quantity: 1 },
{ searchId: 's4', searchName: 'Unassigned Tasks', quantity: 1 }
]
}
];
savedSearches = [
{ id: 's1', name: 'Time Sensitive Issues' },
{ id: 's2', name: 'Unassigned Tier 1 Emails' },
{ id: 's3', name: 'Unassigned Tier 1 Social' },
{ id: 's4', name: 'Unassigned Tasks' },
{ id: 's5', name: 'Unassigned Tier 2 Emails' },
{ id: 's6', name: 'Unassigned Tier 2 Social' }
];
capProfiles = [];
capAgents = [];
}
renderAll();
refreshCapacityStatus();
}
// ── TAB SWITCHING ─────────────────────────────────────────
function switchTab(tab) {
var tabs = ['users', 'settings', 'capacity'];
document.querySelectorAll('.tab').forEach(function(t, i) {
t.classList.toggle('active', tabs[i] === tab);
});
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });
document.getElementById('tab-' + tab).classList.add('active');
if (tab === 'capacity') refreshCapacityStatus();
}
// ── RENDER ALL ────────────────────────────────────────────
function renderAll() {
renderProfilesList();
renderSettingsList();
renderCapacityProfiles();
}
// ══════════════════════════════════════════════════════════
// PLAYLIST — PROFILES & SETTINGS (unchanged logic)
// ══════════════════════════════════════════════════════════
function renderProfilesList() {
var el = document.getElementById('profiles-list');
if (profiles.length === 0) {
el.innerHTML = '<div class="empty-state">No profiles yet. Add one above to get started.</div>';
return;
}
el.innerHTML = profiles.map(function(p) {
return '<div class="card" id="pcard-' + p.id + '">' +
'<div class="card-header">' +
'<div style="display:flex;align-items:center;gap:10px;">' +
'<span class="card-title">' + escHtml(p.name) + '</span>' +
'<span class="profile-badge">' + p.users.length + ' agent' + (p.users.length !== 1 ? 's' : '') + '</span>' +
'</div>' +
'<button class="btn btn-danger btn-sm" onclick="deleteProfile(\'' + p.id + '\')">Delete</button>' +
'</div>' +
'<div class="card-body">' +
'<span class="inline-label">Agents</span>' +
'<div class="user-chips" id="chips-' + p.id + '">' +
(p.users.length === 0
? '<span class="empty-chips">No agents assigned yet</span>'
: p.users.map(function(u) {
return '<span class="user-chip">' +
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' +
escHtml(u) +
'<button onclick="removeUser(\'' + p.id + '\',\'' + u + '\')" title="Remove">\xd7</button>' +
'</span>';
}).join('')
) +
'</div>' +
'<div class="add-user-row">' +
'<input type="email" id="userinput-' + p.id + '" placeholder="agent@propercloth.com" onkeydown="if(event.key===\'Enter\')addUser(\'' + p.id + '\')" />' +
'<button class="btn btn-outline btn-sm" onclick="addUser(\'' + p.id + '\')">Add agent</button>' +
'</div>' +
'</div>' +
'</div>';
}).join('');
}
function renderSettingsList() {
var el = document.getElementById('settings-list');
if (profiles.length === 0) {
el.innerHTML = '<div class="empty-state">No profiles configured yet. Add profiles in the Users & Profiles tab.</div>';
return;
}
el.innerHTML = profiles.map(function(p) {
return '<div class="card" style="margin-bottom:24px;">' +
'<div class="card-header">' +
'<span class="card-title">' + escHtml(p.name) + '</span>' +
'<span class="profile-badge">' + p.searches.length + '/5 searches</span>' +
'</div>' +
'<div class="card-body">' +
'<table class="search-table">' +
'<thead><tr><th>#</th><th>Saved Search</th><th style="width:90px;text-align:center;">Qty</th><th></th></tr></thead>' +
'<tbody id="stbody-' + p.id + '">' +
p.searches.map(function(s, i) { return renderSearchRow(p.id, s, i); }).join('') +
'</tbody>' +
'</table>' +
(p.searches.length < 5
? '<div class="add-search-row"><button class="btn btn-outline btn-sm" onclick="addSearch(\'' + p.id + '\')">' +
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
'Add saved search</button></div>'
: '') +
'</div>' +
'</div>';
}).join('');
}
function renderSearchRow(profileId, search, index) {
var opts = savedSearches.map(function(folder) {
var options = folder.searches.map(function(ss) {
return '<option value="' + ss.id + '"' + (ss.id === search.searchId ? ' selected' : '') + '>' + escHtml(ss.name) + '</option>';
}).join('');
return '<optgroup label="' + escHtml(folder.folderName) + '">' + options + '</optgroup>';
}).join('');
return '<tr class="search-row" id="srow-' + profileId + '-' + index + '">' +
'<td><span class="priority-badge">' + (index + 1) + '</span></td>' +
'<td><select onchange="updateSearch(\'' + profileId + '\',' + index + ',\'searchId\',this.value);updateSearchName(\'' + profileId + '\',' + index + ',this)">' +
'<option value="">— select a search —</option>' + opts +
'</select></td>' +
'<td style="text-align:center;"><input type="number" min="1" max="99" value="' + (search.quantity || 1) + '" onchange="updateSearch(\'' + profileId + '\',' + index + ',\'quantity\',parseInt(this.value)||1)" /></td>' +
'<td><button class="btn btn-danger btn-sm" onclick="removeSearch(\'' + profileId + '\',' + index + ')">\u2715</button></td>' +
'</tr>';
}
// ── PROFILE ACTIONS ───────────────────────────────────────
function addProfile() {
var input = document.getElementById('newProfileName');
var name = input.value.trim();
if (!name) { showToast('Enter a profile name first'); return; }
if (profiles.find(function(p) { return p.name.toLowerCase() === name.toLowerCase(); })) {
showToast('A profile with that name already exists'); return;
}
profiles.push({ id: 'p' + Date.now(), name: name, users: [], searches: [] });
input.value = '';
renderAll();
persistProfiles();
showToast('"' + name + '" profile created');
}
function deleteProfile(id) {
var p = profiles.find(function(p) { return p.id === id; });
if (!confirm('Delete the "' + p.name + '" profile? Agents assigned to it will be unassigned.')) return;
profiles = profiles.filter(function(p) { return p.id !== id; });
renderAll();
persistProfiles();
showToast('"' + p.name + '" deleted');
}
function addUser(profileId) {
var input = document.getElementById('userinput-' + profileId);
var email = input.value.trim().toLowerCase();
if (!email) return;
if (!email.includes('@')) { showToast('Enter a valid email address'); return; }
var existing = profiles.find(function(p) { return p.users.includes(email); });
if (existing) { showToast(email + ' is already in the "' + existing.name + '" profile'); return; }
var p = profiles.find(function(p) { return p.id === profileId; });
p.users.push(email);
input.value = '';
renderAll();
persistProfiles();
}
function removeUser(profileId, email) {
var p = profiles.find(function(p) { return p.id === profileId; });
p.users = p.users.filter(function(u) { return u !== email; });
renderAll();
persistProfiles();
}
function addSearch(profileId) {
var p = profiles.find(function(p) { return p.id === profileId; });
if (p.searches.length >= 5) { showToast('Maximum 5 saved searches per profile'); return; }
p.searches.push({ searchId: '', searchName: '', quantity: 1 });
renderAll();
}
function removeSearch(profileId, index) {
var p = profiles.find(function(p) { return p.id === profileId; });
p.searches.splice(index, 1);
renderAll();
persistProfiles();
}
function updateSearch(profileId, index, field, value) {
var p = profiles.find(function(p) { return p.id === profileId; });
p.searches[index][field] = value;
}
function updateSearchName(profileId, index, selectEl) {
var p = profiles.find(function(p) { return p.id === profileId; });
for (var i = 0; i < savedSearches.length; i++) {
var match = savedSearches[i].searches.find(function(s) { return s.id === selectEl.value; });
if (match) { p.searches[index].searchName = match.name; return; }
}
}
async function persistProfiles() {
try {
await fetch(API_BASE + '/api/profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(profiles)
});
} catch (e) { /* offline */ }
}
async function saveAllSettings() {
profiles.forEach(function(p) {
p.searches.forEach(function(s, i) {
var sel = document.querySelector('#stbody-' + p.id + ' tr:nth-child(' + (i + 1) + ') select');
var inp = document.querySelector('#stbody-' + p.id + ' tr:nth-child(' + (i + 1) + ') input[type=number]');
if (sel) {
s.searchId = sel.value;
var match = savedSearches.find(function(ss) { return ss.id === sel.value; });
if (match) s.searchName = match.name;
}
if (inp) s.quantity = parseInt(inp.value) || 1;
});
});
await persistProfiles();
showToast('Settings saved');
}
// ══════════════════════════════════════════════════════════
// CAPACITY CONTROL
// ══════════════════════════════════════════════════════════
function renderCapacityProfiles() {
var el = document.getElementById('cap-profiles-list');
if (capProfiles.length === 0) {
el.innerHTML = '<div class="empty-state" style="padding:24px 0;">No capacity profiles yet. Add one above.</div>';
return;
}
el.innerHTML = capProfiles.map(function(cp) {
// Agents assigned to this capacity profile
var assigned = capAgents.filter(function(ca) { return ca.profileId === cp.id; });
return '<div class="card">' +
'<div class="card-header">' +
'<div style="display:flex;align-items:center;gap:10px;">' +
'<span class="card-title">' + escHtml(cp.name) + '</span>' +
'<span class="profile-badge">' + cp.threshold + ' chat max</span>' +
'<span class="profile-badge">' + assigned.length + ' agent' + (assigned.length !== 1 ? 's' : '') + '</span>' +
'</div>' +
'<button class="btn btn-danger btn-sm" onclick="deleteCapacityProfile(\'' + cp.id + '\')">Delete</button>' +
'</div>' +
'<div class="card-body">' +
'<span class="inline-label">Agents</span>' +
'<div class="user-chips">' +
(assigned.length === 0
? '<span class="empty-chips">No agents assigned yet</span>'
: assigned.map(function(ca) {
return '<span class="user-chip">' +
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' +
escHtml(ca.agentEmail) +
'<button onclick="removeCapacityAgent(\'' + ca.agentId + '\')" title="Remove">\xd7</button>' +
'</span>';
}).join('')
) +
'</div>' +
'<div class="add-user-row">' +
'<input type="email" id="capagentinput-' + cp.id + '" placeholder="agent@propercloth.com" onkeydown="if(event.key===\'Enter\')addCapacityAgent(\'' + cp.id + '\')" />' +
'<button class="btn btn-outline btn-sm" onclick="addCapacityAgent(\'' + cp.id + '\')">Add agent</button>' +
'</div>' +
'</div>' +
'</div>';
}).join('');
}
function addCapacityProfile() {
var nameInput = document.getElementById('newCapProfileName');
var threshInput = document.getElementById('newCapThreshold');
var name = nameInput.value.trim();
var threshold = parseInt(threshInput.value) || 3;
if (!name) { showToast('Enter a profile name first'); return; }
if (capProfiles.find(function(cp) { return cp.name.toLowerCase() === name.toLowerCase(); })) {
showToast('A capacity profile with that name already exists'); return;
}
if (threshold < 1 || threshold > 20) { showToast('Threshold must be between 1 and 20'); return; }
capProfiles.push({ id: 'cp' + Date.now(), name: name, threshold: threshold });
nameInput.value = '';
threshInput.value = '3';
renderCapacityProfiles();
persistCapacityProfiles();
showToast('"' + name + '" capacity profile created');
}
function deleteCapacityProfile(id) {
var cp = capProfiles.find(function(cp) { return cp.id === id; });
if (!confirm('Delete the "' + cp.name + '" capacity profile? Agents assigned to it will be unassigned.')) return;
capProfiles = capProfiles.filter(function(cp) { return cp.id !== id; });
capAgents = capAgents.filter(function(ca) { return ca.profileId !== id; });
renderCapacityProfiles();
persistCapacityProfiles();
persistCapacityAgents();
showToast('"' + cp.name + '" deleted');
}
async function addCapacityAgent(profileId) {
var input = document.getElementById('capagentinput-' + profileId);
var email = input.value.trim().toLowerCase();
if (!email) return;
if (!email.includes('@')) { showToast('Enter a valid email address'); return; }
// Check already assigned
var existing = capAgents.find(function(ca) { return ca.agentEmail === email; });
if (existing) {
var existingProfile = capProfiles.find(function(cp) { return cp.id === existing.profileId; });
showToast(email + ' is already assigned to "' + (existingProfile ? existingProfile.name : 'a profile') + '"');
return;
}
// Look up Kustomer user ID by email
showToast('Looking up agent…');
try {
var res = await fetch(API_BASE + '/api/kustomer-users?email=' + encodeURIComponent(email));
if (!res.ok) {
var err = await res.json();
showToast('Error: ' + (err.error || 'Could not find agent'));
return;
}
var user = await res.json();
capAgents.push({ agentId: user.id, agentEmail: email, profileId: profileId });
input.value = '';
renderCapacityProfiles();
persistCapacityAgents();
showToast(email + ' added to capacity profile');
} catch (e) {
showToast('Network error looking up agent');
}
}
function removeCapacityAgent(agentId) {
capAgents = capAgents.filter(function(ca) { return ca.agentId !== agentId; });
renderCapacityProfiles();
persistCapacityAgents();
}
async function persistCapacityProfiles() {
try {
await fetch(API_BASE + '/api/capacity-profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(capProfiles)
});
} catch (e) { /* offline */ }
}
async function persistCapacityAgents() {
try {
await fetch(API_BASE + '/api/capacity-agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(capAgents)
});
} catch (e) { /* offline */ }
}
// ── LIVE STATUS ───────────────────────────────────────────
async function refreshCapacityStatus() {
var el = document.getElementById('cap-status-body');
el.innerHTML = '<div class="empty-state">Loading…</div>';
try {
var res = await fetch(API_BASE + '/api/capacity-status');
var statuses = await res.json();
document.getElementById('statusRefreshedAt').textContent = 'Refreshed ' + new Date().toLocaleTimeString();
if (!Array.isArray(statuses) || statuses.length === 0) {
el.innerHTML = '<div class="empty-state">No agents are being monitored yet. Add agents to a capacity profile above.</div>';
return;
}
el.innerHTML = '<table class="capacity-status-table">' +
'<thead><tr>' +
'<th>Agent</th>' +
'<th>Profile</th>' +
'<th style="text-align:center;">Limit</th>' +
'<th>Status</th>' +
'<th></th>' +
'</tr></thead>' +
'<tbody>' +
statuses.map(function(s) {
var lockedHtml = s.locked
? '<span class="lock-indicator locked">' +
'<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' +
'Away (capacity locked)' +
'</span>'
: '<span class="lock-indicator clear">' +
'<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>' +
'Available' +
'</span>';
var unlockBtn = s.locked
? '<button class="btn btn-success btn-sm" onclick="unlockAgent(\'' + s.agentId + '\',\'' + escHtml(s.agentEmail) + '\')">Restore Available</button>'
: '';
return '<tr>' +
'<td><span style="font-family:\'DM Mono\',monospace;font-size:12px;">' + escHtml(s.agentEmail) + '</span></td>' +
'<td>' + escHtml(s.profileName) + '</td>' +
'<td style="text-align:center;"><span class="profile-badge">' + s.threshold + '</span></td>' +
'<td>' + lockedHtml + (s.locked && s.lockedAt ? '<div style="font-size:11px;color:var(--text-muted);margin-top:3px;">Since ' + new Date(s.lockedAt).toLocaleTimeString() + '</div>' : '') + '</td>' +
'<td style="text-align:right;">' + unlockBtn + '</td>' +
'</tr>';
}).join('') +
'</tbody></table>';
} catch (e) {
el.innerHTML = '<div class="empty-state">Could not load status. Check your Worker is deployed.</div>';
}
}
async function unlockAgent(agentId, email) {
try {
await fetch(API_BASE + '/api/capacity-lock/' + agentId, { method: 'DELETE' });
showToast(email + ' unlocked — status can now be manually restored in Kustomer');
refreshCapacityStatus();
} catch (e) {
showToast('Error unlocking agent');
}
}
// ── TOAST ─────────────────────────────────────────────────
function showToast(msg) {
var container = document.getElementById('toastContainer');
var el = document.createElement('div');
el.className = 'toast-msg';
el.textContent = msg;
container.appendChild(el);
setTimeout(function() { el.remove(); }, 3000);
}
// ── UTILS ─────────────────────────────────────────────────
function escHtml(str) {
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
init();
</script>
</body>
</html>