kundencenter/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx
Kundencenter 3371655ca3 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

91 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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';
import { InvoiceList } from '@/components/InvoiceList';
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) }); }
}
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); }
}
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>}
{can('invoices.read') && <section aria-labelledby="h-inv"><InvoiceList orgId={String(id)} /></section>}
<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>
<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>
{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>}
</>);
}