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

Aktive Lizenzen

252→ 252→ 253→ 253→
254→ 254→

{stats.activeLicenses}

255→ 255→
256→ 256→ 257→ 257→
258→ 258→

Aktivierungen

259→ 259→ 260→ 260→
261→ 261→

{stats.totalActivations}

262→ 262→
263→ 263→ 264→ 264→
265→ 265→

Erneuerungen fällig

266→ 266→ 267→ 267→
268→ 268→

{stats.expiringSoon + stats.expiredAssets}

269→ 269→

{stats.expiredAssets} abgelaufen

270→ 270→
271→ 271→ 272→ 272→
273→ 273→

Monatl. Weiterbelastung

274→ 274→ 275→ 275→
276→ 276→

{formatEuro(stats.monthlyRebill)}

277→ 277→
278→ 278→ 279→ 279→
280→ 280→

MRR

281→ 281→ 282→ 282→
283→ 283→

{formatEuroCompact(stats.mrrCents)}

284→ 284→

Monatlich wiederkehrend

285→ 285→
286→ 286→ 287→ 287→
288→ 288→

In Trial

289→ 289→ 290→ 290→
291→ 291→

{stats.inTrial}

292→ 292→

14-Tage-Testphase

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

Ed25519-signierte Lizenz-Tokens

371→ 371→

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

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

{license.product}

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

{license.activations[0].domain}

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

10% Jahresrabatt wird auf den Jahresbetrag angewendet.

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

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

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

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

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

{license.product} — {license.plan}

1055→ 1055→

{license.company.name}

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