diff --git a/data/crm.db b/data/crm.db index 596f564..b0c47cf 100644 Binary files a/data/crm.db and b/data/crm.db differ diff --git a/database/init.sql b/database/init.sql index 8cd558e..103ae51 100644 --- a/database/init.sql +++ b/database/init.sql @@ -15,6 +15,22 @@ CREATE TABLE IF NOT EXISTS "machines" ( "updated_at" TEXT NOT NULL DEFAULT (datetime('now')) ); +CREATE TABLE IF NOT EXISTS "users" ( + "id" TEXT NOT NULL PRIMARY KEY, + "username" TEXT NOT NULL UNIQUE, + "password_hash" TEXT, + "role" TEXT NOT NULL DEFAULT 'after_sales' CHECK ("role" IN ('admin', 'viewer', 'after_sales')), + "source" TEXT NOT NULL DEFAULT 'local' CHECK ("source" IN ('local', 'ldap')), + "ldap_dn" TEXT, + "firstname" TEXT, + "lastname" TEXT, + "active" INTEGER NOT NULL DEFAULT 1 CHECK ("active" IN (0, 1)), + "created_at" TEXT NOT NULL DEFAULT (datetime('now')), + "updated_at" TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS "users_username_idx" ON "users" ("username"); + CREATE TABLE IF NOT EXISTS "tickets" ( "id" TEXT NOT NULL PRIMARY KEY, "machine_id" TEXT NOT NULL, @@ -24,9 +40,11 @@ CREATE TABLE IF NOT EXISTS "tickets" ( "priority" TEXT NOT NULL DEFAULT 'MEDIUM' CHECK ("priority" IN ('LOW', 'MEDIUM', 'HIGH')), "sla_days" INTEGER, "sla_anchor_at" TEXT, + "assigned_user_id" TEXT, "created_at" TEXT NOT NULL DEFAULT (datetime('now')), "updated_at" TEXT NOT NULL DEFAULT (datetime('now')), - FOREIGN KEY ("machine_id") REFERENCES "machines" ("id") ON DELETE RESTRICT ON UPDATE CASCADE + FOREIGN KEY ("machine_id") REFERENCES "machines" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + FOREIGN KEY ("assigned_user_id") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE ); CREATE TABLE IF NOT EXISTS "events" ( @@ -46,6 +64,7 @@ CREATE TABLE IF NOT EXISTS "events" ( CREATE INDEX IF NOT EXISTS "tickets_machine_id_idx" ON "tickets" ("machine_id"); CREATE INDEX IF NOT EXISTS "tickets_status_idx" ON "tickets" ("status"); CREATE INDEX IF NOT EXISTS "tickets_priority_idx" ON "tickets" ("priority"); +CREATE INDEX IF NOT EXISTS "tickets_assigned_user_id_idx" ON "tickets" ("assigned_user_id"); CREATE INDEX IF NOT EXISTS "events_ticket_id_idx" ON "events" ("ticket_id"); CREATE INDEX IF NOT EXISTS "events_created_at_idx" ON "events" ("created_at"); @@ -62,22 +81,6 @@ CREATE TABLE IF NOT EXISTS "ticket_attachments" ( CREATE INDEX IF NOT EXISTS "ticket_attachments_event_idx" ON "ticket_attachments" ("event_id"); -CREATE TABLE IF NOT EXISTS "users" ( - "id" TEXT NOT NULL PRIMARY KEY, - "username" TEXT NOT NULL UNIQUE, - "password_hash" TEXT, - "role" TEXT NOT NULL DEFAULT 'user' CHECK ("role" IN ('admin', 'user')), - "source" TEXT NOT NULL DEFAULT 'local' CHECK ("source" IN ('local', 'ldap')), - "ldap_dn" TEXT, - "firstname" TEXT, - "lastname" TEXT, - "active" INTEGER NOT NULL DEFAULT 1 CHECK ("active" IN (0, 1)), - "created_at" TEXT NOT NULL DEFAULT (datetime('now')), - "updated_at" TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE INDEX IF NOT EXISTS "users_username_idx" ON "users" ("username"); - CREATE TABLE IF NOT EXISTS "app_settings" ( "key" TEXT NOT NULL PRIMARY KEY, "value" TEXT NOT NULL diff --git a/public/css/pages/users.css b/public/css/pages/users.css index 1c3f960..8ef4580 100644 --- a/public/css/pages/users.css +++ b/public/css/pages/users.css @@ -1 +1,6 @@ /* Benutzer – bei Bedarf seiten-spezifische Styles */ + +.users-table .user-role-select { + min-width: 11rem; + max-width: 100%; +} diff --git a/public/css/style.css b/public/css/style.css index e48326f..f23b3f9 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -652,7 +652,13 @@ code { /* ═══════════════════════════════════════════════════ Startseite — Offene Tickets ════════════════════════════════════════════════════ */ -.home-open-tickets { gap: 1rem; } +.home-open-tickets { gap: 1.75rem; } + +.home-ticket-section { + display: flex; + flex-direction: column; + gap: 1rem; +} .home-kpi-bar { display: flex; @@ -841,6 +847,24 @@ code { .badge-waiting { background: rgba(158, 106, 3, 0.15); color: var(--amber-fg); border-color: rgba(210, 153, 34, 0.3); } .badge-done { background: rgba(110, 118, 129, 0.12); color: var(--text-muted); border-color: var(--border); } +/* Zuweisung (Name) — gleiche Badge-Form wie Status */ +.badge-assignee { + background: rgba(88, 166, 255, 0.12); + color: var(--accent-hi); + border-color: rgba(88, 166, 255, 0.35); + font-weight: 500; + max-width: 14rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.badge-assignee--none { + background: rgba(110, 118, 129, 0.12); + color: var(--text-muted); + border-color: var(--border); + font-weight: 400; +} + /* Priority badge variants */ .badge-high { background: rgba(185, 28, 28, 0.18); color: var(--red-fg); border-color: rgba(248, 81, 73, 0.35); } .badge-medium { background: rgba(158, 106, 3, 0.15); color: var(--amber-fg); border-color: rgba(210, 153, 34, 0.3); } diff --git a/public/js/core/auth-guard.js b/public/js/core/auth-guard.js index 696baf6..09cb8f8 100644 --- a/public/js/core/auth-guard.js +++ b/public/js/core/auth-guard.js @@ -7,6 +7,16 @@ window.addEventListener('unhandledrejection', (ev) => { } }); +/** @param {object|null|undefined} user – aus /auth/status */ +export function canEditCrm(user) { + return user?.canEditCrm === true; +} + +/** @param {object|null|undefined} user – aus /auth/status */ +export function canAdmin(user) { + return user?.canAdmin === true; +} + /** * @param {{ needsAdmin?: boolean, activeNav?: string }} opts * @returns {Promise} Session-Status oder null bei Redirect @@ -40,7 +50,7 @@ export async function guard(opts = {}) { location.href = '/start.html'; return null; } - if (needsAdmin && st.user?.role !== 'admin') { + if (needsAdmin && !canAdmin(st.user)) { location.href = '/start.html'; return null; } diff --git a/public/js/core/layout.js b/public/js/core/layout.js index 5069214..74b3c80 100644 --- a/public/js/core/layout.js +++ b/public/js/core/layout.js @@ -19,7 +19,7 @@ export function updateNav(st, activeNav = '') { nav.innerHTML = ''; return; } - const isAdmin = st.user?.role === 'admin'; + const isAdmin = st.user?.canAdmin === true; const na = (key) => (activeNav === key ? 'nav-active' : ''); nav.innerHTML = ` Start diff --git a/public/js/pages/machine-detail.js b/public/js/pages/machine-detail.js index 803b48d..29c97fc 100644 --- a/public/js/pages/machine-detail.js +++ b/public/js/pages/machine-detail.js @@ -71,9 +71,24 @@ function showEditMode() { } async function viewMachineDetail(id, options = {}) { - const { startInEditMode = false } = options; + const { startInEditMode = false, canEdit = true } = options; const m = await apiGet(`/machines/${id}`); + const btnEdit = document.getElementById('btn-m-edit'); + const btnDup = document.getElementById('btn-m-dup'); + const btnDel = document.getElementById('btn-m-del'); + const btnDelEdit = document.getElementById('btn-m-del-edit'); + if (!canEdit) { + btnEdit.hidden = true; + btnDup.hidden = true; + btnDel.hidden = true; + btnDelEdit.hidden = true; + document.getElementById('machine-extras-edit').innerHTML = ''; + fillView(m); + showViewMode(); + return; + } + fillView(m); if (startInEditMode) { fillEdit(m); @@ -143,10 +158,11 @@ async function viewMachineDetail(id, options = {}) { async function init() { const st = await guard({ activeNav: 'machines' }); if (!st) return; + const canEdit = st.user?.canEditCrm === true; const params = new URLSearchParams(location.search); const id = params.get('id'); - const startInEditMode = params.get('edit') === '1'; + const startInEditMode = canEdit && params.get('edit') === '1'; if (!id || !UUID.test(id)) { loadingEl.hidden = true; badIdEl.hidden = false; @@ -157,8 +173,10 @@ async function init() { mainEl.hidden = false; try { - await viewMachineDetail(id, { startInEditMode }); - if (startInEditMode) { + await viewMachineDetail(id, { startInEditMode, canEdit }); + if (!canEdit && params.get('edit') === '1') { + history.replaceState(null, '', `/machine.html?id=${encodeURIComponent(id)}`); + } else if (startInEditMode) { history.replaceState(null, '', `/machine.html?id=${encodeURIComponent(id)}`); } } catch (e) { diff --git a/public/js/pages/machines.js b/public/js/pages/machines.js index e3823b7..a224c7c 100644 --- a/public/js/pages/machines.js +++ b/public/js/pages/machines.js @@ -53,34 +53,39 @@ function initNewMachineCollapse() { toggle.onclick = () => setOpen(body.hidden); } -async function run() { +async function run(canEdit) { const machines = await apiGet('/machines'); document.getElementById('machine-count').textContent = String(machines.length); renderRows(machines); + const newCard = document.getElementById('machines-new-card'); + if (newCard) newCard.hidden = !canEdit; + initNewMachineCollapse(); const formNew = document.getElementById('form-new-machine'); - formNew.addEventListener('submit', async (e) => { - e.preventDefault(); - const fd = new FormData(formNew); - const btn = formNew.querySelector('button[type="submit"]'); - btn.disabled = true; - try { - const body = Object.fromEntries(fd.entries()); - const created = await apiPost('/machines', { - name: body.name, - typ: body.typ, - seriennummer: body.seriennummer, - standort: body.standort, - listStatus: body.listStatus || '', - }); - location.href = `/machine.html?id=${encodeURIComponent(created.id)}`; - } catch (err) { - alert(err.message || 'Anlegen fehlgeschlagen.'); - btn.disabled = false; - } - }); + if (canEdit) { + formNew.addEventListener('submit', async (e) => { + e.preventDefault(); + const fd = new FormData(formNew); + const btn = formNew.querySelector('button[type="submit"]'); + btn.disabled = true; + try { + const body = Object.fromEntries(fd.entries()); + const created = await apiPost('/machines', { + name: body.name, + typ: body.typ, + seriennummer: body.seriennummer, + standort: body.standort, + listStatus: body.listStatus || '', + }); + location.href = `/machine.html?id=${encodeURIComponent(created.id)}`; + } catch (err) { + alert(err.message || 'Anlegen fehlgeschlagen.'); + btn.disabled = false; + } + }); + } const inp = document.getElementById('machine-filter'); const tbody = document.getElementById('machine-table-body'); @@ -95,10 +100,11 @@ async function run() { async function init() { const st = await guard({ activeNav: 'machines' }); if (!st) return; + const canEdit = st.user?.canEditCrm === true; loadingEl.hidden = true; mainEl.hidden = false; try { - await run(); + await run(canEdit); } catch (e) { if (isAuthRedirectError(e)) return; showError(e.message || 'Fehler'); diff --git a/public/js/pages/start.js b/public/js/pages/start.js index 0e8d499..77c1928 100644 --- a/public/js/pages/start.js +++ b/public/js/pages/start.js @@ -19,9 +19,17 @@ const loadingEl = document.getElementById('page-loading'); const mainEl = document.getElementById('page-main'); const errEl = document.getElementById('page-error'); const listEl = document.getElementById('home-ticket-list'); +const listMineEl = document.getElementById('home-ticket-list-mine'); const emptyEl = document.getElementById('home-empty'); const tpl = document.getElementById('tpl-home-ticket'); +function formatAssigneeLabel(ticket) { + const u = ticket.assignedTo; + if (!u) return '—'; + const name = [u.firstName, u.lastName].filter(Boolean).join(' ').trim(); + return name || u.username || u.id; +} + function showError(msg) { loadingEl.hidden = true; mainEl.hidden = true; @@ -48,7 +56,8 @@ function renderEventBoxes(events) { .join(''); } -function fillTicketCard(node, t, events) { +/** @param {'open' | 'mine'} listKind */ +function fillTicketCard(node, t, events, listKind) { const id = t.id; const detailId = `home-ticket-detail-${id}`; node.dataset.ticketId = id; @@ -79,6 +88,25 @@ function fillTicketCard(node, t, events) { pr.textContent = ticketPriorityLabel[t.priority]; pr.className = `badge js-priority ${priorityBadgeClass[t.priority] || ''}`; + const assignTag = node.querySelector('.js-assignee-tag'); + if (assignTag) { + const name = formatAssigneeLabel(t); + if (listKind === 'mine') { + assignTag.textContent = name; + assignTag.hidden = false; + assignTag.className = 'badge js-assignee-tag badge-assignee'; + assignTag.title = `Zugewiesen: ${name}`; + } else { + const hasOther = Boolean(t.assignedTo); + assignTag.textContent = hasOther ? name : 'Nicht zugewiesen'; + assignTag.hidden = false; + assignTag.className = hasOther + ? 'badge js-assignee-tag badge-assignee' + : 'badge js-assignee-tag badge-assignee badge-assignee--none'; + assignTag.title = hasOther ? `Zugewiesen: ${name}` : 'Noch niemandem zugewiesen'; + } + } + const metaM = node.querySelector('.js-meta-machine'); metaM.innerHTML = t.machine ? `Maschine: ${machineLabel}` @@ -87,6 +115,8 @@ function fillTicketCard(node, t, events) { node.querySelector('.js-meta-standort').textContent = standort; node.querySelector('.js-meta-created').textContent = formatDateTime(t.createdAt); node.querySelector('.js-meta-updated').textContent = formatDateTime(t.updatedAt); + const assigneeEl = node.querySelector('.js-meta-assignee'); + if (assigneeEl) assigneeEl.textContent = formatAssigneeLabel(t); const openA = node.querySelector('.js-ticket-open'); openA.href = `/ticket.html?id=${encodeURIComponent(id)}`; @@ -112,32 +142,53 @@ function fillTicketCard(node, t, events) { }; } +function renderTicketListInto(container, tickets, eventsLists, listKind) { + container.innerHTML = ''; + tickets.forEach((t, i) => { + const frag = tpl.content.cloneNode(true); + const article = frag.querySelector('.home-ticket-card'); + fillTicketCard(article, t, eventsLists[i] || [], listKind); + container.appendChild(article); + }); +} + async function run() { - const tickets = await apiGet('/tickets?open=1'); - const eventsLists = - tickets.length === 0 + const [ticketsAll, ticketsMine] = await Promise.all([ + apiGet('/tickets?open=1&assignedTo=not_me'), + apiGet('/tickets?open=1&assignedTo=me'), + ]); + + const eventsAll = + ticketsAll.length === 0 ? [] - : await Promise.all(tickets.map((t) => apiGet(`/tickets/${t.id}/events`))); - - const openCount = tickets.filter((t) => t.status === 'OPEN').length; - const waitingCount = tickets.filter((t) => t.status === 'WAITING').length; + : await Promise.all(ticketsAll.map((t) => apiGet(`/tickets/${t.id}/events`))); + const eventsMine = + ticketsMine.length === 0 + ? [] + : await Promise.all(ticketsMine.map((t) => apiGet(`/tickets/${t.id}/events`))); + const openCount = ticketsAll.filter((t) => t.status === 'OPEN').length; + const waitingCount = ticketsAll.filter((t) => t.status === 'WAITING').length; document.getElementById('kpi-open').textContent = `${openCount} Offen`; document.getElementById('kpi-waiting').textContent = `${waitingCount} Wartend`; - document.getElementById('kpi-total').textContent = `gesamt: ${tickets.length}`; + document.getElementById('kpi-total').textContent = `gesamt: ${ticketsAll.length}`; - listEl.innerHTML = ''; - if (tickets.length === 0) { + const mineOpen = ticketsMine.filter((t) => t.status === 'OPEN').length; + const mineWaiting = ticketsMine.filter((t) => t.status === 'WAITING').length; + document.getElementById('kpi-mine-open').textContent = `${mineOpen} Offen`; + document.getElementById('kpi-mine-waiting').textContent = `${mineWaiting} Wartend`; + document.getElementById('kpi-mine-total').textContent = `gesamt: ${ticketsMine.length}`; + + if (ticketsAll.length === 0) { emptyEl.hidden = false; + emptyEl.textContent = 'Keine offenen Tickets.'; + listEl.innerHTML = ''; } else { emptyEl.hidden = true; - tickets.forEach((t, i) => { - const frag = tpl.content.cloneNode(true); - const article = frag.querySelector('.home-ticket-card'); - fillTicketCard(article, t, eventsLists[i] || []); - listEl.appendChild(article); - }); + renderTicketListInto(listEl, ticketsAll, eventsAll, 'open'); } + + renderTicketListInto(listMineEl, ticketsMine, eventsMine, 'mine'); } async function init() { @@ -146,6 +197,7 @@ async function init() { loadingEl.hidden = true; mainEl.hidden = false; bindAttachmentPreview(document.body); + try { await run(); } catch (e) { diff --git a/public/js/pages/ticket-detail.js b/public/js/pages/ticket-detail.js index 5a4ce9f..72cd065 100644 --- a/public/js/pages/ticket-detail.js +++ b/public/js/pages/ticket-detail.js @@ -21,6 +21,17 @@ import { const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +function formatAssigneeLabel(u) { + if (!u) return 'Nicht zugewiesen'; + const name = [u.firstName, u.lastName].filter(Boolean).join(' ').trim(); + return name || u.username || u.id; +} + +function assigneeOptionLabel(u) { + const name = [u.firstName, u.lastName].filter(Boolean).join(' ').trim(); + return name ? `${name} (${u.username})` : u.username; +} + const loadingEl = document.getElementById('page-loading'); const badIdEl = document.getElementById('ticket-bad-id'); const errEl = document.getElementById('page-error'); @@ -65,6 +76,11 @@ function fillTicketView(ticket) { } else { mrow.hidden = true; } + + const assignLabel = document.getElementById('t-assign-label'); + if (assignLabel) { + assignLabel.textContent = formatAssigneeLabel(ticket.assignedTo); + } } function fillEditForm(ticket) { @@ -103,7 +119,7 @@ function showEditMode() { panelEdit.hidden = false; } -async function viewTicketDetail(id) { +async function viewTicketDetail(id, canEdit) { const [ticket, events] = await Promise.all([ apiGet(`/tickets/${id}`), apiGet(`/tickets/${id}/events`), @@ -123,6 +139,72 @@ async function viewTicketDetail(id) { sect2.hidden = true; } + if (!canEdit) { + document.getElementById('btn-t-edit').hidden = true; + document.getElementById('panel-ticket-edit').hidden = true; + const assignSel = document.getElementById('t-assign-user'); + const assignP = assignSel?.closest('p'); + if (assignP) assignP.hidden = true; + const assignRo = document.getElementById('t-assign-readonly'); + if (assignRo) assignRo.hidden = false; + const slaRd = document.getElementById('t-sla-days'); + if (slaRd) slaRd.disabled = true; + const evForm = document.getElementById('form-ev'); + const evCard = evForm?.closest('.card'); + if (evCard) evCard.hidden = true; + sect2.hidden = true; + const pv = document.getElementById('panel-ticket-view'); + if (pv) { + const hint = document.createElement('p'); + hint.className = 'muted'; + hint.textContent = 'Nur Lesen.'; + pv.appendChild(hint); + } + return; + } + + const assignSel = document.getElementById('t-assign-user'); + const assignRo = document.getElementById('t-assign-readonly'); + if (assignRo) assignRo.hidden = true; + if (assignSel) { + try { + const users = await apiGet('/assignable-users'); + assignSel.innerHTML = ''; + const optNone = document.createElement('option'); + optNone.value = ''; + optNone.textContent = '— nicht zugewiesen —'; + assignSel.appendChild(optNone); + for (const u of users) { + const opt = document.createElement('option'); + opt.value = u.id; + opt.textContent = assigneeOptionLabel(u); + assignSel.appendChild(opt); + } + assignSel.value = currentTicket.assignedTo?.id ?? ''; + } catch (err) { + assignSel.innerHTML = ''; + if (isAuthRedirectError(err)) return; + window.alert(err.message || 'Benutzerliste konnte nicht geladen werden.'); + } + assignSel.onchange = async () => { + const v = assignSel.value; + const assignedUserId = v === '' ? null : v; + try { + const updated = await apiPut(`/tickets/${id}`, { assignedUserId }); + const evs = await apiGet(`/tickets/${id}/events`); + currentTicket = updated; + fillTicketView(updated); + fillEditForm(updated); + assignSel.value = updated.assignedTo?.id ?? ''; + renderEvents(sortEventsChronologicalWithAttachmentsLast(evs)); + } catch (err) { + assignSel.value = currentTicket.assignedTo?.id ?? ''; + if (isAuthRedirectError(err)) return; + window.alert(err.message || 'Zuweisung konnte nicht gespeichert werden.'); + } + }; + } + const slaSel = document.getElementById('t-sla-days'); if (slaSel) { slaSel.onchange = async () => { @@ -259,6 +341,7 @@ async function viewTicketDetail(id) { async function init() { const st = await guard({ activeNav: 'tickets' }); if (!st) return; + const canEdit = st.user?.canEditCrm === true; const id = new URLSearchParams(location.search).get('id'); if (!id || !UUID.test(id)) { @@ -273,7 +356,7 @@ async function init() { bindAttachmentPreview(document.body); try { - await viewTicketDetail(id); + await viewTicketDetail(id, canEdit); } catch (e) { if (isAuthRedirectError(e)) return; showError(e.message || 'Fehler'); diff --git a/public/js/pages/tickets.js b/public/js/pages/tickets.js index f8cda98..28949d3 100644 --- a/public/js/pages/tickets.js +++ b/public/js/pages/tickets.js @@ -61,10 +61,13 @@ function renderTicketRows(tickets) { .join(''); } -async function run() { +async function run(canEdit) { const qs = ticketListQuery(); const urlParams = new URLSearchParams(location.search); + const newCard = document.getElementById('tickets-new-card'); + if (newCard) newCard.hidden = !canEdit; + const [tickets, allMachines] = await Promise.all([ apiGet(`/tickets${qs}`), apiGet('/machines'), @@ -81,18 +84,20 @@ async function run() { renderTicketRows(tickets); - document.getElementById('form-new-ticket').onsubmit = async (e) => { - e.preventDefault(); - const fd = new FormData(e.target); - await apiPost('/tickets', { - machineId: fd.get('machineId'), - title: fd.get('title'), - description: fd.get('description'), - }); - e.target.reset(); - fillMachineSelects(allMachines, mid); - location.reload(); - }; + if (canEdit) { + document.getElementById('form-new-ticket').onsubmit = async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + await apiPost('/tickets', { + machineId: fd.get('machineId'), + title: fd.get('title'), + description: fd.get('description'), + }); + e.target.reset(); + fillMachineSelects(allMachines, mid); + location.reload(); + }; + } document.getElementById('form-filter').onsubmit = (e) => { e.preventDefault(); @@ -109,10 +114,11 @@ async function run() { async function init() { const st = await guard({ activeNav: 'tickets' }); if (!st) return; + const canEdit = st.user?.canEditCrm === true; loadingEl.hidden = true; mainEl.hidden = false; try { - await run(); + await run(canEdit); } catch (e) { if (isAuthRedirectError(e)) return; showError(e.message || 'Fehler'); diff --git a/public/js/pages/users.js b/public/js/pages/users.js index 1c4450d..02c42ad 100644 --- a/public/js/pages/users.js +++ b/public/js/pages/users.js @@ -18,6 +18,20 @@ function formatName(u) { return a.length ? a.map((x) => esc(String(x))).join(' ') : '—'; } +function roleOptionsHtml(current) { + const opts = [ + ['viewer', 'Viewer'], + ['after_sales', 'After-Sales'], + ['admin', 'Administrator'], + ]; + return opts + .map( + ([val, label]) => + ``, + ) + .join(''); +} + function renderRows(users) { const tbody = document.getElementById('users-table-body'); tbody.innerHTML = users @@ -26,7 +40,11 @@ function renderRows(users) { ${esc(u.username)} ${formatName(u)} - ${u.role === 'admin' ? 'Admin' : 'Benutzer'} + + + ${u.source === 'ldap' ? 'LDAP' : 'Lokal'} ${u.active ? 'Ja' : 'Nein'} @@ -56,6 +74,24 @@ async function run() { }; const root = document.getElementById('page-main'); + root.querySelectorAll('.user-role-select').forEach((sel) => { + sel.addEventListener('change', async () => { + const id = sel.getAttribute('data-user-id'); + const prev = sel.getAttribute('data-role-prev'); + const role = sel.value; + sel.disabled = true; + try { + await apiPut(`/users/${id}`, { role }); + sel.setAttribute('data-role-prev', role); + } catch (e) { + window.alert(e.message || 'Rolle konnte nicht gespeichert werden.'); + sel.value = prev; + } finally { + sel.disabled = false; + } + }); + }); + root.querySelectorAll('.btn-pw').forEach((btn) => { btn.onclick = async () => { const uid = btn.getAttribute('data-id'); diff --git a/public/machines.html b/public/machines.html index 1a2686d..0a32d9e 100644 --- a/public/machines.html +++ b/public/machines.html @@ -79,7 +79,7 @@ -
+
Ticket öffnen → diff --git a/public/ticket.html b/public/ticket.html index d8db023..2bf1c21 100644 --- a/public/ticket.html +++ b/public/ticket.html @@ -29,6 +29,15 @@

Priorität:

+

+ + +

+

- + + diff --git a/server/db.js b/server/db.js index f4e0023..c843967 100644 --- a/server/db.js +++ b/server/db.js @@ -128,7 +128,7 @@ if (!tbl) { "id" TEXT NOT NULL PRIMARY KEY, "username" TEXT NOT NULL UNIQUE, "password_hash" TEXT, - "role" TEXT NOT NULL DEFAULT 'user' CHECK ("role" IN ('admin', 'user')), + "role" TEXT NOT NULL DEFAULT 'after_sales' CHECK ("role" IN ('admin', 'viewer', 'after_sales')), "source" TEXT NOT NULL DEFAULT 'local' CHECK ("source" IN ('local', 'ldap')), "ldap_dn" TEXT, "firstname" TEXT, @@ -149,6 +149,65 @@ if (!userCols.some((c) => c.name === 'lastname')) { db.exec('ALTER TABLE users ADD COLUMN lastname TEXT'); } +const usersTableSql = db + .prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='users'", + ) + .get()?.sql; +if (usersTableSql && !usersTableSql.includes('after_sales')) { + db.exec('BEGIN'); + try { + db.exec(` + CREATE TABLE "users_new" ( + "id" TEXT NOT NULL PRIMARY KEY, + "username" TEXT NOT NULL UNIQUE, + "password_hash" TEXT, + "role" TEXT NOT NULL DEFAULT 'after_sales' CHECK ("role" IN ('admin', 'viewer', 'after_sales')), + "source" TEXT NOT NULL DEFAULT 'local' CHECK ("source" IN ('local', 'ldap')), + "ldap_dn" TEXT, + "firstname" TEXT, + "lastname" TEXT, + "active" INTEGER NOT NULL DEFAULT 1 CHECK ("active" IN (0, 1)), + "created_at" TEXT NOT NULL DEFAULT (datetime('now')), + "updated_at" TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + db.exec(` + INSERT INTO users_new ( + id, username, password_hash, role, source, ldap_dn, firstname, lastname, active, created_at, updated_at + ) + SELECT + id, + username, + password_hash, + CASE + WHEN role = 'user' THEN 'after_sales' + WHEN role IN ('admin', 'viewer', 'after_sales') THEN role + ELSE 'after_sales' + END, + source, + ldap_dn, + firstname, + lastname, + active, + created_at, + updated_at + FROM users; + `); + db.exec('DROP TABLE users'); + db.exec('ALTER TABLE users_new RENAME TO users'); + db.exec('CREATE INDEX IF NOT EXISTS users_username_idx ON "users" ("username")'); + db.exec('COMMIT'); + } catch (e) { + try { + db.exec('ROLLBACK'); + } catch { + /* ignore */ + } + throw e; + } +} + const tblSet = db .prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'", @@ -276,4 +335,14 @@ if (ticketCols2.some((c) => c.name === 'sla_anchor_at')) { ).run(); } +const ticketCols3 = db.prepare('PRAGMA table_info(tickets)').all(); +if (!ticketCols3.some((c) => c.name === 'assigned_user_id')) { + db.exec( + 'ALTER TABLE tickets ADD COLUMN assigned_user_id TEXT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE', + ); + db.exec( + 'CREATE INDEX IF NOT EXISTS tickets_assigned_user_id_idx ON tickets (assigned_user_id)', + ); +} + export default db; diff --git a/server/ldap-sync.js b/server/ldap-sync.js index 54ce48a..821c229 100644 --- a/server/ldap-sync.js +++ b/server/ldap-sync.js @@ -198,7 +198,7 @@ export async function performLdapSync(dbSync, loadIntegrations, trigger) { dbSync .prepare( `INSERT INTO users (id, username, password_hash, role, source, ldap_dn, firstname, lastname, active, updated_at) - VALUES (?, ?, ?, 'user', 'ldap', ?, ?, ?, 1, datetime('now'))`, + VALUES (?, ?, ?, 'after_sales', 'ldap', ?, ?, ?, 1, datetime('now'))`, ) .run( id, diff --git a/server/lib/mappers.js b/server/lib/mappers.js index 839ab26..d0a6929 100644 --- a/server/lib/mappers.js +++ b/server/lib/mappers.js @@ -45,10 +45,29 @@ export function mapTicket(r) { createdAt: r.created_at, /** Letzte Änderung: neueres aus Ticket-Zeile oder letztem Event (für Anzeige „Aktualisiert“). */ updatedAt: r.ticket_last_activity_at ?? r.updated_at, + assignedTo: null, }; if (machine_row) { t.machine = mapMachine(machine_row); } + if (r.assignee_row != null && String(r.assignee_row).trim() !== '') { + try { + const ar = + typeof r.assignee_row === 'string' + ? JSON.parse(r.assignee_row) + : r.assignee_row; + if (ar && ar.id) { + t.assignedTo = { + id: ar.id, + username: ar.username, + firstName: ar.firstname ?? null, + lastName: ar.lastname ?? null, + }; + } + } catch { + /* ignore */ + } + } return t; } @@ -135,6 +154,14 @@ export const ticketJoinSelect = ` 'extras', m.extras, 'created_at', m.created_at, 'updated_at', m.updated_at - ) AS machine_row + ) AS machine_row, + CASE WHEN u_assign.id IS NULL THEN NULL ELSE + json_object( + 'id', u_assign.id, + 'username', u_assign.username, + 'firstname', u_assign.firstname, + 'lastname', u_assign.lastname + ) END AS assignee_row FROM tickets t - JOIN machines m ON m.id = t.machine_id`; + JOIN machines m ON m.id = t.machine_id + LEFT JOIN users u_assign ON u_assign.id = t.assigned_user_id`; diff --git a/server/middleware/auth.js b/server/middleware/auth.js index 29adb10..879fca0 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -20,3 +20,12 @@ export function requireAdmin(req, res, next) { } next(); } + +/** Maschinen, Tickets, Events, Anhänge bearbeiten (nicht: nur Viewer). */ +export function requireCrmEdit(req, res, next) { + const r = req.session?.role; + if (r === 'admin' || r === 'after_sales') { + return next(); + } + return res.status(403).json({ message: 'Keine Bearbeitungsrechte.' }); +} diff --git a/server/routes/admin/index.js b/server/routes/admin/index.js index ccdae33..a422824 100644 --- a/server/routes/admin/index.js +++ b/server/routes/admin/index.js @@ -12,6 +12,13 @@ import { import { hashPassword } from '../../password.js'; import { requireAdmin, requireAuth } from '../../middleware/auth.js'; +const CRM_ROLES = new Set(['admin', 'viewer', 'after_sales']); + +function normalizeUserRole(role) { + const r = String(role || '').trim(); + return CRM_ROLES.has(r) ? r : 'after_sales'; +} + export function createAdminRouter() { const admin = Router(); admin.use(requireAuth, requireAdmin); @@ -31,7 +38,7 @@ export function createAdminRouter() { .trim() .toLowerCase(); if (!un || !password) return badRequest(res, 'Benutzername und Passwort erforderlich.'); - const r = role === 'admin' ? 'admin' : 'user'; + const r = normalizeUserRole(role); const id = randomUUID(); const ph = await hashPassword(password); try { @@ -66,7 +73,7 @@ export function createAdminRouter() { ).run(ph, id); } if (b.role !== undefined) { - if (b.role !== 'admin' && b.role !== 'user') { + if (!CRM_ROLES.has(b.role)) { return badRequest(res, 'Ungültige Rolle.'); } const admins = db @@ -74,7 +81,7 @@ export function createAdminRouter() { "SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND active = 1", ) .get().c; - if (cur.role === 'admin' && b.role === 'user' && admins <= 1) { + if (cur.role === 'admin' && b.role !== 'admin' && admins <= 1) { return res.status(400).json({ message: 'Letzter Administrator kann nicht herabgestuft werden.' }); } db.prepare('UPDATE users SET role = ?, updated_at = datetime(\'now\') WHERE id = ?').run( diff --git a/server/routes/api/assignable-users.js b/server/routes/api/assignable-users.js new file mode 100644 index 0000000..41fe528 --- /dev/null +++ b/server/routes/api/assignable-users.js @@ -0,0 +1,27 @@ +import db from '../../db.js'; + +function mapAssignable(r) { + return { + id: r.id, + username: r.username, + firstName: r.firstname ?? null, + lastName: r.lastname ?? null, + }; +} + +/** Aktive CRM-Benutzer für Ticket-Zuweisung (Dropdown). */ +export function registerAssignableUserRoutes(api) { + api.get('/assignable-users', (_req, res) => { + const rows = db + .prepare( + `SELECT id, username, firstname, lastname FROM users + WHERE active = 1 + ORDER BY + COALESCE(lastname, '') COLLATE NOCASE ASC, + COALESCE(firstname, '') COLLATE NOCASE ASC, + username COLLATE NOCASE ASC`, + ) + .all(); + res.json(rows.map(mapAssignable)); + }); +} diff --git a/server/routes/api/attachments.js b/server/routes/api/attachments.js index ef497ea..13207cd 100644 --- a/server/routes/api/attachments.js +++ b/server/routes/api/attachments.js @@ -5,6 +5,7 @@ import { randomUUID } from 'crypto'; import { fileURLToPath } from 'url'; import multer from 'multer'; import db from '../../db.js'; +import { requireCrmEdit } from '../../middleware/auth.js'; import { badRequest, UUID } from '../../lib/http.js'; import { mapEvent } from '../../lib/mappers.js'; @@ -106,6 +107,7 @@ function discardIncomingFiles(files) { export function registerAttachmentRoutes(api) { api.post( '/tickets/:ticketId/events/attachments', + requireCrmEdit, uploadMiddleware, async (req, res) => { const { ticketId } = req.params; diff --git a/server/routes/api/events.js b/server/routes/api/events.js index 5289cab..07dcc68 100644 --- a/server/routes/api/events.js +++ b/server/routes/api/events.js @@ -1,5 +1,6 @@ import { randomUUID } from 'crypto'; import db from '../../db.js'; +import { requireCrmEdit } from '../../middleware/auth.js'; import { badRequest } from '../../lib/http.js'; import { mapEvent } from '../../lib/mappers.js'; import { computeRemoteDurationSeconds } from '../../teamviewer.js'; @@ -7,7 +8,7 @@ import { computeRemoteDurationSeconds } from '../../teamviewer.js'; const EVENT_TYPES_USER = new Set(['NOTE', 'CALL', 'REMOTE', 'PART']); export function registerEventRoutes(api) { - api.post('/events', (req, res) => { + api.post('/events', requireCrmEdit, (req, res) => { const b = req.body || {}; const ticketId = b.ticketId; const type = b.type; diff --git a/server/routes/api/index.js b/server/routes/api/index.js index 8b8b321..b98914d 100644 --- a/server/routes/api/index.js +++ b/server/routes/api/index.js @@ -5,12 +5,14 @@ import { registerTeamViewerRoutes } from '../../teamviewer.js'; import { registerAttachmentRoutes } from './attachments.js'; import { registerEventRoutes } from './events.js'; import { registerMachineRoutes } from './machines.js'; +import { registerAssignableUserRoutes } from './assignable-users.js'; import { registerTicketRoutes } from './tickets.js'; export function createApiRouter() { const api = Router(); api.use(requireAuth); registerMachineRoutes(api); + registerAssignableUserRoutes(api); registerTicketRoutes(api); registerAttachmentRoutes(api); registerTeamViewerRoutes(api, loadIntegrations); diff --git a/server/routes/api/machines.js b/server/routes/api/machines.js index db25761..2c73372 100644 --- a/server/routes/api/machines.js +++ b/server/routes/api/machines.js @@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'; import db from '../../db.js'; import { badRequest, UUID } from '../../lib/http.js'; import { mapMachine } from '../../lib/mappers.js'; +import { requireCrmEdit } from '../../middleware/auth.js'; const ALLOWED_LIST_STATUS = new Set([ '', @@ -49,7 +50,7 @@ export function registerMachineRoutes(api) { res.json(rows.map(mapMachine)); }); - api.post('/machines', (req, res) => { + api.post('/machines', requireCrmEdit, (req, res) => { const b = req.body || {}; const { name, typ, seriennummer, standort, listStatus } = b; if (!name || !typ || !seriennummer || !standort) { @@ -81,7 +82,7 @@ export function registerMachineRoutes(api) { res.json(mapMachine(row)); }); - api.put('/machines/:id', (req, res) => { + api.put('/machines/:id', requireCrmEdit, (req, res) => { const { id } = req.params; if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); const cur = db.prepare('SELECT * FROM machines WHERE id = ?').get(id); @@ -122,7 +123,7 @@ export function registerMachineRoutes(api) { res.json(mapMachine(row)); }); - api.delete('/machines/:id', (req, res) => { + api.delete('/machines/:id', requireCrmEdit, (req, res) => { const { id } = req.params; if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); const cur = db.prepare('SELECT * FROM machines WHERE id = ?').get(id); diff --git a/server/routes/api/tickets.js b/server/routes/api/tickets.js index f8b9d4d..58fe59a 100644 --- a/server/routes/api/tickets.js +++ b/server/routes/api/tickets.js @@ -1,5 +1,6 @@ import { randomUUID } from 'crypto'; import db from '../../db.js'; +import { requireCrmEdit } from '../../middleware/auth.js'; import { badRequest, UUID } from '../../lib/http.js'; import { mergeAttachmentEventsForApi } from '../../lib/ticket-events-merge.js'; import { @@ -36,9 +37,15 @@ ORDER BY CASE WHEN t.status IN ('OPEN','WAITING') AND datetime('now') > ${ticketSlaDueExpr} THEN ${ticketSlaDueExpr} ELSE '9999-12-31' END ASC, ${ticketLastActivityExpr} DESC`; +function userLabelFromRow(row) { + if (!row) return '—'; + const fn = [row.firstname, row.lastname].filter(Boolean).join(' ').trim(); + return fn || row.username || row.id; +} + export function registerTicketRoutes(api) { api.get('/tickets', (req, res) => { - const { status, priority, machineId, open } = req.query; + const { status, priority, machineId, open, assignedTo } = req.query; const cond = ['1=1']; const params = []; const openFilter = open === '1' || open === 'true'; @@ -56,12 +63,21 @@ export function registerTicketRoutes(api) { cond.push('t.machine_id = ?'); params.push(machineId); } + if (assignedTo === 'me' && req.session?.userId) { + cond.push('t.assigned_user_id = ?'); + params.push(req.session.userId); + } else if (assignedTo === 'not_me' && req.session?.userId) { + cond.push( + '(t.assigned_user_id IS NULL OR t.assigned_user_id <> ?)', + ); + params.push(req.session.userId); + } const sql = `${ticketJoinSelect} WHERE ${cond.join(' AND ')} ${ticketListOrderBy}`; const rows = db.prepare(sql).all(...params); res.json(rows.map(mapTicket)); }); - api.post('/tickets', (req, res) => { + api.post('/tickets', requireCrmEdit, (req, res) => { const { machineId, title, description, status, priority, slaDays } = req.body || {}; if (!machineId || !title || !description) { @@ -125,7 +141,7 @@ export function registerTicketRoutes(api) { res.json(mapTicket(row)); }); - api.put('/tickets/:id', (req, res) => { + api.put('/tickets/:id', requireCrmEdit, (req, res) => { const { id } = req.params; if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); const cur = db.prepare('SELECT * FROM tickets WHERE id = ?').get(id); @@ -173,6 +189,46 @@ export function registerTicketRoutes(api) { `Fälligkeit: ${label(cur.sla_days)} → ${label(nextSlaDays)}`, ); } + + let nextAssignedUserId = cur.assigned_user_id ?? null; + if (Object.prototype.hasOwnProperty.call(b, 'assignedUserId')) { + const raw = b.assignedUserId; + if (raw === null || raw === undefined || raw === '') { + nextAssignedUserId = null; + } else if (!UUID.test(String(raw))) { + return badRequest(res, 'assignedUserId ungültig.'); + } else { + const u = db + .prepare( + 'SELECT id, username, firstname, lastname FROM users WHERE id = ? AND active = 1', + ) + .get(String(raw)); + if (!u) { + return badRequest(res, 'Zugewiesener Benutzer nicht gefunden oder inaktiv.'); + } + nextAssignedUserId = u.id; + } + } + if (nextAssignedUserId !== (cur.assigned_user_id ?? null)) { + const prevU = cur.assigned_user_id + ? db + .prepare( + 'SELECT id, username, firstname, lastname FROM users WHERE id = ?', + ) + .get(cur.assigned_user_id) + : null; + const nextU = nextAssignedUserId + ? db + .prepare( + 'SELECT id, username, firstname, lastname FROM users WHERE id = ?', + ) + .get(nextAssignedUserId) + : null; + const fromLabel = prevU ? userLabelFromRow(prevU) : 'nicht zugewiesen'; + const toLabel = nextU ? userLabelFromRow(nextU) : 'nicht zugewiesen'; + lines.push(`Zuweisung: ${fromLabel} → ${toLabel}`); + } + if (lines.length > 0) { const eid = randomUUID(); db.prepare( @@ -184,6 +240,7 @@ export function registerTicketRoutes(api) { db.prepare( `UPDATE tickets SET title = ?, description = ?, status = ?, priority = ?, sla_days = ?, sla_anchor_at = CASE WHEN ? THEN datetime('now') ELSE sla_anchor_at END, + assigned_user_id = ?, updated_at = datetime('now') WHERE id = ?`, ).run( @@ -193,6 +250,7 @@ export function registerTicketRoutes(api) { next.priority, nextSlaDays, resetSlaAnchor ? 1 : 0, + nextAssignedUserId, id, ); diff --git a/server/routes/auth.js b/server/routes/auth.js index 355d744..06ab72c 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -10,12 +10,17 @@ const router = Router(); function userPayload(row) { if (!row) return null; + const role = row.role; + const canAdmin = role === 'admin'; + const canEditCrm = canAdmin || role === 'after_sales'; return { id: row.id, username: row.username, - role: row.role, + role, firstName: row.firstname ?? null, lastName: row.lastname ?? null, + canAdmin, + canEditCrm, }; }