Hosting-Produkte: Staffelpreise nach Laufzeit (1/3/6/12/24 Monate)

This commit is contained in:
Kundencenter 2026-09-27 18:35:34 +02:00
parent e0fd57532d
commit 5efe37ecc0
12 changed files with 145 additions and 28 deletions

View file

@ -7,6 +7,7 @@ import { useSession } from '@/lib/session';
import { Alert, eur, Field, fmt, INTERVAL, Status, CUSTOMER_ACTIONS, ACTION_LABEL } from '@/components/ui';
import type { Prod } from '../page';
const TERMS = [1, 3, 6, 12, 24];
type D = Prod & { versions: NonNullable<Prod['current']>[] };
export default function Produkt() {
const { id } = useParams<{ id: string }>(); const { can } = useSession(); const w = can('products.write');
@ -18,8 +19,9 @@ export default function Produkt() {
async function patch(b: object, ok: string) { try { await api('PATCH', `/admin/products/${id}`, b); setMsg({ k: 'ok', t: ok }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
async function newVersion(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); const f = new FormData(e.currentTarget); const once = cur.billingInterval === 'once';
const termPrices = once ? [] : TERMS.map((m) => ({ termMonths: m, recurringCents: f.get(`term${m}`) === '' || f.get(`term${m}`) === null ? null : Math.round(Number(f.get(`term${m}`)) * 100) })).filter((t): t is { termMonths: number; recurringCents: number } => t.recurringCents !== null);
try { await api('POST', `/admin/products/${id}/versions`, { name: f.get('name'), taxRateId: f.get('tax'), priceBasis: cur.priceBasis, setupCents: Math.round(Number(f.get('setup') || 0) * 100), recurringCents: once ? 0 : Math.round(Number(f.get('recurring') || 0) * 100), billingInterval: cur.billingInterval,
termMonths: cur.termMonths, renewal: cur.renewal, renewalTermMonths: cur.renewalTermMonths || undefined, noticeDays: Number(f.get('notice') || cur.noticeDays), provisioning: d!.provisioning });
termMonths: cur.termMonths, renewal: cur.renewal, renewalTermMonths: cur.renewalTermMonths || undefined, noticeDays: Number(f.get('notice') || cur.noticeDays), provisioning: d!.provisioning, termPrices });
setOpen(false); setMsg({ k: 'ok', t: 'Neue Version angelegt. Bestehende Verträge behalten ihren Preis.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
}
return (<>
@ -29,6 +31,7 @@ export default function Produkt() {
<div className="card"><h2>Aktuelle Konditionen (Version {cur.version})</h2>
<p>{cur.setupCents > 0 && <>Einrichtung {eur(cur.setupCents)} {cur.priceBasis === 'gross' ? 'brutto' : 'netto'}<br /></>}{cur.billingInterval === 'once' ? 'Einmalpreis' : `${INTERVAL[cur.billingInterval]} ${eur(cur.recurringCents)} ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'}`} · Steuer {cur.taxBp / 100} %</p>
<p className="small muted">{cur.termMonths > 0 ? `Mindestlaufzeit ${cur.termMonths} Monate` : 'Keine Mindestlaufzeit'}{cur.renewal === 'auto' ? `, Verlängerung um ${cur.renewalTermMonths} Monat(e)` : ''}, Kündigungsfrist {cur.noticeDays} Tage · Bereitstellung: {d.connectorName ?? 'manuell'}{Object.keys(d.provisioning).length ? ` (${JSON.stringify(d.provisioning)})` : ''}</p>
{d.termPrices.length > 0 && <p className="small muted">Laufzeit-Staffelpreise: {d.termPrices.map((t) => `${t.termMonths} Mon. ${eur(t.recurringCents)}`).join(' · ')}</p>}
{w && <div className="row">{d.status !== 'active' && <button className="btn primary" onClick={() => patch({ status: 'active' }, 'Produkt aktiviert.')}>Aktivieren</button>}
{d.status === 'active' && <button className="btn" onClick={() => patch({ status: 'retired' }, 'Produkt ausgelaufen. Es kann nicht mehr bestellt werden.')}>Auslaufen lassen</button>}
<button className="btn" onClick={() => setOpen(!open)} aria-expanded={open}>Neue Version (Preis ändern)</button></div>}</div>
@ -38,6 +41,11 @@ export default function Produkt() {
{cur.billingInterval !== 'once' && <Field id="recurring" label={`Preis (€ ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'})`}><input id="recurring" name="recurring" type="number" min={0} step="0.01" defaultValue={cur.recurringCents / 100} /></Field>}
<Field id="tax" label="Steuersatz"><select id="tax" name="tax" defaultValue={tax.find((t) => t.rateBp === cur.taxBp)?.id}>{tax.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</select></Field>
<Field id="notice" label="Kündigungsfrist (Tage)"><input id="notice" name="notice" type="number" min={0} defaultValue={cur.noticeDays} /></Field></div>
{cur.billingInterval !== 'once' && <>
<h3>Staffelpreise nach Laufzeit (optional)</h3>
<p className="small muted">Preis jeweils für die gesamte Laufzeit, nicht pro Monat. Leer lassen, wenn diese Laufzeit nicht angeboten wird.</p>
<div className="cols">{TERMS.map((m) => <Field key={m} id={`term${m}`} label={`${m} Monat${m > 1 ? 'e' : ''} (€ ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'}, gesamt)`}><input id={`term${m}`} name={`term${m}`} type="number" min={0} step="0.01" defaultValue={d.termPrices.find((t) => t.termMonths === m) ? d.termPrices.find((t) => t.termMonths === m)!.recurringCents / 100 : ''} /></Field>)}</div>
</>}
<div className="row"><button className="btn primary" type="submit">Version speichern</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div></form>}
<div className="card"><h2>Verkaufsregeln</h2><div className="row">
<label className="row"><input type="checkbox" style={{ width: 20, minHeight: 20 }} disabled={!w} checked={d.orderableByCustomer} onChange={(e) => patch({ orderableByCustomer: e.target.checked }, 'Gespeichert.')} />Von Kunden selbst bestellbar</label>

View file

@ -8,6 +8,7 @@ import { Alert, Field, ACTION_LABEL, CUSTOMER_ACTIONS, INTERVAL } from '@/compon
interface Conn { id: string; name: string; connector: string; capabilities: string[] }
const LIMITS: [string, string, string][] = [['diskSpaceGb', 'Speicher (GB)', '10'], ['trafficGb', 'Traffic pro Monat (GB)', '100'], ['domains', 'Domains', '3'], ['subdomains', 'Subdomains', '10'], ['emailAccounts', 'Postfächer', '10'], ['emailAddresses', 'E-Mail-Adressen', '20'], ['emailForwardings', 'Weiterleitungen', '10'], ['databases', 'Datenbanken', '3'], ['ftpUsers', 'FTP-Benutzer', '3'], ['scheduledTasks', 'Cronjobs', '2']];
const PERMS: [string, string][] = [['panel_access', 'Zugang zum Kundenpanel'], ['ftp', 'FTP'], ['php', 'PHP'], ['ssh', 'SSH'], ['backup', 'Backups'], ['file_manager', 'Dateimanager'], ['dns_editor', 'DNS-Editor'], ['certificate_management', 'SSL-Zertifikate verwalten'], ['domain_email', 'E-Mail-Domains'], ['email_catchall', 'Catch-all'], ['database_remote_access', 'Datenbank-Fernzugriff'], ['delete_main_domain', 'Hauptdomain löschen']];
const TERMS = [1, 3, 6, 12, 24];
export default function HostingTarif() {
const { can } = useSession();
@ -21,11 +22,12 @@ export default function HostingTarif() {
const limits = Object.fromEntries(LIMITS.map(([k]) => [k, unl[k] ? null : (g(k) === '' ? undefined : Number(g(k)))]).filter(([, v]) => v !== undefined));
const permissions = Object.fromEntries(PERMS.map(([k]) => [k, f.get(`perm-${k}`) === 'on']));
const once = interval === 'once';
const termPrices = once ? [] : TERMS.map((m) => ({ termMonths: m, recurringCents: g(`term${m}`) === '' ? null : Math.round(Number(g(`term${m}`)) * 100) })).filter((t): t is { termMonths: number; recurringCents: number } => t.recurringCents !== null);
try {
const r = await api<{ productId: string; plan: { name: string; features: string[] } }>('POST', `/admin/connectors/${connId}/hosting-plans`, {
plan: { name: g('planName'), limits, permissions },
product: { sku: g('sku'), status: f.get('activate') === 'on' ? 'active' : 'draft', orderableByCustomer: f.get('orderable') === 'on', requiresApproval: f.get('approval') === 'on', customerActions: CUSTOMER_ACTIONS.filter((a) => f.get(`act-${a}`) === 'on'),
version: { name: g('name') || g('planName'), description: g('desc') || undefined, taxRateId: g('tax'), priceBasis: basis, setupCents: Math.round(Number(g('setup') || 0) * 100), recurringCents: once ? 0 : Math.round(Number(g('recurring') || 0) * 100), billingInterval: interval, termMonths: once ? 0 : Number(g('term') || 0), renewal: !once && f.get('renewal') === 'on' ? 'auto' : 'none', noticeDays: Number(g('notice') || 30) } } });
version: { name: g('name') || g('planName'), description: g('desc') || undefined, taxRateId: g('tax'), priceBasis: basis, setupCents: Math.round(Number(g('setup') || 0) * 100), recurringCents: once ? 0 : Math.round(Number(g('recurring') || 0) * 100), billingInterval: interval, termMonths: once ? 0 : Number(g('term') || 0), renewal: !once && f.get('renewal') === 'on' ? 'auto' : 'none', noticeDays: Number(g('notice') || 30), termPrices } } });
setOk({ id: r.productId, name: r.plan.name, features: r.plan.features });
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
@ -54,6 +56,11 @@ export default function HostingTarif() {
<Field id="tax" label="Steuersatz"><select id="tax" name="tax" defaultValue={tax.find((t) => t.rateBp === 1900)?.id}>{tax.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</select></Field>
{interval !== 'once' && <><Field id="term" label="Mindestlaufzeit (Monate)"><input id="term" name="term" type="number" min={0} defaultValue={0} /></Field><Field id="notice" label="Kündigungsfrist (Tage)"><input id="notice" name="notice" type="number" min={0} defaultValue={30} /></Field></>}</div>
{interval !== 'once' && <label className="row"><input type="checkbox" name="renewal" defaultChecked style={{ width: 20, minHeight: 20 }} />Automatische Verlängerung</label>}
{interval !== 'once' && <>
<h3>Staffelpreise nach Laufzeit (optional)</h3>
<p className="small muted">Lässt Kunden bei der Bestellung eine Laufzeit wählen, z.&nbsp;B. günstiger bei 12 oder 24 Monaten. Preis jeweils für die gesamte Laufzeit, nicht pro Monat. Leer lassen, wenn diese Laufzeit nicht angeboten wird.</p>
<div className="cols">{TERMS.map((m) => <Field key={m} id={`term${m}`} label={`${m} Monat${m > 1 ? 'e' : ''} (€ ${basis === 'gross' ? 'brutto' : 'netto'}, gesamt)`}><input id={`term${m}`} name={`term${m}`} type="number" min={0} step="0.01" /></Field>)}</div>
</>}
<h3>Verkauf</h3>
<div className="row"><label className="row"><input type="checkbox" name="orderable" style={{ width: 20, minHeight: 20 }} />Von Kunden selbst bestellbar</label><label className="row"><input type="checkbox" name="approval" defaultChecked style={{ width: 20, minHeight: 20 }} />Manuelle Freigabe erforderlich</label><label className="row"><input type="checkbox" name="activate" style={{ width: 20, minHeight: 20 }} />Sofort aktivieren</label></div>
<fieldset style={{ border: 0, padding: 0, margin: '12px 0' }}><legend className="small" style={{ fontWeight: 600, marginBottom: 8 }}>Was Kunden am Hosting-Konto selbst tun dürfen</legend><div className="row">{CUSTOMER_ACTIONS.map((a) => <label key={a} className="row small"><input type="checkbox" name={`act-${a}`} style={{ width: 20, minHeight: 20 }} defaultChecked={['panel.login', 'domain.manage', 'email.manage'].includes(a)} />{ACTION_LABEL[a]}</label>)}</div></fieldset>

View file

@ -6,7 +6,8 @@ import { useSession } from '@/lib/session';
import { Alert, Empty, eur, Field, INTERVAL, Status, CUSTOMER_ACTIONS, ACTION_LABEL } from '@/components/ui';
export interface Prod { id: string; sku: string; category: string; status: string; connectorInstanceId: string | null; connectorName: string | null; orderableByCustomer: boolean; requiresApproval: boolean; customerActions: string[]; provisioning: Record<string, unknown>;
current: { id: string; version: number; name: string; taxBp: number; priceBasis: 'net' | 'gross'; setupCents: number; recurringCents: number; billingInterval: string; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number } | null }
current: { id: string; version: number; name: string; taxBp: number; priceBasis: 'net' | 'gross'; setupCents: number; recurringCents: number; billingInterval: string; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number } | null;
termPrices: { termMonths: number; recurringCents: number }[] }
export const CAT: Record<string, string> = { hosting: 'Hosting', license: 'Lizenz', addon: 'Zusatzleistung', service: 'Dienstleistung' };
const PSTATUS: Record<string, string> = { active: 'active', draft: 'invited', retired: 'closed' };
export default function Produkte() {

View file

@ -3,19 +3,20 @@ import { useEffect, useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Empty, Field, type Price, PriceText } from '@/components/ui';
import { Alert, Empty, eur, Field, type Price, PriceText } from '@/components/ui';
interface P { id: string; sku: string; category: string; name: string; description: string | null; requiresApproval: boolean; price: Price; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number }
interface P { id: string; sku: string; category: string; name: string; description: string | null; requiresApproval: boolean; price: Price; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number; termPrices: { termMonths: number; price: { net: number; tax: number; gross: number } }[] }
interface Cust { id: string; name: string; customerNumber: string; customerType: 'private' | 'business' }
export default function Bestellen() {
const { me, can } = useSession(); const r = useRouter(); const staff = can('orders.write');
const [custs, setCusts] = useState<Cust[]>([]); const [org, setOrg] = useState(''); const [cat, setCat] = useState<P[] | null>(null); const [sel, setSel] = useState<P | null>(null); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false);
const [custs, setCusts] = useState<Cust[]>([]); const [org, setOrg] = useState(''); const [cat, setCat] = useState<P[] | null>(null); const [sel, setSel] = useState<P | null>(null); const [term, setTerm] = useState<number | ''>(''); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false);
useEffect(() => { if (staff) api<Cust[]>('GET', '/admin/customers').then((l) => { setCusts(l); setOrg(l[0]?.id ?? ''); }).catch((e) => setErr(errMsg(e))); else if (me?.organizations[0]) setOrg(me.organizations[0].id); }, [staff, me]);
useEffect(() => { if (org) api<P[]>('GET', `/catalog?org=${org}`).then(setCat).catch((e) => setErr(errMsg(e))); }, [org]);
const priv = (cat?.[0] as (P & { customerType?: string }) | undefined)?.customerType === 'private';
const selPrice: Price | null = sel && (term === '' ? sel.price : { ...sel.price, recurring: sel.termPrices.find((t) => t.termMonths === term)!.price });
async function order(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); if (!sel) return; setBusy(true); setErr(''); const f = new FormData(e.currentTarget);
try { const o = await api<{ status: string; number: string }>('POST', '/orders', { orgId: org, note: (f.get('note') as string) || undefined, items: [{ productId: sel.id, quantity: 1, ...(staff ? { discountBp: Math.round(Number(f.get('discount') || 0) * 100) } : {}) }] }); r.push(`/bestellungen?neu=${o.number}`); }
try { const o = await api<{ status: string; number: string }>('POST', '/orders', { orgId: org, note: (f.get('note') as string) || undefined, items: [{ productId: sel.id, quantity: 1, ...(term !== '' ? { termMonths: term } : {}), ...(staff ? { discountBp: Math.round(Number(f.get('discount') || 0) * 100) } : {}) }] }); r.push(`/bestellungen?neu=${o.number}`); }
catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
}
return (<>
@ -25,8 +26,15 @@ export default function Bestellen() {
<div className="grid">{cat.map((p) => <div className="card" key={p.id} style={sel?.id === p.id ? { borderColor: 'var(--accent)' } : undefined}><h2>{p.name}</h2>{p.description && <p className="muted">{p.description}</p>}
<div className="small"><PriceText p={p.price} priv={priv} /></div>
<p className="small muted">{p.termMonths > 0 ? `Mindestlaufzeit ${p.termMonths} Monate` : 'Keine Mindestlaufzeit'}{p.renewal === 'auto' ? `, Verlängerung um ${p.renewalTermMonths} Monat(e)` : ''}, Kündigungsfrist {p.noticeDays} Tage</p>
<button className="btn" aria-pressed={sel?.id === p.id} onClick={() => setSel(p)}>{sel?.id === p.id ? 'Ausgewählt' : 'Auswählen'}</button></div>)}</div>
{sel && <form className="card" onSubmit={order}><h2>Bestellung prüfen</h2><p><strong>{sel.name}</strong></p><div className="small" style={{ marginBottom: 16 }}><PriceText p={sel.price} priv={priv} /></div>
<button className="btn" aria-pressed={sel?.id === p.id} onClick={() => { setSel(p); setTerm(''); }}>{sel?.id === p.id ? 'Ausgewählt' : 'Auswählen'}</button></div>)}</div>
{sel && selPrice && <form className="card" onSubmit={order}><h2>Bestellung prüfen</h2><p><strong>{sel.name}</strong></p>
{sel.termPrices.length > 0 && <Field id="term" label="Laufzeit" hint="Bei längerer Laufzeit oft günstiger."><select id="term" value={term} onChange={(e) => setTerm(e.target.value === '' ? '' : Number(e.target.value))}><option value="">Standard{sel.termMonths > 0 ? ` (${sel.termMonths} Monate)` : ''}</option>{sel.termPrices.map((t) => <option key={t.termMonths} value={t.termMonths}>{t.termMonths} Monate</option>)}</select></Field>}
<div className="small" style={{ marginBottom: 16 }}>
{term === '' ? <PriceText p={selPrice} priv={priv} /> : (<>
{selPrice.setup.net > 0 && <div>Einrichtung: {priv ? `${eur(selPrice.setup.gross)} inkl. USt.` : `${eur(selPrice.setup.net)} netto (${eur(selPrice.setup.gross)} brutto)`}</div>}
<div>Laufzeit {term} Monate, gesamt: {priv ? `${eur(selPrice.recurring.gross)} inkl. USt.` : `${eur(selPrice.recurring.net)} netto (${eur(selPrice.recurring.gross)} brutto)`}</div>
</>)}
</div>
{staff && <Field id="discount" label="Rabatt in %" hint="Nur Personal. Der Preis wird serverseitig berechnet."><input id="discount" name="discount" type="number" min={0} max={100} step="0.01" defaultValue={0} /></Field>}
<Field id="note" label="Anmerkung (optional)"><input id="note" name="note" maxLength={500} /></Field>
<p className="small muted">{staff ? 'Die Bestellung wird sofort freigegeben und bereitgestellt.' : sel.requiresApproval ? 'Ihre Bestellung wird von uns geprüft und freigegeben. Sie sehen den Stand unter „Bestellungen“.' : 'Die Bereitstellung startet sofort.'}</p>

View file

@ -12,7 +12,7 @@ type Row = { description: string; quantity: string; unitPrice: string; taxBp: st
const emptyRow = (): Row => ({ description: '', quantity: '1', unitPrice: '', taxBp: '19' });
interface Suggested { description: string; quantity: number; unitPriceNetCents: number; taxBp: number }
const toRow = (it: Suggested): Row => ({ description: it.description, quantity: String(it.quantity), unitPrice: (it.unitPriceNetCents / 100).toFixed(2).replace('.', ','), taxBp: String(it.taxBp / 100) });
interface Prod { id: string; status: string; current: { name: string } | null }
interface Prod { id: string; status: string; current: { name: string } | null; termPrices: { termMonths: number }[] }
interface Contract { id: string; number: string; productName: string | null }
interface Dom { id: string; domain: string; sellNetCents: number | null }
@ -32,9 +32,9 @@ export default function RechnungDetail() {
const run = async (fn: () => Promise<unknown>, msg?: string) => { setErr(''); setOk(''); setBusy(true); try { await fn(); if (msg) setOk(msg); void load(); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); } };
function appendSuggested(items: Suggested[]) { setRows((rs) => [...rs.filter((r) => r.description.trim() || r.unitPrice.trim()), ...items.map(toRow)]); }
async function takeFrom(kind: 'product' | 'contract' | 'domain', pickId: string) {
async function takeFrom(kind: 'product' | 'contract' | 'domain', pickId: string, termMonths?: string) {
if (!pickId) return; setErr('');
try { const r = await api<{ items: Suggested[] }>('GET', `/invoices/suggest-from-${kind}/${pickId}`); appendSuggested(r.items); } catch (x) { setErr(errMsg(x)); }
try { const r = await api<{ items: Suggested[] }>('GET', `/invoices/suggest-from-${kind}/${pickId}${termMonths ? `?termMonths=${termMonths}` : ''}`); appendSuggested(r.items); } catch (x) { setErr(errMsg(x)); }
}
async function saveItems(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
@ -55,7 +55,11 @@ export default function RechnungDetail() {
{isDraft && inv.canWrite ? (<form className="card" onSubmit={saveItems}><h2>Positionen</h2>
{(products.length > 0 || contracts.length > 0 || domains.length > 0) && <div className="cols" style={{ marginBottom: 16 }}>
{products.length > 0 && <Field id="fromProduct" label="Position aus Produkt übernehmen" hint="Fügt Einrichtung und/oder wiederkehrenden Preis als eigene Zeilen hinzu."><select id="fromProduct" value="" onChange={(e) => void takeFrom('product', e.target.value)}><option value="">Produkt wählen …</option>{products.map((p) => <option key={p.id} value={p.id}>{p.current!.name}</option>)}</select></Field>}
{products.length > 0 && <Field id="fromProduct" label="Position aus Produkt übernehmen" hint="Fügt Einrichtung und/oder wiederkehrenden Preis als eigene Zeilen hinzu. Bei Laufzeit-Staffelpreisen die gewünschte Laufzeit wählen."><select id="fromProduct" value="" onChange={(e) => { const [pid, tm] = e.target.value.split('|'); void takeFrom('product', pid!, tm); }}><option value="">Produkt wählen …</option>
{products.map((p) => p.termPrices.length > 0
? <optgroup key={p.id} label={p.current!.name}>{p.termPrices.map((t) => <option key={t.termMonths} value={`${p.id}|${t.termMonths}`}>{t.termMonths} Monate</option>)}</optgroup>
: <option key={p.id} value={p.id}>{p.current!.name}</option>)}
</select></Field>}
{contracts.length > 0 && <Field id="fromContract" label="Position aus laufendem Vertrag übernehmen" hint="Übernimmt den wiederkehrenden Preis der aktuellen Periode."><select id="fromContract" value="" onChange={(e) => void takeFrom('contract', e.target.value)}><option value="">Vertrag wählen …</option>{contracts.map((c) => <option key={c.id} value={c.id}>{c.number} · {c.productName ?? '–'}</option>)}</select></Field>}
{domains.length > 0 && <Field id="fromDomain" label="Position aus Domain übernehmen" hint="Verkaufspreis aus der Domain-Aufstellung, nie der Einkauf."><select id="fromDomain" value="" onChange={(e) => void takeFrom('domain', e.target.value)}><option value="">Domain wählen …</option>{domains.map((d) => <option key={d.id} value={d.id} disabled={d.sellNetCents === null}>{d.domain}{d.sellNetCents === null ? ' (kein Verkaufspreis)' : ''}</option>)}</select></Field>}
</div>}