From ca5b48ab47d83aa9301c3d196e493cbc4ddcbab8 Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Sun, 27 Sep 2026 08:36:18 +0200 Subject: [PATCH] =?UTF-8?q?Support-Tickets:=20Modul,=20Oberfl=C3=A4che,=20?= =?UTF-8?q?Discord-Benachrichtigung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- apps/api/src/modules/index.ts | 3 +- apps/api/src/modules/tickets/index.ts | 132 +++++++++++++++++++ apps/api/test/setup.ts | 2 +- apps/api/test/tickets.test.ts | 57 ++++++++ apps/web/src/app/(app)/layout.tsx | 1 + apps/web/src/app/(app)/tickets/[id]/page.tsx | 51 +++++++ apps/web/src/app/(app)/tickets/page.tsx | 53 ++++++++ apps/web/src/components/ui.tsx | 6 + apps/web/tsconfig.tsbuildinfo | 2 +- apps/worker/src/jobs.ts | 4 +- migrations/014_tickets.sql | 31 +++++ 12 files changed, 340 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/modules/tickets/index.ts create mode 100644 apps/api/test/tickets.test.ts create mode 100644 apps/web/src/app/(app)/tickets/[id]/page.tsx create mode 100644 apps/web/src/app/(app)/tickets/page.tsx create mode 100644 migrations/014_tickets.sql diff --git a/README.md b/README.md index d12fc99..6480c5f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ Modulares Kundencenter für Hosting, Server und Lizenzen (Modularer Monolith, Ty Login mit Passwort + TOTP, Wiederherstellungscodes, Sitzungsverwaltung, Passwort-Reset, Einladungen, Benutzerverwaltung (Mitarbeiterrollen), Kunden/Organisationen anlegen und verwalten, Rechnungsanschrift, Audit-Protokoll mit Hash-Kette, persistente Job-Queue, Discord-Bot (optional). Produkte (versioniert), Bestellungen mit Freigabe und unveränderlichem Preis-Snapshot, Verträge mit Kündigung/Verlängerung, Provisionierung über Connectoren (`docs/produkte-bestellungen-vertraege.md`), Connector-Framework (`docs/connector-vertrag.md`). -Noch **nicht** vorhanden: Rechnungen/Zahlungen, Tickets, Selbstregistrierung, KeyHelp/Plesk-Connector. +Support-Tickets (je Kunde, mit internen Notizen für Personal, Discord-Benachrichtigung). +Noch **nicht** vorhanden: Rechnungen/Zahlungen, Selbstregistrierung, Plesk-Connector, Domain-Registrierung über eine Registrar-API (aktuell manuell über die Domain-Aufstellung). ## Modularität API-Module liegen in `apps/api/src/modules/` und implementieren `KcModule` (`core/module.ts`). diff --git a/apps/api/src/modules/index.ts b/apps/api/src/modules/index.ts index 1d542a0..9d0472d 100644 --- a/apps/api/src/modules/index.ts +++ b/apps/api/src/modules/index.ts @@ -7,8 +7,9 @@ import { connectorsModule } from './connectors/index.js'; import { resourcesModule } from './resources/index.js'; import { domainsModule } from './domains/index.js'; import { catalogModule } from './catalog/index.js'; +import { ticketsModule } from './tickets/index.js'; import { ordersModule } from './orders/index.js'; import { backupModule } from './backup/index.js'; /** Aktive Module. Neue Module (Produkte, Verträge, Connectoren, Tickets, Rechnungen) werden hier eingetragen. */ -export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, backupModule]; +export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, backupModule]; diff --git a/apps/api/src/modules/tickets/index.ts b/apps/api/src/modules/tickets/index.ts new file mode 100644 index 0000000..bbaf37c --- /dev/null +++ b/apps/api/src/modules/tickets/index.ts @@ -0,0 +1,132 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { randomUUID } from 'node:crypto'; +import type { PoolConnection } from 'mysql2/promise'; +import { one, query, run, tx } from '../../core/db.js'; +import { audit } from '../../core/audit.js'; +import { enqueue } from '../../core/jobs.js'; +import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js'; +import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js'; +import { can, canInOrg } from '../../core/policy.js'; +import type { KcModule } from '../../core/module.js'; + +const OPEN = new Set(['open', 'pending_customer', 'pending_staff']); +async function nextNumber(c: PoolConnection): Promise { + await run("UPDATE number_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE name = 'ticket'", [], c); + return `T-${(await one('SELECT LAST_INSERT_ID() AS n', [], c))!.n}`; +} +const ticketView = (t: any) => ({ + id: t.id, number: t.number, orgId: t.org_id, orgName: t.org_name, customerNumber: t.customer_number, resourceId: t.resource_id, subject: t.subject, + status: t.status, priority: t.priority, createdBy: t.created_by, assignedTo: t.assigned_to, assignedName: t.assigned_name ?? null, + createdAt: t.created_at, updatedAt: t.updated_at, lastMessageAt: t.last_message_at, resolvedAt: t.resolved_at, closedAt: t.closed_at, +}); +const TICKET_SQL = `SELECT t.*, g.name AS org_name, g.customer_number, u.name AS assigned_name FROM tickets t JOIN organizations g ON g.id = t.org_id LEFT JOIN users u ON u.id = t.assigned_to`; +const messageView = (m: any) => ({ id: m.id, authorId: m.author_id, authorName: m.author_name, authorKind: m.author_kind, body: m.body, internalNote: !!m.internal_note, createdAt: m.created_at }); +const notify = (event: string, extra: Record, key: string, correlationId: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key, correlationId }); + +/** Meldet neue Nachrichten/Status auf CUSTOMER-sichtbare Zeilen; interne Notizen werden für Kunden ausgefiltert. */ +async function loadTicket(id: string, forCustomer: boolean) { + const t = await one(`${TICKET_SQL} WHERE t.id = ?`, [id]); + if (!t) return null; + const msgs = await query( + `SELECT m.*, u.name AS author_name FROM ticket_messages m JOIN users u ON u.id = m.author_id WHERE m.ticket_id = ?${forCustomer ? ' AND m.internal_note = 0' : ''} ORDER BY m.created_at`, [id]); + return { t, msgs }; +} + +export const ticketsModule: KcModule = { + name: 'tickets', + permissions: { + staff: { support: ['tickets.read', 'tickets.write'], accounting: ['tickets.read'], admin: ['tickets.read', 'tickets.write'], superadmin: ['tickets.read', 'tickets.write'] }, + org: { owner: ['tickets.read', 'tickets.create'], admin: ['tickets.read', 'tickets.create'], member: ['tickets.read', 'tickets.create'] }, + }, + register(app: FastifyInstance) { + app.post('/tickets', async (req) => { + const a = requireAuth(req); + const b = z.object({ orgId: z.string().uuid(), subject: z.string().trim().min(3).max(200), body: z.string().trim().min(1).max(10000), resourceId: z.string().uuid().optional(), priority: z.enum(['low', 'normal', 'high', 'urgent']).default('normal') }).parse(req.body); + const staff = can(a.principal, 'tickets.write'); + if (!canInOrg(a.principal, b.orgId, 'tickets.create', 'tickets.write')) { if (!canInOrg(a.principal, b.orgId, 'tickets.read', 'tickets.read')) throw notFound(); throw forbidden(); } + if (!staff && b.priority !== 'normal' && b.priority !== 'high') throw forbidden('Diese Priorität kann nur vom Personal gesetzt werden', 'PRIORITY_FORBIDDEN'); + if (b.resourceId && !(await one('SELECT 1 AS x FROM resources WHERE id = ? AND org_id = ?', [b.resourceId, b.orgId]))) throw badRequest('Ressource gehört nicht zu diesem Kunden'); + const id = randomUUID(); + const number = await tx(async (c) => { + const n = await nextNumber(c); + await run('INSERT INTO tickets (id, number, org_id, resource_id, subject, status, priority, created_by) VALUES (?,?,?,?,?,?,?,?)', + [id, n, b.orgId, b.resourceId ?? null, b.subject, staff ? 'pending_customer' : 'pending_staff', b.priority, a.user.id], c); + await run('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), id, a.user.id, staff ? 'staff' : 'customer', b.body], c); + return n; + }); + await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId, action: 'ticket.create', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { number, subject: b.subject } }); + if (!staff) await notify('ticket.created', { number, subject: b.subject.slice(0, 100) }, `ticket.created:${id}`, req.correlationId); + return { id, number }; + }); + + app.get('/tickets', async (req) => { + const a = requireAuth(req); + const q = z.object({ org: z.string().uuid().optional(), status: z.enum(['open', 'closed']).optional(), assignedToMe: z.coerce.boolean().optional() }).parse(req.query); + const staff = can(a.principal, 'tickets.read'); + const myOrgs = a.principal.memberships.map((m) => m.orgId); + if (!staff && myOrgs.length === 0) return []; + if (staff && q.org && !(await one('SELECT 1 AS x FROM organizations WHERE id = ?', [q.org]))) throw notFound(); + const orgs = staff ? (q.org ? [q.org] : null) : myOrgs; + const statusSet = q.status === 'closed' ? ['resolved', 'closed'] : q.status === 'open' ? [...OPEN] : null; + const orgPlaceholders = orgs ? orgs.map(() => '?').join(',') : ''; + const statusPlaceholders = statusSet ? statusSet.map(() => '?').join(',') : ''; + const rows = await query( + `${TICKET_SQL} WHERE (${orgs ? `t.org_id IN (${orgPlaceholders})` : '1=1'}) AND (${statusSet ? `t.status IN (${statusPlaceholders})` : '1=1'}) AND (? IS NULL OR t.assigned_to = ?) ORDER BY t.last_message_at DESC LIMIT 200`, + [...(orgs ?? []), ...(statusSet ?? []), q.assignedToMe ? 1 : null, q.assignedToMe ? a.user.id : null]); + return rows.map(ticketView); + }); + + app.get('/tickets/:id', async (req) => { + const a = requireAuth(req); const { id } = z.object({ id: z.string().uuid() }).parse(req.params); + const staff = can(a.principal, 'tickets.read'); + const res = await loadTicket(id, !staff); + if (!res || !canInOrg(a.principal, res.t.org_id, 'tickets.read', 'tickets.read')) throw notFound(); + return { ...ticketView(res.t), messages: res.msgs.map(messageView), canWrite: staff || canInOrg(a.principal, res.t.org_id, 'tickets.create', 'tickets.write') }; + }); + + app.post('/tickets/:id/messages', async (req) => { + const a = requireAuth(req); const { id } = z.object({ id: z.string().uuid() }).parse(req.params); + const b = z.object({ body: z.string().trim().min(1).max(10000), internalNote: z.boolean().default(false) }).parse(req.body); + const staff = can(a.principal, 'tickets.write'); + const t = await one('SELECT * FROM tickets WHERE id = ?', [id]); if (!t) throw notFound(); + if (!staff && !canInOrg(a.principal, t.org_id, 'tickets.create', 'tickets.write')) throw notFound(); + if (b.internalNote && !staff) throw forbidden('Interne Notizen sind nur für Personal', 'INTERNAL_NOTE_FORBIDDEN'); + if (t.status === 'closed') throw conflict('Das Ticket ist geschlossen. Bitte ein neues Ticket eröffnen.', 'TICKET_CLOSED'); + const nextStatus = b.internalNote ? t.status : staff ? 'pending_customer' : 'pending_staff'; + await tx(async (c) => { + await run('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body, internal_note) VALUES (?,?,?,?,?,?)', [randomUUID(), id, a.user.id, staff ? 'staff' : 'customer', b.body, b.internalNote ? 1 : 0], c); + await run('UPDATE tickets SET status = ?, last_message_at = UTC_TIMESTAMP(3), resolved_at = NULL, closed_at = NULL WHERE id = ?', [nextStatus, id], c); + }); + await audit({ actorType: 'user', actorId: a.user.id, orgId: t.org_id, action: 'ticket.message', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { internalNote: b.internalNote } }); + if (!b.internalNote && !staff) await notify('ticket.message', { number: t.number }, `ticket.message:${id}:${Date.now()}`, req.correlationId); + return { ok: true }; + }); + + app.patch('/tickets/:id', async (req) => { + const a = requirePermission(req, 'tickets.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params); + const b = z.object({ status: z.enum(['open', 'pending_customer', 'pending_staff', 'resolved', 'closed']).optional(), priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(), assignedTo: z.string().uuid().nullable().optional() }).parse(req.body); + const t = await one('SELECT * FROM tickets WHERE id = ?', [id]); if (!t) throw notFound(); + if (b.assignedTo && !(await one("SELECT 1 AS x FROM users WHERE id = ? AND kind = 'staff'", [b.assignedTo]))) throw badRequest('Nicht gefunden', 'ASSIGNEE_NOT_FOUND'); + const sets: string[] = []; const params: unknown[] = []; + if (b.status) { sets.push('status = ?', 'resolved_at = ?', 'closed_at = ?'); params.push(b.status, b.status === 'resolved' ? new Date() : null, b.status === 'closed' ? new Date() : null); } + if (b.priority) { sets.push('priority = ?'); params.push(b.priority); } + if (b.assignedTo !== undefined) { sets.push('assigned_to = ?'); params.push(b.assignedTo); } + if (sets.length === 0) return { ok: true }; + await run(`UPDATE tickets SET ${sets.join(', ')} WHERE id = ?`, [...params, id]); + await audit({ actorType: 'user', actorId: a.user.id, orgId: t.org_id, action: 'ticket.update', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: b }); + return { ok: true }; + }); + + // Kunde kann sein eigenes gelöstes Ticket erneut öffnen oder als erledigt schließen + app.post('/tickets/:id/close', async (req) => { + const a = requireAuth(req); const { id } = z.object({ id: z.string().uuid() }).parse(req.params); + const t = await one('SELECT * FROM tickets WHERE id = ?', [id]); if (!t) throw notFound(); + if (!canInOrg(a.principal, t.org_id, 'tickets.create', 'tickets.write')) throw notFound(); + if (!OPEN.has(t.status) && t.status !== 'resolved') throw conflict('Das Ticket ist bereits geschlossen.', 'TICKET_CLOSED'); + await run("UPDATE tickets SET status = 'closed', closed_at = UTC_TIMESTAMP(3) WHERE id = ?", [id]); + await audit({ actorType: 'user', actorId: a.user.id, orgId: t.org_id, action: 'ticket.close', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) }); + return { ok: true }; + }); + }, +}; diff --git a/apps/api/test/setup.ts b/apps/api/test/setup.ts index dbee3d7..df10d5f 100644 --- a/apps/api/test/setup.ts +++ b/apps/api/test/setup.ts @@ -3,7 +3,7 @@ import mysql from 'mysql2/promise'; import '../src/core/config.js'; /** Vor jeder Testdatei: Datenzeilen leeren (Testdatenbank!), Stammdaten (Steuersätze, Einstellungen) und Zähler zurücksetzen. */ -const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users']; +const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'ticket_messages', 'tickets', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users']; beforeAll(async () => { if (process.env.DB_NAME !== 'kundencenter_test') throw new Error('Tests dürfen nur gegen kundencenter_test laufen'); const c = await mysql.createConnection({ host: process.env.DB_HOST ?? '127.0.0.1', user: process.env.DB_USER!, password: process.env.DB_PASSWORD!, database: 'kundencenter_test' }); diff --git a/apps/api/test/tickets.test.ts b/apps/api/test/tickets.test.ts new file mode 100644 index 0000000..c3b5f11 --- /dev/null +++ b/apps/api/test/tickets.test.ts @@ -0,0 +1,57 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { buildApp } from '../src/server.js'; +import { call, code, login, makeUser } from './helpers.js'; + +let app: FastifyInstance; +beforeAll(async () => { app = await buildApp(); await app.ready(); }); +async function staff(email: string, role: string) { + await makeUser({ email, kind: 'staff', staffRole: role }); const { client } = await login(app, email); + const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client; +} +async function customer(admin: any, name: string, mail: string) { + const c = (await call(app, admin, 'POST', '/admin/customers', { type: 'business', name, owner: { email: mail, name } })).json(); + await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(c.inviteLink).searchParams.get('token'), password: 'passwort-kunde-123', repeat: 'passwort-kunde-123' } }); + return { org: c.id as string, client: (await login(app, mail, 'passwort-kunde-123')).client }; +} + +describe('Support-Tickets', () => { + it('Kunde eröffnet, Personal antwortet, interne Notiz bleibt verborgen, Zuweisung, Schließen, Mandantentrennung', async () => { + const admin = await staff('tix-admin@example.com', 'admin'); const support = await staff('tix-support@example.com', 'support'); const accounting = await staff('tix-acc@example.com', 'accounting'); + const A = await customer(admin, 'Firma A', 'tix-a@example.com'); const B = await customer(admin, 'Firma B', 'tix-b@example.com'); + + const create = await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'Server langsam', body: 'Seit heute Morgen sehr langsam.' }); + expect(create.statusCode).toBe(200); const id = create.json().id; + // Kunde kann keine hohe Priorität setzen und nicht für einen fremden Kunden + expect((await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'Test', body: 'y', priority: 'urgent' })).statusCode).toBe(403); + expect((await call(app, A.client, 'POST', '/tickets', { orgId: B.org, subject: 'Test', body: 'y' })).statusCode).toBe(404); + expect((await call(app, accounting, 'PATCH', `/tickets/${id}`, { priority: 'high' })).statusCode).toBe(403); // Buchhaltung nur lesend + + const get1 = (await call(app, support, 'GET', `/tickets/${id}`)).json(); expect(get1.status).toBe('pending_staff'); expect(get1.messages).toHaveLength(1); + expect((await call(app, support, 'POST', `/tickets/${id}/messages`, { body: 'Interne Notiz: Ticket klingt nach Netzwerkproblem', internalNote: true })).statusCode).toBe(200); + expect((await call(app, support, 'POST', `/tickets/${id}/messages`, { body: 'Wir prüfen das und melden uns.' })).statusCode).toBe(200); + expect((await call(app, admin, 'PATCH', `/tickets/${id}`, { assignedTo: null })).statusCode).toBe(200); + + // Kunde sieht die interne Notiz nicht, aber die Antwort; Status ist jetzt "wartet auf Kunde" + const seenByCustomer = (await call(app, A.client, 'GET', `/tickets/${id}`)).json(); + expect(seenByCustomer.status).toBe('pending_customer'); expect(seenByCustomer.messages).toHaveLength(2); + expect(seenByCustomer.messages.some((m: any) => m.body.includes('Interne Notiz'))).toBe(false); + expect((await call(app, B.client, 'GET', `/tickets/${id}`)).statusCode).toBe(404); // fremder Kunde + + // Kunde antwortet erneut → wartet auf Personal + await call(app, A.client, 'POST', `/tickets/${id}/messages`, { body: 'Danke, bitte kurzfristig.' }); + expect((await call(app, support, 'GET', `/tickets/${id}`)).json().status).toBe('pending_staff'); + + // Zuweisen, lösen, schließen + expect((await call(app, admin, 'PATCH', `/tickets/${id}`, { status: 'resolved' })).statusCode).toBe(200); + expect((await call(app, A.client, 'POST', `/tickets/${id}/close`)).statusCode).toBe(200); + expect((await call(app, A.client, 'POST', `/tickets/${id}/close`)).statusCode).toBe(409); // schon geschlossen + expect((await call(app, A.client, 'POST', `/tickets/${id}/messages`, { body: 'noch was' })).statusCode).toBe(409); + + // Listen: Kunde sieht nur eigene, Personal sieht alle offenen + const openB = (await call(app, admin, 'POST', '/tickets', { orgId: B.org, subject: 'Frage', body: 'Wie funktioniert X?' })).json(); + expect((await call(app, B.client, 'GET', '/tickets')).json()).toHaveLength(1); + const staffOpen = (await call(app, support, 'GET', '/tickets?status=open')).json(); expect(staffOpen.map((t: any) => t.id)).toContain(openB.id); expect(staffOpen.map((t: any) => t.id)).not.toContain(id); + expect((await call(app, support, 'GET', '/tickets?status=closed')).json().map((t: any) => t.id)).toEqual([id]); + }); +}); diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index 91d74b1..047db45 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -22,6 +22,7 @@ export default function AppLayout({ children }: { children: ReactNode }) { {!enrollNeeded && (me.kind === 'customer' || can('resources.read')) && link('/ressourcen', 'Ressourcen')} {!enrollNeeded && (me.kind === 'customer' || can('contracts.read')) && link('/vertraege', 'Verträge')} {!enrollNeeded && (me.kind === 'customer' || can('orders.read')) && link('/bestellungen', 'Bestellungen')} + {!enrollNeeded && (me.kind === 'customer' || can('tickets.read')) && link('/tickets', 'Tickets')} {!enrollNeeded && me.organizations.length > 0 && link('/organisation', 'Organisation')} {!enrollNeeded && link('/domains', 'Domain prüfen')} {!enrollNeeded && me.kind === 'staff' && <> diff --git a/apps/web/src/app/(app)/tickets/[id]/page.tsx b/apps/web/src/app/(app)/tickets/[id]/page.tsx new file mode 100644 index 0000000..f574ced --- /dev/null +++ b/apps/web/src/app/(app)/tickets/[id]/page.tsx @@ -0,0 +1,51 @@ +'use client'; +import { useCallback, useEffect, useState, type FormEvent } from 'react'; +import { useParams } from 'next/navigation'; +import Link from 'next/link'; +import { api, errMsg } from '@/lib/api'; +import { useSession } from '@/lib/session'; +import { Alert, Field, TicketStatusBadge, TICKET_PRIORITY, fmt } from '@/components/ui'; + +interface Msg { id: string; authorId: string; authorName: string; authorKind: 'customer' | 'staff'; body: string; internalNote: boolean; createdAt: string } +interface Detail { id: string; number: string; orgId: string; orgName: string; subject: string; status: string; priority: string; assignedTo: string | null; assignedName: string | null; messages: Msg[]; canWrite: boolean } +interface StaffUser { id: string; name: string; staffRole: string | null } +const STATUS_OPTIONS = { open: 'Offen', pending_customer: 'Wartet auf Kunde', pending_staff: 'Wartet auf Personal', resolved: 'Gelöst', closed: 'Geschlossen' }; + +export default function TicketDetail() { + const { id } = useParams<{ id: string }>(); const { can } = useSession(); const staff = can('tickets.read'); const write = can('tickets.write'); + const [t, setT] = useState(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [busy, setBusy] = useState(false); + const [staffUsers, setStaffUsers] = useState([]); + const load = useCallback(() => api('GET', `/tickets/${id}`).then(setT).catch((e) => setErr(errMsg(e))), [id]); + useEffect(() => { void load(); }, [load]); + useEffect(() => { if (write) api('GET', '/admin/users').then((l) => setStaffUsers(l.filter((u) => !!u.staffRole))).catch(() => undefined); }, [write]); + + async function send(e: FormEvent) { + e.preventDefault(); setErr(''); setBusy(true); const f = e.currentTarget; const body = String(new FormData(f).get('body') ?? ''); const internal = new FormData(f).get('internal') === 'on'; + try { await api('POST', `/tickets/${id}/messages`, { body, internalNote: internal }); f.reset(); void load(); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); } + } + async function patch(body: object, msg?: string) { setErr(''); setOk(''); try { await api('PATCH', `/tickets/${id}`, body); if (msg) setOk(msg); void load(); } catch (x) { setErr(errMsg(x)); } } + async function close() { setErr(''); try { await api('POST', `/tickets/${id}/close`); void load(); } catch (x) { setErr(errMsg(x)); } } + + if (!t) return err ? {err} :

Wird geladen …

; + const canClose = t.status !== 'closed'; + return (<> +

{t.number}: {t.subject}

← Tickets
+

{staff && <>{t.orgName} · } · Priorität: {TICKET_PRIORITY[t.priority]}{t.assignedName && <> · zugewiesen an {t.assignedName}}

+ {err && {err}}{ok && {ok}} + {write &&
+ + + +
} +
+ {t.messages.map((m) => (
+
{m.authorName} ({m.authorKind === 'staff' ? 'Personal' : 'Kunde'}){m.internalNote && <> · nur intern}{fmt(m.createdAt)}
+

{m.body}

))} +
+ {t.status === 'closed' ?

Dieses Ticket ist geschlossen. Für neue Anliegen bitte ein neues Ticket eröffnen.

: t.canWrite &&
+