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

@ -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' && <>

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>}
</>);
}

View file

@ -0,0 +1,53 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, Field, TicketStatusBadge, TICKET_PRIORITY, fmt } from '@/components/ui';
interface T { id: string; number: string; orgId: string; orgName: string; customerNumber: string; subject: string; status: string; priority: string; assignedName: string | null; lastMessageAt: string }
interface Cust { id: string; name: string; customerNumber: string }
const PRIO_CLS: Record<string, string> = { urgent: 'err', high: 'warn', normal: 'info', low: 'info' };
export default function Tickets() {
const { me, can } = useSession(); const r = useRouter(); const staff = can('tickets.read');
const [list, setList] = useState<T[] | null>(null); const [err, setErr] = useState(''); const [status, setStatus] = useState<'open' | 'closed' | ''>('open'); const [mine, setMine] = useState(false);
const [open, setOpen] = useState(false); const [custs, setCusts] = useState<Cust[]>([]); const [org, setOrg] = useState(''); const [busy, setBusy] = useState(false);
const load = useCallback(() => api<T[]>('GET', `/tickets?${new URLSearchParams({ ...(status ? { status } : {}), ...(mine ? { assignedToMe: '1' } : {}) })}`).then(setList).catch((e) => setErr(errMsg(e))), [status, mine]);
useEffect(() => { void load(); }, [load]);
useEffect(() => {
if (staff) api<Cust[]>('GET', '/admin/customers').then((l) => { setCusts(l); setOrg(l[0]?.id ?? ''); }).catch(() => undefined);
else if (me?.organizations[0]) setOrg(me.organizations[0].id);
}, [staff, me]);
async function create(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr(''); setBusy(true); const f = new FormData(e.currentTarget);
try {
const t = await api<{ id: string }>('POST', '/tickets', { orgId: org, subject: f.get('subject'), body: f.get('body'), ...(staff && f.get('priority') ? { priority: f.get('priority') } : {}) });
r.push(`/tickets/${t.id}`);
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
return (<>
<div className="row between"><h1>Tickets</h1><button className="btn primary" onClick={() => setOpen(!open)} aria-expanded={open}>Neues Ticket</button></div>
{err && <Alert kind="err">{err}</Alert>}
{open && <form className="card" onSubmit={create}><h2>Neues Ticket</h2>
{staff && <Field id="org" label="Kunde"><select id="org" value={org} onChange={(e) => setOrg(e.target.value)}>{custs.map((c) => <option key={c.id} value={c.id}>{c.customerNumber} · {c.name}</option>)}</select></Field>}
<Field id="subject" label="Betreff"><input id="subject" name="subject" required minLength={3} maxLength={200} autoFocus /></Field>
<Field id="body" label="Anliegen"><textarea id="body" name="body" required rows={5} style={{ width: '100%' }} /></Field>
{staff && <Field id="priority" label="Priorität"><select id="priority" name="priority" defaultValue="normal">{Object.entries(TICKET_PRIORITY).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field>}
<div className="row"><button className="btn primary" type="submit" disabled={busy || !org}>Absenden</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div></form>}
<div className="card">
<div className="row" style={{ marginBottom: 12 }}>
<Field id="st" label="Status"><select id="st" value={status} onChange={(e) => setStatus(e.target.value as never)}><option value="open">Offen</option><option value="closed">Geschlossen</option><option value="">Alle</option></select></Field>
{staff && <label className="row small" style={{ alignSelf: 'flex-end', marginBottom: 12 }}><input type="checkbox" style={{ width: 20, minHeight: 20 }} checked={mine} onChange={(e) => setMine(e.target.checked)} /> Nur mir zugewiesen</label>}
</div>
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Keine Tickets">{status === 'open' ? 'Aktuell ist nichts offen.' : 'Nichts gefunden.'}</Empty> :
<div className="tablewrap"><table><thead><tr><th>Nr.</th><th>Betreff</th>{staff && <th>Kunde</th>}<th>Priorität</th><th>Status</th>{staff && <th>Zugewiesen</th>}<th>Letzte Nachricht</th></tr></thead><tbody>
{list.map((t) => (<tr key={t.id}><td className="mono"><Link href={`/tickets/${t.id}`}>{t.number}</Link></td><td><Link href={`/tickets/${t.id}`}>{t.subject}</Link></td>{staff && <td>{t.orgName}</td>}
<td><span className={`badge ${PRIO_CLS[t.priority]}`}>{TICKET_PRIORITY[t.priority]}</span></td><td><TicketStatusBadge value={t.status} /></td>{staff && <td>{t.assignedName ?? <span className="muted small">–</span>}</td>}<td className="small">{fmt(t.lastMessageAt)}</td></tr>))}
</tbody></table></div>}
</div>
</>);
}

View file

@ -45,6 +45,12 @@ const CONTRACT: Record<string, [string, string, string]> = {
const badge = (m: Record<string, [string, string, string]>, v: string) => { const [c, i, l] = m[v] ?? ['info', '○', v]; return <span className={`badge ${c}`}><span aria-hidden="true">{i}</span>{l}</span>; };
export const OrderStatusBadge = ({ value }: { value: string }) => badge(ORDER, value);
export const ContractStatusBadge = ({ value }: { value: string }) => badge(CONTRACT, value);
const TICKET: Record<string, [string, string, string]> = {
open: ['info', '○', 'Offen'], pending_staff: ['warn', '▲', 'Wartet auf Personal'], pending_customer: ['info', '◐', 'Wartet auf Sie'],
resolved: ['ok', '✓', 'Gelöst'], closed: ['lock', '■', 'Geschlossen'],
};
export const TicketStatusBadge = ({ value }: { value: string }) => badge(TICKET, value);
export const TICKET_PRIORITY: Record<string, string> = { low: 'Niedrig', normal: 'Normal', high: 'Hoch', urgent: 'Dringend' };
export interface Price { interval: string; quantity: number; taxBp: number; discountBp: number; setup: { net: number; tax: number; gross: number }; recurring: { net: number; tax: number; gross: number } }
/** Preisdarstellung: Privatkunden brutto, Geschäftskunden netto mit Brutto in Klammern. */
export function PriceText({ p, priv }: { p: Price; priv: boolean }) {

File diff suppressed because one or more lines are too long