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>
91 lines
12 KiB
TypeScript
91 lines
12 KiB
TypeScript
'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 & 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>}
|
||
</>);
|
||
}
|