Ticket-Anhänge: Bilder/PDF hochladen und anzeigen (geprüft anhand der Dateikennung)
This commit is contained in:
parent
236c76b4e2
commit
012f0aa0db
12 changed files with 254 additions and 22 deletions
|
|
@ -2,11 +2,23 @@
|
|||
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 { api, apiUpload, 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 }
|
||||
const MAX_FILES = 5;
|
||||
const ALLOWED = 'image/png,image/jpeg,image/gif,image/webp,application/pdf';
|
||||
interface Attachment { id: string; filename: string; contentType: string; sizeBytes: number }
|
||||
function AttachmentList({ ticketId, atts }: { ticketId: string; atts: Attachment[] }) {
|
||||
if (atts.length === 0) return null;
|
||||
return (<div className="row" style={{ marginTop: 8, flexWrap: 'wrap', gap: 8 }}>
|
||||
{atts.map((a) => { const href = `/api/tickets/${ticketId}/attachments/${a.id}`; const kb = Math.max(1, Math.round(a.sizeBytes / 1024));
|
||||
return a.contentType.startsWith('image/')
|
||||
? <a key={a.id} href={href} target="_blank" rel="noreferrer" title={`${a.filename} (${kb} KB)`}><img src={href} alt={a.filename} style={{ maxWidth: 160, maxHeight: 120, borderRadius: 6, border: '1px solid var(--border)', display: 'block' }} /></a>
|
||||
: <a key={a.id} className="btn small" href={href} target="_blank" rel="noreferrer">📄 {a.filename} <span className="muted small">({kb} KB)</span></a>; })}
|
||||
</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 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' };
|
||||
|
|
@ -20,8 +32,15 @@ export default function TicketDetail() {
|
|||
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); }
|
||||
e.preventDefault(); setErr(''); setBusy(true); const f = e.currentTarget; const fd = new FormData(f);
|
||||
const body = String(fd.get('body') ?? ''); const internal = fd.get('internal') === 'on';
|
||||
const files = (fd.getAll('files') as File[]).filter((x) => x instanceof File && x.size > 0);
|
||||
try {
|
||||
const { messageId } = await api<{ messageId: string }>('POST', `/tickets/${id}/messages`, { body, internalNote: internal });
|
||||
f.reset();
|
||||
if (files.length > 0) { try { await apiUpload(`/tickets/${id}/messages/${messageId}/attachments`, files); } catch (x) { setErr(`Nachricht gesendet, aber Anhang fehlgeschlagen: ${errMsg(x)}`); } }
|
||||
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)); } }
|
||||
|
|
@ -40,10 +59,12 @@ export default function TicketDetail() {
|
|||
<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>))}
|
||||
<p style={{ whiteSpace: 'pre-wrap', margin: '4px 0 0' }}>{m.body}</p>
|
||||
<AttachmentList ticketId={t.id} atts={m.attachments} /></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>
|
||||
<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>
|
||||
{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>}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
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 { 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 Cust { id: string; name: string; customerNumber: 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');
|
||||
|
|
@ -23,9 +24,11 @@ export default function Tickets() {
|
|||
}, [staff, me]);
|
||||
|
||||
async function create(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); setErr(''); setBusy(true); const f = 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);
|
||||
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') } : {}) });
|
||||
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') } : {}) });
|
||||
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,6 +39,7 @@ export default function Tickets() {
|
|||
{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>
|
||||
<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>
|
||||
{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">
|
||||
|
|
|
|||
|
|
@ -11,3 +11,12 @@ export async function api<T = unknown>(method: 'GET' | 'POST' | 'PUT' | 'PATCH'
|
|||
return data as T;
|
||||
}
|
||||
export const errMsg = (e: unknown): string => e instanceof ApiError ? (e.details?.length ? `${e.message}: ${e.details.map((d) => d.message).join(', ')}` : e.message) : 'Der Server ist nicht erreichbar. Bitte später erneut versuchen.';
|
||||
|
||||
/** Dateiupload (multipart/form-data). Kein content-type-Header setzen – der Browser ergänzt die Grenze selbst. */
|
||||
export async function apiUpload<T = unknown>(path: string, files: File[], fieldName = 'file'): Promise<T> {
|
||||
const fd = new FormData(); for (const f of files) fd.append(fieldName, f);
|
||||
const res = await fetch(`/api${path}`, { method: 'POST', credentials: 'same-origin', headers: { ...(csrf ? { 'x-csrf-token': csrf } : {}) }, body: fd });
|
||||
const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null;
|
||||
if (!res.ok) throw new ApiError(res.status, data?.error?.code ?? 'ERROR', data?.error?.message ?? 'Anfrage fehlgeschlagen', data?.error?.details);
|
||||
return data as T;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue