1202 lines
69 KiB
Plaintext
1202 lines
69 KiB
Plaintext
1→ 1→'use client'
|
||
2→ 2→
|
||
3→ 3→import { useEffect, useMemo, useState } from 'react'
|
||
4→ 4→import {
|
||
5→ 5→ Plus,
|
||
6→ 6→ KeyRound,
|
||
7→ 7→ ShieldCheck,
|
||
8→ 8→ AlertTriangle,
|
||
9→ 9→ CalendarClock,
|
||
10→ 10→ RefreshCw,
|
||
11→ 11→ CheckCircle2,
|
||
12→ 12→ XCircle,
|
||
13→ 13→ PauseCircle,
|
||
14→ 14→ ChevronRight,
|
||
15→ 15→ Ban,
|
||
16→ 16→ Play,
|
||
17→ 17→ RotateCcw,
|
||
18→ 18→ TrendingUp,
|
||
19→ 19→ FlaskConical,
|
||
20→ 20→ Loader2,
|
||
21→ 21→ CircleDollarSign,
|
||
22→ 22→} from 'lucide-react'
|
||
23→ 23→import { useFetch } from '@/hooks/use-fetch'
|
||
24→ 24→import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||
25→ 25→import { Badge } from '@/components/ui/badge'
|
||
26→ 26→import { Button } from '@/components/ui/button'
|
||
27→ 27→import { Input } from '@/components/ui/input'
|
||
28→ 28→import { Label } from '@/components/ui/label'
|
||
29→ 29→import { Switch } from '@/components/ui/switch'
|
||
30→ 30→import { Textarea } from '@/components/ui/textarea'
|
||
31→ 31→import { Skeleton } from '@/components/ui/skeleton'
|
||
32→ 32→import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||
33→ 33→import {
|
||
34→ 34→ Dialog,
|
||
35→ 35→ DialogContent,
|
||
36→ 36→ DialogDescription,
|
||
37→ 37→ DialogFooter,
|
||
38→ 38→ DialogHeader,
|
||
39→ 39→ DialogTitle,
|
||
40→ 40→} from '@/components/ui/dialog'
|
||
41→ 41→import {
|
||
42→ 42→ Select,
|
||
43→ 43→ SelectContent,
|
||
44→ 44→ SelectItem,
|
||
45→ 45→ SelectTrigger,
|
||
46→ 46→ SelectValue,
|
||
47→ 47→} from '@/components/ui/select'
|
||
48→ 48→import SlideCommit from '@/components/SlideCommit'
|
||
49→ 49→import { PageHeader } from '../primitives'
|
||
50→ 50→import { formatEuro, formatEuroCompact, formatDate, formatRelative, daysUntil } from '@/lib/format'
|
||
51→ 51→import { cn } from '@/lib/utils'
|
||
52→ 52→import { toast } from 'sonner'
|
||
53→ 53→
|
||
54→ 54→// ---------------------------------------------------------------------------
|
||
55→ 55→// Types
|
||
56→ 56→// ---------------------------------------------------------------------------
|
||
57→ 57→
|
||
58→ 58→interface LicenseModule {
|
||
59→ 59→ id: string
|
||
60→ 60→ key: string
|
||
61→ 61→ module: string | null
|
||
62→ 62→ status: string
|
||
63→ 63→ environment: 'demo' | 'production'
|
||
64→ 64→ maxActivations: number
|
||
65→ 65→ validUntil: string | null
|
||
66→ 66→ createdAt: string
|
||
67→ 67→ // B2B billing fields (mirror the parent License shape so module rows can
|
||
68→ 68→ // show their own billing state — most modules inherit "via Core" and have
|
||
69→ 69→ // priceCents = 0, but modules with their own pricing show their netPriceCents).
|
||
70→ 70→ billingCycle: 'monthly' | 'yearly'
|
||
71→ 71→ priceCents: number
|
||
72→ 72→ discountPct: number
|
||
73→ 73→ netPriceCents: number
|
||
74→ 74→ trialEndsAt: string | null
|
||
75→ 75→ subscriptionStartsAt: string | null
|
||
76→ 76→ cancelledAt: string | null
|
||
77→ 77→ cancellationEffectiveAt: string | null
|
||
78→ 78→}
|
||
79→ 79→
|
||
80→ 80→interface License {
|
||
81→ 81→ id: string
|
||
82→ 82→ key: string
|
||
83→ 83→ product: string
|
||
84→ 84→ plan: string
|
||
85→ 85→ status: string
|
||
86→ 86→ environment: 'demo' | 'production'
|
||
87→ 87→ parentId: string | null
|
||
88→ 88→ module: string | null
|
||
89→ 89→ maxActivations: number
|
||
90→ 90→ validUntil: string | null
|
||
91→ 91→ createdAt: string
|
||
92→ 92→ suspendReason: string | null
|
||
93→ 93→ suspendedAt: string | null
|
||
94→ 94→ // --- B2B Subscription & Billing (German law) ---
|
||
95→ 95→ noticePeriodDays: number
|
||
96→ 96→ trialEndsAt: string | null
|
||
97→ 97→ subscriptionStartsAt: string | null
|
||
98→ 98→ billingCycle: 'monthly' | 'yearly'
|
||
99→ 99→ priceCents: number
|
||
100→ 100→ discountPct: number
|
||
101→ 101→ netPriceCents: number
|
||
102→ 102→ cancelledAt: string | null
|
||
103→ 103→ cancellationEffectiveAt: string | null
|
||
104→ 104→ cancellationReason: string | null
|
||
105→ 105→ company: { id: string; name: string }
|
||
106→ 106→ activations: { id: string; domain: string; environment: string; version: string | null; lastCheck: string }[]
|
||
107→ 107→ modules: LicenseModule[]
|
||
108→ 108→}
|
||
109→ 109→
|
||
110→ 110→interface Asset {
|
||
111→ 111→ id: string
|
||
112→ 112→ name: string
|
||
113→ 113→ type: string
|
||
114→ 114→ provider: string | null
|
||
115→ 115→ costCentsPerMonth: number | null
|
||
116→ 116→ renewalDate: string
|
||
117→ 117→ rebill: boolean
|
||
118→ 118→ markupPct: number
|
||
119→ 119→ status: string
|
||
120→ 120→}
|
||
121→ 121→
|
||
122→ 122→interface LicensesData {
|
||
123→ 123→ licenses: License[]
|
||
124→ 124→ assets: Asset[]
|
||
125→ 125→ stats: {
|
||
126→ 126→ activeLicenses: number
|
||
127→ 127→ suspended: number
|
||
128→ 128→ expired: number
|
||
129→ 129→ cancelled: number
|
||
130→ 130→ inTrial: number
|
||
131→ 131→ totalActivations: number
|
||
132→ 132→ expiringSoon: number
|
||
133→ 133→ expiredAssets: number
|
||
134→ 134→ monthlyRebill: number
|
||
135→ 135→ demoLicenses: number
|
||
136→ 136→ mrrCents: number
|
||
137→ 137→ }
|
||
138→ 138→}
|
||
139→ 139→
|
||
140→ 140→// Minimal company shape for the create-license dialog. The CRM route returns
|
||
141→ 141→// the full Prisma Company (including the agency fields needed to preview the
|
||
142→ 142→// agency discount in the pricing breakdown).
|
||
143→ 143→interface CompanyLite {
|
||
144→ 144→ id: string
|
||
145→ 145→ name: string
|
||
146→ 146→ isAgency: boolean
|
||
147→ 147→ agencyStatus: string | null
|
||
148→ 148→ agencyDiscountPct: number
|
||
149→ 149→ parentAgencyId: string | null
|
||
150→ 150→}
|
||
151→ 151→
|
||
152→ 152→// ---------------------------------------------------------------------------
|
||
153→ 153→// Constants
|
||
154→ 154→// ---------------------------------------------------------------------------
|
||
155→ 155→
|
||
156→ 156→const LIC_STATUS: Record<string, { label: string; icon: typeof CheckCircle2 }> = {
|
||
157→ 157→ active: { label: 'Aktiv', icon: CheckCircle2 },
|
||
158→ 158→ suspended: { label: 'Suspendiert', icon: PauseCircle },
|
||
159→ 159→ expired: { label: 'Abgelaufen', icon: XCircle },
|
||
160→ 160→ revoked: { label: 'Widerrufen', icon: XCircle },
|
||
161→ 161→ cancelled: { label: 'Gekündigt', icon: Ban },
|
||
162→ 162→}
|
||
163→ 163→
|
||
164→ 164→const ASSET_TYPE_LABELS: Record<string, string> = {
|
||
165→ 165→ domain: 'Domain',
|
||
166→ 166→ hosting: 'Hosting',
|
||
167→ 167→ ssl: 'SSL',
|
||
168→ 168→ 'plugin-seat': 'Plugin-Seat',
|
||
169→ 169→ saas: 'SaaS',
|
||
170→ 170→}
|
||
171→ 171→
|
||
172→ 172→// Default monthly prices per plan (mirror of the backend PLAN_PRICES table).
|
||
173→ 173→const PLAN_PRICES: Record<string, number> = {
|
||
174→ 174→ starter: 2900,
|
||
175→ 175→ pro: 5000,
|
||
176→ 176→ enterprise: 15000,
|
||
177→ 177→}
|
||
178→ 178→const YEARLY_DISCOUNT_PCT = 10
|
||
179→ 179→
|
||
180→ 180→type FilterKey = 'all' | 'active' | 'trial' | 'cancelled' | 'demo'
|
||
181→ 181→const FILTERS: { key: FilterKey; label: string }[] = [
|
||
182→ 182→ { key: 'all', label: 'Alle' },
|
||
183→ 183→ { key: 'active', label: 'Aktiv' },
|
||
184→ 184→ { key: 'trial', label: 'In Trial' },
|
||
185→ 185→ { key: 'cancelled', label: 'Gekündigt' },
|
||
186→ 186→ { key: 'demo', label: 'Demo' },
|
||
187→ 187→]
|
||
188→ 188→
|
||
189→ 189→// ---------------------------------------------------------------------------
|
||
190→ 190→// Main view
|
||
191→ 191→// ---------------------------------------------------------------------------
|
||
192→ 192→
|
||
193→ 193→export function LicensesView() {
|
||
194→ 194→ const { data, loading, refetch } = useFetch<LicensesData>('/api/aos/licenses')
|
||
195→ 195→ // Companies are needed for the create-license dialog (parent agency lookup).
|
||
196→ 196→ const { data: crmData } = useFetch<{ companies: CompanyLite[] } | null>('/api/aos/crm')
|
||
197→ 197→ const [createOpen, setCreateOpen] = useState(false)
|
||
198→ 198→ const [suspendTarget, setSuspendTarget] = useState<License | null>(null)
|
||
199→ 199→ const [cancelTarget, setCancelTarget] = useState<License | null>(null)
|
||
200→ 200→ const [filter, setFilter] = useState<FilterKey>('all')
|
||
201→ 201→
|
||
202→ 202→ if (loading || !data) {
|
||
203→ 203→ return (
|
||
204→ 204→ <div className="space-y-6 p-4 md:p-6">
|
||
205→ 205→ <Skeleton className="h-9 w-32" />
|
||
206→ 206→ <Skeleton className="h-48" />
|
||
207→ 207→ </div>
|
||
208→ 208→ )
|
||
209→ 209→ }
|
||
210→ 210→
|
||
211→ 211→ const { licenses, assets, stats } = data
|
||
212→ 212→ const companies = crmData?.companies ?? []
|
||
213→ 213→
|
||
214→ 214→ const now = new Date()
|
||
215→ 215→ const filtered = licenses.filter((l) => {
|
||
216→ 216→ if (filter === 'all') return true
|
||
217→ 217→ if (filter === 'demo') return l.environment === 'demo'
|
||
218→ 218→ if (filter === 'cancelled') return !!l.cancelledAt || l.status === 'cancelled'
|
||
219→ 219→ if (filter === 'trial') return !!l.trialEndsAt && new Date(l.trialEndsAt) > now
|
||
220→ 220→ if (filter === 'active') {
|
||
221→ 221→ return (
|
||
222→ 222→ l.status === 'active' &&
|
||
223→ 223→ !l.cancelledAt &&
|
||
224→ 224→ !(l.trialEndsAt && new Date(l.trialEndsAt) > now)
|
||
225→ 225→ )
|
||
226→ 226→ }
|
||
227→ 227→ return true
|
||
228→ 228→ })
|
||
229→ 229→
|
||
230→ 230→ const handlePromote = (license: License) => {
|
||
231→ 231→ void promoteLicense(license, refetch)
|
||
232→ 232→ }
|
||
233→ 233→
|
||
234→ 234→ return (
|
||
235→ 235→ <div className="space-y-6 p-4 md:p-6">
|
||
236→ 236→ <PageHeader
|
||
237→ 237→ title="Lizenzverwaltung"
|
||
238→ 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→ 239→ actions={
|
||
240→ 240→ <Button size="sm" onClick={() => setCreateOpen(true)}>
|
||
241→ 241→ <Plus className="size-4" />
|
||
242→ 242→ Lizenz ausstellen
|
||
243→ 243→ </Button>
|
||
244→ 244→ }
|
||
245→ 245→ />
|
||
246→ 246→
|
||
247→ 247→ {/* KPI row — 4 existing + MRR + In Trial = 6 cards */}
|
||
248→ 248→ <div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6">
|
||
249→ 249→ <Card className="p-4">
|
||
250→ 250→ <div className="flex items-center justify-between">
|
||
251→ 251→ <p className="text-xs text-muted-foreground">Aktive Lizenzen</p>
|
||
252→ 252→ <KeyRound className="size-3.5 text-muted-foreground" />
|
||
253→ 253→ </div>
|
||
254→ 254→ <p className="mt-1 text-2xl font-semibold tabular-nums">{stats.activeLicenses}</p>
|
||
255→ 255→ </Card>
|
||
256→ 256→ <Card className="p-4">
|
||
257→ 257→ <div className="flex items-center justify-between">
|
||
258→ 258→ <p className="text-xs text-muted-foreground">Aktivierungen</p>
|
||
259→ 259→ <ShieldCheck className="size-3.5 text-muted-foreground" />
|
||
260→ 260→ </div>
|
||
261→ 261→ <p className="mt-1 text-2xl font-semibold tabular-nums">{stats.totalActivations}</p>
|
||
262→ 262→ </Card>
|
||
263→ 263→ <Card className="p-4">
|
||
264→ 264→ <div className="flex items-center justify-between">
|
||
265→ 265→ <p className="text-xs text-muted-foreground">Erneuerungen fällig</p>
|
||
266→ 266→ <AlertTriangle className="size-3.5 text-muted-foreground" />
|
||
267→ 267→ </div>
|
||
268→ 268→ <p className="mt-1 text-2xl font-semibold tabular-nums">{stats.expiringSoon + stats.expiredAssets}</p>
|
||
269→ 269→ <p className="text-[10px] text-muted-foreground">{stats.expiredAssets} abgelaufen</p>
|
||
270→ 270→ </Card>
|
||
271→ 271→ <Card className="p-4">
|
||
272→ 272→ <div className="flex items-center justify-between">
|
||
273→ 273→ <p className="text-xs text-muted-foreground">Monatl. Weiterbelastung</p>
|
||
274→ 274→ <CalendarClock className="size-3.5 text-muted-foreground" />
|
||
275→ 275→ </div>
|
||
276→ 276→ <p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuro(stats.monthlyRebill)}</p>
|
||
277→ 277→ </Card>
|
||
278→ 278→ <Card className="p-4">
|
||
279→ 279→ <div className="flex items-center justify-between">
|
||
280→ 280→ <p className="text-xs text-muted-foreground">MRR</p>
|
||
281→ 281→ <TrendingUp className="size-3.5 text-muted-foreground" />
|
||
282→ 282→ </div>
|
||
283→ 283→ <p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(stats.mrrCents)}</p>
|
||
284→ 284→ <p className="text-[10px] text-muted-foreground">Monatlich wiederkehrend</p>
|
||
285→ 285→ </Card>
|
||
286→ 286→ <Card className="p-4">
|
||
287→ 287→ <div className="flex items-center justify-between">
|
||
288→ 288→ <p className="text-xs text-muted-foreground">In Trial</p>
|
||
289→ 289→ <FlaskConical className="size-3.5 text-muted-foreground" />
|
||
290→ 290→ </div>
|
||
291→ 291→ <p className="mt-1 text-2xl font-semibold tabular-nums">{stats.inTrial}</p>
|
||
292→ 292→ <p className="text-[10px] text-muted-foreground">14-Tage-Testphase</p>
|
||
293→ 293→ </Card>
|
||
294→ 294→ </div>
|
||
295→ 295→
|
||
296→ 296→ <Tabs defaultValue="licenses">
|
||
297→ 297→ <TabsList>
|
||
298→ 298→ <TabsTrigger value="licenses">Ausgestellte Lizenzen</TabsTrigger>
|
||
299→ 299→ <TabsTrigger value="assets">Erneuerungsregister</TabsTrigger>
|
||
300→ 300→ </TabsList>
|
||
301→ 301→
|
||
302→ 302→ <TabsContent value="licenses" className="mt-4">
|
||
303→ 303→ {/* Filter tabs */}
|
||
304→ 304→ <div className="mb-3 flex flex-wrap items-center gap-1.5">
|
||
305→ 305→ {FILTERS.map((f) => (
|
||
306→ 306→ <button
|
||
307→ 307→ key={f.key}
|
||
308→ 308→ onClick={() => setFilter(f.key)}
|
||
309→ 309→ className={cn(
|
||
310→ 310→ 'shrink-0 rounded-md border px-3 py-1.5 text-xs transition',
|
||
311→ 311→ filter === f.key ? 'border-foreground/40 bg-foreground/5' : 'border-border hover:border-foreground/30'
|
||
312→ 312→ )}
|
||
313→ 313→ >
|
||
314→ 314→ {f.label}
|
||
315→ 315→ </button>
|
||
316→ 316→ ))}
|
||
317→ 317→ </div>
|
||
318→ 318→
|
||
319→ 319→ <Card>
|
||
320→ 320→ <CardHeader className="pb-2">
|
||
321→ 321→ <CardTitle className="text-sm">Produktlizenzen ({filtered.length})</CardTitle>
|
||
322→ 322→ </CardHeader>
|
||
323→ 323→ <CardContent className="p-0">
|
||
324→ 324→ <div className="overflow-x-auto">
|
||
325→ 325→ <table className="w-full text-sm">
|
||
326→ 326→ <thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
|
||
327→ 327→ <tr>
|
||
328→ 328→ <th className="w-8 px-2 py-2" />
|
||
329→ 329→ <th className="px-4 py-2 font-medium">Lizenzschlüssel</th>
|
||
330→ 330→ <th className="px-4 py-2 font-medium">Produkt / Plan</th>
|
||
331→ 331→ <th className="px-4 py-2 font-medium">Kunde</th>
|
||
332→ 332→ <th className="px-4 py-2 font-medium">Aktivierungen</th>
|
||
333→ 333→ <th className="px-4 py-2 font-medium">Status & Billing</th>
|
||
334→ 334→ <th className="px-4 py-2 font-medium">Gültig bis</th>
|
||
335→ 335→ <th className="px-4 py-2 font-medium">Aktionen</th>
|
||
336→ 336→ </tr>
|
||
337→ 337→ </thead>
|
||
338→ 338→ <tbody className="divide-y divide-border">
|
||
339→ 339→ {filtered.length === 0 ? (
|
||
340→ 340→ <tr>
|
||
341→ 341→ <td colSpan={8} className="px-4 py-10 text-center text-xs text-muted-foreground">
|
||
342→ 342→ Keine Lizenzen in diesem Filter.
|
||
343→ 343→ </td>
|
||
344→ 344→ </tr>
|
||
345→ 345→ ) : (
|
||
346→ 346→ filtered.map((l) => (
|
||
347→ 347→ <LicenseRow
|
||
348→ 348→ key={l.id}
|
||
349→ 349→ license={l}
|
||
350→ 350→ onSuspend={(lic) => setSuspendTarget(lic)}
|
||
351→ 351→ onCancel={(lic) => setCancelTarget(lic)}
|
||
352→ 352→ onPromote={handlePromote}
|
||
353→ 353→ />
|
||
354→ 354→ ))
|
||
355→ 355→ )}
|
||
356→ 356→ </tbody>
|
||
357→ 357→ </table>
|
||
358→ 358→ </div>
|
||
359→ 359→ </CardContent>
|
||
360→ 360→ </Card>
|
||
361→ 361→
|
||
362→ 362→ {/* Signing info card */}
|
||
363→ 363→ <Card className="mt-4">
|
||
364→ 364→ <CardContent className="p-4">
|
||
365→ 365→ <div className="flex items-start gap-3">
|
||
366→ 366→ <div className="flex size-9 items-center justify-center rounded-md border border-border">
|
||
367→ 367→ <ShieldCheck className="size-4 text-muted-foreground" />
|
||
368→ 368→ </div>
|
||
369→ 369→ <div className="flex-1">
|
||
370→ 370→ <p className="text-sm font-medium">Ed25519-signierte Lizenz-Tokens</p>
|
||
371→ 371→ <p className="mt-1 text-xs text-muted-foreground">
|
||
372→ 372→ Jede Lizenz hat einen menschenlesbaren Schlüssel plus einen kryptografisch signierten Token. Kunden können offline verifizieren.
|
||
373→ 373→ Der private Schlüssel ist verschlüsselt (AES-256-GCM); Rotation wird unterstützt.
|
||
374→ 374→ </p>
|
||
375→ 375→ <div className="mt-2 flex flex-wrap gap-2">
|
||
376→ 376→ <Badge variant="outline" className="text-[10px]">Public API: /api/v1/validate</Badge>
|
||
377→ 377→ <Badge variant="outline" className="text-[10px]">WordPress-Updater kompatibel</Badge>
|
||
378→ 378→ <Badge variant="outline" className="text-[10px]">Offline-Grace: 14 d</Badge>
|
||
379→ 379→ </div>
|
||
380→ 380→ </div>
|
||
381→ 381→ </div>
|
||
382→ 382→ </CardContent>
|
||
383→ 383→ </Card>
|
||
384→ 384→ </TabsContent>
|
||
385→ 385→
|
||
386→ 386→ <TabsContent value="assets" className="mt-4">
|
||
387→ 387→ <Card>
|
||
388→ 388→ <CardHeader className="pb-2">
|
||
389→ 389→ <CardTitle className="text-sm">Asset- & Erneuerungsregister ({assets.length})</CardTitle>
|
||
390→ 390→ </CardHeader>
|
||
391→ 391→ <CardContent className="p-0">
|
||
392→ 392→ <div className="overflow-x-auto">
|
||
393→ 393→ <table className="w-full text-sm">
|
||
394→ 394→ <thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
|
||
395→ 395→ <tr>
|
||
396→ 396→ <th className="px-4 py-2 font-medium">Asset</th>
|
||
397→ 397→ <th className="px-4 py-2 font-medium">Typ</th>
|
||
398→ 398→ <th className="px-4 py-2 font-medium">Anbieter</th>
|
||
399→ 399→ <th className="px-4 py-2 font-medium">Kosten/Monat</th>
|
||
400→ 400→ <th className="px-4 py-2 font-medium">Erneuerung</th>
|
||
401→ 401→ <th className="px-4 py-2 font-medium">Weiterbelasten</th>
|
||
402→ 402→ <th className="px-4 py-2 font-medium">Status</th>
|
||
403→ 403→ </tr>
|
||
404→ 404→ </thead>
|
||
405→ 405→ <tbody className="divide-y divide-border">
|
||
406→ 406→ {assets.map((a) => {
|
||
407→ 407→ const days = daysUntil(a.renewalDate)
|
||
408→ 408→ return (
|
||
409→ 409→ <tr key={a.id} className="transition hover:bg-muted/40">
|
||
410→ 410→ <td className="px-4 py-2.5 text-xs font-medium">{a.name}</td>
|
||
411→ 411→ <td className="px-4 py-2.5">
|
||
412→ 412→ <Badge variant="secondary" className="text-[10px]">{ASSET_TYPE_LABELS[a.type] ?? a.type}</Badge>
|
||
413→ 413→ </td>
|
||
414→ 414→ <td className="px-4 py-2.5 text-xs text-muted-foreground">{a.provider ?? '—'}</td>
|
||
415→ 415→ <td className="px-4 py-2.5 text-xs tabular-nums">
|
||
416→ 416→ {a.costCentsPerMonth ? formatEuro(a.costCentsPerMonth) : '—'}
|
||
417→ 417→ </td>
|
||
418→ 418→ <td className="px-4 py-2.5 text-xs">
|
||
419→ 419→ <span className={cn(days < 0 && 'text-foreground')}>
|
||
420→ 420→ {formatDate(a.renewalDate)}
|
||
421→ 421→ </span>
|
||
422→ 422→ <span className="ml-1 text-[10px] text-muted-foreground">
|
||
423→ 423→ {days < 0 ? `(${Math.abs(days)} d vorbei)` : `(${days} d)`}
|
||
424→ 424→ </span>
|
||
425→ 425→ </td>
|
||
426→ 426→ <td className="px-4 py-2.5">
|
||
427→ 427→ {a.rebill ? (
|
||
428→ 428→ <Badge variant="outline" className="text-[10px] gap-1">
|
||
429→ 429→ <RefreshCw className="size-2.5" />+{a.markupPct}%
|
||
430→ 430→ </Badge>
|
||
431→ 431→ ) : (
|
||
432→ 432→ <span className="text-[10px] text-muted-foreground">—</span>
|
||
433→ 433→ )}
|
||
434→ 434→ </td>
|
||
435→ 435→ <td className="px-4 py-2.5">
|
||
436→ 436→ <Badge
|
||
437→ 437→ variant="outline"
|
||
438→ 438→ className={cn(
|
||
439→ 439→ 'text-[10px]',
|
||
440→ 440→ a.status === 'expired' && 'border-foreground/40',
|
||
441→ 441→ a.status === 'expiring' && 'border-foreground/30'
|
||
442→ 442→ )}
|
||
443→ 443→ >
|
||
444→ 444→ {a.status === 'expired' ? 'Abgelaufen' : a.status === 'expiring' ? 'Läuft ab' : 'Aktiv'}
|
||
445→ 445→ </Badge>
|
||
446→ 446→ </td>
|
||
447→ 447→ </tr>
|
||
448→ 448→ )
|
||
449→ 449→ })}
|
||
450→ 450→ </tbody>
|
||
451→ 451→ </table>
|
||
452→ 452→ </div>
|
||
453→ 453→ </CardContent>
|
||
454→ 454→ </Card>
|
||
455→ 455→ </TabsContent>
|
||
456→ 456→ </Tabs>
|
||
457→ 457→
|
||
458→ 458→ <CreateLicenseDialog
|
||
459→ 459→ open={createOpen}
|
||
460→ 460→ onOpenChange={setCreateOpen}
|
||
461→ 461→ companies={companies}
|
||
462→ 462→ onCreated={refetch}
|
||
463→ 463→ />
|
||
464→ 464→ <SuspendDialog
|
||
465→ 465→ license={suspendTarget}
|
||
466→ 466→ onOpenChange={(o) => !o && setSuspendTarget(null)}
|
||
467→ 467→ onDone={refetch}
|
||
468→ 468→ />
|
||
469→ 469→ <CancelDialog
|
||
470→ 470→ license={cancelTarget}
|
||
471→ 471→ onOpenChange={(o) => !o && setCancelTarget(null)}
|
||
472→ 472→ onDone={refetch}
|
||
473→ 473→ />
|
||
474→ 474→ </div>
|
||
475→ 475→ )
|
||
476→ 476→}
|
||
477→ 477→
|
||
478→ 478→// ---------------------------------------------------------------------------
|
||
479→ 479→// LicenseRow — expandable row with billing badges + Cancel/Suspend/Promote
|
||
480→ 480→// ---------------------------------------------------------------------------
|
||
481→ 481→
|
||
482→ 482→function LicenseRow({
|
||
483→ 483→ license,
|
||
484→ 484→ onSuspend,
|
||
485→ 485→ onCancel,
|
||
486→ 486→ onPromote,
|
||
487→ 487→}: {
|
||
488→ 488→ license: License
|
||
489→ 489→ onSuspend: (l: License) => void
|
||
490→ 490→ onCancel: (l: License) => void
|
||
491→ 491→ onPromote: (l: License) => void
|
||
492→ 492→}) {
|
||
493→ 493→ const [open, setOpen] = useState(false)
|
||
494→ 494→ const st = LIC_STATUS[license.status] ?? LIC_STATUS.active
|
||
495→ 495→ const Icon = st.icon
|
||
496→ 496→ const valid = license.validUntil ? daysUntil(license.validUntil) : null
|
||
497→ 497→ const isModule = !!license.parentId
|
||
498→ 498→ const inTrial = !!license.trialEndsAt && new Date(license.trialEndsAt) > new Date()
|
||
499→ 499→ const trialDaysLeft = inTrial ? Math.max(0, daysUntil(license.trialEndsAt!)) : 0
|
||
500→ 500→ const cancelled = !!license.cancelledAt || license.status === 'cancelled'
|
||
501→ 501→ const canCancel = !isModule && !cancelled && license.status === 'active'
|
||
502→ 502→ const canReactivate = cancelled
|
||
503→ 503→ const hasModules = !isModule && license.modules.length > 0
|
||
504→ 504→ const showPrice = !isModule && license.netPriceCents > 0
|
||
505→ 505→
|
||
506→ 506→ return (
|
||
507→ 507→ <>
|
||
508→ 508→ <tr className="transition hover:bg-muted/40">
|
||
509→ 509→ <td className="px-2 py-2.5 align-middle">
|
||
510→ 510→ {hasModules ? (
|
||
511→ 511→ <button
|
||
512→ 512→ onClick={() => setOpen((o) => !o)}
|
||
513→ 513→ className="flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-muted"
|
||
514→ 514→ aria-label={open ? 'Einklappen' : 'Ausklappen'}
|
||
515→ 515→ >
|
||
516→ 516→ <ChevronRight className={cn('size-4 transition-transform', open && 'rotate-90')} />
|
||
517→ 517→ </button>
|
||
518→ 518→ ) : (
|
||
519→ 519→ <span className="block size-6" />
|
||
520→ 520→ )}
|
||
521→ 521→ </td>
|
||
522→ 522→ <td className="px-4 py-2.5">
|
||
523→ 523→ <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">{license.key}</code>
|
||
524→ 524→ {isModule && (
|
||
525→ 525→ <Badge variant="outline" className="ml-1 text-[9px]">Modul</Badge>
|
||
526→ 526→ )}
|
||
527→ 527→ </td>
|
||
528→ 528→ <td className="px-4 py-2.5">
|
||
529→ 529→ <p className="text-xs font-medium">{license.product}</p>
|
||
530→ 530→ <Badge variant="outline" className="mt-0.5 text-[10px] capitalize">{license.plan}</Badge>
|
||
531→ 531→ </td>
|
||
532→ 532→ <td className="px-4 py-2.5 text-xs">{license.company.name}</td>
|
||
533→ 533→ <td className="px-4 py-2.5">
|
||
534→ 534→ <span className="text-xs tabular-nums">
|
||
535→ 535→ {license.activations.length} / {license.maxActivations}
|
||
536→ 536→ </span>
|
||
537→ 537→ {license.activations[0] && (
|
||
538→ 538→ <p className="text-[10px] text-muted-foreground">{license.activations[0].domain}</p>
|
||
539→ 539→ )}
|
||
540→ 540→ </td>
|
||
541→ 541→ <td className="px-4 py-2.5">
|
||
542→ 542→ <div className="flex flex-wrap items-center gap-1">
|
||
543→ 543→ <Badge
|
||
544→ 544→ variant="outline"
|
||
545→ 545→ className={cn(
|
||
546→ 546→ 'gap-1 text-[10px]',
|
||
547→ 547→ cancelled ? 'border-foreground/40'
|
||
548→ 548→ : inTrial ? 'border-foreground/30'
|
||
549→ 549→ : license.status === 'active' ? 'border-foreground/40' : ''
|
||
550→ 550→ )}
|
||
551→ 551→ >
|
||
552→ 552→ <Icon className="size-3" />{st.label}
|
||
553→ 553→ </Badge>
|
||
554→ 554→ {showPrice && (
|
||
555→ 555→ <>
|
||
556→ 556→ <Badge
|
||
557→ 557→ variant="outline"
|
||
558→ 558→ className="text-[9px] tabular-nums"
|
||
559→ 559→ title={license.billingCycle === 'yearly' ? 'Jährliche Abrechnung' : 'Monatliche Abrechnung'}
|
||
560→ 560→ >
|
||
561→ 561→ {license.billingCycle === 'yearly' ? 'Y' : 'M'}
|
||
562→ 562→ </Badge>
|
||
563→ 563→ <span className="text-[11px] tabular-nums text-muted-foreground">
|
||
564→ 564→ {formatEuroCompact(license.netPriceCents)}
|
||
565→ 565→ <span className="text-[9px]">/{license.billingCycle === 'yearly' ? 'Jahr' : 'Mon.'}</span>
|
||
566→ 566→ </span>
|
||
567→ 567→ </>
|
||
568→ 568→ )}
|
||
569→ 569→ {inTrial && (
|
||
570→ 570→ <Badge variant="outline" className="gap-1 text-[9px] border-foreground/30">
|
||
571→ 571→ <FlaskConical className="size-2.5" />Trial ({trialDaysLeft} d)
|
||
572→ 572→ </Badge>
|
||
573→ 573→ )}
|
||
574→ 574→ {cancelled && license.cancellationEffectiveAt && (
|
||
575→ 575→ <Badge
|
||
576→ 576→ variant="outline"
|
||
577→ 577→ className="text-[9px] border-foreground/40"
|
||
578→ 578→ title={`Gekündigt am ${formatDate(license.cancelledAt!)}`}
|
||
579→ 579→ >
|
||
580→ 580→ Gekündigt → {formatDate(license.cancellationEffectiveAt)}
|
||
581→ 581→ </Badge>
|
||
582→ 582→ )}
|
||
583→ 583→ {license.environment === 'demo' && (
|
||
584→ 584→ <Badge variant="outline" className="text-[9px]">Demo</Badge>
|
||
585→ 585→ )}
|
||
586→ 586→ </div>
|
||
587→ 587→ </td>
|
||
588→ 588→ <td className="px-4 py-2.5 text-xs">
|
||
589→ 589→ {license.validUntil ? (
|
||
590→ 590→ <span className={cn(valid !== null && valid < 0 && 'text-foreground')}>
|
||
591→ 591→ {formatDate(license.validUntil)}
|
||
592→ 592→ {valid !== null && (
|
||
593→ 593→ <span className="ml-1 text-[10px] text-muted-foreground">
|
||
594→ 594→ {valid < 0 ? `(${Math.abs(valid)} d vorbei)` : `(${valid} d)`}
|
||
595→ 595→ </span>
|
||
596→ 596→ )}
|
||
597→ 597→ </span>
|
||
598→ 598→ ) : (
|
||
599→ 599→ <span className="text-muted-foreground">—</span>
|
||
600→ 600→ )}
|
||
601→ 601→ </td>
|
||
602→ 602→ <td className="px-4 py-2.5">
|
||
603→ 603→ <div className="flex items-center gap-1">
|
||
604→ 604→ {canCancel && (
|
||
605→ 605→ <Button size="sm" variant="outline" className="h-7 gap-1 text-[10px]" onClick={() => onCancel(license)}>
|
||
606→ 606→ <Ban className="size-3" />Kündigen
|
||
607→ 607→ </Button>
|
||
608→ 608→ )}
|
||
609→ 609→ {license.status === 'active' && !cancelled && (
|
||
610→ 610→ <Button size="sm" variant="ghost" className="h-7 px-2 text-[10px]" onClick={() => onSuspend(license)} title="Suspendieren">
|
||
611→ 611→ <PauseCircle className="size-3" />
|
||
612→ 612→ </Button>
|
||
613→ 613→ )}
|
||
614→ 614→ {canReactivate && (
|
||
615→ 615→ <Button size="sm" variant="outline" className="h-7 gap-1 text-[10px]" onClick={() => onPromote(license)} title="Reaktivieren">
|
||
616→ 616→ <RotateCcw className="size-3" />Reaktivieren
|
||
617→ 617→ </Button>
|
||
618→ 618→ )}
|
||
619→ 619→ {license.environment === 'demo' && !cancelled && (
|
||
620→ 620→ <Button size="sm" variant="ghost" className="h-7 px-2 text-[10px]" onClick={() => onPromote(license)} title="Demo → Produktion">
|
||
621→ 621→ <Play className="size-3" />
|
||
622→ 622→ </Button>
|
||
623→ 623→ )}
|
||
624→ 624→ </div>
|
||
625→ 625→ </td>
|
||
626→ 626→ </tr>
|
||
627→ 627→
|
||
628→ 628→ {/* Expanded module rows */}
|
||
629→ 629→ {open && hasModules && (
|
||
630→ 630→ <tr className="bg-muted/20">
|
||
631→ 631→ <td colSpan={8} className="px-4 pb-3 pt-1">
|
||
632→ 632→ <div className="rounded-md border border-border bg-background">
|
||
633→ 633→ <table className="w-full text-xs">
|
||
634→ 634→ <thead className="border-b border-border text-left text-[10px] uppercase tracking-wide text-muted-foreground">
|
||
635→ 635→ <tr>
|
||
636→ 636→ <th className="px-3 py-2 font-medium">Modul</th>
|
||
637→ 637→ <th className="px-3 py-2 font-medium">Schlüssel</th>
|
||
638→ 638→ <th className="px-3 py-2 font-medium">Billing</th>
|
||
639→ 639→ <th className="px-3 py-2 font-medium">Status</th>
|
||
640→ 640→ <th className="px-3 py-2 font-medium">Gültig bis</th>
|
||
641→ 641→ </tr>
|
||
642→ 642→ </thead>
|
||
643→ 643→ <tbody className="divide-y divide-border">
|
||
644→ 644→ {license.modules.map((m) => {
|
||
645→ 645→ const mst = LIC_STATUS[m.status] ?? LIC_STATUS.active
|
||
646→ 646→ const MIcon = mst.icon
|
||
647→ 647→ const mInTrial = !!m.trialEndsAt && new Date(m.trialEndsAt) > new Date()
|
||
648→ 648→ const mCancelled = !!m.cancelledAt || m.status === 'cancelled'
|
||
649→ 649→ return (
|
||
650→ 650→ <tr key={m.id}>
|
||
651→ 651→ <td className="px-3 py-2 font-medium">{m.module ?? '—'}</td>
|
||
652→ 652→ <td className="px-3 py-2">
|
||
653→ 653→ <code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">{m.key}</code>
|
||
654→ 654→ </td>
|
||
655→ 655→ <td className="px-3 py-2">
|
||
656→ 656→ {m.priceCents > 0 ? (
|
||
657→ 657→ <span className="tabular-nums text-muted-foreground">
|
||
658→ 658→ {formatEuroCompact(m.netPriceCents)}
|
||
659→ 659→ <span className="ml-0.5 text-[9px]">/{m.billingCycle === 'yearly' ? 'Jahr' : 'Mon.'}</span>
|
||
660→ 660→ </span>
|
||
661→ 661→ ) : (
|
||
662→ 662→ <Badge variant="outline" className="text-[9px]">via Core</Badge>
|
||
663→ 663→ )}
|
||
664→ 664→ </td>
|
||
665→ 665→ <td className="px-3 py-2">
|
||
666→ 666→ <Badge
|
||
667→ 667→ variant="outline"
|
||
668→ 668→ className={cn(
|
||
669→ 669→ 'gap-1 text-[9px]',
|
||
670→ 670→ mCancelled ? 'border-foreground/40' : mInTrial ? 'border-foreground/30' : ''
|
||
671→ 671→ )}
|
||
672→ 672→ >
|
||
673→ 673→ <MIcon className="size-2.5" />{mst.label}
|
||
674→ 674→ </Badge>
|
||
675→ 675→ {mInTrial && (
|
||
676→ 676→ <Badge variant="outline" className="ml-1 gap-1 text-[9px] border-foreground/30">
|
||
677→ 677→ <FlaskConical className="size-2.5" />Trial
|
||
678→ 678→ </Badge>
|
||
679→ 679→ )}
|
||
680→ 680→ </td>
|
||
681→ 681→ <td className="px-3 py-2 text-muted-foreground">
|
||
682→ 682→ {m.validUntil ? formatDate(m.validUntil) : '—'}
|
||
683→ 683→ </td>
|
||
684→ 684→ </tr>
|
||
685→ 685→ )
|
||
686→ 686→ })}
|
||
687→ 687→ </tbody>
|
||
688→ 688→ </table>
|
||
689→ 689→ </div>
|
||
690→ 690→ </td>
|
||
691→ 691→ </tr>
|
||
692→ 692→ )}
|
||
693→ 693→ </>
|
||
694→ 694→ )
|
||
695→ 695→}
|
||
696→ 696→
|
||
697→ 697→// ---------------------------------------------------------------------------
|
||
698→ 698→// Promote / Reactivate helper
|
||
699→ 699→// ---------------------------------------------------------------------------
|
||
700→ 700→
|
||
701→ 701→async function promoteLicense(license: License, refetch: () => void) {
|
||
702→ 702→ const t = toast.loading('Lizenz wird reaktiviert…')
|
||
703→ 703→ try {
|
||
704→ 704→ const r = await fetch(`/api/aos/licenses/${license.id}/promote`, {
|
||
705→ 705→ method: 'PATCH',
|
||
706→ 706→ headers: { 'content-type': 'application/json' },
|
||
707→ 707→ body: JSON.stringify({ environment: 'production' }),
|
||
708→ 708→ })
|
||
709→ 709→ if (!r.ok) {
|
||
710→ 710→ const e = await r.json().catch(() => ({}))
|
||
711→ 711→ throw new Error(e.error || 'promote failed')
|
||
712→ 712→ }
|
||
713→ 713→ toast.success('Lizenz reaktiviert', { id: t })
|
||
714→ 714→ refetch()
|
||
715→ 715→ } catch (err) {
|
||
716→ 716→ toast.error(err instanceof Error ? err.message : 'Reaktivierung fehlgeschlagen', { id: t })
|
||
717→ 717→ }
|
||
718→ 718→}
|
||
719→ 719→
|
||
720→ 720→// ---------------------------------------------------------------------------
|
||
721→ 721→// Create License Dialog — with Billing section + live pricing preview
|
||
722→ 722→// ---------------------------------------------------------------------------
|
||
723→ 723→
|
||
724→ 724→function CreateLicenseDialog({
|
||
725→ 725→ open,
|
||
726→ 726→ onOpenChange,
|
||
727→ 727→ companies,
|
||
728→ 728→ onCreated,
|
||
729→ 729→}: {
|
||
730→ 730→ open: boolean
|
||
731→ 731→ onOpenChange: (o: boolean) => void
|
||
732→ 732→ companies: CompanyLite[]
|
||
733→ 733→ onCreated: () => void
|
||
734→ 734→}) {
|
||
735→ 735→ const [companyId, setCompanyId] = useState('')
|
||
736→ 736→ const [product, setProduct] = useState('aos-cms')
|
||
737→ 737→ const [plan, setPlan] = useState<'starter' | 'pro' | 'enterprise'>('pro')
|
||
738→ 738→ const [environment, setEnvironment] = useState<'demo' | 'production'>('production')
|
||
739→ 739→ const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly')
|
||
740→ 740→ const [monthlyPriceCents, setMonthlyPriceCents] = useState<string>('')
|
||
741→ 741→ const [startTrial, setStartTrial] = useState(true)
|
||
742→ 742→ const [maxActivations, setMaxActivations] = useState(1)
|
||
743→ 743→ const [saving, setSaving] = useState(false)
|
||
744→ 744→
|
||
745→ 745→ // Default trial ON for production, OFF for demo.
|
||
746→ 746→ useEffect(() => {
|
||
747→ 747→ if (environment === 'demo') setStartTrial(false)
|
||
748→ 748→ else setStartTrial(true)
|
||
749→ 749→ }, [environment])
|
||
750→ 750→
|
||
751→ 751→ // Pick first company when the dialog opens.
|
||
752→ 752→ useEffect(() => {
|
||
753→ 753→ if (open && companies[0] && !companyId) setCompanyId(companies[0].id)
|
||
754→ 754→ }, [open, companies, companyId])
|
||
755→ 755→
|
||
756→ 756→ // Reset everything when the dialog closes.
|
||
757→ 757→ useEffect(() => {
|
||
758→ 758→ if (open) return
|
||
759→ 759→ setCompanyId('')
|
||
760→ 760→ setProduct('aos-cms')
|
||
761→ 761→ setPlan('pro')
|
||
762→ 762→ setEnvironment('production')
|
||
763→ 763→ setBillingCycle('monthly')
|
||
764→ 764→ setMonthlyPriceCents('')
|
||
765→ 765→ setStartTrial(true)
|
||
766→ 766→ setMaxActivations(1)
|
||
767→ 767→ }, [open])
|
||
768→ 768→
|
||
769→ 769→ const selectedCompany = companies.find((c) => c.id === companyId) ?? null
|
||
770→ 770→
|
||
771→ 771→ // Live pricing preview — mirrors the backend computePricing() formula.
|
||
772→ 772→ const pricing = useMemo(() => {
|
||
773→ 773→ const monthlyCents = monthlyPriceCents
|
||
774→ 774→ ? Math.max(0, parseInt(monthlyPriceCents, 10) || 0)
|
||
775→ 775→ : PLAN_PRICES[plan] ?? 0
|
||
776→ 776→ const baseCents = billingCycle === 'yearly' ? monthlyCents * 12 : monthlyCents
|
||
777→ 777→ const yearlyPct = billingCycle === 'yearly' ? YEARLY_DISCOUNT_PCT : 0
|
||
778→ 778→ let agencyPct = 0
|
||
779→ 779→ if (selectedCompany?.parentAgencyId) {
|
||
780→ 780→ const agency = companies.find((c) => c.id === selectedCompany.parentAgencyId)
|
||
781→ 781→ if (agency?.isAgency && agency.agencyStatus === 'approved') {
|
||
782→ 782→ agencyPct = agency.agencyDiscountPct ?? 0
|
||
783→ 783→ }
|
||
784→ 784→ }
|
||
785→ 785→ const totalPct = agencyPct + yearlyPct
|
||
786→ 786→ const discountAmount = Math.round((baseCents * totalPct) / 100)
|
||
787→ 787→ const netPriceCents = baseCents - discountAmount
|
||
788→ 788→ return { monthlyCents, baseCents, agencyPct, yearlyPct, totalPct, discountAmount, netPriceCents }
|
||
789→ 789→ }, [monthlyPriceCents, billingCycle, plan, selectedCompany, companies])
|
||
790→ 790→
|
||
791→ 791→ const submit = async () => {
|
||
792→ 792→ if (!companyId) { toast.error('Firma auswählen'); return }
|
||
793→ 793→ if (!product.trim()) { toast.error('Produkt eingeben'); return }
|
||
794→ 794→ setSaving(true)
|
||
795→ 795→ const t = toast.loading('Lizenz wird ausgestellt…')
|
||
796→ 796→ try {
|
||
797→ 797→ const body: Record<string, unknown> = {
|
||
798→ 798→ companyId,
|
||
799→ 799→ product: product.trim(),
|
||
800→ 800→ plan,
|
||
801→ 801→ environment,
|
||
802→ 802→ billingCycle,
|
||
803→ 803→ maxActivations,
|
||
804→ 804→ startTrial,
|
||
805→ 805→ }
|
||
806→ 806→ const parsed = monthlyPriceCents ? parseInt(monthlyPriceCents, 10) : NaN
|
||
807→ 807→ if (!Number.isNaN(parsed) && parsed > 0) body.monthlyPriceCents = parsed
|
||
808→ 808→ const r = await fetch('/api/aos/licenses', {
|
||
809→ 809→ method: 'POST',
|
||
810→ 810→ headers: { 'content-type': 'application/json' },
|
||
811→ 811→ body: JSON.stringify(body),
|
||
812→ 812→ })
|
||
813→ 813→ const res = await r.json()
|
||
814→ 814→ if (!r.ok) throw new Error(res.error || 'create failed')
|
||
815→ 815→ const pricingEcho = res.pricing
|
||
816→ 816→ const cycleLabel = billingCycle === 'yearly' ? 'Jahr' : 'Monat'
|
||
817→ 817→ const netCents = typeof pricingEcho?.netPriceCents === 'number'
|
||
818→ 818→ ? pricingEcho.netPriceCents
|
||
819→ 819→ : pricing.netPriceCents
|
||
820→ 820→ const discPct = typeof pricingEcho?.totalDiscountPct === 'number'
|
||
821→ 821→ ? pricingEcho.totalDiscountPct
|
||
822→ 822→ : pricing.totalPct
|
||
823→ 823→ const netEuro = (netCents / 100).toLocaleString('de-DE', {
|
||
824→ 824→ minimumFractionDigits: 2,
|
||
825→ 825→ maximumFractionDigits: 2,
|
||
826→ 826→ })
|
||
827→ 827→ toast.success(
|
||
828→ 828→ `Lizenz ausgestellt — ${netEuro} €/${cycleLabel} (inkl. ${discPct}% Rabatt)`,
|
||
829→ 829→ { id: t, duration: 6000 }
|
||
830→ 830→ )
|
||
831→ 831→ onOpenChange(false)
|
||
832→ 832→ onCreated()
|
||
833→ 833→ } catch (err) {
|
||
834→ 834→ toast.error(err instanceof Error ? err.message : 'Ausstellung fehlgeschlagen', { id: t })
|
||
835→ 835→ } finally {
|
||
836→ 836→ setSaving(false)
|
||
837→ 837→ }
|
||
838→ 838→ }
|
||
839→ 839→
|
||
840→ 840→ return (
|
||
841→ 841→ <Dialog open={open} onOpenChange={onOpenChange}>
|
||
842→ 842→ <DialogContent className="sm:max-w-lg">
|
||
843→ 843→ <DialogHeader>
|
||
844→ 844→ <DialogTitle>Lizenz ausstellen</DialogTitle>
|
||
845→ 845→ <DialogDescription>
|
||
846→ 846→ Stellt eine neue Lizenz aus. Billing, Probezeit und Kündigungsfrist (90 Tage) werden automatisch konfiguriert.
|
||
847→ 847→ </DialogDescription>
|
||
848→ 848→ </DialogHeader>
|
||
849→ 849→ <div className="space-y-3">
|
||
850→ 850→ {/* Customer */}
|
||
851→ 851→ <div className="space-y-1.5">
|
||
852→ 852→ <Label className="text-xs">Firma</Label>
|
||
853→ 853→ <Select value={companyId} onValueChange={setCompanyId}>
|
||
854→ 854→ <SelectTrigger className="w-full"><SelectValue placeholder="Firma wählen" /></SelectTrigger>
|
||
855→ 855→ <SelectContent>
|
||
856→ 856→ {companies.map((c) => (
|
||
857→ 857→ <SelectItem key={c.id} value={c.id}>
|
||
858→ 858→ {c.name}
|
||
859→ 859→ {c.isAgency && c.agencyStatus === 'approved' && ' (Agentur)'}
|
||
860→ 860→ </SelectItem>
|
||
861→ 861→ ))}
|
||
862→ 862→ </SelectContent>
|
||
863→ 863→ </Select>
|
||
864→ 864→ </div>
|
||
865→ 865→ {/* Product + Plan */}
|
||
866→ 866→ <div className="grid grid-cols-2 gap-3">
|
||
867→ 867→ <div className="space-y-1.5">
|
||
868→ 868→ <Label className="text-xs">Produkt</Label>
|
||
869→ 869→ <Input value={product} onChange={(e) => setProduct(e.target.value)} placeholder="z.B. aos-cms" />
|
||
870→ 870→ </div>
|
||
871→ 871→ <div className="space-y-1.5">
|
||
872→ 872→ <Label className="text-xs">Plan</Label>
|
||
873→ 873→ <Select value={plan} onValueChange={(v) => setPlan(v as 'starter' | 'pro' | 'enterprise')}>
|
||
874→ 874→ <SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||
875→ 875→ <SelectContent>
|
||
876→ 876→ <SelectItem value="starter">Starter (29 €/Mon.)</SelectItem>
|
||
877→ 877→ <SelectItem value="pro">Pro (50 €/Mon.)</SelectItem>
|
||
878→ 878→ <SelectItem value="enterprise">Enterprise (150 €/Mon.)</SelectItem>
|
||
879→ 879→ </SelectContent>
|
||
880→ 880→ </Select>
|
||
881→ 881→ </div>
|
||
882→ 882→ </div>
|
||
883→ 883→ {/* Environment + Activations */}
|
||
884→ 884→ <div className="grid grid-cols-2 gap-3">
|
||
885→ 885→ <div className="space-y-1.5">
|
||
886→ 886→ <Label className="text-xs">Umgebung</Label>
|
||
887→ 887→ <Select value={environment} onValueChange={(v) => setEnvironment(v as 'demo' | 'production')}>
|
||
888→ 888→ <SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||
889→ 889→ <SelectContent>
|
||
890→ 890→ <SelectItem value="production">Produktion</SelectItem>
|
||
891→ 891→ <SelectItem value="demo">Demo (14 d, Wasserzeichen)</SelectItem>
|
||
892→ 892→ </SelectContent>
|
||
893→ 893→ </Select>
|
||
894→ 894→ </div>
|
||
895→ 895→ <div className="space-y-1.5">
|
||
896→ 896→ <Label className="text-xs">Max. Aktivierungen</Label>
|
||
897→ 897→ <Input
|
||
898→ 898→ type="number"
|
||
899→ 899→ min={1}
|
||
900→ 900→ max={100}
|
||
901→ 901→ value={maxActivations}
|
||
902→ 902→ onChange={(e) => setMaxActivations(Math.max(1, parseInt(e.target.value, 10) || 1))}
|
||
903→ 903→ />
|
||
904→ 904→ </div>
|
||
905→ 905→ </div>
|
||
906→ 906→
|
||
907→ 907→ {/* Billing section */}
|
||
908→ 908→ <div className="space-y-3 rounded-md border border-border p-3">
|
||
909→ 909→ <div className="flex items-center gap-2">
|
||
910→ 910→ <CircleDollarSign className="size-3.5 text-muted-foreground" />
|
||
911→ 911→ <span className="text-xs font-semibold uppercase tracking-wide">Billing</span>
|
||
912→ 912→ </div>
|
||
913→ 913→ <div className="grid grid-cols-2 gap-3">
|
||
914→ 914→ <div className="space-y-1.5">
|
||
915→ 915→ <Label className="text-xs">Abrechnung</Label>
|
||
916→ 916→ <Select value={billingCycle} onValueChange={(v) => setBillingCycle(v as 'monthly' | 'yearly')}>
|
||
917→ 917→ <SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||
918→ 918→ <SelectContent>
|
||
919→ 919→ <SelectItem value="monthly">Monatlich</SelectItem>
|
||
920→ 920→ <SelectItem value="yearly">Jährlich</SelectItem>
|
||
921→ 921→ </SelectContent>
|
||
922→ 922→ </Select>
|
||
923→ 923→ {billingCycle === 'yearly' && (
|
||
924→ 924→ <p className="text-[10px] text-muted-foreground">10% Jahresrabatt wird auf den Jahresbetrag angewendet.</p>
|
||
925→ 925→ )}
|
||
926→ 926→ </div>
|
||
927→ 927→ <div className="space-y-1.5">
|
||
928→ 928→ <Label className="text-xs">Monatspreis (Cents)</Label>
|
||
929→ 929→ <Input
|
||
930→ 930→ type="number"
|
||
931→ 931→ min={0}
|
||
932→ 932→ value={monthlyPriceCents}
|
||
933→ 933→ onChange={(e) => setMonthlyPriceCents(e.target.value)}
|
||
934→ 934→ placeholder="z.B. 5000 (= 50€)"
|
||
935→ 935→ />
|
||
936→ 936→ <p className="text-[10px] text-muted-foreground">
|
||
937→ 937→ Standard: 29€ (Starter), 50€ (Pro), 150€ (Enterprise). Überschreiben für Custom-Preis.
|
||
938→ 938→ </p>
|
||
939→ 939→ </div>
|
||
940→ 940→ </div>
|
||
941→ 941→ <div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/20 p-2.5">
|
||
942→ 942→ <div>
|
||
943→ 943→ <Label className="text-xs">14-Tage-Testphase starten</Label>
|
||
944→ 944→ <p className="text-[10px] text-muted-foreground">Tag 1–14 kostenlos kündbar. Tag 15 beginnt das kostenpflichtige Abo.</p>
|
||
945→ 945→ </div>
|
||
946→ 946→ <Switch
|
||
947→ 947→ checked={startTrial}
|
||
948→ 948→ onCheckedChange={setStartTrial}
|
||
949→ 949→ disabled={environment === 'demo'}
|
||
950→ 950→ />
|
||
951→ 951→ </div>
|
||
952→ 952→
|
||
953→ 953→ {/* Live pricing preview */}
|
||
954→ 954→ <div className="rounded-md border border-border bg-background p-2.5 text-xs">
|
||
955→ 955→ <div className="flex items-center justify-between">
|
||
956→ 956→ <span className="text-muted-foreground">Basispreis ({billingCycle === 'yearly' ? 'Jahr' : 'Monat'})</span>
|
||
957→ 957→ <span className="tabular-nums">{formatEuro(pricing.baseCents)}</span>
|
||
958→ 958→ </div>
|
||
959→ 959→ {pricing.agencyPct > 0 && (
|
||
960→ 960→ <div className="mt-1 flex items-center justify-between">
|
||
961→ 961→ <span className="text-muted-foreground">Agentur-Rabatt ({pricing.agencyPct}%)</span>
|
||
962→ 962→ <span className="tabular-nums text-foreground">−{formatEuro(Math.round((pricing.baseCents * pricing.agencyPct) / 100))}</span>
|
||
963→ 963→ </div>
|
||
964→ 964→ )}
|
||
965→ 965→ {pricing.yearlyPct > 0 && (
|
||
966→ 966→ <div className="mt-1 flex items-center justify-between">
|
||
967→ 967→ <span className="text-muted-foreground">Jahresrabatt ({pricing.yearlyPct}%)</span>
|
||
968→ 968→ <span className="tabular-nums text-foreground">−{formatEuro(Math.round((pricing.baseCents * pricing.yearlyPct) / 100))}</span>
|
||
969→ 969→ </div>
|
||
970→ 970→ )}
|
||
971→ 971→ {pricing.totalPct > 0 && (
|
||
972→ 972→ <div className="mt-1 flex items-center justify-between text-muted-foreground">
|
||
973→ 973→ <span>Gesamtrabatt</span>
|
||
974→ 974→ <span>{pricing.totalPct}%</span>
|
||
975→ 975→ </div>
|
||
976→ 976→ )}
|
||
977→ 977→ <div className="mt-2 flex items-center justify-between border-t border-border pt-2 font-medium">
|
||
978→ 978→ <span>Netto / {billingCycle === 'yearly' ? 'Jahr' : 'Monat'}</span>
|
||
979→ 979→ <span className="tabular-nums">{formatEuro(pricing.netPriceCents)}</span>
|
||
980→ 980→ </div>
|
||
981→ 981→ </div>
|
||
982→ 982→ </div>
|
||
983→ 983→ </div>
|
||
984→ 984→ <DialogFooter>
|
||
985→ 985→ <Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
|
||
986→ 986→ <Button onClick={submit} disabled={saving} className="gap-1.5">
|
||
987→ 987→ {saving && <Loader2 className="size-3.5 animate-spin" />}
|
||
988→ 988→ Ausstellen
|
||
989→ 989→ </Button>
|
||
990→ 990→ </DialogFooter>
|
||
991→ 991→ </DialogContent>
|
||
992→ 992→ </Dialog>
|
||
993→ 993→ )
|
||
994→ 994→}
|
||
995→ 995→
|
||
996→ 996→// ---------------------------------------------------------------------------
|
||
997→ 997→// Suspend Dialog
|
||
998→ 998→// ---------------------------------------------------------------------------
|
||
999→ 999→
|
||
1000→ 1000→function SuspendDialog({
|
||
1001→ 1001→ license,
|
||
1002→ 1002→ onOpenChange,
|
||
1003→ 1003→ onDone,
|
||
1004→ 1004→}: {
|
||
1005→ 1005→ license: License | null
|
||
1006→ 1006→ onOpenChange: (o: boolean) => void
|
||
1007→ 1007→ onDone: () => void
|
||
1008→ 1008→}) {
|
||
1009→ 1009→ const [reason, setReason] = useState('')
|
||
1010→ 1010→ const [saving, setSaving] = useState(false)
|
||
1011→ 1011→
|
||
1012→ 1012→ useEffect(() => {
|
||
1013→ 1013→ if (!license) setReason('')
|
||
1014→ 1014→ }, [license])
|
||
1015→ 1015→
|
||
1016→ 1016→ const submit = async () => {
|
||
1017→ 1017→ if (!license) return
|
||
1018→ 1018→ if (!reason.trim()) { toast.error('Grund erforderlich'); return }
|
||
1019→ 1019→ setSaving(true)
|
||
1020→ 1020→ const t = toast.loading('Lizenz wird suspendiert…')
|
||
1021→ 1021→ try {
|
||
1022→ 1022→ const r = await fetch(`/api/aos/licenses/${license.id}/suspend`, {
|
||
1023→ 1023→ method: 'PATCH',
|
||
1024→ 1024→ headers: { 'content-type': 'application/json' },
|
||
1025→ 1025→ body: JSON.stringify({ reason: reason.trim() }),
|
||
1026→ 1026→ })
|
||
1027→ 1027→ const res = await r.json()
|
||
1028→ 1028→ if (!r.ok) throw new Error(res.error || 'suspend failed')
|
||
1029→ 1029→ toast.success(
|
||
1030→ 1030→ `Lizenz suspendiert${res.suspendedModules ? ` (+${res.suspendedModules} Module)` : ''}`,
|
||
1031→ 1031→ { id: t }
|
||
1032→ 1032→ )
|
||
1033→ 1033→ onOpenChange(false)
|
||
1034→ 1034→ onDone()
|
||
1035→ 1035→ } catch (err) {
|
||
1036→ 1036→ toast.error(err instanceof Error ? err.message : 'Suspension fehlgeschlagen', { id: t })
|
||
1037→ 1037→ } finally {
|
||
1038→ 1038→ setSaving(false)
|
||
1039→ 1039→ }
|
||
1040→ 1040→ }
|
||
1041→ 1041→
|
||
1042→ 1042→ return (
|
||
1043→ 1043→ <Dialog open={!!license} onOpenChange={onOpenChange}>
|
||
1044→ 1044→ <DialogContent className="sm:max-w-md">
|
||
1045→ 1045→ <DialogHeader>
|
||
1046→ 1046→ <DialogTitle>Lizenz suspendieren</DialogTitle>
|
||
1047→ 1047→ <DialogDescription>
|
||
1048→ 1048→ Suspendiert die Lizenz (und alle Module). Aktivierte Installationen verweigern den Dienst bis zur Reaktivierung.
|
||
1049→ 1049→ </DialogDescription>
|
||
1050→ 1050→ </DialogHeader>
|
||
1051→ 1051→ {license && (
|
||
1052→ 1052→ <div className="space-y-3">
|
||
1053→ 1053→ <div className="rounded-md border border-border bg-muted/30 p-2.5 text-xs">
|
||
1054→ 1054→ <p className="font-medium">{license.product} — {license.plan}</p>
|
||
1055→ 1055→ <p className="mt-0.5 text-muted-foreground">{license.company.name}</p>
|
||
1056→ 1056→ <code className="mt-1 inline-block rounded bg-muted px-1 py-0.5 font-mono text-[10px]">{license.key}</code>
|
||
1057→ 1057→ </div>
|
||
1058→ 1058→ <div className="space-y-1.5">
|
||
1059→ 1059→ <Label className="text-xs">Grund</Label>
|
||
1060→ 1060→ <Textarea
|
||
1061→ 1061→ value={reason}
|
||
1062→ 1062→ onChange={(e) => setReason(e.target.value)}
|
||
1063→ 1063→ placeholder="z.B. Zahlungsverzug Mahnstufe 3, Missbrauch, Kunden-Anfrage…"
|
||
1064→ 1064→ rows={3}
|
||
1065→ 1065→ />
|
||
1066→ 1066→ </div>
|
||
1067→ 1067→ </div>
|
||
1068→ 1068→ )}
|
||
1069→ 1069→ <DialogFooter>
|
||
1070→ 1070→ <Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
|
||
1071→ 1071→ <Button variant="destructive" onClick={submit} disabled={saving} className="gap-1.5">
|
||
1072→ 1072→ {saving && <Loader2 className="size-3.5 animate-spin" />}
|
||
1073→ 1073→ Suspendieren
|
||
1074→ 1074→ </Button>
|
||
1075→ 1075→ </DialogFooter>
|
||
1076→ 1076→ </DialogContent>
|
||
1077→ 1077→ </Dialog>
|
||
1078→ 1078→ )
|
||
1079→ 1079→}
|
||
1080→ 1080→
|
||
1081→ 1081→// ---------------------------------------------------------------------------
|
||
1082→ 1082→// Cancel Dialog — slide-to-confirm (SlideCommit)
|
||
1083→ 1083→// ---------------------------------------------------------------------------
|
||
1084→ 1084→
|
||
1085→ 1085→function CancelDialog({
|
||
1086→ 1086→ license,
|
||
1087→ 1087→ onOpenChange,
|
||
1088→ 1088→ onDone,
|
||
1089→ 1089→}: {
|
||
1090→ 1090→ license: License | null
|
||
1091→ 1091→ onOpenChange: (o: boolean) => void
|
||
1092→ 1092→ onDone: () => void
|
||
1093→ 1093→}) {
|
||
1094→ 1094→ const [reason, setReason] = useState('')
|
||
1095→ 1095→ const [committing, setCommitting] = useState(false)
|
||
1096→ 1096→
|
||
1097→ 1097→ useEffect(() => {
|
||
1098→ 1098→ if (!license) {
|
||
1099→ 1099→ setReason('')
|
||
1100→ 1100→ setCommitting(false)
|
||
1101→ 1101→ }
|
||
1102→ 1102→ }, [license])
|
||
1103→ 1103→
|
||
1104→ 1104→ if (!license) return null
|
||
1105→ 1105→
|
||
1106→ 1106→ const inTrial = !!license.trialEndsAt && new Date(license.trialEndsAt) > new Date()
|
||
1107→ 1107→ // Estimated effective date (mirrors the backend formula for preview only —
|
||
1108→ 1108→ // the backend returns the authoritative effectiveAt in the response).
|
||
1109→ 1109→ const effectiveAt = inTrial
|
||
1110→ 1110→ ? new Date()
|
||
1111→ 1111→ : new Date(Date.now() + (license.noticePeriodDays || 90) * 86400000)
|
||
1112→ 1112→
|
||
1113→ 1113→ const handleCommit = async () => {
|
||
1114→ 1114→ setCommitting(true)
|
||
1115→ 1115→ const t = toast.loading('Kündigung wird eingereicht…')
|
||
1116→ 1116→ try {
|
||
1117→ 1117→ const r = await fetch(`/api/aos/licenses/${license.id}/cancel`, {
|
||
1118→ 1118→ method: 'PATCH',
|
||
1119→ 1119→ headers: { 'content-type': 'application/json' },
|
||
1120→ 1120→ body: JSON.stringify({ reason: reason.trim() || undefined }),
|
||
1121→ 1121→ })
|
||
1122→ 1122→ const res = await r.json()
|
||
1123→ 1123→ if (!r.ok) throw new Error(res.error || 'cancel failed')
|
||
1124→ 1124→ toast.success(res.message ?? 'Kündigung eingegangen', { id: t, duration: 6000 })
|
||
1125→ 1125→ onOpenChange(false)
|
||
1126→ 1126→ onDone()
|
||
1127→ 1127→ } catch (err) {
|
||
1128→ 1128→ toast.error(err instanceof Error ? err.message : 'Kündigung fehlgeschlagen', { id: t })
|
||
1129→ 1129→ } finally {
|
||
1130→ 1130→ setCommitting(false)
|
||
1131→ 1131→ }
|
||
1132→ 1132→ }
|
||
1133→ 1133→
|
||
1134→ 1134→ return (
|
||
1135→ 1135→ <Dialog open={!!license} onOpenChange={onOpenChange}>
|
||
1136→ 1136→ <DialogContent className="sm:max-w-md">
|
||
1137→ 1137→ <DialogHeader>
|
||
1138→ 1138→ <DialogTitle className="flex items-center gap-2">
|
||
1139→ 1139→ <Ban className="size-4" />
|
||
1140→ 1140→ Lizenz kündigen
|
||
1141→ 1141→ </DialogTitle>
|
||
1142→ 1142→ <DialogDescription>
|
||
1143→ 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→ 1144→ </DialogDescription>
|
||
1145→ 1145→ </DialogHeader>
|
||
1146→ 1146→ <div className="space-y-3">
|
||
1147→ 1147→ <div className="rounded-md border border-border bg-muted/30 p-2.5 text-xs">
|
||
1148→ 1148→ <p className="font-medium">{license.product} — {license.plan}</p>
|
||
1149→ 1149→ <p className="mt-0.5 text-muted-foreground">{license.company.name}</p>
|
||
1150→ 1150→ <code className="mt-1 inline-block rounded bg-muted px-1 py-0.5 font-mono text-[10px]">{license.key}</code>
|
||
1151→ 1151→ </div>
|
||
1152→ 1152→
|
||
1153→ 1153→ {/* Trial status banner */}
|
||
1154→ 1154→ <div
|
||
1155→ 1155→ className={cn(
|
||
1156→ 1156→ 'flex items-start gap-2 rounded-md border p-2.5 text-xs',
|
||
1157→ 1157→ inTrial ? 'border-foreground/30 bg-foreground/5' : 'border-foreground/40 bg-foreground/5'
|
||
1158→ 1158→ )}
|
||
1159→ 1159→ >
|
||
1160→ 1160→ <FlaskConical className="mt-0.5 size-3.5 shrink-0 text-foreground" />
|
||
1161→ 1161→ <div>
|
||
1162→ 1162→ {inTrial ? (
|
||
1163→ 1163→ <>
|
||
1164→ 1164→ <p className="font-medium">In Probezeit — sofortige Kündigung</p>
|
||
1165→ 1165→ <p className="mt-0.5 text-muted-foreground">
|
||
1166→ 1166→ Sie befinden sich in der 14-Tage-Testphase — Kündigung wird sofort wirksam, keine Abbuchung.
|
||
1167→ 1167→ </p>
|
||
1168→ 1168→ </>
|
||
1169→ 1169→ ) : (
|
||
1170→ 1170→ <>
|
||
1171→ 1171→ <p className="font-medium">Kündigungsfrist: 90 Tage</p>
|
||
1172→ 1172→ <p className="mt-0.5 text-muted-foreground">
|
||
1173→ 1173→ Kündigung frist: 90 Tage. Wirksam zum{' '}
|
||
1174→ 1174→ <span className="font-medium">{formatDate(effectiveAt)}</span>{' '}
|
||
1175→ 1175→ <span className="text-muted-foreground">({formatRelative(effectiveAt)}).</span>
|
||
1176→ 1176→ </p>
|
||
1177→ 1177→ </>
|
||
1178→ 1178→ )}
|
||
1179→ 1179→ </div>
|
||
1180→ 1180→ </div>
|
||
1181→ 1181→
|
||
1182→ 1182→ <div className="space-y-1.5">
|
||
1183→ 1183→ <Label className="text-xs">Kündigungsgrund (optional)</Label>
|
||
1184→ 1184→ <Textarea
|
||
1185→ 1185→ value={reason}
|
||
1186→ 1186→ onChange={(e) => setReason(e.target.value)}
|
||
1187→ 1187→ placeholder="z.B. Projektabbruch, Umzug auf andere Lösung, Kosten…"
|
||
1188→ 1188→ rows={3}
|
||
1189→ 1189→ />
|
||
1190→ 1190→ </div>
|
||
1191→ 1191→
|
||
1192→ 1192→ <SlideCommit
|
||
1193→ 1193→ label="Kündigung bestätigen →"
|
||
1194→ 1194→ onCommit={handleCommit}
|
||
1195→ 1195→ loading={committing}
|
||
1196→ 1196→ />
|
||
1197→ 1197→ </div>
|
||
1198→ 1198→ </DialogContent>
|
||
1199→ 1199→ </Dialog>
|
||
1200→ 1200→ )
|
||
1201→ 1201→}
|
||
1202→ 1202→ |