1→'use client' 2→ 3→import { useEffect, useMemo, useState } from 'react' 4→import { 5→ Plus, 6→ KeyRound, 7→ ShieldCheck, 8→ AlertTriangle, 9→ CalendarClock, 10→ RefreshCw, 11→ CheckCircle2, 12→ XCircle, 13→ PauseCircle, 14→ ChevronRight, 15→ Ban, 16→ Play, 17→ RotateCcw, 18→ TrendingUp, 19→ FlaskConical, 20→ Loader2, 21→ CircleDollarSign, 22→} from 'lucide-react' 23→import { useFetch } from '@/hooks/use-fetch' 24→import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' 25→import { Badge } from '@/components/ui/badge' 26→import { Button } from '@/components/ui/button' 27→import { Input } from '@/components/ui/input' 28→import { Label } from '@/components/ui/label' 29→import { Switch } from '@/components/ui/switch' 30→import { Textarea } from '@/components/ui/textarea' 31→import { Skeleton } from '@/components/ui/skeleton' 32→import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' 33→import { 34→ Dialog, 35→ DialogContent, 36→ DialogDescription, 37→ DialogFooter, 38→ DialogHeader, 39→ DialogTitle, 40→} from '@/components/ui/dialog' 41→import { 42→ Select, 43→ SelectContent, 44→ SelectItem, 45→ SelectTrigger, 46→ SelectValue, 47→} from '@/components/ui/select' 48→import SlideCommit from '@/components/SlideCommit' 49→import { PageHeader } from '../primitives' 50→import { formatEuro, formatEuroCompact, formatDate, formatRelative, daysUntil } from '@/lib/format' 51→import { cn } from '@/lib/utils' 52→import { toast } from 'sonner' 53→ 54→// --------------------------------------------------------------------------- 55→// Types 56→// --------------------------------------------------------------------------- 57→ 58→interface LicenseModule { 59→ id: string 60→ key: string 61→ module: string | null 62→ status: string 63→ environment: 'demo' | 'production' 64→ maxActivations: number 65→ validUntil: string | null 66→ createdAt: string 67→ // B2B billing fields (mirror the parent License shape so module rows can 68→ // show their own billing state — most modules inherit "via Core" and have 69→ // priceCents = 0, but modules with their own pricing show their netPriceCents). 70→ billingCycle: 'monthly' | 'yearly' 71→ priceCents: number 72→ discountPct: number 73→ netPriceCents: number 74→ trialEndsAt: string | null 75→ subscriptionStartsAt: string | null 76→ cancelledAt: string | null 77→ cancellationEffectiveAt: string | null 78→} 79→ 80→interface License { 81→ id: string 82→ key: string 83→ product: string 84→ plan: string 85→ status: string 86→ environment: 'demo' | 'production' 87→ parentId: string | null 88→ module: string | null 89→ maxActivations: number 90→ validUntil: string | null 91→ createdAt: string 92→ suspendReason: string | null 93→ suspendedAt: string | null 94→ // --- B2B Subscription & Billing (German law) --- 95→ noticePeriodDays: number 96→ trialEndsAt: string | null 97→ subscriptionStartsAt: string | null 98→ billingCycle: 'monthly' | 'yearly' 99→ priceCents: number 100→ discountPct: number 101→ netPriceCents: number 102→ cancelledAt: string | null 103→ cancellationEffectiveAt: string | null 104→ cancellationReason: string | null 105→ company: { id: string; name: string } 106→ activations: { id: string; domain: string; environment: string; version: string | null; lastCheck: string }[] 107→ modules: LicenseModule[] 108→} 109→ 110→interface Asset { 111→ id: string 112→ name: string 113→ type: string 114→ provider: string | null 115→ costCentsPerMonth: number | null 116→ renewalDate: string 117→ rebill: boolean 118→ markupPct: number 119→ status: string 120→} 121→ 122→interface LicensesData { 123→ licenses: License[] 124→ assets: Asset[] 125→ stats: { 126→ activeLicenses: number 127→ suspended: number 128→ expired: number 129→ cancelled: number 130→ inTrial: number 131→ totalActivations: number 132→ expiringSoon: number 133→ expiredAssets: number 134→ monthlyRebill: number 135→ demoLicenses: number 136→ mrrCents: number 137→ } 138→} 139→ 140→// Minimal company shape for the create-license dialog. The CRM route returns 141→// the full Prisma Company (including the agency fields needed to preview the 142→// agency discount in the pricing breakdown). 143→interface CompanyLite { 144→ id: string 145→ name: string 146→ isAgency: boolean 147→ agencyStatus: string | null 148→ agencyDiscountPct: number 149→ parentAgencyId: string | null 150→} 151→ 152→// --------------------------------------------------------------------------- 153→// Constants 154→// --------------------------------------------------------------------------- 155→ 156→const LIC_STATUS: Record = { 157→ active: { label: 'Aktiv', icon: CheckCircle2 }, 158→ suspended: { label: 'Suspendiert', icon: PauseCircle }, 159→ expired: { label: 'Abgelaufen', icon: XCircle }, 160→ revoked: { label: 'Widerrufen', icon: XCircle }, 161→ cancelled: { label: 'Gekündigt', icon: Ban }, 162→} 163→ 164→const ASSET_TYPE_LABELS: Record = { 165→ domain: 'Domain', 166→ hosting: 'Hosting', 167→ ssl: 'SSL', 168→ 'plugin-seat': 'Plugin-Seat', 169→ saas: 'SaaS', 170→} 171→ 172→// Default monthly prices per plan (mirror of the backend PLAN_PRICES table). 173→const PLAN_PRICES: Record = { 174→ starter: 2900, 175→ pro: 5000, 176→ enterprise: 15000, 177→} 178→const YEARLY_DISCOUNT_PCT = 10 179→ 180→type FilterKey = 'all' | 'active' | 'trial' | 'cancelled' | 'demo' 181→const FILTERS: { key: FilterKey; label: string }[] = [ 182→ { key: 'all', label: 'Alle' }, 183→ { key: 'active', label: 'Aktiv' }, 184→ { key: 'trial', label: 'In Trial' }, 185→ { key: 'cancelled', label: 'Gekündigt' }, 186→ { key: 'demo', label: 'Demo' }, 187→] 188→ 189→// --------------------------------------------------------------------------- 190→// Main view 191→// --------------------------------------------------------------------------- 192→ 193→export function LicensesView() { 194→ const { data, loading, refetch } = useFetch('/api/aos/licenses') 195→ // Companies are needed for the create-license dialog (parent agency lookup). 196→ const { data: crmData } = useFetch<{ companies: CompanyLite[] } | null>('/api/aos/crm') 197→ const [createOpen, setCreateOpen] = useState(false) 198→ const [suspendTarget, setSuspendTarget] = useState(null) 199→ const [cancelTarget, setCancelTarget] = useState(null) 200→ const [filter, setFilter] = useState('all') 201→ 202→ if (loading || !data) { 203→ return ( 204→
205→ 206→ 207→
208→ ) 209→ } 210→ 211→ const { licenses, assets, stats } = data 212→ const companies = crmData?.companies ?? [] 213→ 214→ const now = new Date() 215→ const filtered = licenses.filter((l) => { 216→ if (filter === 'all') return true 217→ if (filter === 'demo') return l.environment === 'demo' 218→ if (filter === 'cancelled') return !!l.cancelledAt || l.status === 'cancelled' 219→ if (filter === 'trial') return !!l.trialEndsAt && new Date(l.trialEndsAt) > now 220→ if (filter === 'active') { 221→ return ( 222→ l.status === 'active' && 223→ !l.cancelledAt && 224→ !(l.trialEndsAt && new Date(l.trialEndsAt) > now) 225→ ) 226→ } 227→ return true 228→ }) 229→ 230→ const handlePromote = (license: License) => { 231→ void promoteLicense(license, refetch) 232→ } 233→ 234→ return ( 235→
236→ setCreateOpen(true)}> 241→ 242→ Lizenz ausstellen 243→ 244→ } 245→ /> 246→ 247→ {/* KPI row — 4 existing + MRR + In Trial = 6 cards */} 248→
249→ 250→
251→

