feat: sidebar nav (default), group member management, per-customer ticketing identity mapping

- Navigation: new per-user setting (default "side") renders the page links as a
  left sidebar while keeping theme/bell/avatar/logout in the top-right; falls
  back to a top bar under 760px or when set to "top". Profile selector added.
- Group conversations: record a creatorId; the creator gets a "Manage members"
  button to add/remove participants (new GET/POST/DELETE members endpoints,
  creator-only). Existing groups backfilled.
- Customer ticketing: admin can map each assigned consultant to their username
  in that customer's ticketing system. Per-customer mapping takes precedence
  over the consultant's global ticketingIdentities during sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-13 11:05:33 +02:00
parent 0d28f69320
commit e8beb7d4f3
16 changed files with 367 additions and 19 deletions
+46
View File
@@ -303,6 +303,52 @@ table, .badge, input[type=number] { font-variant-numeric: tabular-nums; }
}
.topnav .right { display: flex; align-items: center; gap: 8px; }
/* ---- sidebar navigation (default; user can switch to top bar) ---- */
body[data-nav=side] .topnav {
justify-content: flex-end; /* only the right controls remain in the flow */
padding-left: 232px;
}
body[data-nav=side] .topnav .brand {
position: fixed; top: 14px; left: 18px; z-index: 41;
font-size: 1.05rem;
}
body[data-nav=side] .topnav .links {
position: fixed; top: 0; left: 0; bottom: 0;
width: 214px;
flex: none; flex-direction: column; flex-wrap: nowrap; align-items: stretch;
gap: 4px;
background-color: var(--surface2);
background-image: var(--dither);
border-right: 2px solid var(--border);
padding: 58px 12px 18px;
overflow-y: auto;
z-index: 40;
}
:root[data-theme=light] body[data-nav=side] .topnav .links { background-image: none; }
body[data-nav=side] .topnav .links a {
border-bottom: none;
border-left: 3px solid transparent;
border-radius: 0;
padding: 9px 12px;
}
body[data-nav=side] .topnav .links a[aria-current=page] {
border-left-color: var(--accent);
background: var(--surface);
}
body[data-nav=side] main.container { margin-left: 214px; }
@media (max-width: 760px) {
/* collapse the sidebar back into a normal top bar on small screens */
body[data-nav=side] .topnav { padding-left: 20px; justify-content: flex-start; flex-wrap: wrap; }
body[data-nav=side] .topnav .brand { position: static; }
body[data-nav=side] .topnav .links {
position: static; width: auto; flex: 1; flex-direction: row; flex-wrap: wrap;
background: none; border-right: none; padding: 0; overflow: visible;
}
body[data-nav=side] .topnav .links a { border-left: none; border-bottom: 2px solid transparent; }
body[data-nav=side] .topnav .links a[aria-current=page] { border-bottom-color: var(--accent); background: none; }
body[data-nav=side] main.container { margin-left: auto; }
}
/* ---- cards: printed stock with hard Y2K shadows ---- */
.card {
background: var(--surface);
+36
View File
@@ -57,6 +57,36 @@ async function loadConsultants() {
}));
}
// 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 =
@@ -73,6 +103,7 @@ function openCustomerDialog(customer) {
[...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();
@@ -114,6 +145,11 @@ document.getElementById('customer-form').addEventListener('submit', async (e) =>
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) {
+69
View File
@@ -62,6 +62,8 @@ async function openConversation(c) {
current = c;
oldestCursor = '';
document.getElementById('chat-title').textContent = c.title;
const mbtn = document.getElementById('chat-members');
mbtn.hidden = !(c.kind !== 'dm' && c.creatorId && c.creatorId === meId);
form.hidden = false;
msgHost.replaceChildren();
renderConvList();
@@ -297,6 +299,73 @@ document.getElementById('newconv-form').addEventListener('submit', async (e) =>
} catch (err) { toast(err.message, 'err'); }
});
// ---- manage members (group creator only) ----
const membersDlg = document.getElementById('members-dialog');
let mmMemberIds = new Set();
async function renderMembers() {
const res = await api('GET', `/api/v1/conversations/${current.id}/members`);
mmMemberIds = new Set(res.members.map((u) => u.id));
const host = document.getElementById('mm-list');
host.replaceChildren(...res.members.map((u) => {
const li = document.createElement('li');
li.className = 'conv-item';
li.style.justifyContent = 'space-between';
li.innerHTML = `<span>${esc(u.name)} ${u.isCreator ? '<span class="badge accent">creator</span>' : ''}
<br><span class="muted">${esc(u.email)}</span></span>`;
if (res.canManage && !u.isCreator) {
const rm = document.createElement('button');
rm.className = 'btn small';
rm.textContent = 'Remove';
rm.addEventListener('click', async () => {
try {
await api('DELETE', `/api/v1/conversations/${current.id}/members/${u.id}`);
await renderMembers();
} catch (e) { toast(e.message, 'err'); }
});
li.appendChild(rm);
}
return li;
}));
document.getElementById('mm-add-wrap').hidden = !res.canManage;
}
let mmTimer = null;
async function mmSearch() {
const q = document.getElementById('mm-search').value.trim();
const res = await api('GET', `/api/v1/users?q=${encodeURIComponent(q)}`);
const host = document.getElementById('mm-results');
const rows = res.users.filter((u) => !mmMemberIds.has(u.id)).map((u) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'nc-row';
b.textContent = `${u.name}${u.email}`;
b.addEventListener('click', async () => {
try {
await api('POST', `/api/v1/conversations/${current.id}/members`, { userId: u.id });
document.getElementById('mm-search').value = '';
host.replaceChildren();
await renderMembers();
} catch (e) { toast(e.message, 'err'); }
});
return b;
});
host.replaceChildren(...rows);
}
document.getElementById('chat-members').addEventListener('click', async () => {
if (!current) return;
document.getElementById('mm-search').value = '';
document.getElementById('mm-results').replaceChildren();
try { await renderMembers(); membersDlg.showModal(); }
catch (e) { toast(e.message, 'err'); }
});
document.getElementById('mm-close').addEventListener('click', () => membersDlg.close());
document.getElementById('mm-search').addEventListener('input', () => {
clearTimeout(mmTimer);
mmTimer = setTimeout(mmSearch, 250);
});
(async () => {
try {
const me = await api('GET', '/api/v1/auth/me');
+5 -1
View File
@@ -56,12 +56,16 @@ form.addEventListener('submit', async (e) => {
links,
},
extra,
settings: { theme: document.getElementById('p-theme').value },
settings: {
theme: document.getElementById('p-theme').value,
navLayout: document.getElementById('p-nav').value,
},
});
version = res.user.version;
const theme = res.user.settings.theme;
document.documentElement.dataset.theme = theme;
try { localStorage.setItem('theme', theme); } catch (err) { /* ignore */ }
document.body.dataset.nav = res.user.settings.navLayout || 'side';
okBox.textContent = 'Profile saved.';
} catch (err) {
if (err.status === 409) {