Stand vor Einführung des Nacht-Agenten

This commit is contained in:
Kundencenter 2026-09-27 00:51:32 +02:00
commit 4763548bfb
168 changed files with 12726 additions and 0 deletions

View file

@ -0,0 +1,81 @@
'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 { 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) }); }
}
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>}
<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"><h2>Benutzer</h2><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>}
</>);
}

View file

@ -0,0 +1,53 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, Field, Status } from '@/components/ui';
interface Cust { id: string; customerNumber: string; name: string; customerType: 'private' | 'business'; status: string; city: string | null; billingEmail: string | null; members: number }
export default function Kunden() {
const { can } = useSession();
const [list, setList] = useState<Cust[] | null>(null); const [q, setQ] = useState(''); const [err, setErr] = useState(''); const [open, setOpen] = useState(false); const [ctype, setCtype] = useState<'private' | 'business'>('business'); const [typeFilter, setTypeFilter] = useState('');
const [created, setCreated] = useState<{ id: string; customerNumber: string; mail: string; inviteLink?: string } | null>(null);
const load = useCallback(() => api<Cust[]>('GET', `/admin/customers?${new URLSearchParams({ ...(q ? { q } : {}), ...(typeFilter ? { type: typeFilter } : {}) })}`).then((l) => { setList(l); setErr(''); }).catch((e) => setErr(errMsg(e))), [q, typeFilter]);
useEffect(() => { const t = setTimeout(() => void load(), 250); return () => clearTimeout(t); }, [load]);
async function create(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr(''); const f = new FormData(e.currentTarget); const s = (k: string) => (f.get(k) as string) || undefined;
try {
const priv = ctype === 'private';
const name = priv ? `${f.get('firstName')} ${f.get('lastName')}`.trim() : (f.get('name') as string);
const r = await api<NonNullable<typeof created>>('POST', '/admin/customers', {
type: ctype, name, owner: priv ? { email: f.get('ownerEmail') } : { name: f.get('ownerName'), email: f.get('ownerEmail') },
billing: { street: s('street'), zip: s('zip'), city: s('city'), country: s('country') ?? 'DE', phone: s('phone'), ...(priv ? {} : { vatId: s('vatId') }) } });
setCreated(r); setOpen(false); void load();
} catch (x) { setErr(errMsg(x)); }
}
return (<>
<div className="row between"><h1>Kunden</h1>{can('customers.write') && <div className="row"><Link className="btn" href="/admin/kunden/uebernahme">Aus Verbindung übernehmen</Link><button className="btn primary" onClick={() => { setOpen(!open); setCreated(null); }} aria-expanded={open}>Kunde anlegen</button></div>}</div>
{err && <Alert kind="err">{err}</Alert>}
{created && <Alert kind={created.mail === 'sent' ? 'ok' : 'warn'}>Kunde {created.customerNumber} angelegt. {created.mail === 'sent' ? 'Die Einladung wurde per E-Mail versendet.' : <>E-Mail-Versand ist nicht eingerichtet. Einladungslink (wird nur jetzt angezeigt): <code>{created.inviteLink}</code></>}</Alert>}
{open && <form className="card" onSubmit={create}><h2>Neuer Kunde</h2>
<fieldset style={{ border: 0, padding: 0, margin: '0 0 16px' }}><legend style={{ fontWeight: 600, marginBottom: 8 }}>Kundenart</legend>
<div className="row">{([['business', 'Geschäftskunde'], ['private', 'Privatkunde']] as const).map(([v, l]) => <label key={v} className="row"><input type="radio" name="ctype" style={{ width: 20, minHeight: 20 }} checked={ctype === v} onChange={() => setCtype(v)} />{l}</label>)}</div></fieldset>
{ctype === 'business' ? (<>
<div className="cols"><Field id="name" label="Firmenname"><input id="name" name="name" required autoFocus /></Field><Field id="vatId" label="USt-IdNr." hint="Optional, z. B. DE123456789"><input id="vatId" name="vatId" /></Field></div>
<h3>Ansprechpartner (erhält die Einladung)</h3>
<div className="cols"><Field id="ownerName" label="Name"><input id="ownerName" name="ownerName" required /></Field><Field id="ownerEmail" label="E-Mail"><input id="ownerEmail" name="ownerEmail" type="email" required /></Field></div>
</>) : (<>
<div className="cols"><Field id="firstName" label="Vorname"><input id="firstName" name="firstName" required autoFocus /></Field><Field id="lastName" label="Nachname"><input id="lastName" name="lastName" required /></Field>
<Field id="ownerEmail" label="E-Mail (erhält die Einladung)"><input id="ownerEmail" name="ownerEmail" type="email" required /></Field></div>
</>)}
<h3>Rechnungsanschrift</h3>
<div className="cols"><Field id="street" label="Straße"><input id="street" name="street" /></Field><Field id="zip" label="PLZ"><input id="zip" name="zip" /></Field><Field id="city" label="Ort"><input id="city" name="city" /></Field>
<Field id="country" label="Land (ISO)"><input id="country" name="country" defaultValue="DE" maxLength={2} /></Field><Field id="phone" label="Telefon"><input id="phone" name="phone" /></Field></div>
<div className="row"><button className="btn primary" type="submit">Kunde anlegen</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div></form>}
<div className="card">
<div className="cols"><Field id="q" label="Suche"><input id="q" type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name, Kundennummer oder E-Mail" /></Field>
<Field id="tf" label="Kundenart"><select id="tf" value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}><option value="">Alle</option><option value="business">Geschäftskunden</option><option value="private">Privatkunden</option></select></Field></div>
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title={q ? 'Keine Treffer' : 'Noch keine Kunden'}>{!q && 'Legen Sie den ersten Kunden über „Kunde anlegen“ an.'}</Empty> :
<div className="tablewrap"><table><thead><tr><th>Nr.</th><th>Name</th><th>Art</th><th>Ort</th><th>Benutzer</th><th>Status</th></tr></thead><tbody>
{list.map((c) => <tr key={c.id}><td className="mono">{c.customerNumber}</td><td><Link href={`/admin/kunden/${c.id}`}>{c.name}</Link></td><td>{c.customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde'}</td><td>{c.city ?? '–'}</td><td>{c.members}</td><td><Status value={c.status} /></td></tr>)}</tbody></table></div>}
</div></>);
}

View file

@ -0,0 +1,70 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, Field, fmt } from '@/components/ui';
interface Cust { externalRef: string; displayName: string; company: string | null; firstName: string | null; lastName: string | null; email: string | null; planName: string | null; state: 'active' | 'suspended'; createdAt: string | null;
imported: { orgId: string; customerNumber: string } | null; resourceKnown: boolean; resourceOrgId: string | null; suggestedProducts: { id: string; sku: string; name: string; status: string }[]; possibleOrgs: { id: string; customerNumber: string; name: string }[] }
interface Conn { id: string; name: string; connector: string; capabilities: string[]; health: string }
interface Row { sel: boolean; type: 'private' | 'business'; productId: string; start: string; link: string }
interface Result { externalRef: string; status: 'created' | 'linked' | 'skipped'; reason?: string; orgId?: string; customerNumber?: string; contractNumber?: string }
export default function KundenUebernahme() {
const { can } = useSession();
const [conns, setConns] = useState<Conn[]>([]); const [connId, setConnId] = useState(''); const [list, setList] = useState<Cust[] | null>(null); const [err, setErr] = useState('');
const [rows, setRows] = useState<Record<string, Row>>({}); const [q, setQ] = useState(''); const [busy, setBusy] = useState(false); const [results, setResults] = useState<Result[] | null>(null);
useEffect(() => { api<Conn[]>('GET', '/admin/connectors').then((l) => { const c = l.filter((x) => x.capabilities.includes('customers.list')); setConns(c); setConnId(c[0]?.id ?? ''); if (!c.length) setList([]); }).catch((e) => setErr(errMsg(e))); }, []);
const load = useCallback(() => {
if (!connId) return; setList(null); setErr(''); setResults(null);
api<Cust[]>('GET', `/admin/connectors/${connId}/customers`).then((l) => {
setList(l);
setRows(Object.fromEntries(l.map((c) => [c.externalRef, { sel: false, type: c.company ? 'business' : 'private', productId: c.suggestedProducts[0]?.id ?? '', start: (c.createdAt ?? new Date().toISOString()).slice(0, 10), link: '' } as Row])));
}).catch((e) => { setErr(errMsg(e)); setList([]); });
}, [connId]);
useEffect(() => { load(); }, [load]);
const set = (ref: string, p: Partial<Row>) => setRows((r) => ({ ...r, [ref]: { ...r[ref]!, ...p } }));
const visible = useMemo(() => (list ?? []).filter((c) => !q || [c.displayName, c.email, c.planName].some((x) => x?.toLowerCase().includes(q.toLowerCase()))), [list, q]);
const open = visible.filter((c) => !c.imported && (!c.resourceOrgId));
const chosen = (list ?? []).filter((c) => rows[c.externalRef]?.sel);
if (!can('customers.write')) return <Alert kind="err">Keine Berechtigung.</Alert>;
async function run() {
setBusy(true); setErr('');
try {
const items = chosen.map((c) => { const r = rows[c.externalRef]!; return { externalRef: c.externalRef, type: r.type, ...(r.link ? { linkToOrgId: r.link } : {}), ...(r.productId ? { contract: { productId: r.productId, startedAt: new Date(r.start).toISOString() } } : {}) }; });
const r = await api<{ results: Result[] }>('POST', `/admin/connectors/${connId}/customers/import`, { items }); setResults(r.results); load();
} catch (e) { setErr(errMsg(e)); } finally { setBusy(false); }
}
return (<>
<p><Link href="/admin/kunden">← Alle Kunden</Link></p>
<h1>Kunden übernehmen</h1>
<p className="muted">Die Kunden werden live aus der Verbindung gelesen. Beim Anbieter wird nichts verändert, und es werden <strong>keine E-Mails</strong> an die Kunden versendet. Die Einladung zum Kundencenter senden Sie später gezielt.</p>
{err && <Alert kind="err">{err} <button className="btn small" onClick={load}>Erneut versuchen</button></Alert>}
<div className="card"><Field id="conn" label="Verbindung"><select id="conn" value={connId} onChange={(e) => setConnId(e.target.value)}>{conns.map((c) => <option key={c.id} value={c.id}>{c.name}{c.health !== 'ok' ? ' (gestört)' : ''}</option>)}</select></Field></div>
{results && <div className="card"><h2>Ergebnis</h2>
<div className="tablewrap"><table><thead><tr><th>Konto</th><th>Ergebnis</th><th>Kunde</th><th>Vertrag</th></tr></thead><tbody>{results.map((r) => { const c = list?.find((x) => x.externalRef === r.externalRef); return <tr key={r.externalRef}><td>{c?.displayName ?? r.externalRef}</td>
<td><span className={`badge ${r.status === 'skipped' ? 'warn' : 'ok'}`}><span aria-hidden="true">{r.status === 'skipped' ? '▲' : '✓'}</span>{r.status === 'created' ? 'Angelegt' : r.status === 'linked' ? 'Verknüpft' : 'Übersprungen'}</span>{r.reason && <div className="small muted">{r.reason}</div>}</td>
<td>{r.orgId ? <Link href={`/admin/kunden/${r.orgId}`}>{r.customerNumber}</Link> : '–'}</td><td className="mono">{r.contractNumber ?? '–'}</td></tr>; })}</tbody></table></div>
<p className="small muted" style={{ marginTop: 12 }}>Die Inhaber wurden als „eingeladen“ angelegt. Zum Zugang senden Sie die Einladung auf der Kundenseite unter „Benutzer“.</p></div>}
{conns.length === 0 && list !== null && <div className="card"><Empty title="Keine geeignete Verbindung">Legen Sie unter Einstellungen → Verbindungen eine Verbindung an, die Kunden bereitstellt (z. B. KeyHelp).</Empty></div>}
{list === null ? <p className="muted" role="status">Kunden werden beim Anbieter gelesen …</p> : list.length > 0 && (
<div className="card"><div className="row between"><Field id="q" label="Suche"><input id="q" type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name, E-Mail oder Tarif" /></Field>
<div className="row"><button className="btn small" onClick={() => setRows((r) => Object.fromEntries(Object.entries(r).map(([k, v]) => [k, { ...v, sel: open.some((c) => c.externalRef === k) ? true : v.sel }])))}>Alle noch nicht übernommenen wählen</button><button className="btn small" onClick={() => setRows((r) => Object.fromEntries(Object.entries(r).map(([k, v]) => [k, { ...v, sel: false }])))}>Auswahl aufheben</button></div></div>
<div className="tablewrap"><table><thead><tr><th><span className="sr">Auswahl</span></th><th>Konto</th><th>Tarif</th><th>Art</th><th>Vertrag (Bestand)</th><th>Verknüpfen mit</th></tr></thead><tbody>
{visible.map((c) => { const r = rows[c.externalRef]; if (!r) return null; const done = !!c.imported || !!c.resourceOrgId; return (<tr key={c.externalRef}>
<td><input type="checkbox" style={{ width: 20, minHeight: 20 }} aria-label={`${c.displayName} auswählen`} checked={r.sel} disabled={done} onChange={(e) => set(c.externalRef, { sel: e.target.checked })} /></td>
<td>{c.displayName}<div className="small muted">{c.email ?? 'keine E-Mail'} · seit {c.createdAt ? fmt(c.createdAt).split(',')[0] : '–'}{c.state === 'suspended' && <> · <span className="badge lock"><span aria-hidden="true">⊘</span>gesperrt</span></>}</div>
{c.imported && <div className="small"><span className="badge ok"><span aria-hidden="true">✓</span>Übernommen</span> <Link href={`/admin/kunden/${c.imported.orgId}`}>{c.imported.customerNumber}</Link></div>}
{!c.imported && c.resourceOrgId && <div className="small"><span className="badge info"><span aria-hidden="true">○</span>Konto bereits zugeordnet</span></div>}</td>
<td>{c.planName ?? '–'}</td>
<td><select aria-label="Kundenart" value={r.type} disabled={done} onChange={(e) => set(c.externalRef, { type: e.target.value as Row['type'] })} style={{ minHeight: 36, padding: '4px 8px' }}><option value="business">Geschäftskunde</option><option value="private">Privatkunde</option></select></td>
<td><select aria-label="Produkt für den Bestandsvertrag" value={r.productId} disabled={done} onChange={(e) => set(c.externalRef, { productId: e.target.value })} style={{ minHeight: 36, padding: '4px 8px', maxWidth: 220 }}><option value="">Keinen Vertrag anlegen</option>{c.suggestedProducts.map((p) => <option key={p.id} value={p.id}>{p.name}{p.status !== 'active' ? ' (Entwurf)' : ''}</option>)}</select>
{r.productId && <div style={{ marginTop: 6 }}><label className="small muted" htmlFor={`st-${c.externalRef}`}>Vertragsbeginn</label><input id={`st-${c.externalRef}`} type="date" value={r.start} onChange={(e) => set(c.externalRef, { start: e.target.value })} style={{ minHeight: 36, padding: '4px 8px' }} /></div>}
{c.suggestedProducts.length === 0 && <div className="small muted">Kein Produkt zum Tarif vorhanden (erst Tarif als Produkt übernehmen).</div>}</td>
<td>{c.possibleOrgs.length > 0 ? <select aria-label="Mit vorhandenem Kunden verknüpfen" value={r.link} disabled={done} onChange={(e) => set(c.externalRef, { link: e.target.value })} style={{ minHeight: 36, padding: '4px 8px' }}><option value="">Neuen Kunden anlegen</option>{c.possibleOrgs.map((o) => <option key={o.id} value={o.id}>{o.customerNumber} · {o.name}</option>)}</select> : <span className="muted small">Neuer Kunde</span>}</td></tr>); })}</tbody></table></div>
<div className="row" style={{ marginTop: 16 }}><button className="btn primary" disabled={busy || chosen.length === 0} onClick={run}>{busy ? 'Bitte warten …' : `${chosen.length} Kunde(n) übernehmen`}</button>
<span className="small muted">Bestandsverträge übernehmen Preis und Bedingungen des gewählten Produkts. Abrechnung folgt mit dem Rechnungsmodul.</span></div></div>)}
</>);
}