import { api, toast } from '/static/js/api.js'; const errorBox = document.getElementById('error'); const showErr = (e) => { errorBox.textContent = e.message || String(e); }; // ---- tabs ---- const tabs = document.querySelectorAll('[role=tab]'); tabs.forEach((btn) => btn.addEventListener('click', () => { tabs.forEach((b) => b.setAttribute('aria-selected', b === btn ? 'true' : 'false')); document.querySelectorAll('[role=tabpanel]').forEach((p) => { p.hidden = true; }); document.getElementById('tab-' + btn.dataset.tab).hidden = false; loaders[btn.dataset.tab](); })); // ---- customers ---- const dlg = document.getElementById('customer-dialog'); let editingCustomer = null; // null = creating let consultants = []; function credsFromForm(type) { switch (type) { case 'jira': return { email: val('c-jira-email'), apiToken: val('c-jira-token') }; case 'azure_devops': return { organization: val('c-ado-org'), project: val('c-ado-project'), pat: val('c-ado-pat') }; case 'youtrack': return { permanentToken: val('c-yt-token') }; case 'wekan': return { username: val('c-wekan-user'), password: val('c-wekan-pass') }; default: return {}; } } const val = (id) => document.getElementById(id).value.trim(); function credsEntered(type) { const c = credsFromForm(type); return Object.values(c).some(Boolean); } document.getElementById('c-type').addEventListener('change', (e) => { document.querySelectorAll('.creds').forEach((f) => { f.hidden = true; }); document.getElementById('creds-' + e.target.value).hidden = false; document.getElementById('f-baseurl').style.display = e.target.value === 'demo' ? 'none' : ''; }); async function loadConsultants() { const res = await api('GET', '/api/v1/admin/users?role=consultant&limit=200'); consultants = res.users; const sel = document.getElementById('c-consultants'); sel.replaceChildren(...consultants.map((u) => { const o = document.createElement('option'); o.value = u.id; o.textContent = `${u.name} <${u.email}>`; return o; })); } // Render one username input per selected consultant, prefilled from the // customer's saved per-consultant ticketing identity map. function renderIdentities() { const wrap = document.getElementById('c-identities-wrap'); const host = document.getElementById('c-identities'); const selectedIds = [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value); const existing = (editingCustomer && editingCustomer.ticketing.consultantIdentities) || {}; if (!selectedIds.length) { wrap.hidden = true; host.replaceChildren(); return; } wrap.hidden = false; host.replaceChildren(...selectedIds.map((id) => { const u = consultants.find((c) => c.id === id) || { name: id, email: '' }; const row = document.createElement('div'); row.className = 'spread'; row.style.marginTop = '6px'; const label = document.createElement('span'); label.className = 'muted'; label.style.flex = '1'; label.textContent = u.name; const inp = document.createElement('input'); inp.type = 'text'; inp.dataset.identity = id; inp.placeholder = 'username in ticketing system'; inp.value = existing[id] || ''; inp.style.flex = '1'; row.append(label, inp); return row; })); } document.getElementById('c-consultants').addEventListener('change', renderIdentities); function openCustomerDialog(customer) { editingCustomer = customer || null; document.getElementById('customer-dialog-title').textContent = customer ? `Edit ${customer.name}` : 'New customer'; document.getElementById('c-name').value = customer ? customer.name : ''; document.getElementById('c-type').value = customer ? customer.ticketing.type : 'jira'; document.getElementById('c-project').value = customer ? customer.ticketing.projectKey : ''; document.getElementById('c-baseurl').value = customer ? customer.ticketing.baseUrl : ''; document.getElementById('c-poll').value = customer ? customer.ticketing.pollIntervalSec : 60; document.getElementById('c-budget').value = customer ? customer.defaultBudget : 1000; document.querySelectorAll('.creds input').forEach((i) => { i.value = ''; }); document.getElementById('c-type').dispatchEvent(new Event('change')); const sel = document.getElementById('c-consultants'); [...sel.options].forEach((o) => { o.selected = customer ? customer.consultantIds.includes(o.value) : false; }); renderIdentities(); document.getElementById('c-test-result').textContent = customer && customer.ticketing.hasCredentials ? 'Stored credentials are kept unless you enter new ones.' : ''; dlg.showModal(); } document.getElementById('customer-new').addEventListener('click', async () => { try { await loadConsultants(); openCustomerDialog(null); } catch (e) { showErr(e); } }); document.getElementById('c-cancel').addEventListener('click', () => dlg.close()); document.getElementById('c-test').addEventListener('click', async () => { const out = document.getElementById('c-test-result'); out.textContent = 'Testing…'; const type = val('c-type'); try { let res; if (editingCustomer && !credsEntered(type)) { res = await api('POST', `/api/v1/admin/customers/${editingCustomer.id}/test-connection`, {}); } else { res = await api('POST', '/api/v1/admin/customers/test-connection', { type, baseUrl: val('c-baseurl'), projectKey: val('c-project'), credentials: credsFromForm(type), }); } out.textContent = res.ok ? `✓ Connected (${res.latencyMs} ms)` : `✗ ${res.error}`; out.style.color = res.ok ? 'var(--ok)' : 'var(--err)'; } catch (e) { out.textContent = '✗ ' + e.message; out.style.color = 'var(--err)'; } }); document.getElementById('customer-form').addEventListener('submit', async (e) => { e.preventDefault(); const type = val('c-type'); const body = { name: val('c-name'), type, baseUrl: val('c-baseurl'), projectKey: val('c-project'), pollIntervalSec: parseInt(val('c-poll'), 10) || 60, defaultBudget: parseFloat(val('c-budget')) || 0, consultantIds: [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value), }; const identities = {}; document.querySelectorAll('#c-identities input[data-identity]').forEach((inp) => { if (inp.value.trim()) identities[inp.dataset.identity] = inp.value.trim(); }); body.consultantIdentities = identities; if (!editingCustomer || credsEntered(type)) body.credentials = credsFromForm(type); try { if (editingCustomer) { body.version = editingCustomer.version; await api('PATCH', `/api/v1/admin/customers/${editingCustomer.id}`, body); } else { await api('POST', '/api/v1/admin/customers', body); } dlg.close(); toast('Customer saved.', 'ok'); loadCustomers(); } catch (err) { if (err.status === 409) toast('Conflict: ' + err.message, 'err'); else showErr(err); } }); async function loadCustomers() { try { const res = await api('GET', '/api/v1/admin/customers?includeArchived=true'); const host = document.getElementById('customer-list'); host.replaceChildren(...res.customers.map(renderCustomerCard)); if (!res.customers.length) { host.textContent = 'No customers yet — create the first one.'; } } catch (e) { showErr(e); } } function renderCustomerCard(c) { const card = document.createElement('div'); card.className = 'card'; const syncBadge = c.ticketing.lastSyncStatus === 'ok' ? `sync ok` : c.ticketing.lastSyncStatus === 'error' ? `sync error` : 'never synced'; card.innerHTML = `

