Files
AOS/tool-results/read_1793204612141_0f15d70a7836.txt
T

1202 lines
58 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, { label: string; icon: typeof CheckCircle2 }> = {
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<string, string> = {
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<string, number> = {
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<LicensesData>('/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<License | null>(null)
199→ const [cancelTarget, setCancelTarget] = useState<License | null>(null)
200→ const [filter, setFilter] = useState<FilterKey>('all')
201→
202→ if (loading || !data) {
203→ return (
204→ <div className="space-y-6 p-4 md:p-6">
205→ <Skeleton className="h-9 w-32" />
206→ <Skeleton className="h-48" />
207→ </div>
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→ <div className="space-y-6 p-4 md:p-6">
236→ <PageHeader
237→ title="Lizenzverwaltung"
238→ description="Ausgestellte Produktlizenzen mit Ed25519-signierten Tokens, B2B-Abrechnung (Monat/Jahr), 14-Tage-Testphase und 90-Tage-Kündigungsfrist. Erneuerungsregister für verwaltete Assets."
239→ actions={
240→ <Button size="sm" onClick={() => setCreateOpen(true)}>
241→ <Plus className="size-4" />
242→ Lizenz ausstellen
243→ </Button>
244→ }
245→ />
246→
247→ {/* KPI row — 4 existing + MRR + In Trial = 6 cards */}
248→ <div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6">
249→ <Card className="p-4">
250→ <div className="flex items-center justify-between">
251→ <p className="text-xs text-muted-foreground">Aktive Lizenzen</p>
252→ <KeyRound className="size-3.5 text-muted-foreground" />
253→ </div>
254→ <p className="mt-1 text-2xl font-semibold tabular-nums">{stats.activeLicenses}</p>
255→ </Card>
256→ <Card className="p-4">
257→ <div className="flex items-center justify-between">
258→ <p className="text-xs text-muted-foreground">Aktivierungen</p>
259→ <ShieldCheck className="size-3.5 text-muted-foreground" />
260→ </div>
261→ <p className="mt-1 text-2xl font-semibold tabular-nums">{stats.totalActivations}</p>
262→ </Card>
263→ <Card className="p-4">
264→ <div className="flex items-center justify-between">
265→ <p className="text-xs text-muted-foreground">Erneuerungen fällig</p>
266→ <AlertTriangle className="size-3.5 text-muted-foreground" />
267→ </div>
268→ <p className="mt-1 text-2xl font-semibold tabular-nums">{stats.expiringSoon + stats.expiredAssets}</p>
269→ <p className="text-[10px] text-muted-foreground">{stats.expiredAssets} abgelaufen</p>
270→ </Card>
271→ <Card className="p-4">
272→ <div className="flex items-center justify-between">
273→ <p className="text-xs text-muted-foreground">Monatl. Weiterbelastung</p>
274→ <CalendarClock className="size-3.5 text-muted-foreground" />
275→ </div>
276→ <p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuro(stats.monthlyRebill)}</p>
277→ </Card>
278→ <Card className="p-4">
279→ <div className="flex items-center justify-between">
280→ <p className="text-xs text-muted-foreground">MRR</p>
281→ <TrendingUp className="size-3.5 text-muted-foreground" />
282→ </div>
283→ <p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(stats.mrrCents)}</p>
284→ <p className="text-[10px] text-muted-foreground">Monatlich wiederkehrend</p>
285→ </Card>
286→ <Card className="p-4">
287→ <div className="flex items-center justify-between">
288→ <p className="text-xs text-muted-foreground">In Trial</p>
289→ <FlaskConical className="size-3.5 text-muted-foreground" />
290→ </div>
291→ <p className="mt-1 text-2xl font-semibold tabular-nums">{stats.inTrial}</p>
292→ <p className="text-[10px] text-muted-foreground">14-Tage-Testphase</p>
293→ </Card>
294→ </div>
295→
296→ <Tabs defaultValue="licenses">
297→ <TabsList>
298→ <TabsTrigger value="licenses">Ausgestellte Lizenzen</TabsTrigger>
299→ <TabsTrigger value="assets">Erneuerungsregister</TabsTrigger>
300→ </TabsList>
301→
302→ <TabsContent value="licenses" className="mt-4">
303→ {/* Filter tabs */}
304→ <div className="mb-3 flex flex-wrap items-center gap-1.5">
305→ {FILTERS.map((f) => (
306→ <button
307→ key={f.key}
308→ onClick={() => setFilter(f.key)}
309→ className={cn(
310→ 'shrink-0 rounded-md border px-3 py-1.5 text-xs transition',
311→ filter === f.key ? 'border-foreground/40 bg-foreground/5' : 'border-border hover:border-foreground/30'
312→ )}
313→ >
314→ {f.label}
315→ </button>
316→ ))}
317→ </div>
318→
319→ <Card>
320→ <CardHeader className="pb-2">
321→ <CardTitle className="text-sm">Produktlizenzen ({filtered.length})</CardTitle>
322→ </CardHeader>
323→ <CardContent className="p-0">
324→ <div className="overflow-x-auto">
325→ <table className="w-full text-sm">
326→ <thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
327→ <tr>
328→ <th className="w-8 px-2 py-2" />
329→ <th className="px-4 py-2 font-medium">Lizenzschlüssel</th>
330→ <th className="px-4 py-2 font-medium">Produkt / Plan</th>
331→ <th className="px-4 py-2 font-medium">Kunde</th>
332→ <th className="px-4 py-2 font-medium">Aktivierungen</th>
333→ <th className="px-4 py-2 font-medium">Status & Billing</th>
334→ <th className="px-4 py-2 font-medium">Gültig bis</th>
335→ <th className="px-4 py-2 font-medium">Aktionen</th>
336→ </tr>
337→ </thead>
338→ <tbody className="divide-y divide-border">
339→ {filtered.length === 0 ? (
340→ <tr>
341→ <td colSpan={8} className="px-4 py-10 text-center text-xs text-muted-foreground">
342→ Keine Lizenzen in diesem Filter.
343→ </td>
344→ </tr>
345→ ) : (
346→ filtered.map((l) => (
347→ <LicenseRow
348→ key={l.id}
349→ license={l}
350→ onSuspend={(lic) => setSuspendTarget(lic)}
351→ onCancel={(lic) => setCancelTarget(lic)}
352→ onPromote={handlePromote}
353→ />
354→ ))
355→ )}
356→ </tbody>
357→ </table>
358→ </div>
359→ </CardContent>
360→ </Card>
361→
362→ {/* Signing info card */}
363→ <Card className="mt-4">
364→ <CardContent className="p-4">
365→ <div className="flex items-start gap-3">
366→ <div className="flex size-9 items-center justify-center rounded-md border border-border">
367→ <ShieldCheck className="size-4 text-muted-foreground" />
368→ </div>
369→ <div className="flex-1">
370→ <p className="text-sm font-medium">Ed25519-signierte Lizenz-Tokens</p>
371→ <p className="mt-1 text-xs text-muted-foreground">
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→ </p>
375→ <div className="mt-2 flex flex-wrap gap-2">
376→ <Badge variant="outline" className="text-[10px]">Public API: /api/v1/validate</Badge>
377→ <Badge variant="outline" className="text-[10px]">WordPress-Updater kompatibel</Badge>
378→ <Badge variant="outline" className="text-[10px]">Offline-Grace: 14 d</Badge>
379→ </div>
380→ </div>
381→ </div>
382→ </CardContent>
383→ </Card>
384→ </TabsContent>
385→
386→ <TabsContent value="assets" className="mt-4">
387→ <Card>
388→ <CardHeader className="pb-2">
389→ <CardTitle className="text-sm">Asset- & Erneuerungsregister ({assets.length})</CardTitle>
390→ </CardHeader>
391→ <CardContent className="p-0">
392→ <div className="overflow-x-auto">
393→ <table className="w-full text-sm">
394→ <thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
395→ <tr>
396→ <th className="px-4 py-2 font-medium">Asset</th>
397→ <th className="px-4 py-2 font-medium">Typ</th>
398→ <th className="px-4 py-2 font-medium">Anbieter</th>
399→ <th className="px-4 py-2 font-medium">Kosten/Monat</th>
400→ <th className="px-4 py-2 font-medium">Erneuerung</th>
401→ <th className="px-4 py-2 font-medium">Weiterbelasten</th>
402→ <th className="px-4 py-2 font-medium">Status</th>
403→ </tr>
404→ </thead>
405→ <tbody className="divide-y divide-border">
406→ {assets.map((a) => {
407→ const days = daysUntil(a.renewalDate)
408→ return (
409→ <tr key={a.id} className="transition hover:bg-muted/40">
410→ <td className="px-4 py-2.5 text-xs font-medium">{a.name}</td>
411→ <td className="px-4 py-2.5">
412→ <Badge variant="secondary" className="text-[10px]">{ASSET_TYPE_LABELS[a.type] ?? a.type}</Badge>
413→ </td>
414→ <td className="px-4 py-2.5 text-xs text-muted-foreground">{a.provider ?? '—'}</td>
415→ <td className="px-4 py-2.5 text-xs tabular-nums">
416→ {a.costCentsPerMonth ? formatEuro(a.costCentsPerMonth) : '—'}
417→ </td>
418→ <td className="px-4 py-2.5 text-xs">
419→ <span className={cn(days < 0 && 'text-foreground')}>
420→ {formatDate(a.renewalDate)}
421→ </span>
422→ <span className="ml-1 text-[10px] text-muted-foreground">
423→ {days < 0 ? `(${Math.abs(days)} d vorbei)` : `(${days} d)`}
424→ </span>
425→ </td>
426→ <td className="px-4 py-2.5">
427→ {a.rebill ? (
428→ <Badge variant="outline" className="text-[10px] gap-1">
429→ <RefreshCw className="size-2.5" />+{a.markupPct}%
430→ </Badge>
431→ ) : (
432→ <span className="text-[10px] text-muted-foreground">—</span>
433→ )}
434→ </td>
435→ <td className="px-4 py-2.5">
436→ <Badge
437→ variant="outline"
438→ className={cn(
439→ 'text-[10px]',
440→ a.status === 'expired' && 'border-foreground/40',
441→ a.status === 'expiring' && 'border-foreground/30'
442→ )}
443→ >
444→ {a.status === 'expired' ? 'Abgelaufen' : a.status === 'expiring' ? 'Läuft ab' : 'Aktiv'}
445→ </Badge>
446→ </td>
447→ </tr>
448→ )
449→ })}
450→ </tbody>
451→ </table>
452→ </div>
453→ </CardContent>
454→ </Card>
455→ </TabsContent>
456→ </Tabs>
457→
458→ <CreateLicenseDialog
459→ open={createOpen}
460→ onOpenChange={setCreateOpen}
461→ companies={companies}
462→ onCreated={refetch}
463→ />
464→ <SuspendDialog
465→ license={suspendTarget}
466→ onOpenChange={(o) => !o && setSuspendTarget(null)}
467→ onDone={refetch}
468→ />
469→ <CancelDialog
470→ license={cancelTarget}
471→ onOpenChange={(o) => !o && setCancelTarget(null)}
472→ onDone={refetch}
473→ />
474→ </div>
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→ <tr className="transition hover:bg-muted/40">
509→ <td className="px-2 py-2.5 align-middle">
510→ {hasModules ? (
511→ <button
512→ onClick={() => setOpen((o) => !o)}
513→ className="flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-muted"
514→ aria-label={open ? 'Einklappen' : 'Ausklappen'}
515→ >
516→ <ChevronRight className={cn('size-4 transition-transform', open && 'rotate-90')} />
517→ </button>
518→ ) : (
519→ <span className="block size-6" />
520→ )}
521→ </td>
522→ <td className="px-4 py-2.5">
523→ <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">{license.key}</code>
524→ {isModule && (
525→ <Badge variant="outline" className="ml-1 text-[9px]">Modul</Badge>
526→ )}
527→ </td>
528→ <td className="px-4 py-2.5">
529→ <p className="text-xs font-medium">{license.product}</p>
530→ <Badge variant="outline" className="mt-0.5 text-[10px] capitalize">{license.plan}</Badge>
531→ </td>
532→ <td className="px-4 py-2.5 text-xs">{license.company.name}</td>
533→ <td className="px-4 py-2.5">
534→ <span className="text-xs tabular-nums">
535→ {license.activations.length} / {license.maxActivations}
536→ </span>
537→ {license.activations[0] && (
538→ <p className="text-[10px] text-muted-foreground">{license.activations[0].domain}</p>
539→ )}
540→ </td>
541→ <td className="px-4 py-2.5">
542→ <div className="flex flex-wrap items-center gap-1">
543→ <Badge
544→ variant="outline"
545→ className={cn(
546→ 'gap-1 text-[10px]',
547→ cancelled ? 'border-foreground/40'
548→ : inTrial ? 'border-foreground/30'
549→ : license.status === 'active' ? 'border-foreground/40' : ''
550→ )}
551→ >
552→ <Icon className="size-3" />{st.label}
553→ </Badge>
554→ {showPrice && (
555→ <>
556→ <Badge
557→ variant="outline"
558→ className="text-[9px] tabular-nums"
559→ title={license.billingCycle === 'yearly' ? 'Jährliche Abrechnung' : 'Monatliche Abrechnung'}
560→ >
561→ {license.billingCycle === 'yearly' ? 'Y' : 'M'}
562→ </Badge>
563→ <span className="text-[11px] tabular-nums text-muted-foreground">
564→ {formatEuroCompact(license.netPriceCents)}
565→ <span className="text-[9px]">/{license.billingCycle === 'yearly' ? 'Jahr' : 'Mon.'}</span>
566→ </span>
567→ </>
568→ )}
569→ {inTrial && (
570→ <Badge variant="outline" className="gap-1 text-[9px] border-foreground/30">
571→ <FlaskConical className="size-2.5" />Trial ({trialDaysLeft} d)
572→ </Badge>
573→ )}
574→ {cancelled && license.cancellationEffectiveAt && (
575→ <Badge
576→ variant="outline"
577→ className="text-[9px] border-foreground/40"
578→ title={`Gekündigt am ${formatDate(license.cancelledAt!)}`}
579→ >
580→ Gekündigt → {formatDate(license.cancellationEffectiveAt)}
581→ </Badge>
582→ )}
583→ {license.environment === 'demo' && (
584→ <Badge variant="outline" className="text-[9px]">Demo</Badge>
585→ )}
586→ </div>
587→ </td>
588→ <td className="px-4 py-2.5 text-xs">
589→ {license.validUntil ? (
590→ <span className={cn(valid !== null && valid < 0 && 'text-foreground')}>
591→ {formatDate(license.validUntil)}
592→ {valid !== null && (
593→ <span className="ml-1 text-[10px] text-muted-foreground">
594→ {valid < 0 ? `(${Math.abs(valid)} d vorbei)` : `(${valid} d)`}
595→ </span>
596→ )}
597→ </span>
598→ ) : (
599→ <span className="text-muted-foreground">—</span>
600→ )}
601→ </td>
602→ <td className="px-4 py-2.5">
603→ <div className="flex items-center gap-1">
604→ {canCancel && (
605→ <Button size="sm" variant="outline" className="h-7 gap-1 text-[10px]" onClick={() => onCancel(license)}>
606→ <Ban className="size-3" />Kündigen
607→ </Button>
608→ )}
609→ {license.status === 'active' && !cancelled && (
610→ <Button size="sm" variant="ghost" className="h-7 px-2 text-[10px]" onClick={() => onSuspend(license)} title="Suspendieren">
611→ <PauseCircle className="size-3" />
612→ </Button>
613→ )}
614→ {canReactivate && (
615→ <Button size="sm" variant="outline" className="h-7 gap-1 text-[10px]" onClick={() => onPromote(license)} title="Reaktivieren">
616→ <RotateCcw className="size-3" />Reaktivieren
617→ </Button>
618→ )}
619→ {license.environment === 'demo' && !cancelled && (
620→ <Button size="sm" variant="ghost" className="h-7 px-2 text-[10px]" onClick={() => onPromote(license)} title="Demo → Produktion">
621→ <Play className="size-3" />
622→ </Button>
623→ )}
624→ </div>
625→ </td>
626→ </tr>
627→
628→ {/* Expanded module rows */}
629→ {open && hasModules && (
630→ <tr className="bg-muted/20">
631→ <td colSpan={8} className="px-4 pb-3 pt-1">
632→ <div className="rounded-md border border-border bg-background">
633→ <table className="w-full text-xs">
634→ <thead className="border-b border-border text-left text-[10px] uppercase tracking-wide text-muted-foreground">
635→ <tr>
636→ <th className="px-3 py-2 font-medium">Modul</th>
637→ <th className="px-3 py-2 font-medium">Schlüssel</th>
638→ <th className="px-3 py-2 font-medium">Billing</th>
639→ <th className="px-3 py-2 font-medium">Status</th>
640→ <th className="px-3 py-2 font-medium">Gültig bis</th>
641→ </tr>
642→ </thead>
643→ <tbody className="divide-y divide-border">
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→ <tr key={m.id}>
651→ <td className="px-3 py-2 font-medium">{m.module ?? '—'}</td>
652→ <td className="px-3 py-2">
653→ <code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">{m.key}</code>
654→ </td>
655→ <td className="px-3 py-2">
656→ {m.priceCents > 0 ? (
657→ <span className="tabular-nums text-muted-foreground">
658→ {formatEuroCompact(m.netPriceCents)}
659→ <span className="ml-0.5 text-[9px]">/{m.billingCycle === 'yearly' ? 'Jahr' : 'Mon.'}</span>
660→ </span>
661→ ) : (
662→ <Badge variant="outline" className="text-[9px]">via Core</Badge>
663→ )}
664→ </td>
665→ <td className="px-3 py-2">
666→ <Badge
667→ variant="outline"
668→ className={cn(
669→ 'gap-1 text-[9px]',
670→ mCancelled ? 'border-foreground/40' : mInTrial ? 'border-foreground/30' : ''
671→ )}
672→ >
673→ <MIcon className="size-2.5" />{mst.label}
674→ </Badge>
675→ {mInTrial && (
676→ <Badge variant="outline" className="ml-1 gap-1 text-[9px] border-foreground/30">
677→ <FlaskConical className="size-2.5" />Trial
678→ </Badge>
679→ )}
680→ </td>
681→ <td className="px-3 py-2 text-muted-foreground">
682→ {m.validUntil ? formatDate(m.validUntil) : '—'}
683→ </td>
684→ </tr>
685→ )
686→ })}
687→ </tbody>
688→ </table>
689→ </div>
690→ </td>
691→ </tr>
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<string>('')
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<string, unknown> = {
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→ <Dialog open={open} onOpenChange={onOpenChange}>
842→ <DialogContent className="sm:max-w-lg">
843→ <DialogHeader>
844→ <DialogTitle>Lizenz ausstellen</DialogTitle>
845→ <DialogDescription>
846→ Stellt eine neue Lizenz aus. Billing, Probezeit und Kündigungsfrist (90 Tage) werden automatisch konfiguriert.
847→ </DialogDescription>
848→ </DialogHeader>
849→ <div className="space-y-3">
850→ {/* Customer */}
851→ <div className="space-y-1.5">
852→ <Label className="text-xs">Firma</Label>
853→ <Select value={companyId} onValueChange={setCompanyId}>
854→ <SelectTrigger className="w-full"><SelectValue placeholder="Firma wählen" /></SelectTrigger>
855→ <SelectContent>
856→ {companies.map((c) => (
857→ <SelectItem key={c.id} value={c.id}>
858→ {c.name}
859→ {c.isAgency && c.agencyStatus === 'approved' && ' (Agentur)'}
860→ </SelectItem>
861→ ))}
862→ </SelectContent>
863→ </Select>
864→ </div>
865→ {/* Product + Plan */}
866→ <div className="grid grid-cols-2 gap-3">
867→ <div className="space-y-1.5">
868→ <Label className="text-xs">Produkt</Label>
869→ <Input value={product} onChange={(e) => setProduct(e.target.value)} placeholder="z.B. aos-cms" />
870→ </div>
871→ <div className="space-y-1.5">
872→ <Label className="text-xs">Plan</Label>
873→ <Select value={plan} onValueChange={(v) => setPlan(v as 'starter' | 'pro' | 'enterprise')}>
874→ <SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
875→ <SelectContent>
876→ <SelectItem value="starter">Starter (29 €/Mon.)</SelectItem>
877→ <SelectItem value="pro">Pro (50 €/Mon.)</SelectItem>
878→ <SelectItem value="enterprise">Enterprise (150 €/Mon.)</SelectItem>
879→ </SelectContent>
880→ </Select>
881→ </div>
882→ </div>
883→ {/* Environment + Activations */}
884→ <div className="grid grid-cols-2 gap-3">
885→ <div className="space-y-1.5">
886→ <Label className="text-xs">Umgebung</Label>
887→ <Select value={environment} onValueChange={(v) => setEnvironment(v as 'demo' | 'production')}>
888→ <SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
889→ <SelectContent>
890→ <SelectItem value="production">Produktion</SelectItem>
891→ <SelectItem value="demo">Demo (14 d, Wasserzeichen)</SelectItem>
892→ </SelectContent>
893→ </Select>
894→ </div>
895→ <div className="space-y-1.5">
896→ <Label className="text-xs">Max. Aktivierungen</Label>
897→ <Input
898→ type="number"
899→ min={1}
900→ max={100}
901→ value={maxActivations}
902→ onChange={(e) => setMaxActivations(Math.max(1, parseInt(e.target.value, 10) || 1))}
903→ />
904→ </div>
905→ </div>
906→
907→ {/* Billing section */}
908→ <div className="space-y-3 rounded-md border border-border p-3">
909→ <div className="flex items-center gap-2">
910→ <CircleDollarSign className="size-3.5 text-muted-foreground" />
911→ <span className="text-xs font-semibold uppercase tracking-wide">Billing</span>
912→ </div>
913→ <div className="grid grid-cols-2 gap-3">
914→ <div className="space-y-1.5">
915→ <Label className="text-xs">Abrechnung</Label>
916→ <Select value={billingCycle} onValueChange={(v) => setBillingCycle(v as 'monthly' | 'yearly')}>
917→ <SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
918→ <SelectContent>
919→ <SelectItem value="monthly">Monatlich</SelectItem>
920→ <SelectItem value="yearly">Jährlich</SelectItem>
921→ </SelectContent>
922→ </Select>
923→ {billingCycle === 'yearly' && (
924→ <p className="text-[10px] text-muted-foreground">10% Jahresrabatt wird auf den Jahresbetrag angewendet.</p>
925→ )}
926→ </div>
927→ <div className="space-y-1.5">
928→ <Label className="text-xs">Monatspreis (Cents)</Label>
929→ <Input
930→ type="number"
931→ min={0}
932→ value={monthlyPriceCents}
933→ onChange={(e) => setMonthlyPriceCents(e.target.value)}
934→ placeholder="z.B. 5000 (= 50€)"
935→ />
936→ <p className="text-[10px] text-muted-foreground">
937→ Standard: 29€ (Starter), 50€ (Pro), 150€ (Enterprise). Überschreiben für Custom-Preis.
938→ </p>
939→ </div>
940→ </div>
941→ <div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/20 p-2.5">
942→ <div>
943→ <Label className="text-xs">14-Tage-Testphase starten</Label>
944→ <p className="text-[10px] text-muted-foreground">Tag 1–14 kostenlos kündbar. Tag 15 beginnt das kostenpflichtige Abo.</p>
945→ </div>
946→ <Switch
947→ checked={startTrial}
948→ onCheckedChange={setStartTrial}
949→ disabled={environment === 'demo'}
950→ />
951→ </div>
952→
953→ {/* Live pricing preview */}
954→ <div className="rounded-md border border-border bg-background p-2.5 text-xs">
955→ <div className="flex items-center justify-between">
956→ <span className="text-muted-foreground">Basispreis ({billingCycle === 'yearly' ? 'Jahr' : 'Monat'})</span>
957→ <span className="tabular-nums">{formatEuro(pricing.baseCents)}</span>
958→ </div>
959→ {pricing.agencyPct > 0 && (
960→ <div className="mt-1 flex items-center justify-between">
961→ <span className="text-muted-foreground">Agentur-Rabatt ({pricing.agencyPct}%)</span>
962→ <span className="tabular-nums text-foreground">−{formatEuro(Math.round((pricing.baseCents * pricing.agencyPct) / 100))}</span>
963→ </div>
964→ )}
965→ {pricing.yearlyPct > 0 && (
966→ <div className="mt-1 flex items-center justify-between">
967→ <span className="text-muted-foreground">Jahresrabatt ({pricing.yearlyPct}%)</span>
968→ <span className="tabular-nums text-foreground">−{formatEuro(Math.round((pricing.baseCents * pricing.yearlyPct) / 100))}</span>
969→ </div>
970→ )}
971→ {pricing.totalPct > 0 && (
972→ <div className="mt-1 flex items-center justify-between text-muted-foreground">
973→ <span>Gesamtrabatt</span>
974→ <span>{pricing.totalPct}%</span>
975→ </div>
976→ )}
977→ <div className="mt-2 flex items-center justify-between border-t border-border pt-2 font-medium">
978→ <span>Netto / {billingCycle === 'yearly' ? 'Jahr' : 'Monat'}</span>
979→ <span className="tabular-nums">{formatEuro(pricing.netPriceCents)}</span>
980→ </div>
981→ </div>
982→ </div>
983→ </div>
984→ <DialogFooter>
985→ <Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
986→ <Button onClick={submit} disabled={saving} className="gap-1.5">
987→ {saving && <Loader2 className="size-3.5 animate-spin" />}
988→ Ausstellen
989→ </Button>
990→ </DialogFooter>
991→ </DialogContent>
992→ </Dialog>
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→ <Dialog open={!!license} onOpenChange={onOpenChange}>
1044→ <DialogContent className="sm:max-w-md">
1045→ <DialogHeader>
1046→ <DialogTitle>Lizenz suspendieren</DialogTitle>
1047→ <DialogDescription>
1048→ Suspendiert die Lizenz (und alle Module). Aktivierte Installationen verweigern den Dienst bis zur Reaktivierung.
1049→ </DialogDescription>
1050→ </DialogHeader>
1051→ {license && (
1052→ <div className="space-y-3">
1053→ <div className="rounded-md border border-border bg-muted/30 p-2.5 text-xs">
1054→ <p className="font-medium">{license.product} — {license.plan}</p>
1055→ <p className="mt-0.5 text-muted-foreground">{license.company.name}</p>
1056→ <code className="mt-1 inline-block rounded bg-muted px-1 py-0.5 font-mono text-[10px]">{license.key}</code>
1057→ </div>
1058→ <div className="space-y-1.5">
1059→ <Label className="text-xs">Grund</Label>
1060→ <Textarea
1061→ value={reason}
1062→ onChange={(e) => setReason(e.target.value)}
1063→ placeholder="z.B. Zahlungsverzug Mahnstufe 3, Missbrauch, Kunden-Anfrage…"
1064→ rows={3}
1065→ />
1066→ </div>
1067→ </div>
1068→ )}
1069→ <DialogFooter>
1070→ <Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
1071→ <Button variant="destructive" onClick={submit} disabled={saving} className="gap-1.5">
1072→ {saving && <Loader2 className="size-3.5 animate-spin" />}
1073→ Suspendieren
1074→ </Button>
1075→ </DialogFooter>
1076→ </DialogContent>
1077→ </Dialog>
1078→ )
1079→}
1080→
1081→// ---------------------------------------------------------------------------
1082→// Cancel Dialog — slide-to-confirm (SlideCommit)
1083→// ---------------------------------------------------------------------------
1084→
1085→function CancelDialog({
1086→ license,
1087→ onOpenChange,
1088→ onDone,
1089→}: {
1090→ license: License | null
1091→ onOpenChange: (o: boolean) => void
1092→ onDone: () => void
1093→}) {
1094→ const [reason, setReason] = useState('')
1095→ const [committing, setCommitting] = useState(false)
1096→
1097→ useEffect(() => {
1098→ if (!license) {
1099→ setReason('')
1100→ setCommitting(false)
1101→ }
1102→ }, [license])
1103→
1104→ if (!license) return null
1105→
1106→ const inTrial = !!license.trialEndsAt && new Date(license.trialEndsAt) > new Date()
1107→ // Estimated effective date (mirrors the backend formula for preview only —
1108→ // the backend returns the authoritative effectiveAt in the response).
1109→ const effectiveAt = inTrial
1110→ ? new Date()
1111→ : new Date(Date.now() + (license.noticePeriodDays || 90) * 86400000)
1112→
1113→ const handleCommit = async () => {
1114→ setCommitting(true)
1115→ const t = toast.loading('Kündigung wird eingereicht…')
1116→ try {
1117→ const r = await fetch(`/api/aos/licenses/${license.id}/cancel`, {
1118→ method: 'PATCH',
1119→ headers: { 'content-type': 'application/json' },
1120→ body: JSON.stringify({ reason: reason.trim() || undefined }),
1121→ })
1122→ const res = await r.json()
1123→ if (!r.ok) throw new Error(res.error || 'cancel failed')
1124→ toast.success(res.message ?? 'Kündigung eingegangen', { id: t, duration: 6000 })
1125→ onOpenChange(false)
1126→ onDone()
1127→ } catch (err) {
1128→ toast.error(err instanceof Error ? err.message : 'Kündigung fehlgeschlagen', { id: t })
1129→ } finally {
1130→ setCommitting(false)
1131→ }
1132→ }
1133→
1134→ return (
1135→ <Dialog open={!!license} onOpenChange={onOpenChange}>
1136→ <DialogContent className="sm:max-w-md">
1137→ <DialogHeader>
1138→ <DialogTitle className="flex items-center gap-2">
1139→ <Ban className="size-4" />
1140→ Lizenz kündigen
1141→ </DialogTitle>
1142→ <DialogDescription>
1143→ Kündigt das Abonnement. Innerhalb der 14-Tage-Testphase wird die Kündigung sofort wirksam (keine Abbuchung). Nach der Testphase gilt die gesetzliche Kündigungsfrist von 90 Tagen.
1144→ </DialogDescription>
1145→ </DialogHeader>
1146→ <div className="space-y-3">
1147→ <div className="rounded-md border border-border bg-muted/30 p-2.5 text-xs">
1148→ <p className="font-medium">{license.product} — {license.plan}</p>
1149→ <p className="mt-0.5 text-muted-foreground">{license.company.name}</p>
1150→ <code className="mt-1 inline-block rounded bg-muted px-1 py-0.5 font-mono text-[10px]">{license.key}</code>
1151→ </div>
1152→
1153→ {/* Trial status banner */}
1154→ <div
1155→ className={cn(
1156→ 'flex items-start gap-2 rounded-md border p-2.5 text-xs',
1157→ inTrial ? 'border-foreground/30 bg-foreground/5' : 'border-foreground/40 bg-foreground/5'
1158→ )}
1159→ >
1160→ <FlaskConical className="mt-0.5 size-3.5 shrink-0 text-foreground" />
1161→ <div>
1162→ {inTrial ? (
1163→ <>
1164→ <p className="font-medium">In Probezeit — sofortige Kündigung</p>
1165→ <p className="mt-0.5 text-muted-foreground">
1166→ Sie befinden sich in der 14-Tage-Testphase — Kündigung wird sofort wirksam, keine Abbuchung.
1167→ </p>
1168→ </>
1169→ ) : (
1170→ <>
1171→ <p className="font-medium">Kündigungsfrist: 90 Tage</p>
1172→ <p className="mt-0.5 text-muted-foreground">
1173→ Kündigung frist: 90 Tage. Wirksam zum{' '}
1174→ <span className="font-medium">{formatDate(effectiveAt)}</span>{' '}
1175→ <span className="text-muted-foreground">({formatRelative(effectiveAt)}).</span>
1176→ </p>
1177→ </>
1178→ )}
1179→ </div>
1180→ </div>
1181→
1182→ <div className="space-y-1.5">
1183→ <Label className="text-xs">Kündigungsgrund (optional)</Label>
1184→ <Textarea
1185→ value={reason}
1186→ onChange={(e) => setReason(e.target.value)}
1187→ placeholder="z.B. Projektabbruch, Umzug auf andere Lösung, Kosten…"
1188→ rows={3}
1189→ />
1190→ </div>
1191→
1192→ <SlideCommit
1193→ label="Kündigung bestätigen →"
1194→ onCommit={handleCommit}
1195→ loading={committing}
1196→ />
1197→ </div>
1198→ </DialogContent>
1199→ </Dialog>
1200→ )
1201→}
1202→