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,11 +19,14 @@ async function nextNumber(c: PoolConnection): Promise<string> {
return `T-${(await one('SELECT LAST_INSERT_ID() AS n', [], c))!.n}`; return `T-${(await one('SELECT LAST_INSERT_ID() AS n', [], c))!.n}`;
} }
const ticketView = (t: any) => ({ 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, id: t.id, number: t.number, orgId: t.org_id, orgName: t.org_name, customerNumber: t.customer_number, subject: t.subject,
resourceId: t.resource_id, resourceName: t.resource_name ?? null, resourceType: t.resource_type ?? null,
domainRecordId: t.domain_record_id, domain: t.domain ?? null,
status: t.status, priority: t.priority, createdBy: t.created_by, assignedTo: t.assigned_to, assignedName: t.assigned_name ?? null, 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, 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 TICKET_SQL = `SELECT t.*, g.name AS org_name, g.customer_number, u.name AS assigned_name, r.name AS resource_name, r.type AS resource_type, d.domain AS domain
FROM tickets t JOIN organizations g ON g.id = t.org_id LEFT JOIN users u ON u.id = t.assigned_to LEFT JOIN resources r ON r.id = t.resource_id LEFT JOIN domain_records d ON d.id = t.domain_record_id`;
const messageView = (m: any, atts: 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 messageView = (m: any, atts: 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,
attachments: atts.filter((a) => a.message_id === m.id).map((a) => ({ id: a.id, filename: a.filename, contentType: a.content_type, sizeBytes: a.size_bytes })) }); attachments: atts.filter((a) => a.message_id === m.id).map((a) => ({ id: a.id, filename: a.filename, contentType: a.content_type, sizeBytes: a.size_bytes })) });
const notify = (event: string, extra: Record<string, unknown>, key: string, correlationId: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key, correlationId }); const notify = (event: string, extra: Record<string, unknown>, key: string, correlationId: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key, correlationId });
@ -47,16 +50,18 @@ export const ticketsModule: KcModule = {
register(app: FastifyInstance) { register(app: FastifyInstance) {
app.post('/tickets', async (req) => { app.post('/tickets', async (req) => {
const a = requireAuth(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 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(), domainRecordId: z.string().uuid().optional(), priority: z.enum(['low', 'normal', 'high', 'urgent']).default('normal') }).parse(req.body);
const staff = can(a.principal, 'tickets.write'); 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 (!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 (!staff && b.priority !== 'normal' && b.priority !== 'high') throw forbidden('Diese Priorität kann nur vom Personal gesetzt werden', 'PRIORITY_FORBIDDEN');
if (b.resourceId && b.domainRecordId) throw badRequest('Ein Ticket kann sich nur auf eine Ressource oder eine Domain beziehen, nicht beides');
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'); 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');
if (b.domainRecordId && !(await one('SELECT 1 AS x FROM domain_records WHERE id = ? AND org_id = ?', [b.domainRecordId, b.orgId]))) throw badRequest('Domain gehört nicht zu diesem Kunden');
const id = randomUUID(); const messageId = randomUUID(); const id = randomUUID(); const messageId = randomUUID();
const number = await tx(async (c) => { const number = await tx(async (c) => {
const n = await nextNumber(c); const n = await nextNumber(c);
await run('INSERT INTO tickets (id, number, org_id, resource_id, subject, status, priority, created_by) VALUES (?,?,?,?,?,?,?,?)', await run('INSERT INTO tickets (id, number, org_id, resource_id, domain_record_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); [id, n, b.orgId, b.resourceId ?? null, b.domainRecordId ?? 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 (?,?,?,?,?)', [messageId, id, a.user.id, staff ? 'staff' : 'customer', b.body], c); await run('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [messageId, id, a.user.id, staff ? 'staff' : 'customer', b.body], c);
return n; return n;
}); });

View file

@ -111,3 +111,22 @@ describe('Ticket-Anhänge', () => {
expect((await upload(A.client, `/tickets/${t.id}/messages/${manyMsgId}/attachments`, many)).statusCode).toBe(400); expect((await upload(A.client, `/tickets/${t.id}/messages/${manyMsgId}/attachments`, many)).statusCode).toBe(400);
}); });
}); });
describe('Ticket-Bezug: Ressource oder Domain', () => {
it('Ticket kann sich auf eine Ressource oder eine Domain beziehen (nie beides), fremde Zuordnung wird abgelehnt', async () => {
const admin = await staff('link-admin@example.com', 'admin');
const A = await customer(admin, 'Firma A', 'link-a@example.com'); const B = await customer(admin, 'Firma B', 'link-b@example.com');
const dom = (await call(app, admin, 'POST', '/admin/domain-records', { domain: 'link-test.de', orgId: A.org })).json();
const domB = (await call(app, admin, 'POST', '/admin/domain-records', { domain: 'link-testb.de', orgId: B.org })).json();
const tDom = (await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'Domain zeigt Fehler', body: 'x', domainRecordId: dom.id })).json();
const detail = (await call(app, A.client, 'GET', `/tickets/${tDom.id}`)).json();
expect(detail.domainRecordId).toBe(dom.id); expect(detail.domain).toBe('link-test.de'); expect(detail.resourceId).toBeNull();
expect((await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'fremde Domain', body: 'x', domainRecordId: domB.id })).statusCode).toBe(400);
expect((await call(app, A.client, 'POST', '/tickets', { orgId: A.org, subject: 'beides', body: 'x', domainRecordId: dom.id, resourceId: dom.id })).statusCode).toBe(400);
const list = (await call(app, admin, 'GET', '/tickets?status=open')).json();
expect(list.find((t: any) => t.id === tDom.id)?.domain).toBe('link-test.de');
});
});

