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
+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');