Support-Tickets: Modul, Oberfläche, Discord-Benachrichtigung

This commit is contained in:
Kundencenter 2026-09-27 08:36:18 +02:00
parent fd34d121b1
commit ca5b48ab47
12 changed files with 340 additions and 5 deletions

View file

@ -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<Detail | null>(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [busy, setBusy] = useState(false);
const [staffUsers, setStaffUsers] = useState<StaffUser[]>([]);
const load = useCallback(() => api<Detail>('GET', `/tickets/${id}`).then(setT).catch((e) => setErr(errMsg(e))), [id]);
useEffect(() => { void load(); }, [load]);
useEffect(() => { if (write) api<StaffUser[]>('GET', '/admin/users').then((l) => setStaffUsers(l.filter((u) => !!u.staffRole))).catch(() => undefined); }, [write]);
async function send(e: FormEvent<HTMLFormElement>) {
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 ? <Alert kind="err">{err}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
const canClose = t.status !== 'closed';
return (<>
<div className="row between"><h1>{t.number}: {t.subject}</h1><Link className="btn" href="/tickets">← Tickets</Link></div>
<p className="muted small">{staff && <>{t.orgName} · </>}<TicketStatusBadge value={t.status} /> · Priorität: {TICKET_PRIORITY[t.priority]}{t.assignedName && <> · zugewiesen an {t.assignedName}</>}</p>
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
{write && <div className="card"><div className="cols">
<Field id="status" label="Status"><select id="status" value={t.status} onChange={(e) => patch({ status: e.target.value })}>{Object.entries(STATUS_OPTIONS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field>
<Field id="priority" label="Priorität"><select id="priority" value={t.priority} onChange={(e) => patch({ priority: e.target.value })}>{Object.entries(TICKET_PRIORITY).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field>
<Field id="assignee" label="Zugewiesen an"><select id="assignee" value={t.assignedTo ?? ''} onChange={(e) => patch({ assignedTo: e.target.value || null })}><option value="">Niemand</option>{staffUsers.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}</select></Field>
</div></div>}
<div className="card">
{t.messages.map((m) => (<div key={m.id} style={{ padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
<div className="row between small muted"><span>{m.authorName} ({m.authorKind === 'staff' ? 'Personal' : 'Kunde'}){m.internalNote && <> · <span className="badge warn">nur intern</span></>}</span><span>{fmt(m.createdAt)}</span></div>
<p style={{ whiteSpace: 'pre-wrap', margin: '4px 0 0' }}>{m.body}</p></div>))}
</div>
{t.status === 'closed' ? <p className="muted">Dieses Ticket ist geschlossen. Für neue Anliegen bitte ein neues Ticket eröffnen.</p> : t.canWrite && <form className="card" onSubmit={send}>
<Field id="body" label="Antwort"><textarea id="body" name="body" required rows={4} style={{ width: '100%' }} /></Field>
{staff && <label className="row small" style={{ marginBottom: 12 }}><input type="checkbox" name="internal" style={{ width: 20, minHeight: 20 }} /> Nur intern (für den Kunden nicht sichtbar)</label>}
<div className="row"><button className="btn primary" type="submit" disabled={busy}>Absenden</button>{!staff && canClose && <button className="btn" type="button" onClick={close}>Als erledigt schließen</button>}</div>
</form>}
</>);
}