Tickets: Bezug zu Ressource oder Domain wählen, Verknüpfung direkt aus der Domain-Aufstellung
This commit is contained in:
parent
43dc5bc827
commit
a1472617b9
6 changed files with 62 additions and 18 deletions
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
3
migrations/017_ticket_domain_link.sql
Normal file
3
migrations/017_ticket_domain_link.sql
Normal 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;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue