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
+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) {