${esc(c.name)} ${c.archived ? 'archived' : ''}

${syncBadge}

${esc(c.ticketing.type)} · project ${esc(c.ticketing.projectKey || '—')} · poll ${c.ticketing.pollIntervalSec}s · budget ${c.defaultBudget} · ${c.consultantIds.length} consultant(s) ${c.ticketing.lastSyncAt && !c.ticketing.lastSyncAt.startsWith('0001') ? ' · last sync ' + new Date(c.ticketing.lastSyncAt).toLocaleString() : ''}

`; card.querySelector('[data-act=edit]').addEventListener('click', async () => { try { await loadConsultants(); openCustomerDialog(c); } catch (e) { showErr(e); } }); card.querySelector('[data-act=sync]').addEventListener('click', async () => { try { await api('POST', `/api/v1/admin/customers/${c.id}/sync-now`, {}); toast('Sync queued.', 'ok'); } catch (e) { toast(e.message, 'err'); } }); card.querySelector('[data-act=archive]').addEventListener('click', async () => { try { await api('PATCH', `/api/v1/admin/customers/${c.id}`, { archived: !c.archived, version: c.version }); loadCustomers(); } catch (e) { toast(e.message, 'err'); } }); card.querySelector('[data-act=delete]').addEventListener('click', async () => { if (!confirm(`Delete customer "${c.name}"? This cannot be undone.`)) return; try { await api('DELETE', `/api/v1/admin/customers/${c.id}`); loadCustomers(); } catch (e) { toast(e.message, 'err'); } }); return card; } // ---- users ---- let userSearchTimer; document.getElementById('user-search').addEventListener('input', () => { clearTimeout(userSearchTimer); userSearchTimer = setTimeout(loadUsers, 250); }); async function loadUsers() { try { const q = encodeURIComponent(document.getElementById('user-search').value.trim()); const res = await api('GET', `/api/v1/admin/users?q=${q}&limit=200`); const tbody = document.querySelector('#user-table tbody'); tbody.replaceChildren(...res.users.map(renderUserRow)); } catch (e) { showErr(e); } } function renderUserRow(u) { const tr = document.createElement('tr'); const roleCell = (role) => { const td = document.createElement('td'); const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = u.roles[role]; cb.setAttribute('aria-label', `${role} role for ${u.name}`); cb.addEventListener('change', async () => { const roles = { ...u.roles, [role]: cb.checked }; try { const res = await api('PATCH', `/api/v1/admin/users/${u.id}`, { roles }); u = res.user; toast(`Roles updated for ${u.name}.`, 'ok'); } catch (e) { cb.checked = !cb.checked; toast(e.message, 'err'); } }); td.appendChild(cb); return td; }; const name = document.createElement('td'); name.textContent = u.name; const email = document.createElement('td'); email.textContent = u.email; const status = document.createElement('td'); status.textContent = u.disabled ? 'disabled' : 'active'; const actions = document.createElement('td'); const disableBtn = document.createElement('button'); disableBtn.className = 'btn small'; disableBtn.textContent = u.disabled ? 'Enable' : 'Disable'; disableBtn.addEventListener('click', async () => { try { const res = await api('PATCH', `/api/v1/admin/users/${u.id}`, { disabled: !u.disabled }); u = res.user; status.textContent = u.disabled ? 'disabled' : 'active'; disableBtn.textContent = u.disabled ? 'Enable' : 'Disable'; } catch (e) { toast(e.message, 'err'); } }); const resetBtn = document.createElement('button'); resetBtn.className = 'btn small'; resetBtn.textContent = 'Force reset'; resetBtn.addEventListener('click', async () => { try { await api('PATCH', `/api/v1/admin/users/${u.id}`, { forceReset: true }); toast('Password reset forced.', 'ok'); } catch (e) { toast(e.message, 'err'); } }); const delBtn = document.createElement('button'); delBtn.className = 'btn small danger'; delBtn.textContent = 'Delete'; delBtn.addEventListener('click', async () => { if (!confirm(`Delete user ${u.email}?`)) return; try { await api('DELETE', `/api/v1/admin/users/${u.id}`); tr.remove(); } catch (e) { toast(e.message, 'err'); } }); actions.append(disableBtn, document.createTextNode(' '), resetBtn, document.createTextNode(' '), delBtn); tr.append(name, email, roleCell('admin'), roleCell('consultant'), roleCell('developer'), status, actions); return tr; } // ---- settings ---- async function loadSettings() { try { const res = await api('GET', '/api/v1/admin/settings'); const s = res.settings; document.getElementById('s-branding').value = s.brandingName; document.getElementById('s-atomizer').value = s.atomizerBaseUrlOverride; document.getElementById('s-poll').value = s.defaultPollIntervalSec; document.getElementById('s-budget').value = s.defaultCustomerBudget; document.getElementById('s-hideclaims').checked = s.hideCompetingClaims; } catch (e) { showErr(e); } } document.getElementById('settings-form').addEventListener('submit', async (e) => { e.preventDefault(); try { await api('PATCH', '/api/v1/admin/settings', { brandingName: val('s-branding'), atomizerBaseUrlOverride: val('s-atomizer'), defaultPollIntervalSec: parseInt(val('s-poll'), 10), defaultCustomerBudget: parseFloat(val('s-budget')), hideCompetingClaims: document.getElementById('s-hideclaims').checked, }); toast('Settings saved.', 'ok'); } catch (err) { toast(err.message, 'err'); } }); // ---- status ---- async function loadStatus() { const host = document.getElementById('status-body'); host.textContent = 'Loading…'; try { const s = await api('GET', '/api/v1/admin/service-status'); const ext = (e) => `
${esc(e.name)} ${esc(e.url)} ${e.healthy ? `healthy · ${e.latencyMs} ms` : `down`} breaker: ${esc(e.breaker)}
`; let workers = '

No sync workers running.

'; if (Array.isArray(s.syncWorkers) && s.syncWorkers.length) { workers = s.syncWorkers.map((wkr) => `
${esc(wkr.customerName)} every ${wkr.pollIntervalSec}s ${esc(wkr.lastStatus || 'pending')}
`).join(''); } host.innerHTML = `
MongoDB ${s.mongo.healthy ? 'healthy' : `${esc(s.mongo.error)}`}
${ext(s.atomizer)} ${ext(s.workPerformer)}