Aktive Lizenzen

252→ 253→
254→

{stats.activeLicenses}

255→
256→ 257→
258→

Aktivierungen

259→ 260→
261→

{stats.totalActivations}

262→
263→ 264→
265→

Erneuerungen fällig

266→ 267→
268→

{stats.expiringSoon + stats.expiredAssets}

269→

{stats.expiredAssets} abgelaufen

270→
271→ 272→
273→

Monatl. Weiterbelastung

274→ 275→
276→

{formatEuro(stats.monthlyRebill)}

277→
278→ 279→
280→

MRR

281→ 282→
283→

{formatEuroCompact(stats.mrrCents)}

284→

Monatlich wiederkehrend

285→
286→ 287→
288→

In Trial

289→ 290→
291→

{stats.inTrial}

292→

14-Tage-Testphase

293→
294→
295→ 296→ 297→ 298→ Ausgestellte Lizenzen 299→ Erneuerungsregister 300→ 301→ 302→ 303→ {/* Filter tabs */} 304→
305→ {FILTERS.map((f) => ( 306→ 316→ ))} 317→
318→ 319→ 320→ 321→ Produktlizenzen ({filtered.length}) 322→ 323→ 324→
325→ 326→ 327→ 328→ 330→ 331→ 332→ 333→ 334→ 335→ 336→ 337→ 338→ 339→ {filtered.length === 0 ? ( 340→ 341→ 344→ 345→ ) : ( 346→ filtered.map((l) => ( 347→ setSuspendTarget(lic)} 351→ onCancel={(lic) => setCancelTarget(lic)} 352→ onPromote={handlePromote} 353→ /> 354→ )) 355→ )} 356→ 357→
329→ LizenzschlüsselProdukt / PlanKundeAktivierungenStatus & BillingGültig bisAktionen
342→ Keine Lizenzen in diesem Filter. 343→
358→
359→
360→
361→ 362→ {/* Signing info card */} 363→ 364→ 365→
366→
367→ 368→
369→
370→