View file

@ -19,7 +19,7 @@ function AttachmentList({ ticketId, atts }: { ticketId: string; atts: Attachment
</div>); </div>);
} }
interface Msg { id: string; authorId: string; authorName: string; authorKind: 'customer' | 'staff'; body: string; internalNote: boolean; createdAt: string; attachments: Attachment[] } 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 } 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' }; 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 (<> return (<>
<div className="row between"><h1>{t.number}: {t.subject}</h1><Link className="btn" href="/tickets">← Tickets</Link></div> <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> <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>} {err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
{write && <div className="card"><div className="cols"> {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="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'; 'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react'; import { useCallback, useEffect, useState, type FormEvent } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { api, apiUpload, errMsg } from '@/lib/api'; import { api, apiUpload, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session'; import { useSession } from '@/lib/session';
import { Alert, Empty, Field, TicketStatusBadge, TICKET_PRIORITY, fmt } from '@/components/ui'; 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 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 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'; const MAX_FILES = 5; const ALLOWED = 'image/png,image/jpeg,image/gif,image/webp,application/pdf';
export default function Tickets() { 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 [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]); 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(() => { void load(); }, [load]);
useEffect(() => { 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); else if (me?.organizations[0]) setOrg(me.organizations[0].id);
}, [staff, me]); }, [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>) { async function create(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr(''); setBusy(true); const fd = new FormData(e.currentTarget); 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 files = (fd.getAll('files') as File[]).filter((x) => x instanceof File && x.size > 0);
const [kind, linkId] = link.split(':');
try { 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); if (files.length > 0) await apiUpload(`/tickets/${t.id}/messages/${t.messageId}/attachments`, files);
r.push(`/tickets/${t.id}`); r.push(`/tickets/${t.id}`);
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); } } 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> <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>} {err && <Alert kind="err">{err}</Alert>}
{open && <form className="card" onSubmit={create}><h2>Neues Ticket</h2> {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="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="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> <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> </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> : {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> <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>))} <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>} </tbody></table></div>}
</div> </div>

View file

@ -1,10 +1,12 @@
'use client'; 'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react'; 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 { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session'; import { useSession } from '@/lib/session';
import { Alert, Empty, Field, eur, fmt } from '@/components/ui'; 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 } 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 PROC = { open: 'Bei KCS zu bestellen', ordered: 'Bei KCS bestellt', external: 'Anderer Anbieter' } as const;
const money = (c: number | null) => c === null ? '–' : eur(c); 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); 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 }) { export function DomainRecords({ orgId }: { orgId?: string }) {
const { can } = useSession(); const w = can('domains.write'); 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(''); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); 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 [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]); 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]); 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> {!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> <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> {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> <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></>} <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> <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>

View file

@ -0,0 +1,3 @@
-- Tickets können sich statt auf eine synchronisierte Ressource auch auf einen Eintrag der
-- Domain-Aufstellung beziehen (z. B. eine nur bei KCS geführte, noch nicht als Ressource geführte Domain).
ALTER TABLE tickets ADD COLUMN domain_record_id CHAR(36) NULL REFERENCES domain_records(id) AFTER resource_id;