Sync workers

${workers}

Job queue

${esc(JSON.stringify(s.jobs))}

`; } catch (e) { host.textContent = e.message; } } document.getElementById('status-refresh').addEventListener('click', loadStatus); // ---- audit ---- let auditCursor = ''; async function loadAudit(reset = true) { try { const entity = encodeURIComponent(document.getElementById('audit-entity').value.trim()); if (reset) auditCursor = ''; const res = await api('GET', `/api/v1/admin/audit-log?entityId=${entity}&cursor=${auditCursor}`); auditCursor = res.nextCursor; const rows = res.entries.map((e) => { const tr = document.createElement('tr'); const cells = [ new Date(e.at).toLocaleString(), e.actorId, e.action, `${e.entity}/${e.entityId}`, e.diff ? JSON.stringify(e.diff) : '', ]; cells.forEach((c) => { const td = document.createElement('td'); td.textContent = c; tr.appendChild(td); }); return tr; }); const tbody = document.querySelector('#audit-table tbody'); if (reset) tbody.replaceChildren(...rows); else tbody.append(...rows); } catch (e) { showErr(e); } } document.getElementById('audit-apply').addEventListener('click', () => loadAudit(true)); document.getElementById('audit-more').addEventListener('click', () => loadAudit(false)); function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[c])); } const loaders = { customers: loadCustomers, users: loadUsers, settings: loadSettings, status: loadStatus, audit: () => loadAudit(true), }; loadCustomers();