Ed25519-signierte Lizenz-Tokens

371→

372→ Jede Lizenz hat einen menschenlesbaren Schlüssel plus einen kryptografisch signierten Token. Kunden können offline verifizieren. 373→ Der private Schlüssel ist verschlüsselt (AES-256-GCM); Rotation wird unterstützt. 374→

375→
376→ Public API: /api/v1/validate 377→ WordPress-Updater kompatibel 378→ Offline-Grace: 14 d 379→
380→
381→
382→
383→
384→
385→ 386→ 387→ 388→ 389→ Asset- & Erneuerungsregister ({assets.length}) 390→ 391→ 392→
393→ 394→ 395→ 396→ 397→ 398→ 399→ 400→ 401→ 402→ 403→ 404→ 405→ 406→ {assets.map((a) => { 407→ const days = daysUntil(a.renewalDate) 408→ return ( 409→ 410→ 411→ 414→ 415→ 418→ 426→ 435→ 447→ 448→ ) 449→ })} 450→ 451→
AssetTypAnbieterKosten/MonatErneuerungWeiterbelastenStatus
{a.name} 412→ {ASSET_TYPE_LABELS[a.type] ?? a.type} 413→ {a.provider ?? '—'} 416→ {a.costCentsPerMonth ? formatEuro(a.costCentsPerMonth) : '—'} 417→ 419→ 420→ {formatDate(a.renewalDate)} 421→ 422→ 423→ {days < 0 ? `(${Math.abs(days)} d vorbei)` : `(${days} d)`} 424→ 425→ 427→ {a.rebill ? ( 428→ 429→ +{a.markupPct}% 430→ 431→ ) : ( 432→ — 433→ )} 434→ 436→ 444→ {a.status === 'expired' ? 'Abgelaufen' : a.status === 'expiring' ? 'Läuft ab' : 'Aktiv'} 445→ 446→
452→
453→
454→
455→
456→
457→ 458→ 464→ !o && setSuspendTarget(null)} 467→ onDone={refetch} 468→ /> 469→ !o && setCancelTarget(null)} 472→ onDone={refetch} 473→ /> 474→
475→ ) 476→} 477→ 478→// --------------------------------------------------------------------------- 479→// LicenseRow — expandable row with billing badges + Cancel/Suspend/Promote 480→// --------------------------------------------------------------------------- 481→ 482→function LicenseRow({ 483→ license, 484→ onSuspend, 485→ onCancel, 486→ onPromote, 487→}: { 488→ license: License 489→ onSuspend: (l: License) => void 490→ onCancel: (l: License) => void 491→ onPromote: (l: License) => void 492→}) { 493→ const [open, setOpen] = useState(false) 494→ const st = LIC_STATUS[license.status] ?? LIC_STATUS.active 495→ const Icon = st.icon 496→ const valid = license.validUntil ? daysUntil(license.validUntil) : null 497→ const isModule = !!license.parentId 498→ const inTrial = !!license.trialEndsAt && new Date(license.trialEndsAt) > new Date() 499→ const trialDaysLeft = inTrial ? Math.max(0, daysUntil(license.trialEndsAt!)) : 0 500→ const cancelled = !!license.cancelledAt || license.status === 'cancelled' 501→ const canCancel = !isModule && !cancelled && license.status === 'active' 502→ const canReactivate = cancelled 503→ const hasModules = !isModule && license.modules.length > 0 504→ const showPrice = !isModule && license.netPriceCents > 0 505→ 506→ return ( 507→ <> 508→ 509→ 510→ {hasModules ? ( 511→ 518→ ) : ( 519→ 520→ )} 521→ 522→ 523→ {license.key} 524→ {isModule && ( 525→ Modul 526→ )} 527→ 528→ 529→

