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:
1 parent
17e0cfe8a3
commit
d97dd955d1
1 file changed
+251
-3
@@ -37,10 +37,20 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Bell,
|
Bell,
|
||||||
Info,
|
Info,
|
||||||
|
CreditCard,
|
||||||
|
Plus,
|
||||||
|
Ban,
|
||||||
} from 'lucide-react'
|
} 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 { formatEuro, formatDate, formatRelative, daysUntil } from '@/lib/format'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -94,6 +104,8 @@ interface PortalData {
|
|||||||
licenses: {
|
licenses: {
|
||||||
id: string; key: string; product: string; plan: string; status: string
|
id: string; key: string; product: string; plan: string; status: string
|
||||||
validUntil: string | null; activations: { domain: string; environment: 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 }) {
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<p className="text-xs text-muted-foreground">Deine Rechnungen — Ansicht nur für dein Unternehmen.</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -557,6 +635,47 @@ function PortalInvoices({ data }: { data: PortalData }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PortalTickets({ 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<p className="text-xs text-muted-foreground">Support-Anfragen und Konversationen.</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{data.tickets.map((t) => (
|
{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>}
|
{data.tickets.length === 0 && <p className="py-8 text-center text-xs text-muted-foreground">Keine Tickets.</p>}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PortalLicenses({ data }: { data: PortalData }) {
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<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>}
|
{l.validUntil && <p>Gültig bis: {formatDate(l.validUntil)}</p>}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user