feat: client portal actions — Stripe pay + ticket create + license cancel

- PortalInvoices: Stripe checkout button (redirects to Stripe Checkout)
- PortalTickets: 'Neues Ticket' dialog (subject, category, priority, message)
  - Calls POST /api/aos/tickets (auto-routes to agency if parent set)
- PortalLicenses: 'Kündigen' button + inline cancel dialog
  - Shows trial vs notice period info
  - Calls PATCH /api/aos/licenses/[id]/cancel
- Updated PortalData.licenses interface with billing fields
This commit is contained in:
Z User committed 2026-10-28 23:09:54 +00:00
1 parent 17e0cfe8a3
commit d97dd955d1
1 file changed
+251 -3
+251 -3
View File
@@ -37,10 +37,20 @@ import {
AlertTriangle,
Bell,
Info,
CreditCard,
Plus,
Ban,
} from 'lucide-react'
import { toast } from 'sonner'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { formatEuro, formatDate, formatRelative, daysUntil } from '@/lib/format'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import {
Popover,
PopoverContent,
@@ -94,6 +104,8 @@ interface PortalData {
licenses: {
id: string; key: string; product: string; plan: string; status: string
validUntil: string | null; activations: { domain: string; environment: string }[]
netPriceCents: number; billingCycle: string; parentId: string | null
trialEndsAt: string | null; noticePeriodDays: number; cancelledAt: string | null
}[]
}
@@ -503,6 +515,32 @@ function PortalApprovals({ data, refetch }: { data: PortalData; refetch: () => v
}
function PortalInvoices({ data }: { data: PortalData }) {
const [paying, setPaying] = useState<string | null>(null)
const companyId = data.company.id
const pay = async (licenseId: string) => {
setPaying(licenseId)
const t = toast.loading('Stripe Checkout wird vorbereitet…')
try {
const r = await fetch('/api/aos/stripe/checkout', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ licenseId }),
})
const res = await r.json()
if (!r.ok) throw new Error(res.error || 'checkout failed')
toast.success('Weiterleitung zu Stripe…', { id: t })
window.location.href = res.url
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Checkout fehlgeschlagen', { id: t })
} finally {
setPaying(null)
}
}
// Find licenses with a price > 0 that could be paid
const payableLicenses = data.licenses.filter((l) => l.netPriceCents > 0 && l.status === 'active' && !l.parentId)
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
@@ -512,6 +550,46 @@ function PortalInvoices({ data }: { data: PortalData }) {
<p className="text-xs text-muted-foreground">Deine Rechnungen — Ansicht nur für dein Unternehmen.</p>
</div>
</div>
{/* Pay subscription */}
{payableLicenses.length > 0 && (
<Card className="border-foreground/30 bg-foreground/5">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2">
<CreditCard className="mt-0.5 size-4 text-foreground" />
<div>
<p className="text-sm font-medium">Abonnement bezahlen</p>
<p className="text-xs text-muted-foreground">
{payableLicenses.length} Lizenz(en) mit offener Zahlung. Klicke auf „Jetzt zahlen", um zur Stripe-Checkout-Seite zu gelangen.
</p>
</div>
</div>
</div>
<div className="mt-3 space-y-2">
{payableLicenses.map((l) => (
<div key={l.id} className="flex items-center justify-between rounded-md border border-border p-2">
<div>
<p className="text-xs font-medium">{l.product} — {l.plan}</p>
<p className="text-[10px] text-muted-foreground">{formatEuro(l.netPriceCents)} / {l.billingCycle === 'yearly' ? 'Jahr' : 'Monat'}</p>
</div>
<Button
size="sm"
className="gap-1.5"
disabled={paying === l.id}
onClick={() => pay(l.id)}
>
{paying === l.id && <Loader2 className="size-3.5 animate-spin" />}
<CreditCard className="size-3.5" />
Jetzt zahlen
</Button>
</div>
))}
</div>
</CardContent>
</Card>
)}
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
@@ -557,6 +635,47 @@ function PortalInvoices({ data }: { data: PortalData }) {
}
function PortalTickets({ data }: { data: PortalData }) {
const companyId = data.company.id
const [showCreate, setShowCreate] = useState(false)
const [subject, setSubject] = useState('')
const [category, setCategory] = useState('anfrage')
const [priority, setPriority] = useState<'low' | 'normal' | 'high' | 'urgent'>('normal')
const [message, setMessage] = useState('')
const [creating, setCreating] = useState(false)
const createTicket = async () => {
if (!subject.trim() || !message.trim()) return
setCreating(true)
const t = toast.loading('Ticket wird erstellt…')
try {
const r = await fetch('/api/aos/tickets', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
companyId,
subject: subject.trim(),
categoryId: category,
priority,
message: message.trim(),
fromName: data.company.name,
}),
})
const res = await r.json()
if (!r.ok) throw new Error(res.error || 'create failed')
toast.success(`Ticket ${res.ticket?.number ?? ''} erstellt`, { id: t })
setShowCreate(false)
setSubject('')
setCategory('anfrage')
setPriority('normal')
setMessage('')
window.location.reload()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Erstellung fehlgeschlagen', { id: t })
} finally {
setCreating(false)
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -567,7 +686,9 @@ function PortalTickets({ data }: { data: PortalData }) {
<p className="text-xs text-muted-foreground">Support-Anfragen und Konversationen.</p>
</div>
</div>
<Button size="sm"><LifeBuoy className="size-4" />Neues Ticket</Button>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="size-4" />Neues Ticket
</Button>
</div>
<div className="space-y-2">
{data.tickets.map((t) => (
@@ -587,11 +708,94 @@ function PortalTickets({ data }: { data: PortalData }) {
))}
{data.tickets.length === 0 && <p className="py-8 text-center text-xs text-muted-foreground">Keine Tickets.</p>}
</div>
{/* Create Ticket Dialog */}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Neues Ticket</DialogTitle>
<DialogDescription>
Support-Anfrage erstellen. Dein Ticket wird an den zuständigen Reseller oder DFM Solutions weitergeleitet.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-xs">Betreff</Label>
<Input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Kurze Beschreibung des Problems" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Kategorie</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="anfrage">Anfrage</SelectItem>
<SelectItem value="bug">Fehler</SelectItem>
<SelectItem value="aenderung">Änderung</SelectItem>
<SelectItem value="rechnung">Rechnung</SelectItem>
<SelectItem value="konto">Konto</SelectItem>
<SelectItem value="hosting">Hosting</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Priorität</Label>
<Select value={priority} onValueChange={(v) => setPriority(v as 'low' | 'normal' | 'high' | 'urgent')}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="low">Niedrig</SelectItem>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="high">Hoch</SelectItem>
<SelectItem value="urgent">Dringend</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Nachricht</Label>
<Textarea value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Beschreibe dein Anliegen…" rows={4} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreate(false)}>Abbrechen</Button>
<Button onClick={createTicket} disabled={creating || !subject.trim() || !message.trim()} className="gap-1.5">
{creating && <Loader2 className="size-3.5 animate-spin" />}
Erstellen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function PortalLicenses({ data }: { data: PortalData }) {
const [cancelling, setCancelling] = useState<string | null>(null)
const [showCancel, setShowCancel] = useState<string | null>(null)
const [cancelReason, setCancelReason] = useState('')
const cancelLicense = async (licenseId: string) => {
setCancelling(licenseId)
const t = toast.loading('Kündigung wird verarbeitet…')
try {
const r = await fetch(`/api/aos/licenses/${licenseId}/cancel`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ reason: cancelReason || undefined }),
})
const res = await r.json()
if (!r.ok) throw new Error(res.error || 'cancel failed')
toast.success(res.message || 'Lizenz gekündigt', { id: t, duration: 6000 })
setShowCancel(null)
setCancelReason('')
window.location.reload()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Kündigung fehlgeschlagen', { id: t })
} finally {
setCancelling(null)
}
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
@@ -616,8 +820,52 @@ function PortalLicenses({ data }: { data: PortalData }) {
{l.validUntil && <p>Gültig bis: {formatDate(l.validUntil)}</p>}
</div>
</div>
<Badge variant="outline" className="text-[10px] capitalize">{l.status}</Badge>
<div className="flex flex-col items-end gap-2">
<Badge variant="outline" className="text-[10px] capitalize">{l.status}</Badge>
{l.status === 'active' && !l.parentId && !l.cancelledAt && (
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 text-[10px] text-muted-foreground hover:text-foreground"
onClick={() => setShowCancel(l.id)}
>
<Ban className="size-3" />
Kündigen
</Button>
)}
</div>
</div>
{/* Cancel dialog */}
{showCancel === l.id && (
<div className="mt-3 rounded-md border border-foreground/30 bg-foreground/5 p-3">
<p className="text-xs font-medium">Lizenz kündigen</p>
<p className="mt-1 text-[11px] text-muted-foreground">
{l.trialEndsAt && new Date(l.trialEndsAt) > new Date()
? 'Du befindest dich in der 14-Tage-Testphase — Kündigung wird sofort wirksam, keine Abbuchung.'
: `Kündigungsfrist: ${l.noticePeriodDays ?? 90} Tage. Wirksam zum ${new Date(Date.now() + (l.noticePeriodDays ?? 90) * 86400000).toLocaleDateString('de-DE')}.`}
</p>
<Input
className="mt-2"
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
placeholder="Grund (optional)"
/>
<div className="mt-2 flex gap-2">
<Button size="sm" variant="ghost" onClick={() => { setShowCancel(null); setCancelReason('') }}>Abbrechen</Button>
<Button
size="sm"
variant="destructive"
disabled={cancelling === l.id}
onClick={() => cancelLicense(l.id)}
className="gap-1.5"
>
{cancelling === l.id && <Loader2 className="size-3.5 animate-spin" />}
Kündigung bestätigen
</Button>
</div>
</div>
)}
</Card>
))}
</div>