Benutzer, Ticketzuweißungen
This commit is contained in:
BIN
data/crm.db
BIN
data/crm.db
Binary file not shown.
@@ -15,6 +15,22 @@ CREATE TABLE IF NOT EXISTS "machines" (
|
|||||||
"updated_at" TEXT NOT NULL DEFAULT (datetime('now'))
|
"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" (
|
CREATE TABLE IF NOT EXISTS "tickets" (
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
"machine_id" TEXT NOT NULL,
|
"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')),
|
"priority" TEXT NOT NULL DEFAULT 'MEDIUM' CHECK ("priority" IN ('LOW', 'MEDIUM', 'HIGH')),
|
||||||
"sla_days" INTEGER,
|
"sla_days" INTEGER,
|
||||||
"sla_anchor_at" TEXT,
|
"sla_anchor_at" TEXT,
|
||||||
|
"assigned_user_id" TEXT,
|
||||||
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
|
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
"updated_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" (
|
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_machine_id_idx" ON "tickets" ("machine_id");
|
||||||
CREATE INDEX IF NOT EXISTS "tickets_status_idx" ON "tickets" ("status");
|
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_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_ticket_id_idx" ON "events" ("ticket_id");
|
||||||
CREATE INDEX IF NOT EXISTS "events_created_at_idx" ON "events" ("created_at");
|
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 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" (
|
CREATE TABLE IF NOT EXISTS "app_settings" (
|
||||||
"key" TEXT NOT NULL PRIMARY KEY,
|
"key" TEXT NOT NULL PRIMARY KEY,
|
||||||
"value" TEXT NOT NULL
|
"value" TEXT NOT NULL
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
/* Benutzer – bei Bedarf seiten-spezifische Styles */
|
/* Benutzer – bei Bedarf seiten-spezifische Styles */
|
||||||
|
|
||||||
|
.users-table .user-role-select {
|
||||||
|
min-width: 11rem;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -652,7 +652,13 @@ code {
|
|||||||
/* ═══════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════
|
||||||
Startseite — Offene Tickets
|
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 {
|
.home-kpi-bar {
|
||||||
display: flex;
|
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-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); }
|
.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 */
|
/* 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-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); }
|
.badge-medium { background: rgba(158, 106, 3, 0.15); color: var(--amber-fg); border-color: rgba(210, 153, 34, 0.3); }
|
||||||
|
|||||||
@@ -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
|
* @param {{ needsAdmin?: boolean, activeNav?: string }} opts
|
||||||
* @returns {Promise<object|null>} Session-Status oder null bei Redirect
|
* @returns {Promise<object|null>} Session-Status oder null bei Redirect
|
||||||
@@ -40,7 +50,7 @@ export async function guard(opts = {}) {
|
|||||||
location.href = '/start.html';
|
location.href = '/start.html';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (needsAdmin && st.user?.role !== 'admin') {
|
if (needsAdmin && !canAdmin(st.user)) {
|
||||||
location.href = '/start.html';
|
location.href = '/start.html';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function updateNav(st, activeNav = '') {
|
|||||||
nav.innerHTML = '';
|
nav.innerHTML = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const isAdmin = st.user?.role === 'admin';
|
const isAdmin = st.user?.canAdmin === true;
|
||||||
const na = (key) => (activeNav === key ? 'nav-active' : '');
|
const na = (key) => (activeNav === key ? 'nav-active' : '');
|
||||||
nav.innerHTML = `
|
nav.innerHTML = `
|
||||||
<a href="/start.html" class="${na('start')}">Start</a>
|
<a href="/start.html" class="${na('start')}">Start</a>
|
||||||
|
|||||||
@@ -71,9 +71,24 @@ function showEditMode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function viewMachineDetail(id, options = {}) {
|
async function viewMachineDetail(id, options = {}) {
|
||||||
const { startInEditMode = false } = options;
|
const { startInEditMode = false, canEdit = true } = options;
|
||||||
const m = await apiGet(`/machines/${id}`);
|
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);
|
fillView(m);
|
||||||
if (startInEditMode) {
|
if (startInEditMode) {
|
||||||
fillEdit(m);
|
fillEdit(m);
|
||||||
@@ -143,10 +158,11 @@ async function viewMachineDetail(id, options = {}) {
|
|||||||
async function init() {
|
async function init() {
|
||||||
const st = await guard({ activeNav: 'machines' });
|
const st = await guard({ activeNav: 'machines' });
|
||||||
if (!st) return;
|
if (!st) return;
|
||||||
|
const canEdit = st.user?.canEditCrm === true;
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const id = params.get('id');
|
const id = params.get('id');
|
||||||
const startInEditMode = params.get('edit') === '1';
|
const startInEditMode = canEdit && params.get('edit') === '1';
|
||||||
if (!id || !UUID.test(id)) {
|
if (!id || !UUID.test(id)) {
|
||||||
loadingEl.hidden = true;
|
loadingEl.hidden = true;
|
||||||
badIdEl.hidden = false;
|
badIdEl.hidden = false;
|
||||||
@@ -157,8 +173,10 @@ async function init() {
|
|||||||
mainEl.hidden = false;
|
mainEl.hidden = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await viewMachineDetail(id, { startInEditMode });
|
await viewMachineDetail(id, { startInEditMode, canEdit });
|
||||||
if (startInEditMode) {
|
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)}`);
|
history.replaceState(null, '', `/machine.html?id=${encodeURIComponent(id)}`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -53,34 +53,39 @@ function initNewMachineCollapse() {
|
|||||||
toggle.onclick = () => setOpen(body.hidden);
|
toggle.onclick = () => setOpen(body.hidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run(canEdit) {
|
||||||
const machines = await apiGet('/machines');
|
const machines = await apiGet('/machines');
|
||||||
document.getElementById('machine-count').textContent = String(machines.length);
|
document.getElementById('machine-count').textContent = String(machines.length);
|
||||||
renderRows(machines);
|
renderRows(machines);
|
||||||
|
|
||||||
|
const newCard = document.getElementById('machines-new-card');
|
||||||
|
if (newCard) newCard.hidden = !canEdit;
|
||||||
|
|
||||||
initNewMachineCollapse();
|
initNewMachineCollapse();
|
||||||
|
|
||||||
const formNew = document.getElementById('form-new-machine');
|
const formNew = document.getElementById('form-new-machine');
|
||||||
formNew.addEventListener('submit', async (e) => {
|
if (canEdit) {
|
||||||
e.preventDefault();
|
formNew.addEventListener('submit', async (e) => {
|
||||||
const fd = new FormData(formNew);
|
e.preventDefault();
|
||||||
const btn = formNew.querySelector('button[type="submit"]');
|
const fd = new FormData(formNew);
|
||||||
btn.disabled = true;
|
const btn = formNew.querySelector('button[type="submit"]');
|
||||||
try {
|
btn.disabled = true;
|
||||||
const body = Object.fromEntries(fd.entries());
|
try {
|
||||||
const created = await apiPost('/machines', {
|
const body = Object.fromEntries(fd.entries());
|
||||||
name: body.name,
|
const created = await apiPost('/machines', {
|
||||||
typ: body.typ,
|
name: body.name,
|
||||||
seriennummer: body.seriennummer,
|
typ: body.typ,
|
||||||
standort: body.standort,
|
seriennummer: body.seriennummer,
|
||||||
listStatus: body.listStatus || '',
|
standort: body.standort,
|
||||||
});
|
listStatus: body.listStatus || '',
|
||||||
location.href = `/machine.html?id=${encodeURIComponent(created.id)}`;
|
});
|
||||||
} catch (err) {
|
location.href = `/machine.html?id=${encodeURIComponent(created.id)}`;
|
||||||
alert(err.message || 'Anlegen fehlgeschlagen.');
|
} catch (err) {
|
||||||
btn.disabled = false;
|
alert(err.message || 'Anlegen fehlgeschlagen.');
|
||||||
}
|
btn.disabled = false;
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const inp = document.getElementById('machine-filter');
|
const inp = document.getElementById('machine-filter');
|
||||||
const tbody = document.getElementById('machine-table-body');
|
const tbody = document.getElementById('machine-table-body');
|
||||||
@@ -95,10 +100,11 @@ async function run() {
|
|||||||
async function init() {
|
async function init() {
|
||||||
const st = await guard({ activeNav: 'machines' });
|
const st = await guard({ activeNav: 'machines' });
|
||||||
if (!st) return;
|
if (!st) return;
|
||||||
|
const canEdit = st.user?.canEditCrm === true;
|
||||||
loadingEl.hidden = true;
|
loadingEl.hidden = true;
|
||||||
mainEl.hidden = false;
|
mainEl.hidden = false;
|
||||||
try {
|
try {
|
||||||
await run();
|
await run(canEdit);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isAuthRedirectError(e)) return;
|
if (isAuthRedirectError(e)) return;
|
||||||
showError(e.message || 'Fehler');
|
showError(e.message || 'Fehler');
|
||||||
|
|||||||
@@ -19,9 +19,17 @@ const loadingEl = document.getElementById('page-loading');
|
|||||||
const mainEl = document.getElementById('page-main');
|
const mainEl = document.getElementById('page-main');
|
||||||
const errEl = document.getElementById('page-error');
|
const errEl = document.getElementById('page-error');
|
||||||
const listEl = document.getElementById('home-ticket-list');
|
const listEl = document.getElementById('home-ticket-list');
|
||||||
|
const listMineEl = document.getElementById('home-ticket-list-mine');
|
||||||
const emptyEl = document.getElementById('home-empty');
|
const emptyEl = document.getElementById('home-empty');
|
||||||
const tpl = document.getElementById('tpl-home-ticket');
|
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) {
|
function showError(msg) {
|
||||||
loadingEl.hidden = true;
|
loadingEl.hidden = true;
|
||||||
mainEl.hidden = true;
|
mainEl.hidden = true;
|
||||||
@@ -48,7 +56,8 @@ function renderEventBoxes(events) {
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillTicketCard(node, t, events) {
|
/** @param {'open' | 'mine'} listKind */
|
||||||
|
function fillTicketCard(node, t, events, listKind) {
|
||||||
const id = t.id;
|
const id = t.id;
|
||||||
const detailId = `home-ticket-detail-${id}`;
|
const detailId = `home-ticket-detail-${id}`;
|
||||||
node.dataset.ticketId = id;
|
node.dataset.ticketId = id;
|
||||||
@@ -79,6 +88,25 @@ function fillTicketCard(node, t, events) {
|
|||||||
pr.textContent = ticketPriorityLabel[t.priority];
|
pr.textContent = ticketPriorityLabel[t.priority];
|
||||||
pr.className = `badge js-priority ${priorityBadgeClass[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');
|
const metaM = node.querySelector('.js-meta-machine');
|
||||||
metaM.innerHTML = t.machine
|
metaM.innerHTML = t.machine
|
||||||
? `<span class="muted">Maschine:</span> ${machineLabel}`
|
? `<span class="muted">Maschine:</span> ${machineLabel}`
|
||||||
@@ -87,6 +115,8 @@ function fillTicketCard(node, t, events) {
|
|||||||
node.querySelector('.js-meta-standort').textContent = standort;
|
node.querySelector('.js-meta-standort').textContent = standort;
|
||||||
node.querySelector('.js-meta-created').textContent = formatDateTime(t.createdAt);
|
node.querySelector('.js-meta-created').textContent = formatDateTime(t.createdAt);
|
||||||
node.querySelector('.js-meta-updated').textContent = formatDateTime(t.updatedAt);
|
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');
|
const openA = node.querySelector('.js-ticket-open');
|
||||||
openA.href = `/ticket.html?id=${encodeURIComponent(id)}`;
|
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() {
|
async function run() {
|
||||||
const tickets = await apiGet('/tickets?open=1');
|
const [ticketsAll, ticketsMine] = await Promise.all([
|
||||||
const eventsLists =
|
apiGet('/tickets?open=1&assignedTo=not_me'),
|
||||||
tickets.length === 0
|
apiGet('/tickets?open=1&assignedTo=me'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const eventsAll =
|
||||||
|
ticketsAll.length === 0
|
||||||
? []
|
? []
|
||||||
: await Promise.all(tickets.map((t) => apiGet(`/tickets/${t.id}/events`)));
|
: await Promise.all(ticketsAll.map((t) => apiGet(`/tickets/${t.id}/events`)));
|
||||||
|
const eventsMine =
|
||||||
const openCount = tickets.filter((t) => t.status === 'OPEN').length;
|
ticketsMine.length === 0
|
||||||
const waitingCount = tickets.filter((t) => t.status === 'WAITING').length;
|
? []
|
||||||
|
: 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-open').textContent = `${openCount} Offen`;
|
||||||
document.getElementById('kpi-waiting').textContent = `${waitingCount} Wartend`;
|
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 = '';
|
const mineOpen = ticketsMine.filter((t) => t.status === 'OPEN').length;
|
||||||
if (tickets.length === 0) {
|
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.hidden = false;
|
||||||
|
emptyEl.textContent = 'Keine offenen Tickets.';
|
||||||
|
listEl.innerHTML = '';
|
||||||
} else {
|
} else {
|
||||||
emptyEl.hidden = true;
|
emptyEl.hidden = true;
|
||||||
tickets.forEach((t, i) => {
|
renderTicketListInto(listEl, ticketsAll, eventsAll, 'open');
|
||||||
const frag = tpl.content.cloneNode(true);
|
|
||||||
const article = frag.querySelector('.home-ticket-card');
|
|
||||||
fillTicketCard(article, t, eventsLists[i] || []);
|
|
||||||
listEl.appendChild(article);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderTicketListInto(listMineEl, ticketsMine, eventsMine, 'mine');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -146,6 +197,7 @@ async function init() {
|
|||||||
loadingEl.hidden = true;
|
loadingEl.hidden = true;
|
||||||
mainEl.hidden = false;
|
mainEl.hidden = false;
|
||||||
bindAttachmentPreview(document.body);
|
bindAttachmentPreview(document.body);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await run();
|
await run();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ import {
|
|||||||
const UUID =
|
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;
|
/^[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 loadingEl = document.getElementById('page-loading');
|
||||||
const badIdEl = document.getElementById('ticket-bad-id');
|
const badIdEl = document.getElementById('ticket-bad-id');
|
||||||
const errEl = document.getElementById('page-error');
|
const errEl = document.getElementById('page-error');
|
||||||
@@ -65,6 +76,11 @@ function fillTicketView(ticket) {
|
|||||||
} else {
|
} else {
|
||||||
mrow.hidden = true;
|
mrow.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const assignLabel = document.getElementById('t-assign-label');
|
||||||
|
if (assignLabel) {
|
||||||
|
assignLabel.textContent = formatAssigneeLabel(ticket.assignedTo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillEditForm(ticket) {
|
function fillEditForm(ticket) {
|
||||||
@@ -103,7 +119,7 @@ function showEditMode() {
|
|||||||
panelEdit.hidden = false;
|
panelEdit.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function viewTicketDetail(id) {
|
async function viewTicketDetail(id, canEdit) {
|
||||||
const [ticket, events] = await Promise.all([
|
const [ticket, events] = await Promise.all([
|
||||||
apiGet(`/tickets/${id}`),
|
apiGet(`/tickets/${id}`),
|
||||||
apiGet(`/tickets/${id}/events`),
|
apiGet(`/tickets/${id}/events`),
|
||||||
@@ -123,6 +139,72 @@ async function viewTicketDetail(id) {
|
|||||||
sect2.hidden = true;
|
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 = '<option value="">— Fehler beim Laden —</option>';
|
||||||
|
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');
|
const slaSel = document.getElementById('t-sla-days');
|
||||||
if (slaSel) {
|
if (slaSel) {
|
||||||
slaSel.onchange = async () => {
|
slaSel.onchange = async () => {
|
||||||
@@ -259,6 +341,7 @@ async function viewTicketDetail(id) {
|
|||||||
async function init() {
|
async function init() {
|
||||||
const st = await guard({ activeNav: 'tickets' });
|
const st = await guard({ activeNav: 'tickets' });
|
||||||
if (!st) return;
|
if (!st) return;
|
||||||
|
const canEdit = st.user?.canEditCrm === true;
|
||||||
|
|
||||||
const id = new URLSearchParams(location.search).get('id');
|
const id = new URLSearchParams(location.search).get('id');
|
||||||
if (!id || !UUID.test(id)) {
|
if (!id || !UUID.test(id)) {
|
||||||
@@ -273,7 +356,7 @@ async function init() {
|
|||||||
bindAttachmentPreview(document.body);
|
bindAttachmentPreview(document.body);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await viewTicketDetail(id);
|
await viewTicketDetail(id, canEdit);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isAuthRedirectError(e)) return;
|
if (isAuthRedirectError(e)) return;
|
||||||
showError(e.message || 'Fehler');
|
showError(e.message || 'Fehler');
|
||||||
|
|||||||
@@ -61,10 +61,13 @@ function renderTicketRows(tickets) {
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run(canEdit) {
|
||||||
const qs = ticketListQuery();
|
const qs = ticketListQuery();
|
||||||
const urlParams = new URLSearchParams(location.search);
|
const urlParams = new URLSearchParams(location.search);
|
||||||
|
|
||||||
|
const newCard = document.getElementById('tickets-new-card');
|
||||||
|
if (newCard) newCard.hidden = !canEdit;
|
||||||
|
|
||||||
const [tickets, allMachines] = await Promise.all([
|
const [tickets, allMachines] = await Promise.all([
|
||||||
apiGet(`/tickets${qs}`),
|
apiGet(`/tickets${qs}`),
|
||||||
apiGet('/machines'),
|
apiGet('/machines'),
|
||||||
@@ -81,18 +84,20 @@ async function run() {
|
|||||||
|
|
||||||
renderTicketRows(tickets);
|
renderTicketRows(tickets);
|
||||||
|
|
||||||
document.getElementById('form-new-ticket').onsubmit = async (e) => {
|
if (canEdit) {
|
||||||
e.preventDefault();
|
document.getElementById('form-new-ticket').onsubmit = async (e) => {
|
||||||
const fd = new FormData(e.target);
|
e.preventDefault();
|
||||||
await apiPost('/tickets', {
|
const fd = new FormData(e.target);
|
||||||
machineId: fd.get('machineId'),
|
await apiPost('/tickets', {
|
||||||
title: fd.get('title'),
|
machineId: fd.get('machineId'),
|
||||||
description: fd.get('description'),
|
title: fd.get('title'),
|
||||||
});
|
description: fd.get('description'),
|
||||||
e.target.reset();
|
});
|
||||||
fillMachineSelects(allMachines, mid);
|
e.target.reset();
|
||||||
location.reload();
|
fillMachineSelects(allMachines, mid);
|
||||||
};
|
location.reload();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('form-filter').onsubmit = (e) => {
|
document.getElementById('form-filter').onsubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -109,10 +114,11 @@ async function run() {
|
|||||||
async function init() {
|
async function init() {
|
||||||
const st = await guard({ activeNav: 'tickets' });
|
const st = await guard({ activeNav: 'tickets' });
|
||||||
if (!st) return;
|
if (!st) return;
|
||||||
|
const canEdit = st.user?.canEditCrm === true;
|
||||||
loadingEl.hidden = true;
|
loadingEl.hidden = true;
|
||||||
mainEl.hidden = false;
|
mainEl.hidden = false;
|
||||||
try {
|
try {
|
||||||
await run();
|
await run(canEdit);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isAuthRedirectError(e)) return;
|
if (isAuthRedirectError(e)) return;
|
||||||
showError(e.message || 'Fehler');
|
showError(e.message || 'Fehler');
|
||||||
|
|||||||
@@ -18,6 +18,20 @@ function formatName(u) {
|
|||||||
return a.length ? a.map((x) => esc(String(x))).join(' ') : '—';
|
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]) =>
|
||||||
|
`<option value="${esc(val)}"${current === val ? ' selected' : ''}>${esc(label)}</option>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
function renderRows(users) {
|
function renderRows(users) {
|
||||||
const tbody = document.getElementById('users-table-body');
|
const tbody = document.getElementById('users-table-body');
|
||||||
tbody.innerHTML = users
|
tbody.innerHTML = users
|
||||||
@@ -26,7 +40,11 @@ function renderRows(users) {
|
|||||||
<tr data-id="${esc(u.id)}">
|
<tr data-id="${esc(u.id)}">
|
||||||
<td>${esc(u.username)}</td>
|
<td>${esc(u.username)}</td>
|
||||||
<td class="muted">${formatName(u)}</td>
|
<td class="muted">${formatName(u)}</td>
|
||||||
<td><span class="badge">${u.role === 'admin' ? 'Admin' : 'Benutzer'}</span></td>
|
<td>
|
||||||
|
<select id="role-${esc(u.id)}" class="user-role-select" data-user-id="${esc(u.id)}" data-role-prev="${esc(u.role)}" aria-label="Rolle">
|
||||||
|
${roleOptionsHtml(u.role)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
<td class="muted">${u.source === 'ldap' ? 'LDAP' : 'Lokal'}</td>
|
<td class="muted">${u.source === 'ldap' ? 'LDAP' : 'Lokal'}</td>
|
||||||
<td>${u.active ? 'Ja' : 'Nein'}</td>
|
<td>${u.active ? 'Ja' : 'Nein'}</td>
|
||||||
<td class="users-actions">
|
<td class="users-actions">
|
||||||
@@ -56,6 +74,24 @@ async function run() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const root = document.getElementById('page-main');
|
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) => {
|
root.querySelectorAll('.btn-pw').forEach((btn) => {
|
||||||
btn.onclick = async () => {
|
btn.onclick = async () => {
|
||||||
const uid = btn.getAttribute('data-id');
|
const uid = btn.getAttribute('data-id');
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
<tbody id="machine-table-body"></tbody>
|
<tbody id="machine-table-body"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="card options-section ldap-section machines-new-machine">
|
<div class="card options-section ldap-section machines-new-machine" id="machines-new-card">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="ldap-section-toggle"
|
class="ldap-section-toggle"
|
||||||
|
|||||||
@@ -16,20 +16,38 @@
|
|||||||
<p id="page-loading" class="muted">Lade …</p>
|
<p id="page-loading" class="muted">Lade …</p>
|
||||||
<p id="page-error" class="error" hidden></p>
|
<p id="page-error" class="error" hidden></p>
|
||||||
<div id="page-main" class="stack home-open-tickets" hidden>
|
<div id="page-main" class="stack home-open-tickets" hidden>
|
||||||
<div class="home-kpi-bar">
|
<section class="home-ticket-section" aria-labelledby="home-heading-open">
|
||||||
<h2>Offene Tickets</h2>
|
<div class="home-kpi-bar">
|
||||||
<div class="home-kpi-pills">
|
<h2 id="home-heading-open">Offene Tickets</h2>
|
||||||
<span class="kpi-pill"
|
<div class="home-kpi-pills">
|
||||||
><span id="kpi-open" class="badge badge-open">0 Offen</span></span
|
<span class="kpi-pill"
|
||||||
>
|
><span id="kpi-open" class="badge badge-open">0 Offen</span></span
|
||||||
<span class="kpi-pill"
|
>
|
||||||
><span id="kpi-waiting" class="badge badge-waiting">0 Wartend</span></span
|
<span class="kpi-pill"
|
||||||
>
|
><span id="kpi-waiting" class="badge badge-waiting">0 Wartend</span></span
|
||||||
<span id="kpi-total" class="kpi-pill muted">gesamt: 0</span>
|
>
|
||||||
|
<span id="kpi-total" class="kpi-pill muted">gesamt: 0</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div id="home-ticket-list" class="home-ticket-list"></div>
|
||||||
<div id="home-ticket-list" class="home-ticket-list"></div>
|
<p id="home-empty" class="muted" hidden>Keine offenen Tickets.</p>
|
||||||
<p id="home-empty" class="muted" hidden>Keine offenen Tickets.</p>
|
</section>
|
||||||
|
|
||||||
|
<section class="home-ticket-section" aria-labelledby="home-heading-mine">
|
||||||
|
<div class="home-kpi-bar">
|
||||||
|
<h2 id="home-heading-mine">Meine Tickets</h2>
|
||||||
|
<div class="home-kpi-pills">
|
||||||
|
<span class="kpi-pill"
|
||||||
|
><span id="kpi-mine-open" class="badge badge-open">0 Offen</span></span
|
||||||
|
>
|
||||||
|
<span class="kpi-pill"
|
||||||
|
><span id="kpi-mine-waiting" class="badge badge-waiting">0 Wartend</span></span
|
||||||
|
>
|
||||||
|
<span id="kpi-mine-total" class="kpi-pill muted">gesamt: 0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="home-ticket-list-mine" class="home-ticket-list"></div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -49,12 +67,14 @@
|
|||||||
<a class="js-ticket-link" href="#">Titel</a>
|
<a class="js-ticket-link" href="#">Titel</a>
|
||||||
<span class="badge js-status"></span>
|
<span class="badge js-status"></span>
|
||||||
<span class="badge js-priority"></span>
|
<span class="badge js-priority"></span>
|
||||||
|
<span class="badge js-assignee-tag badge-assignee" title="Zugewiesen"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="home-ticket-meta-row">
|
<div class="home-ticket-meta-row">
|
||||||
<span class="js-meta-machine"></span>
|
<span class="js-meta-machine"></span>
|
||||||
<span><span class="muted">Standort:</span> <span class="js-meta-standort"></span></span>
|
<span><span class="muted">Standort:</span> <span class="js-meta-standort"></span></span>
|
||||||
<span><span class="muted">Erstellt:</span> <span class="js-meta-created"></span></span>
|
<span><span class="muted">Erstellt:</span> <span class="js-meta-created"></span></span>
|
||||||
<span><span class="muted">Aktualisiert:</span> <span class="js-meta-updated"></span></span>
|
<span><span class="muted">Aktualisiert:</span> <span class="js-meta-updated"></span></span>
|
||||||
|
<span><span class="muted">Zugewiesen:</span> <span class="js-meta-assignee"></span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="home-ticket-open js-ticket-open" href="#">Ticket öffnen →</a>
|
<a class="home-ticket-open js-ticket-open" href="#">Ticket öffnen →</a>
|
||||||
|
|||||||
@@ -29,6 +29,15 @@
|
|||||||
<span id="t-status-badge" class="badge"></span>
|
<span id="t-status-badge" class="badge"></span>
|
||||||
</p>
|
</p>
|
||||||
<p><strong>Priorität:</strong> <span id="t-priority-label"></span></p>
|
<p><strong>Priorität:</strong> <span id="t-priority-label"></span></p>
|
||||||
|
<p>
|
||||||
|
<label for="t-assign-user"><strong>Zugewiesen an</strong></label>
|
||||||
|
<select id="t-assign-user" aria-label="Benutzer zuweisen">
|
||||||
|
<option value="">— lädt … —</option>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<p id="t-assign-readonly" class="muted" hidden>
|
||||||
|
<strong>Zugewiesen an:</strong> <span id="t-assign-label"></span>
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<label for="t-sla-days"><strong>Fälligkeit (Bearbeitungszeit)</strong></label>
|
<label for="t-sla-days"><strong>Fälligkeit (Bearbeitungszeit)</strong></label>
|
||||||
<select id="t-sla-days" aria-label="Fälligkeit in Tagen">
|
<select id="t-sla-days" aria-label="Fälligkeit in Tagen">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<p id="page-error" class="error" hidden></p>
|
<p id="page-error" class="error" hidden></p>
|
||||||
<div id="page-main" class="stack" hidden>
|
<div id="page-main" class="stack" hidden>
|
||||||
<h2>Tickets</h2>
|
<h2>Tickets</h2>
|
||||||
<div class="card">
|
<div class="card" id="tickets-new-card">
|
||||||
<h3>Neues Ticket</h3>
|
<h3>Neues Ticket</h3>
|
||||||
<form id="form-new-ticket" class="stack ticket-new-form">
|
<form id="form-new-ticket" class="stack ticket-new-form">
|
||||||
<div class="ticket-form-machine">
|
<div class="ticket-form-machine">
|
||||||
|
|||||||
@@ -30,7 +30,8 @@
|
|||||||
<label
|
<label
|
||||||
>Rolle
|
>Rolle
|
||||||
<select name="role">
|
<select name="role">
|
||||||
<option value="user">Benutzer</option>
|
<option value="viewer">Viewer</option>
|
||||||
|
<option value="after_sales" selected>After-Sales</option>
|
||||||
<option value="admin">Administrator</option>
|
<option value="admin">Administrator</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
71
server/db.js
71
server/db.js
@@ -128,7 +128,7 @@ if (!tbl) {
|
|||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
"username" TEXT NOT NULL UNIQUE,
|
"username" TEXT NOT NULL UNIQUE,
|
||||||
"password_hash" TEXT,
|
"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')),
|
"source" TEXT NOT NULL DEFAULT 'local' CHECK ("source" IN ('local', 'ldap')),
|
||||||
"ldap_dn" TEXT,
|
"ldap_dn" TEXT,
|
||||||
"firstname" TEXT,
|
"firstname" TEXT,
|
||||||
@@ -149,6 +149,65 @@ if (!userCols.some((c) => c.name === 'lastname')) {
|
|||||||
db.exec('ALTER TABLE users ADD COLUMN lastname TEXT');
|
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
|
const tblSet = db
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'",
|
"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();
|
).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;
|
export default db;
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ export async function performLdapSync(dbSync, loadIntegrations, trigger) {
|
|||||||
dbSync
|
dbSync
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO users (id, username, password_hash, role, source, ldap_dn, firstname, lastname, active, updated_at)
|
`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(
|
.run(
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -45,10 +45,29 @@ export function mapTicket(r) {
|
|||||||
createdAt: r.created_at,
|
createdAt: r.created_at,
|
||||||
/** Letzte Änderung: neueres aus Ticket-Zeile oder letztem Event (für Anzeige „Aktualisiert“). */
|
/** Letzte Änderung: neueres aus Ticket-Zeile oder letztem Event (für Anzeige „Aktualisiert“). */
|
||||||
updatedAt: r.ticket_last_activity_at ?? r.updated_at,
|
updatedAt: r.ticket_last_activity_at ?? r.updated_at,
|
||||||
|
assignedTo: null,
|
||||||
};
|
};
|
||||||
if (machine_row) {
|
if (machine_row) {
|
||||||
t.machine = mapMachine(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;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +154,14 @@ export const ticketJoinSelect = `
|
|||||||
'extras', m.extras,
|
'extras', m.extras,
|
||||||
'created_at', m.created_at,
|
'created_at', m.created_at,
|
||||||
'updated_at', m.updated_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
|
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`;
|
||||||
|
|||||||
@@ -20,3 +20,12 @@ export function requireAdmin(req, res, next) {
|
|||||||
}
|
}
|
||||||
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.' });
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ import {
|
|||||||
import { hashPassword } from '../../password.js';
|
import { hashPassword } from '../../password.js';
|
||||||
import { requireAdmin, requireAuth } from '../../middleware/auth.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() {
|
export function createAdminRouter() {
|
||||||
const admin = Router();
|
const admin = Router();
|
||||||
admin.use(requireAuth, requireAdmin);
|
admin.use(requireAuth, requireAdmin);
|
||||||
@@ -31,7 +38,7 @@ export function createAdminRouter() {
|
|||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
if (!un || !password) return badRequest(res, 'Benutzername und Passwort erforderlich.');
|
if (!un || !password) return badRequest(res, 'Benutzername und Passwort erforderlich.');
|
||||||
const r = role === 'admin' ? 'admin' : 'user';
|
const r = normalizeUserRole(role);
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const ph = await hashPassword(password);
|
const ph = await hashPassword(password);
|
||||||
try {
|
try {
|
||||||
@@ -66,7 +73,7 @@ export function createAdminRouter() {
|
|||||||
).run(ph, id);
|
).run(ph, id);
|
||||||
}
|
}
|
||||||
if (b.role !== undefined) {
|
if (b.role !== undefined) {
|
||||||
if (b.role !== 'admin' && b.role !== 'user') {
|
if (!CRM_ROLES.has(b.role)) {
|
||||||
return badRequest(res, 'Ungültige Rolle.');
|
return badRequest(res, 'Ungültige Rolle.');
|
||||||
}
|
}
|
||||||
const admins = db
|
const admins = db
|
||||||
@@ -74,7 +81,7 @@ export function createAdminRouter() {
|
|||||||
"SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND active = 1",
|
"SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND active = 1",
|
||||||
)
|
)
|
||||||
.get().c;
|
.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.' });
|
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(
|
db.prepare('UPDATE users SET role = ?, updated_at = datetime(\'now\') WHERE id = ?').run(
|
||||||
|
|||||||
27
server/routes/api/assignable-users.js
Normal file
27
server/routes/api/assignable-users.js
Normal file
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { randomUUID } from 'crypto';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import db from '../../db.js';
|
import db from '../../db.js';
|
||||||
|
import { requireCrmEdit } from '../../middleware/auth.js';
|
||||||
import { badRequest, UUID } from '../../lib/http.js';
|
import { badRequest, UUID } from '../../lib/http.js';
|
||||||
import { mapEvent } from '../../lib/mappers.js';
|
import { mapEvent } from '../../lib/mappers.js';
|
||||||
|
|
||||||
@@ -106,6 +107,7 @@ function discardIncomingFiles(files) {
|
|||||||
export function registerAttachmentRoutes(api) {
|
export function registerAttachmentRoutes(api) {
|
||||||
api.post(
|
api.post(
|
||||||
'/tickets/:ticketId/events/attachments',
|
'/tickets/:ticketId/events/attachments',
|
||||||
|
requireCrmEdit,
|
||||||
uploadMiddleware,
|
uploadMiddleware,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const { ticketId } = req.params;
|
const { ticketId } = req.params;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import db from '../../db.js';
|
import db from '../../db.js';
|
||||||
|
import { requireCrmEdit } from '../../middleware/auth.js';
|
||||||
import { badRequest } from '../../lib/http.js';
|
import { badRequest } from '../../lib/http.js';
|
||||||
import { mapEvent } from '../../lib/mappers.js';
|
import { mapEvent } from '../../lib/mappers.js';
|
||||||
import { computeRemoteDurationSeconds } from '../../teamviewer.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']);
|
const EVENT_TYPES_USER = new Set(['NOTE', 'CALL', 'REMOTE', 'PART']);
|
||||||
|
|
||||||
export function registerEventRoutes(api) {
|
export function registerEventRoutes(api) {
|
||||||
api.post('/events', (req, res) => {
|
api.post('/events', requireCrmEdit, (req, res) => {
|
||||||
const b = req.body || {};
|
const b = req.body || {};
|
||||||
const ticketId = b.ticketId;
|
const ticketId = b.ticketId;
|
||||||
const type = b.type;
|
const type = b.type;
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { registerTeamViewerRoutes } from '../../teamviewer.js';
|
|||||||
import { registerAttachmentRoutes } from './attachments.js';
|
import { registerAttachmentRoutes } from './attachments.js';
|
||||||
import { registerEventRoutes } from './events.js';
|
import { registerEventRoutes } from './events.js';
|
||||||
import { registerMachineRoutes } from './machines.js';
|
import { registerMachineRoutes } from './machines.js';
|
||||||
|
import { registerAssignableUserRoutes } from './assignable-users.js';
|
||||||
import { registerTicketRoutes } from './tickets.js';
|
import { registerTicketRoutes } from './tickets.js';
|
||||||
|
|
||||||
export function createApiRouter() {
|
export function createApiRouter() {
|
||||||
const api = Router();
|
const api = Router();
|
||||||
api.use(requireAuth);
|
api.use(requireAuth);
|
||||||
registerMachineRoutes(api);
|
registerMachineRoutes(api);
|
||||||
|
registerAssignableUserRoutes(api);
|
||||||
registerTicketRoutes(api);
|
registerTicketRoutes(api);
|
||||||
registerAttachmentRoutes(api);
|
registerAttachmentRoutes(api);
|
||||||
registerTeamViewerRoutes(api, loadIntegrations);
|
registerTeamViewerRoutes(api, loadIntegrations);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto';
|
|||||||
import db from '../../db.js';
|
import db from '../../db.js';
|
||||||
import { badRequest, UUID } from '../../lib/http.js';
|
import { badRequest, UUID } from '../../lib/http.js';
|
||||||
import { mapMachine } from '../../lib/mappers.js';
|
import { mapMachine } from '../../lib/mappers.js';
|
||||||
|
import { requireCrmEdit } from '../../middleware/auth.js';
|
||||||
|
|
||||||
const ALLOWED_LIST_STATUS = new Set([
|
const ALLOWED_LIST_STATUS = new Set([
|
||||||
'',
|
'',
|
||||||
@@ -49,7 +50,7 @@ export function registerMachineRoutes(api) {
|
|||||||
res.json(rows.map(mapMachine));
|
res.json(rows.map(mapMachine));
|
||||||
});
|
});
|
||||||
|
|
||||||
api.post('/machines', (req, res) => {
|
api.post('/machines', requireCrmEdit, (req, res) => {
|
||||||
const b = req.body || {};
|
const b = req.body || {};
|
||||||
const { name, typ, seriennummer, standort, listStatus } = b;
|
const { name, typ, seriennummer, standort, listStatus } = b;
|
||||||
if (!name || !typ || !seriennummer || !standort) {
|
if (!name || !typ || !seriennummer || !standort) {
|
||||||
@@ -81,7 +82,7 @@ export function registerMachineRoutes(api) {
|
|||||||
res.json(mapMachine(row));
|
res.json(mapMachine(row));
|
||||||
});
|
});
|
||||||
|
|
||||||
api.put('/machines/:id', (req, res) => {
|
api.put('/machines/:id', requireCrmEdit, (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||||
const cur = db.prepare('SELECT * FROM machines WHERE id = ?').get(id);
|
const cur = db.prepare('SELECT * FROM machines WHERE id = ?').get(id);
|
||||||
@@ -122,7 +123,7 @@ export function registerMachineRoutes(api) {
|
|||||||
res.json(mapMachine(row));
|
res.json(mapMachine(row));
|
||||||
});
|
});
|
||||||
|
|
||||||
api.delete('/machines/:id', (req, res) => {
|
api.delete('/machines/:id', requireCrmEdit, (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||||
const cur = db.prepare('SELECT * FROM machines WHERE id = ?').get(id);
|
const cur = db.prepare('SELECT * FROM machines WHERE id = ?').get(id);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import db from '../../db.js';
|
import db from '../../db.js';
|
||||||
|
import { requireCrmEdit } from '../../middleware/auth.js';
|
||||||
import { badRequest, UUID } from '../../lib/http.js';
|
import { badRequest, UUID } from '../../lib/http.js';
|
||||||
import { mergeAttachmentEventsForApi } from '../../lib/ticket-events-merge.js';
|
import { mergeAttachmentEventsForApi } from '../../lib/ticket-events-merge.js';
|
||||||
import {
|
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,
|
CASE WHEN t.status IN ('OPEN','WAITING') AND datetime('now') > ${ticketSlaDueExpr} THEN ${ticketSlaDueExpr} ELSE '9999-12-31' END ASC,
|
||||||
${ticketLastActivityExpr} DESC`;
|
${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) {
|
export function registerTicketRoutes(api) {
|
||||||
api.get('/tickets', (req, res) => {
|
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 cond = ['1=1'];
|
||||||
const params = [];
|
const params = [];
|
||||||
const openFilter = open === '1' || open === 'true';
|
const openFilter = open === '1' || open === 'true';
|
||||||
@@ -56,12 +63,21 @@ export function registerTicketRoutes(api) {
|
|||||||
cond.push('t.machine_id = ?');
|
cond.push('t.machine_id = ?');
|
||||||
params.push(machineId);
|
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 sql = `${ticketJoinSelect} WHERE ${cond.join(' AND ')} ${ticketListOrderBy}`;
|
||||||
const rows = db.prepare(sql).all(...params);
|
const rows = db.prepare(sql).all(...params);
|
||||||
res.json(rows.map(mapTicket));
|
res.json(rows.map(mapTicket));
|
||||||
});
|
});
|
||||||
|
|
||||||
api.post('/tickets', (req, res) => {
|
api.post('/tickets', requireCrmEdit, (req, res) => {
|
||||||
const { machineId, title, description, status, priority, slaDays } =
|
const { machineId, title, description, status, priority, slaDays } =
|
||||||
req.body || {};
|
req.body || {};
|
||||||
if (!machineId || !title || !description) {
|
if (!machineId || !title || !description) {
|
||||||
@@ -125,7 +141,7 @@ export function registerTicketRoutes(api) {
|
|||||||
res.json(mapTicket(row));
|
res.json(mapTicket(row));
|
||||||
});
|
});
|
||||||
|
|
||||||
api.put('/tickets/:id', (req, res) => {
|
api.put('/tickets/:id', requireCrmEdit, (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||||
const cur = db.prepare('SELECT * FROM tickets WHERE id = ?').get(id);
|
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)}`,
|
`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) {
|
if (lines.length > 0) {
|
||||||
const eid = randomUUID();
|
const eid = randomUUID();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -184,6 +240,7 @@ export function registerTicketRoutes(api) {
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE tickets SET title = ?, description = ?, status = ?, priority = ?, sla_days = ?,
|
`UPDATE tickets SET title = ?, description = ?, status = ?, priority = ?, sla_days = ?,
|
||||||
sla_anchor_at = CASE WHEN ? THEN datetime('now') ELSE sla_anchor_at END,
|
sla_anchor_at = CASE WHEN ? THEN datetime('now') ELSE sla_anchor_at END,
|
||||||
|
assigned_user_id = ?,
|
||||||
updated_at = datetime('now')
|
updated_at = datetime('now')
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
).run(
|
).run(
|
||||||
@@ -193,6 +250,7 @@ export function registerTicketRoutes(api) {
|
|||||||
next.priority,
|
next.priority,
|
||||||
nextSlaDays,
|
nextSlaDays,
|
||||||
resetSlaAnchor ? 1 : 0,
|
resetSlaAnchor ? 1 : 0,
|
||||||
|
nextAssignedUserId,
|
||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,17 @@ const router = Router();
|
|||||||
|
|
||||||
function userPayload(row) {
|
function userPayload(row) {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
const role = row.role;
|
||||||
|
const canAdmin = role === 'admin';
|
||||||
|
const canEditCrm = canAdmin || role === 'after_sales';
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
role: row.role,
|
role,
|
||||||
firstName: row.firstname ?? null,
|
firstName: row.firstname ?? null,
|
||||||
lastName: row.lastname ?? null,
|
lastName: row.lastname ?? null,
|
||||||
|
canAdmin,
|
||||||
|
canEditCrm,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user