{license.product}

530→ {license.plan} 531→ 532→ {license.company.name} 533→ 534→ 535→ {license.activations.length} / {license.maxActivations} 536→ 537→ {license.activations[0] && ( 538→

{license.activations[0].domain}

539→ )} 540→ 541→ 542→
543→ 552→ {st.label} 553→ 554→ {showPrice && ( 555→ <> 556→ 561→ {license.billingCycle === 'yearly' ? 'Y' : 'M'} 562→ 563→ 564→ {formatEuroCompact(license.netPriceCents)} 565→ /{license.billingCycle === 'yearly' ? 'Jahr' : 'Mon.'} 566→ 567→ 568→ )} 569→ {inTrial && ( 570→ 571→ Trial ({trialDaysLeft} d) 572→ 573→ )} 574→ {cancelled && license.cancellationEffectiveAt && ( 575→ 580→ Gekündigt → {formatDate(license.cancellationEffectiveAt)} 581→ 582→ )} 583→ {license.environment === 'demo' && ( 584→ Demo 585→ )} 586→
587→ 588→ 589→ {license.validUntil ? ( 590→ 591→ {formatDate(license.validUntil)} 592→ {valid !== null && ( 593→ 594→ {valid < 0 ? `(${Math.abs(valid)} d vorbei)` : `(${valid} d)`} 595→ 596→ )} 597→ 598→ ) : ( 599→ — 600→ )} 601→ 602→ 603→
604→ {canCancel && ( 605→ 608→ )} 609→ {license.status === 'active' && !cancelled && ( 610→ 613→ )} 614→ {canReactivate && ( 615→ 618→ )} 619→ {license.environment === 'demo' && !cancelled && ( 620→ 623→ )} 624→
625→ 626→ 627→ 628→ {/* Expanded module rows */} 629→ {open && hasModules && ( 630→ 631→ 632→
633→ 634→ 635→ 636→ 637→ 638→ 639→ 640→ 641→ 642→ 643→ 644→ {license.modules.map((m) => { 645→ const mst = LIC_STATUS[m.status] ?? LIC_STATUS.active 646→ const MIcon = mst.icon 647→ const mInTrial = !!m.trialEndsAt && new Date(m.trialEndsAt) > new Date() 648→ const mCancelled = !!m.cancelledAt || m.status === 'cancelled' 649→ return ( 650→ 651→ 652→ 655→ 665→ 681→ 684→ 685→ ) 686→ })} 687→ 688→
ModulSchlüsselBillingStatusGültig bis
{m.module ?? '—'} 653→ {m.key} 654→ 656→ {m.priceCents > 0 ? ( 657→ 658→ {formatEuroCompact(m.netPriceCents)} 659→ /{m.billingCycle === 'yearly' ? 'Jahr' : 'Mon.'} 660→ 661→ ) : ( 662→ via Core 663→ )} 664→ 666→ 673→ {mst.label} 674→ 675→ {mInTrial && ( 676→ 677→ Trial 678→ 679→ )} 680→ 682→ {m.validUntil ? formatDate(m.validUntil) : '—'} 683→
689→
690→ 691→ 692→ )} 693→ 694→ ) 695→} 696→ 697→// --------------------------------------------------------------------------- 698→// Promote / Reactivate helper 699→// --------------------------------------------------------------------------- 700→ 701→async function promoteLicense(license: License, refetch: () => void) { 702→ const t = toast.loading('Lizenz wird reaktiviert…') 703→ try { 704→ const r = await fetch(`/api/aos/licenses/${license.id}/promote`, { 705→ method: 'PATCH', 706→ headers: { 'content-type': 'application/json' }, 707→ body: JSON.stringify({ environment: 'production' }), 708→ }) 709→ if (!r.ok) { 710→ const e = await r.json().catch(() => ({})) 711→ throw new Error(e.error || 'promote failed') 712→ } 713→ toast.success('Lizenz reaktiviert', { id: t }) 714→ refetch() 715→ } catch (err) { 716→ toast.error(err instanceof Error ? err.message : 'Reaktivierung fehlgeschlagen', { id: t }) 717→ } 718→} 719→ 720→// --------------------------------------------------------------------------- 721→// Create License Dialog — with Billing section + live pricing preview 722→// --------------------------------------------------------------------------- 723→ 724→function CreateLicenseDialog({ 725→ open, 726→ onOpenChange, 727→ companies, 728→ onCreated, 729→}: { 730→ open: boolean 731→ onOpenChange: (o: boolean) => void 732→ companies: CompanyLite[] 733→ onCreated: () => void 734→}) { 735→ const [companyId, setCompanyId] = useState('') 736→ const [product, setProduct] = useState('aos-cms') 737→ const [plan, setPlan] = useState<'starter' | 'pro' | 'enterprise'>('pro') 738→ const [environment, setEnvironment] = useState<'demo' | 'production'>('production') 739→ const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly') 740→ const [monthlyPriceCents, setMonthlyPriceCents] = useState('') 741→ const [startTrial, setStartTrial] = useState(true) 742→ const [maxActivations, setMaxActivations] = useState(1) 743→ const [saving, setSaving] = useState(false) 744→ 745→ // Default trial ON for production, OFF for demo. 746→ useEffect(() => { 747→ if (environment === 'demo') setStartTrial(false) 748→ else setStartTrial(true) 749→ }, [environment]) 750→ 751→ // Pick first company when the dialog opens. 752→ useEffect(() => { 753→ if (open && companies[0] && !companyId) setCompanyId(companies[0].id) 754→ }, [open, companies, companyId]) 755→ 756→ // Reset everything when the dialog closes. 757→ useEffect(() => { 758→ if (open) return 759→ setCompanyId('') 760→ setProduct('aos-cms') 761→ setPlan('pro') 762→ setEnvironment('production') 763→ setBillingCycle('monthly') 764→ setMonthlyPriceCents('') 765→ setStartTrial(true) 766→ setMaxActivations(1) 767→ }, [open]) 768→ 769→ const selectedCompany = companies.find((c) => c.id === companyId) ?? null 770→ 771→ // Live pricing preview — mirrors the backend computePricing() formula. 772→ const pricing = useMemo(() => { 773→ const monthlyCents = monthlyPriceCents 774→ ? Math.max(0, parseInt(monthlyPriceCents, 10) || 0) 775→ : PLAN_PRICES[plan] ?? 0 776→ const baseCents = billingCycle === 'yearly' ? monthlyCents * 12 : monthlyCents 777→ const yearlyPct = billingCycle === 'yearly' ? YEARLY_DISCOUNT_PCT : 0 778→ let agencyPct = 0 779→ if (selectedCompany?.parentAgencyId) { 780→ const agency = companies.find((c) => c.id === selectedCompany.parentAgencyId) 781→ if (agency?.isAgency && agency.agencyStatus === 'approved') { 782→ agencyPct = agency.agencyDiscountPct ?? 0 783→ } 784→ } 785→ const totalPct = agencyPct + yearlyPct 786→ const discountAmount = Math.round((baseCents * totalPct) / 100) 787→ const netPriceCents = baseCents - discountAmount 788→ return { monthlyCents, baseCents, agencyPct, yearlyPct, totalPct, discountAmount, netPriceCents } 789→ }, [monthlyPriceCents, billingCycle, plan, selectedCompany, companies]) 790→ 791→ const submit = async () => { 792→ if (!companyId) { toast.error('Firma auswählen'); return } 793→ if (!product.trim()) { toast.error('Produkt eingeben'); return } 794→ setSaving(true) 795→ const t = toast.loading('Lizenz wird ausgestellt…') 796→ try { 797→ const body: Record = { 798→ companyId, 799→ product: product.trim(), 800→ plan, 801→ environment, 802→ billingCycle, 803→ maxActivations, 804→ startTrial, 805→ } 806→ const parsed = monthlyPriceCents ? parseInt(monthlyPriceCents, 10) : NaN 807→ if (!Number.isNaN(parsed) && parsed > 0) body.monthlyPriceCents = parsed 808→ const r = await fetch('/api/aos/licenses', { 809→ method: 'POST', 810→ headers: { 'content-type': 'application/json' }, 811→ body: JSON.stringify(body), 812→ }) 813→ const res = await r.json() 814→ if (!r.ok) throw new Error(res.error || 'create failed') 815→ const pricingEcho = res.pricing 816→ const cycleLabel = billingCycle === 'yearly' ? 'Jahr' : 'Monat' 817→ const netCents = typeof pricingEcho?.netPriceCents === 'number' 818→ ? pricingEcho.netPriceCents 819→ : pricing.netPriceCents 820→ const discPct = typeof pricingEcho?.totalDiscountPct === 'number' 821→ ? pricingEcho.totalDiscountPct 822→ : pricing.totalPct 823→ const netEuro = (netCents / 100).toLocaleString('de-DE', { 824→ minimumFractionDigits: 2, 825→ maximumFractionDigits: 2, 826→ }) 827→ toast.success( 828→ `Lizenz ausgestellt — ${netEuro} €/${cycleLabel} (inkl. ${discPct}% Rabatt)`, 829→ { id: t, duration: 6000 } 830→ ) 831→ onOpenChange(false) 832→ onCreated() 833→ } catch (err) { 834→ toast.error(err instanceof Error ? err.message : 'Ausstellung fehlgeschlagen', { id: t }) 835→ } finally { 836→ setSaving(false) 837→ } 838→ } 839→ 840→ return ( 841→ 842→ 843→ 844→ Lizenz ausstellen 845→ 846→ Stellt eine neue Lizenz aus. Billing, Probezeit und Kündigungsfrist (90 Tage) werden automatisch konfiguriert. 847→ 848→ 849→
850→ {/* Customer */} 851→
852→ 853→ 864→
865→ {/* Product + Plan */} 866→
867→
868→ 869→ setProduct(e.target.value)} placeholder="z.B. aos-cms" /> 870→
871→
872→ 873→ 881→
882→
883→ {/* Environment + Activations */} 884→
885→
886→ 887→ 894→
895→
896→ 897→ setMaxActivations(Math.max(1, parseInt(e.target.value, 10) || 1))} 903→ /> 904→
905→
906→ 907→ {/* Billing section */} 908→
909→
910→ 911→ Billing 912→
913→
914→
915→ 916→ 923→ {billingCycle === 'yearly' && ( 924→

10% Jahresrabatt wird auf den Jahresbetrag angewendet.

925→ )} 926→
927→
928→ 929→ setMonthlyPriceCents(e.target.value)} 934→ placeholder="z.B. 5000 (= 50€)" 935→ /> 936→

