Tickets: Bezug zu Ressource oder Domain wählen, Verknüpfung direkt aus der Domain-Aufstellung

This commit is contained in:
Kundencenter 2026-09-27 16:40:14 +02:00
parent 43dc5bc827
commit a1472617b9
6 changed files with 62 additions and 18 deletions

View file

@ -19,7 +19,7 @@ function AttachmentList({ ticketId, atts }: { ticketId: string; atts: Attachment
</div>);
}
interface Msg { id: string; authorId: string; authorName: string; authorKind: 'customer' | 'staff'; body: string; internalNote: boolean; createdAt: string; attachments: Attachment[] }
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 Detail { id: string; number: string; orgId: string; orgName: string; subject: string; status: string; priority: string; assignedTo: string | null; assignedName: string | null; resourceId: string | null; resourceName: string | null; resourceType: string | null; domainRecordId: string | null; domain: 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' };
@ -50,6 +50,7 @@ export default function TicketDetail() {
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>
{(t.domain || t.resourceName) && <p className="muted small">Betrifft: {t.resourceId ? <Link href={`/ressourcen/${t.resourceId}`}>{t.resourceName}</Link> : staff ? <Link href={`/admin/domains?q=${encodeURIComponent(t.domain!)}`}>{t.domain}</Link> : t.domain}</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>

View file

@ -1,33 +1,42 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { api, apiUpload, 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 T { id: string; number: string; orgId: string; orgName: string; customerNumber: string; subject: string; domain: string | null; resourceName: string | null; status: string; priority: string; assignedName: string | null; lastMessageAt: string }
interface Cust { id: string; name: string; customerNumber: string }
interface Res { id: string; name: string; type: string }
interface Dom { id: string; domain: string }
const PRIO_CLS: Record<string, string> = { urgent: 'err', high: 'warn', normal: 'info', low: 'info' };
const MAX_FILES = 5; const ALLOWED = 'image/png,image/jpeg,image/gif,image/webp,application/pdf';
export default function Tickets() {
const { me, can } = useSession(); const r = useRouter(); const staff = can('tickets.read');
const { me, can } = useSession(); const r = useRouter(); const sp = useSearchParams(); 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 [open, setOpen] = useState(sp.get('new') === '1'); const [custs, setCusts] = useState<Cust[]>([]); const [org, setOrg] = useState(sp.get('org') ?? ''); const [busy, setBusy] = useState(false);
const [resources, setResources] = useState<Res[]>([]); const [domains, setDomains] = useState<Dom[]>([]); const [link, setLink] = useState(sp.get('domain') ? `dom:${sp.get('domain')}` : '');
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);
if (staff) api<Cust[]>('GET', '/admin/customers').then((l) => { setCusts(l); setOrg((cur) => cur || l[0]?.id || ''); }).catch(() => undefined);
else if (me?.organizations[0]) setOrg(me.organizations[0].id);
}, [staff, me]);
useEffect(() => {
if (!org) { setResources([]); setDomains([]); return; }
api<Res[]>('GET', `/resources?org=${org}`).then(setResources).catch(() => setResources([]));
if (staff) api<Dom[]>('GET', `/admin/domain-records?orgId=${org}`).then(setDomains).catch(() => setDomains([])); else setDomains([]);
}, [org, staff]);
async function create(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr(''); setBusy(true); const fd = new FormData(e.currentTarget);
const files = (fd.getAll('files') as File[]).filter((x) => x instanceof File && x.size > 0);
const [kind, linkId] = link.split(':');
try {
const t = await api<{ id: string; messageId: string }>('POST', '/tickets', { orgId: org, subject: fd.get('subject'), body: fd.get('body'), ...(staff && fd.get('priority') ? { priority: fd.get('priority') } : {}) });
const t = await api<{ id: string; messageId: string }>('POST', '/tickets', { orgId: org, subject: fd.get('subject'), body: fd.get('body'), ...(staff && fd.get('priority') ? { priority: fd.get('priority') } : {}), ...(kind === 'res' ? { resourceId: linkId } : kind === 'dom' ? { domainRecordId: linkId } : {}) });
if (files.length > 0) await apiUpload(`/tickets/${t.id}/messages/${t.messageId}/attachments`, files);
r.push(`/tickets/${t.id}`);
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
@ -36,7 +45,12 @@ export default function Tickets() {
<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>}
{staff && <Field id="org" label="Kunde"><select id="org" value={org} onChange={(e) => { setOrg(e.target.value); setLink(''); }}>{custs.map((c) => <option key={c.id} value={c.id}>{c.customerNumber} · {c.name}</option>)}</select></Field>}
{(resources.length > 0 || domains.length > 0) && <Field id="link" label="Betrifft (optional)" hint="Ordnet das Ticket einer bestimmten Ressource oder Domain zu."><select id="link" value={link} onChange={(e) => setLink(e.target.value)}>
<option value="">Kein bestimmtes Produkt</option>
{resources.length > 0 && <optgroup label="Ressourcen">{resources.map((res) => <option key={res.id} value={`res:${res.id}`}>{res.name}</option>)}</optgroup>}
{domains.length > 0 && <optgroup label="Domains">{domains.map((d) => <option key={d.id} value={`dom:${d.id}`}>{d.domain}</option>)}</optgroup>}
</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>
<Field id="files" label="Anhänge (optional)" hint={`Bilder oder PDF, höchstens ${MAX_FILES} Dateien, je 15 MB.`}><input id="files" name="files" type="file" accept={ALLOWED} multiple /></Field>
@ -49,7 +63,7 @@ export default function Tickets() {
</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>}
{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>{(t.domain || t.resourceName) && <div className="muted small">{t.domain ?? t.resourceName}</div>}</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

@ -1,10 +1,12 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, Field, eur, fmt } from '@/components/ui';
interface Rec { id: string; domain: string; tld: string; customer: string | null; customerNumber: string | null; source: string; termMonths: number | null; costNetCents: number | null; costGrossCents: number | null; setupCostCents: number; sellNetCents: number | null; taxBp: number; sellGrossCents: number | null; profitNetCents: number | null; procurement: 'open' | 'ordered' | 'external'; orderedAt: string | null; orderedRef: string | null; note: string | null }
interface Rec { id: string; domain: string; tld: string; orgId: string | null; customer: string | null; customerNumber: string | null; source: string; termMonths: number | null; costNetCents: number | null; costGrossCents: number | null; setupCostCents: number; sellNetCents: number | null; taxBp: number; sellGrossCents: number | null; profitNetCents: number | null; procurement: 'open' | 'ordered' | 'external'; orderedAt: string | null; orderedRef: string | null; note: string | null }
interface Res { id: string; name: string; type: string; orgId: string | null }
const PROC = { open: 'Bei KCS zu bestellen', ordered: 'Bei KCS bestellt', external: 'Anderer Anbieter' } as const;
const money = (c: number | null) => c === null ? '–' : eur(c);
@ -18,8 +20,8 @@ function download(rows: Rec[], name: string) {
const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
}
export function DomainRecords({ orgId }: { orgId?: string }) {
const { can } = useSession(); const w = can('domains.write');
const [list, setList] = useState<Rec[] | null>(null); const [status, setStatus] = useState(''); const [q, setQ] = useState(''); const [err, setErr] = useState(''); const [ok, setOk] = useState('');
const { can } = useSession(); const w = can('domains.write'); const sp = useSearchParams();
const [list, setList] = useState<Rec[] | null>(null); const [status, setStatus] = useState(''); const [q, setQ] = useState(sp.get('q') ?? ''); const [err, setErr] = useState(''); const [ok, setOk] = useState('');
const [res, setRes] = useState<Res[]>([]); const [ref, setRef] = useState<Record<string, string>>({});
const load = useCallback(() => api<Rec[]>('GET', `/admin/domain-records?${new URLSearchParams({ ...(status ? { status } : {}), ...(q ? { q } : {}), ...(orgId ? { orgId } : {}) })}`).then((l) => { setList(l); setErr(''); }).catch((e) => setErr(errMsg(e))), [status, q, orgId]);
useEffect(() => { const t = setTimeout(() => void load(), 250); return () => clearTimeout(t); }, [load]);
@ -43,7 +45,7 @@ export function DomainRecords({ orgId }: { orgId?: string }) {
{!orgId && <td>{r.customer ? <>{r.customer}<div className="muted small">{r.customerNumber}</div></> : <span className="muted small">–</span>}</td>}<td>{money(r.costGrossCents)}<div className="muted small">inkl. USt</div></td><td>{money(r.costNetCents)}</td><td><strong>{money(r.sellGrossCents)}</strong><div className="muted small">inkl. {r.taxBp / 100} % USt</div></td><td>{money(r.sellNetCents)}</td><td>{money(r.profitNetCents)}</td>
<td>{w ? <><select aria-label={`Status ${r.domain}`} value={r.procurement} onChange={(e) => run(() => api('PATCH', `/admin/domain-records/${r.id}`, { procurement: e.target.value }))}>{Object.entries(PROC).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select>
{r.procurement === 'ordered' && <div className="row" style={{ marginTop: 4 }}><input aria-label="Bestellnummer bei KCS" placeholder="Bestellnr." style={{ width: 120 }} value={ref[r.id] ?? r.orderedRef ?? ''} onChange={(e) => setRef({ ...ref, [r.id]: e.target.value })} onBlur={() => ref[r.id] !== undefined && run(() => api('PATCH', `/admin/domain-records/${r.id}`, { orderedRef: ref[r.id] || null }))} />{r.orderedAt && <span className="muted small">{fmt(r.orderedAt)}</span>}</div>}</> : PROC[r.procurement]}</td>
{w && <td><div className="row"><button className="btn small" title="Einkauf und Verkauf mit der aktuellen Preisliste neu berechnen" onClick={() => run(() => api('PATCH', `/admin/domain-records/${r.id}`, { reprice: true }), `${r.domain} neu berechnet.`)}>Neu berechnen</button><button className="btn small" onClick={() => confirm(`${r.domain} aus der Aufstellung entfernen?`) && run(() => api('DELETE', `/admin/domain-records/${r.id}`))}>Entfernen</button></div></td>}</tr>))}
{w && <td><div className="row">{r.orgId && <Link className="btn small" href={`/tickets?new=1&org=${r.orgId}&domain=${r.id}`}>Ticket erstellen</Link>}<button className="btn small" title="Einkauf und Verkauf mit der aktuellen Preisliste neu berechnen" onClick={() => run(() => api('PATCH', `/admin/domain-records/${r.id}`, { reprice: true }), `${r.domain} neu berechnet.`)}>Neu berechnen</button><button className="btn small" onClick={() => confirm(`${r.domain} aus der Aufstellung entfernen?`) && run(() => api('DELETE', `/admin/domain-records/${r.id}`))}>Entfernen</button></div></td>}</tr>))}
<tr><th>Summe ({list.length})</th>{!orgId && <th></th>}<th>{eur(sum((r) => r.costGrossCents))}</th><th>{eur(sum((r) => r.costNetCents))}</th><th>{eur(sum((r) => r.sellGrossCents))}</th><th>{eur(sum((r) => r.sellNetCents))}</th><th>{eur(sum((r) => r.profitNetCents))}</th><th colSpan={w ? 2 : 1}></th></tr></tbody></table></div>
<div className="row"><button className="btn primary" disabled={open.length === 0} onClick={() => download(open, `KCS-Bestellliste-${new Date().toISOString().slice(0, 10)}.csv`)}>Bestellliste für KCS (offen: {open.length})</button><button className="btn" onClick={() => download(list, `Domain-Aufstellung-${new Date().toISOString().slice(0, 10)}.csv`)}>Gesamte Aufstellung als CSV</button></div></>}
<p className="muted small">Summen beziehen sich auf die angezeigte Auswahl. Preise sind Momentaufnahmen; „Neu berechnen“ übernimmt geänderte Preislisten oder Aufschläge.</p></div>