1202 lines
58 KiB
Plaintext
1202 lines
58 KiB
Plaintext
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→ |