2026-09-27 00:51:32 +02:00
'use client' ;
import { useCallback , useEffect , useState , type FormEvent } from 'react' ;
import Link from 'next/link' ;
import { useParams } from 'next/navigation' ;
import { api , errMsg } from '@/lib/api' ;
import { useSession } from '@/lib/session' ;
import { SecretField } from '@/components/SecretField' ;
import { DomainRecords } from '@/components/DomainRecords' ;
2026-09-27 09:05:13 +02:00
import { InvoiceList } from '@/components/InvoiceList' ;
2026-09-27 00:51:32 +02:00
import { Alert , ContractStatusBadge , Empty , Field , fmt , OrderStatusBadge , ResState , ROLE_LABEL , Status } from '@/components/ui' ;
interface Org { id : string ; name : string ; customerNumber : string ; customerType : 'private' | 'business' ; status : string ; billing : Record < string , string | null > ; members : { id : string ; email : string ; name : string ; status : string ; role : string } [ ] }
interface CRow { id : string ; number : string ; status : string ; productName : string ; termEnd : string | null ; cancelEffectiveAt : string | null }
interface ORow { id : string ; number : string ; status : string ; createdAt : string ; items : { snapshot : { name : string } } [ ] }
interface RRow { id : string ; name : string ; state : string ; validUntil : string | null ; stale : boolean ; canReveal? : boolean }
export default function Kunde() {
const { id } = useParams < { id : string } > ( ) ; const { can } = useSession ( ) ;
const [ contracts , setContracts ] = useState < CRow [ ] | null > ( null ) ; const [ orders , setOrders ] = useState < ORow [ ] | null > ( null ) ; const [ ress , setRess ] = useState < RRow [ ] | null > ( null ) ;
const [ o , setO ] = useState < Org | null > ( null ) ; const [ msg , setMsg ] = useState < { k : 'ok' | 'err' | 'warn' ; t : string } | null > ( null ) ; const [ confirm , setConfirm ] = useState < string | null > ( null ) ; const [ switchTo , setSwitchTo ] = useState < 'private' | 'business' | null > ( null ) ;
const load = useCallback ( ( ) = > api < Org > ( 'GET' , ` /admin/customers/ ${ id } ` ) . then ( setO ) . catch ( ( e ) = > setMsg ( { k : 'err' , t : errMsg ( e ) } ) ) , [ id ] ) ;
useEffect ( ( ) = > { void load ( ) ; api < CRow [ ] > ( 'GET' , ` /contracts?org= ${ id } ` ) . then ( setContracts ) . catch ( ( ) = > setContracts ( [ ] ) ) ; api < ORow [ ] > ( 'GET' , ` /orders?org= ${ id } ` ) . then ( setOrders ) . catch ( ( ) = > setOrders ( [ ] ) ) ; api < RRow [ ] > ( 'GET' , ` /resources?org= ${ id } ` ) . then ( setRess ) . catch ( ( ) = > setRess ( [ ] ) ) ; } , [ load , id ] ) ;
if ( ! o ) return msg ? < Alert kind = { msg . k } > { msg . t } < / Alert > : < p className = "muted" role = "status" > Wird geladen … < / p > ;
const w = can ( 'customers.write' ) ;
async function save ( e : FormEvent < HTMLFormElement > ) {
e . preventDefault ( ) ; const f = new FormData ( e . currentTarget ) ; const g = ( k : string ) = > ( f . get ( k ) as string | null ) ? ? '' ;
const priv = o ! . customerType === 'private' ;
try { setO ( await api < Org > ( 'PATCH' , ` /admin/customers/ ${ id } ` , { name : g ( 'name' ) , billing : { street : g ( 'street' ) , zip : g ( 'zip' ) , city : g ( 'city' ) , billingEmail : g ( 'billingEmail' ) , phone : g ( 'phone' ) , . . . ( priv ? { } : { company : g ( 'company' ) , vatId : g ( 'vatId' ) } ) } } ) ) ; setMsg ( { k : 'ok' , t : 'Gespeichert.' } ) ; }
catch ( x ) { setMsg ( { k : 'err' , t : errMsg ( x ) } ) ; }
}
async function changeType ( t : 'private' | 'business' ) {
// Beim Wechsel zum Privatkunden werden Firma und USt-IdNr. entfernt (Bestätigung vorher).
try { setO ( await api < Org > ( 'PATCH' , ` /admin/customers/ ${ id } ` , { customerType : t , . . . ( t === 'private' ? { billing : { company : '' , vatId : '' } } : { billing : { company : o ! . name } } ) } ) ) ; setSwitchTo ( null ) ; setMsg ( { k : 'ok' , t : 'Kundenart geändert.' } ) ; } catch ( x ) { setMsg ( { k : 'err' , t : errMsg ( x ) } ) ; }
}
async function reinvite ( uid : string , email : string ) {
try { const r = await api < { mail : string ; inviteLink? : string } > ( 'POST' , ` /admin/users/ ${ uid } /reinvite ` ) ; setMsg ( r . mail === 'sent' ? { k : 'ok' , t : ` Die Einladung wurde an ${ email } gesendet. ` } : { k : 'warn' , t : ` E-Mail-Versand ist nicht eingerichtet. Einladungslink (wird nur jetzt angezeigt): ${ r . inviteLink } ` } ) ; } catch ( x ) { setMsg ( { k : 'err' , t : errMsg ( x ) } ) ; }
}
async function setStatus ( status : string ) {
try { setO ( await api < Org > ( 'PATCH' , ` /admin/customers/ ${ id } ` , { status } ) ) ; setConfirm ( null ) ; setMsg ( { k : 'ok' , t : 'Status geändert.' } ) ; } catch ( x ) { setMsg ( { k : 'err' , t : errMsg ( x ) } ) ; }
}
feat(mail): Begrüßungsmail mit Zugang, Produkten, Preisen und Discord
Neue Mailvorlage "welcome" (Migration 028), von Personal gezielt über
einen Button auf der Kundenseite ausgelöst ("Begrüßungsmail senden"),
statt automatisch bei der Kundenanlage – sinnvoll ist sie erst, sobald
Produkte zugeordnet sind.
Enthält: Begrüßung, Kundennummer, eine Klartext-Aufstellung der
aktuellen aktiven Produkte mit Preisen (Privatkunden brutto,
Geschäftskunden netto+brutto, wie im Rest der Oberfläche), eine kurze
Anleitung (Ressourcen/Tickets/Rechnungen) und einen Hinweis zur
Discord-Verknüpfung unter Mein Konto. Zugangslink ist je nach Status
entweder ein frischer Einladungslink (noch nicht aktiviert) oder der
normale Login-Link (bereits aktiv).
Außerdem: renderTemplateHtml verlinkt jetzt auch weitere nackte URLs
im Text automatisch (nicht nur die Haupt-Link-Variable), damit z. B.
der Discord-Hinweis in der HTML-Ansicht klickbar ist.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 23:50:53 +02:00
const [ welcomeBusy , setWelcomeBusy ] = useState ( false ) ;
async function sendWelcome() {
setWelcomeBusy ( true ) ;
try { const r = await api < { status : string } > ( 'POST' , ` /admin/customers/ ${ id } /welcome-mail ` ) ; setMsg ( { k : r.status === 'sent' ? 'ok' : 'warn' , t : r.status === 'sent' ? 'Begrüßungsmail gesendet.' : ` Nicht gesendet (Status: ${ r . status } ). ` } ) ; }
catch ( x ) { setMsg ( { k : 'err' , t : errMsg ( x ) } ) ; } finally { setWelcomeBusy ( false ) ; }
}
2026-09-27 00:51:32 +02:00
return ( < >
< p > < Link href = "/admin/kunden" > ← Alle Kunden < / Link > < / p >
< div className = "row between" > < h1 > { o . name } < / h1 > < Status value = { o . status } / > < / div >
< p className = "muted" > < span className = "mono" > { o . customerNumber } < / span > · < span className = "badge info" > { o . customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde' } < / span > < / p >
{ msg && < Alert kind = { msg . k } > { msg . t } < / Alert > }
< form className = "card" onSubmit = { save } > < h2 > Stammdaten & amp ; Rechnungsanschrift < / h2 >
< div className = "cols" > < Field id = "name" label = { o . customerType === 'business' ? 'Firmenname' : 'Name' } > < input id = "name" name = "name" defaultValue = { o . name } disabled = { ! w } required / > < / Field >
{ o . customerType === 'business' && < Field id = "company" label = "Firma (Rechnung)" > < input id = "company" name = "company" defaultValue = { o . billing . company ? ? '' } disabled = { ! w } / > < / Field > }
< Field id = "street" label = "Straße" > < input id = "street" name = "street" defaultValue = { o . billing . street ? ? '' } disabled = { ! w } / > < / Field >
< Field id = "zip" label = "PLZ" > < input id = "zip" name = "zip" defaultValue = { o . billing . zip ? ? '' } disabled = { ! w } / > < / Field >
< Field id = "city" label = "Ort" > < input id = "city" name = "city" defaultValue = { o . billing . city ? ? '' } disabled = { ! w } / > < / Field >
{ o . customerType === 'business' && < Field id = "vatId" label = "USt-IdNr." > < input id = "vatId" name = "vatId" defaultValue = { o . billing . vatId ? ? '' } disabled = { ! w } / > < / Field > }
< Field id = "billingEmail" label = "Rechnungs-E-Mail" > < input id = "billingEmail" name = "billingEmail" type = "email" defaultValue = { o . billing . billingEmail ? ? '' } disabled = { ! w } / > < / Field >
< Field id = "phone" label = "Telefon" > < input id = "phone" name = "phone" defaultValue = { o . billing . phone ? ? '' } disabled = { ! w } / > < / Field > < / div >
{ w && < button className = "btn primary" type = "submit" > Speichern < / button > } < / form >
< div className = "card" > < div className = "row between" > < h2 > Verträge < / h2 > { can ( 'orders.write' ) && < Link className = "btn small" href = "/bestellen" > Bestellung anlegen < / Link > } < / div >
{ contracts === null ? < p className = "muted" role = "status" > Wird geladen … < / p > : contracts . length === 0 ? < Empty title = "Noch keine Verträge" / > :
< div className = "tablewrap" > < table > < thead > < tr > < th > Nr . < / th > < th > Produkt < / th > < th > Laufzeit bis < / th > < th > Status < / th > < / tr > < / thead > < tbody >
{ contracts . map ( ( c ) = > < tr key = { c . id } > < td className = "mono" > < Link href = { ` /vertraege/ ${ c . id } ` } > { c . number } < / Link > < / td > < td > { c . productName } < / td > < td > { c . termEnd ? fmt ( c . termEnd ) : 'unbefristet' } { c . cancelEffectiveAt && < div className = "small" > < span className = "badge warn" > < span aria-hidden = "true" > ▲ < / span > Gekündigt zum { fmt ( c . cancelEffectiveAt ) } < / span > < / div > } < / td > < td > < ContractStatusBadge value = { c . status } / > < / td > < / tr > ) } < / tbody > < / table > < / div > } < / div >
< div className = "card" > < h2 > Ressourcen und Lizenzen < / h2 >
{ ress === null ? < p className = "muted" role = "status" > Wird geladen … < / p > : ress . length === 0 ? < Empty title = "Keine Ressourcen zugeordnet" / > :
< div className = "tablewrap" > < table > < thead > < tr > < th > Name < / th > < th > Status < / th > < th > Gültig bis < / th > < th > Lizenzschlüssel < / th > < / tr > < / thead > < tbody >
{ ress . map ( ( r ) = > < tr key = { r . id } > < td > < Link href = { ` /ressourcen/ ${ r . id } ` } > { r . name } < / Link > { r . stale && < > < span className = "badge warn" > < span aria-hidden = "true" > ▲ < / span > Veraltet < / span > < / > } < / td > < td > < ResState value = { r . state } / > < / td > < td > { r . validUntil ? fmt ( r . validUntil ) : 'unbefristet' } < / td > < td > { r . canReveal ? < SecretField resourceId = { r . id } compact / > : < span className = "muted small" > – < / span > } < / td > < / tr > ) } < / tbody > < / table > < / div > } < / div >
{ can ( 'domains.read' ) && < section aria-labelledby = "h-dom" > < h2 id = "h-dom" > Domains < / h2 > < DomainRecords orgId = { String ( id ) } / > < / section > }
2026-09-27 09:05:13 +02:00
{ can ( 'invoices.read' ) && < section aria-labelledby = "h-inv" > < InvoiceList orgId = { String ( id ) } / > < / section > }
2026-09-27 00:51:32 +02:00
< div className = "card" > < h2 > Bestellungen < / h2 >
{ orders === null ? < p className = "muted" role = "status" > Wird geladen … < / p > : orders . length === 0 ? < Empty title = "Noch keine Bestellungen" / > :
< div className = "tablewrap" > < table > < thead > < tr > < th > Nr . < / th > < th > Positionen < / th > < th > Bestellt am < / th > < th > Status < / th > < / tr > < / thead > < tbody >
{ orders . map ( ( x ) = > < tr key = { x . id } > < td className = "mono" > < Link href = "/bestellungen" > { x . number } < / Link > < / td > < td > { x . items . map ( ( i ) = > i . snapshot . name ) . join ( ', ' ) } < / td > < td > { fmt ( x . createdAt ) } < / td > < td > < OrderStatusBadge value = { x . status } / > < / td > < / tr > ) } < / tbody > < / table > < / div > } < / div >
feat(mail): Begrüßungsmail mit Zugang, Produkten, Preisen und Discord
Neue Mailvorlage "welcome" (Migration 028), von Personal gezielt über
einen Button auf der Kundenseite ausgelöst ("Begrüßungsmail senden"),
statt automatisch bei der Kundenanlage – sinnvoll ist sie erst, sobald
Produkte zugeordnet sind.
Enthält: Begrüßung, Kundennummer, eine Klartext-Aufstellung der
aktuellen aktiven Produkte mit Preisen (Privatkunden brutto,
Geschäftskunden netto+brutto, wie im Rest der Oberfläche), eine kurze
Anleitung (Ressourcen/Tickets/Rechnungen) und einen Hinweis zur
Discord-Verknüpfung unter Mein Konto. Zugangslink ist je nach Status
entweder ein frischer Einladungslink (noch nicht aktiviert) oder der
normale Login-Link (bereits aktiv).
Außerdem: renderTemplateHtml verlinkt jetzt auch weitere nackte URLs
im Text automatisch (nicht nur die Haupt-Link-Variable), damit z. B.
der Discord-Hinweis in der HTML-Ansicht klickbar ist.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 23:50:53 +02:00
< div className = "card" > < div className = "row between" > < h2 > Benutzer < / h2 > { w && < button className = "btn small" disabled = { welcomeBusy } onClick = { sendWelcome } > { welcomeBusy ? 'Bitte warten …' : 'Begrüßungsmail senden' } < / button > } < / div >
< p className = "muted small" > Enthält den Zugangslink , die aktuellen Produkte mit Preisen und eine kurze Anleitung inkl . Discord - Verknüpfung . Geht an den ersten Ansprechpartner . < / p >
< div className = "tablewrap" > < table > < thead > < tr > < th > Name < / th > < th > E - Mail < / th > < th > Rolle < / th > < th > Status < / th > < th > < span className = "sr" > Aktionen < / span > < / th > < / tr > < / thead > < tbody >
2026-09-27 00:51:32 +02:00
{ o . members . map ( ( m ) = > < tr key = { m . id } > < td > { m . name } < / td > < td > { m . email } < / td > < td > { ROLE_LABEL [ m . role ] } < / td > < td > < Status value = { m . status } / > < / td > < td > { w && m . status === 'invited' && < button className = "btn small" onClick = { ( ) = > reinvite ( m . id , m . email ) } > Einladung senden < / button > } < / td > < / tr > ) } < / tbody > < / table > < / div > < / div >
{ w && < div className = "card" > < h2 > Kundenart < / h2 >
{ switchTo ? ( < div role = "alertdialog" aria-labelledby = "st" > < p id = "st" > < strong > Zu { switchTo === 'private' ? 'Privatkunde' : 'Geschäftskunde' } wechseln ? < / strong > { switchTo === 'private' ? 'Firma und USt-IdNr. werden entfernt.' : 'Als Firma wird der bisherige Name übernommen.' } Das kann Auswirkungen auf Rechnungen und Steuer haben . Die Ä nderung wird protokolliert . < / p >
< div className = "row" > < button className = "btn primary" onClick = { ( ) = > changeType ( switchTo ) } > Ja , ä ndern < / button > < button className = "btn" onClick = { ( ) = > setSwitchTo ( null ) } > Abbrechen < / button > < / div > < / div > )
: < button className = "btn" onClick = { ( ) = > setSwitchTo ( o . customerType === 'business' ? 'private' : 'business' ) } > Zu { o . customerType === 'business' ? 'Privatkunde' : 'Geschäftskunde' } ä ndern < / button > } < / div > }
{ w && < div className = "card" > < h2 > Gefährliche Aktionen < / h2 >
{ confirm ? ( < div role = "alertdialog" aria-labelledby = "ct" > < p id = "ct" > < strong > { confirm === 'suspended' ? 'Kunde sperren?' : confirm === 'closed' ? 'Kunde beenden?' : 'Kunde entsperren?' } < / strong > Die Aktion wird protokolliert . < / p >
< div className = "row" > < button className = "btn danger" onClick = { ( ) = > setStatus ( confirm ) } > Ja , ausführen < / button > < button className = "btn" onClick = { ( ) = > setConfirm ( null ) } > Abbrechen < / button > < / div > < / div > )
: < div className = "row" > { o . status !== 'active' && o . status !== 'closed' && < button className = "btn" onClick = { ( ) = > setConfirm ( 'active' ) } > Entsperren < / button > }
{ o . status === 'active' && < button className = "btn danger" onClick = { ( ) = > setConfirm ( 'suspended' ) } > Sperren < / button > }
{ o . status !== 'closed' && < button className = "btn danger" onClick = { ( ) = > setConfirm ( 'closed' ) } > Beenden < / button > } < / div > }
< p className = "small muted" style = { { marginTop : 12 } } > Hinweis : Sperren wirkt aktuell nur auf den Kundenstatus . Provider - Ressourcen werden erst mit den Connectoren gesperrt . < / p > < / div > }
< / > ) ;
}