2026-09-27 08:36:18 +02:00
'use client' ;
import { useCallback , useEffect , useState , type FormEvent } from 'react' ;
import { useParams } from 'next/navigation' ;
import Link from 'next/link' ;
2026-09-27 08:53:27 +02:00
import { api , apiUpload , errMsg } from '@/lib/api' ;
2026-09-27 08:36:18 +02:00
import { useSession } from '@/lib/session' ;
import { Alert , Field , TicketStatusBadge , TICKET_PRIORITY , fmt } from '@/components/ui' ;
2026-09-27 08:53:27 +02:00
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 [ ] }
2026-09-27 08:36:18 +02:00
interface Detail { id : string ; number : string ; orgId : string ; orgName : string ; subject : string ; status : string ; priority : string ; assignedTo : string | null ; assignedName : string | null ; messages : Msg [ ] ; canWrite : boolean }
interface StaffUser { id : string ; name : string ; staffRole : string | null }
const STATUS_OPTIONS = { open : 'Offen' , pending_customer : 'Wartet auf Kunde' , pending_staff : 'Wartet auf Personal' , resolved : 'Gelöst' , closed : 'Geschlossen' } ;
export default function TicketDetail() {
const { id } = useParams < { id : string } > ( ) ; const { can } = useSession ( ) ; const staff = can ( 'tickets.read' ) ; const write = can ( 'tickets.write' ) ;
const [ t , setT ] = useState < Detail | null > ( null ) ; const [ err , setErr ] = useState ( '' ) ; const [ ok , setOk ] = useState ( '' ) ; const [ busy , setBusy ] = useState ( false ) ;
const [ staffUsers , setStaffUsers ] = useState < StaffUser [ ] > ( [ ] ) ;
const load = useCallback ( ( ) = > api < Detail > ( 'GET' , ` /tickets/ ${ id } ` ) . then ( setT ) . catch ( ( e ) = > setErr ( errMsg ( e ) ) ) , [ id ] ) ;
useEffect ( ( ) = > { void load ( ) ; } , [ load ] ) ;
useEffect ( ( ) = > { if ( write ) api < StaffUser [ ] > ( 'GET' , '/admin/users' ) . then ( ( l ) = > setStaffUsers ( l . filter ( ( u ) = > ! ! u . staffRole ) ) ) . catch ( ( ) = > undefined ) ; } , [ write ] ) ;
async function send ( e : FormEvent < HTMLFormElement > ) {
2026-09-27 08:53:27 +02:00
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 ) ; }
2026-09-27 08:36:18 +02:00
}
async function patch ( body : object , msg? : string ) { setErr ( '' ) ; setOk ( '' ) ; try { await api ( 'PATCH' , ` /tickets/ ${ id } ` , body ) ; if ( msg ) setOk ( msg ) ; void load ( ) ; } catch ( x ) { setErr ( errMsg ( x ) ) ; } }
async function close() { setErr ( '' ) ; try { await api ( 'POST' , ` /tickets/ ${ id } /close ` ) ; void load ( ) ; } catch ( x ) { setErr ( errMsg ( x ) ) ; } }
if ( ! t ) return err ? < Alert kind = "err" > { err } < / Alert > : < p className = "muted" role = "status" > Wird geladen … < / p > ;
const canClose = t . status !== 'closed' ;
return ( < >
< div className = "row between" > < h1 > { t . number } : { t . subject } < / h1 > < Link className = "btn" href = "/tickets" > ← Tickets < / Link > < / div >
< p className = "muted small" > { staff && < > { t . orgName } · < / > } < TicketStatusBadge value = { t . status } / > · Priorität : { TICKET_PRIORITY [ t . priority ] } { t . assignedName && < > · zugewiesen an { t . assignedName } < / > } < / p >
{ err && < Alert kind = "err" > { err } < / Alert > } { ok && < Alert kind = "ok" > { ok } < / Alert > }
{ write && < div className = "card" > < div className = "cols" >
< Field id = "status" label = "Status" > < select id = "status" value = { t . status } onChange = { ( e ) = > patch ( { status : e.target.value } ) } > { Object . entries ( STATUS_OPTIONS ) . map ( ( [ k , v ] ) = > < option key = { k } value = { k } > { v } < / option > ) } < / select > < / Field >
< Field id = "priority" label = "Priorität" > < select id = "priority" value = { t . priority } onChange = { ( e ) = > patch ( { priority : e.target.value } ) } > { Object . entries ( TICKET_PRIORITY ) . map ( ( [ k , v ] ) = > < option key = { k } value = { k } > { v } < / option > ) } < / select > < / Field >
< Field id = "assignee" label = "Zugewiesen an" > < select id = "assignee" value = { t . assignedTo ? ? '' } onChange = { ( e ) = > patch ( { assignedTo : e.target.value || null } ) } > < option value = "" > Niemand < / option > { staffUsers . map ( ( u ) = > < option key = { u . id } value = { u . id } > { u . name } < / option > ) } < / select > < / Field >
< / div > < / div > }
< div className = "card" >
{ t . messages . map ( ( m ) = > ( < div key = { m . id } style = { { padding : '12px 0' , borderBottom : '1px solid var(--border)' } } >
< div className = "row between small muted" > < span > { m . authorName } ( { m . authorKind === 'staff' ? 'Personal' : 'Kunde' } ) { m . internalNote && < > · < span className = "badge warn" > nur intern < / span > < / > } < / span > < span > { fmt ( m . createdAt ) } < / span > < / div >
2026-09-27 08:53:27 +02:00
< p style = { { whiteSpace : 'pre-wrap' , margin : '4px 0 0' } } > { m . body } < / p >
< AttachmentList ticketId = { t . id } atts = { m . attachments } / > < / div > ) ) }
2026-09-27 08:36:18 +02:00
< / 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 >
2026-09-27 08:53:27 +02:00
< 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 >
2026-09-27 08:36:18 +02:00
{ 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 > }
< / > ) ;
}