937→ Standard: 29€ (Starter), 50€ (Pro), 150€ (Enterprise). Überschreiben für Custom-Preis. 938→

939→
940→
941→
942→
943→ 944→

Tag 1–14 kostenlos kündbar. Tag 15 beginnt das kostenpflichtige Abo.

945→
946→ 951→
952→ 953→ {/* Live pricing preview */} 954→
955→
956→ Basispreis ({billingCycle === 'yearly' ? 'Jahr' : 'Monat'}) 957→ {formatEuro(pricing.baseCents)} 958→
959→ {pricing.agencyPct > 0 && ( 960→
961→ Agentur-Rabatt ({pricing.agencyPct}%) 962→ −{formatEuro(Math.round((pricing.baseCents * pricing.agencyPct) / 100))} 963→
964→ )} 965→ {pricing.yearlyPct > 0 && ( 966→
967→ Jahresrabatt ({pricing.yearlyPct}%) 968→ −{formatEuro(Math.round((pricing.baseCents * pricing.yearlyPct) / 100))} 969→
970→ )} 971→ {pricing.totalPct > 0 && ( 972→
973→ Gesamtrabatt 974→ {pricing.totalPct}% 975→
976→ )} 977→
978→ Netto / {billingCycle === 'yearly' ? 'Jahr' : 'Monat'} 979→ {formatEuro(pricing.netPriceCents)} 980→
981→
982→
983→
984→ 985→ 986→ 990→ 991→
992→
993→ ) 994→} 995→ 996→// --------------------------------------------------------------------------- 997→// Suspend Dialog 998→// --------------------------------------------------------------------------- 999→ 1000→function SuspendDialog({ 1001→ license, 1002→ onOpenChange, 1003→ onDone, 1004→}: { 1005→ license: License | null 1006→ onOpenChange: (o: boolean) => void 1007→ onDone: () => void 1008→}) { 1009→ const [reason, setReason] = useState('') 1010→ const [saving, setSaving] = useState(false) 1011→ 1012→ useEffect(() => { 1013→ if (!license) setReason('') 1014→ }, [license]) 1015→ 1016→ const submit = async () => { 1017→ if (!license) return 1018→ if (!reason.trim()) { toast.error('Grund erforderlich'); return } 1019→ setSaving(true) 1020→ const t = toast.loading('Lizenz wird suspendiert…') 1021→ try { 1022→ const r = await fetch(`/api/aos/licenses/${license.id}/suspend`, { 1023→ method: 'PATCH', 1024→ headers: { 'content-type': 'application/json' }, 1025→ body: JSON.stringify({ reason: reason.trim() }), 1026→ }) 1027→ const res = await r.json() 1028→ if (!r.ok) throw new Error(res.error || 'suspend failed') 1029→ toast.success( 1030→ `Lizenz suspendiert${res.suspendedModules ? ` (+${res.suspendedModules} Module)` : ''}`, 1031→ { id: t } 1032→ ) 1033→ onOpenChange(false) 1034→ onDone() 1035→ } catch (err) { 1036→ toast.error(err instanceof Error ? err.message : 'Suspension fehlgeschlagen', { id: t }) 1037→ } finally { 1038→ setSaving(false) 1039→ } 1040→ } 1041→ 1042→ return ( 1043→ 1044→ 1045→ 1046→ Lizenz suspendieren 1047→ 1048→ Suspendiert die Lizenz (und alle Module). Aktivierte Installationen verweigern den Dienst bis zur Reaktivierung. 1049→ 1050→ 1051→ {license && ( 1052→
1053→
1054→

{license.product} — {license.plan}

1055→

{license.company.name}

1056→ {license.key} 1057→
1058→
1059→ 1060→