From 39b3636333427c0d68daa0d4d09e7f3f21e25869 Mon Sep 17 00:00:00 2001 From: Z User Date: Wed, 28 Oct 2026 23:46:54 +0000 Subject: [PATCH] feat: reseller self-service portal (clients + licenses + tickets) - ResellerPortalView with 3 tabs: 1. Kunden: list agency's clients + create new client (POST /api/aos/clients) 2. Lizenzen: list agency-filtered licenses + create new license with live pricing preview (base + agency discount + yearly discount = net) 3. Tickets: agency-scoped ticket inbox + escalate button - Top: agency selector (pick which approved reseller to act as) - Full integration: store, sidebar, top-bar, command-menu, i18n, app-shell - Mobile responsive, German labels, no indigo/blue --- src/components/aos/app-shell.tsx | 2 + src/components/aos/command-menu.tsx | 2 + src/components/aos/sidebar-nav.tsx | 2 + src/components/aos/top-bar.tsx | 1 + src/components/aos/views/reseller-portal.tsx | 1308 +++++++++++++++++ src/lib/i18n.ts | 3 + src/store/aos.ts | 1 + .../read_1793230719374_07f1dc503692.txt | 876 +++++++++++ .../read_1793230721870_848aab285542.txt | 876 +++++++++++ 9 files changed, 3071 insertions(+) create mode 100644 src/components/aos/views/reseller-portal.tsx create mode 100644 tool-results/read_1793230719374_07f1dc503692.txt create mode 100644 tool-results/read_1793230721870_848aab285542.txt diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx index 537a555..1721865 100755 --- a/src/components/aos/app-shell.tsx +++ b/src/components/aos/app-shell.tsx @@ -19,6 +19,7 @@ import { TimesheetView } from './views/timesheet' import { SupportView } from './views/support' import { LicensesView } from './views/licenses' import { AgencyView } from './views/agency' +import { ResellerPortalView } from './views/reseller-portal' import { InstancesView } from './views/instances' import { ContractsView } from './views/contracts' import { ExpensesView } from './views/expenses' @@ -97,6 +98,7 @@ export function AppShell() { {view === 'support' && } {view === 'licenses' && } {view === 'agency' && } + {view === 'reseller_portal' && } {view === 'instances' && } {view === 'contracts' && } {view === 'expenses' && } diff --git a/src/components/aos/command-menu.tsx b/src/components/aos/command-menu.tsx index f9529fd..9d25743 100755 --- a/src/components/aos/command-menu.tsx +++ b/src/components/aos/command-menu.tsx @@ -27,6 +27,7 @@ import { CalendarDays, Database, Server, + Store, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { @@ -51,6 +52,7 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st { id: 'support', label: 'Support', icon: LifeBuoy, hint: 'Tickets & SLA' }, { id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' }, { id: 'agency', label: 'Reseller', icon: Users, hint: 'Reseller-Bewerbungen & Rabatte' }, + { id: 'reseller_portal', label: 'Reseller-Portal', icon: Store, hint: 'Selbstbedienung: Kunden, Lizenzen, Tickets' }, { id: 'instances', label: 'Instanzen', icon: Server, hint: 'Tenant-Instanzen & Provisioning' }, { id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' }, { id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' }, diff --git a/src/components/aos/sidebar-nav.tsx b/src/components/aos/sidebar-nav.tsx index a800e36..3fa62a2 100755 --- a/src/components/aos/sidebar-nav.tsx +++ b/src/components/aos/sidebar-nav.tsx @@ -29,6 +29,7 @@ import { RefreshCw, Landmark, Server, + Store, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { translate } from '@/lib/i18n' @@ -69,6 +70,7 @@ const NAV: { items: [ { id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' }, { id: 'agency', label: 'Reseller', icon: Users, permission: 'admin' }, + { id: 'reseller_portal', label: 'Reseller-Portal', icon: Store, permission: 'admin' }, { id: 'instances', label: 'Instanzen', icon: Server, permission: 'admin' }, { id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' }, { id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' }, diff --git a/src/components/aos/top-bar.tsx b/src/components/aos/top-bar.tsx index 25b2e70..6b8fa0f 100755 --- a/src/components/aos/top-bar.tsx +++ b/src/components/aos/top-bar.tsx @@ -29,6 +29,7 @@ const TITLES: Record = { support: { title: 'Support', crumb: 'Lieferung / Tickets' }, licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' }, agency: { title: 'Reseller', crumb: 'Betrieb / Reseller' }, + reseller_portal: { title: 'Reseller-Portal', crumb: 'Betrieb / Reseller-Portal' }, instances: { title: 'Instanzen', crumb: 'Betrieb / Instanzen' }, contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' }, expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' }, diff --git a/src/components/aos/views/reseller-portal.tsx b/src/components/aos/views/reseller-portal.tsx new file mode 100644 index 0000000..9acab1a --- /dev/null +++ b/src/components/aos/views/reseller-portal.tsx @@ -0,0 +1,1308 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { + Store, + Users, + KeyRound, + LifeBuoy, + Plus, + Building2, + Mail, + MapPin, + Percent, + Loader2, + Inbox, + ArrowUpCircle, + CheckCircle2, + PauseCircle, + Ban, + XCircle, + FlaskConical, + CircleDollarSign, +} from 'lucide-react' +import { useFetch } from '@/hooks/use-fetch' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Skeleton } from '@/components/ui/skeleton' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Table, + TableHeader, + TableBody, + TableHead, + TableRow, + TableCell, +} from '@/components/ui/table' +import { PageHeader } from '../primitives' +import { + formatEuro, + formatEuroCompact, + formatDate, + formatRelative, + daysUntil, +} from '@/lib/format' +import { cn } from '@/lib/utils' +import { toast } from 'sonner' + +/* ----------------------------- types ----------------------------- */ + +interface AgencyCompany { + id: string + name: string + email: string | null + website: string | null + city: string | null + isAgency: boolean + agencyStatus: string | null + agencyDiscountPct: number + agencyApprovedAt: string | null +} + +interface AgencyListResponse { + agencies: AgencyCompany[] + applications: { + pending: unknown[] + reviewed: unknown[] + } + stats: { pending: number; approved: number; totalApplications: number } +} + +interface AgencyClient { + id: string + name: string + email: string | null + phone: string | null + city: string | null + zip: string | null + street: string | null + website: string | null + _count: { licenses: number; tickets: number } + createdAt: string +} + +interface ClientsResponse { + clients: AgencyClient[] +} + +interface LicenseCompany { + id: string + name: string + parentAgencyId: string | null +} + +interface License { + id: string + key: string + product: string + plan: string + status: string + environment: 'demo' | 'production' + parentId: string | null + module: string | null + billingCycle: 'monthly' | 'yearly' + priceCents: number + discountPct: number + netPriceCents: number + validUntil: string | null + trialEndsAt: string | null + cancelledAt: string | null + createdAt: string + company: LicenseCompany +} + +interface LicensesResponse { + licenses: License[] + assets: unknown[] + stats: Record +} + +interface TicketLastMessage { + fromName: string + fromType: string + body: string + createdAt: string +} + +interface AgencyTicket { + id: string + number: string + subject: string + status: string + priority: string + companyName: string + escalatedToAdmin: boolean + escalatedAt: string | null + createdAt: string + updatedAt: string + messageCount: number + lastMessage: TicketLastMessage | null + sla: { + responseHours: number + resolveHours: number + respondedAt: string | null + resolvedAt: string | null + responseBreached: boolean + resolveBreached: boolean + ageHours: number + } +} + +interface TicketsResponse { + tickets: AgencyTicket[] + stats: Record +} + +/* ----------------------------- constants ----------------------------- */ + +const PLAN_PRICES: Record = { + starter: 2900, + pro: 5000, + enterprise: 15000, +} +const YEARLY_DISCOUNT_PCT = 10 + +const LIC_STATUS: Record = { + active: { label: 'Aktiv', icon: CheckCircle2 }, + suspended: { label: 'Suspendiert', icon: PauseCircle }, + expired: { label: 'Abgelaufen', icon: XCircle }, + revoked: { label: 'Widerrufen', icon: XCircle }, + cancelled: { label: 'Gekündigt', icon: Ban }, +} + +const TICKET_STATUS: Record = { + open: { label: 'Offen', cls: 'border-foreground/40' }, + pending: { label: 'Wartend', cls: 'border-foreground/30' }, + resolved: { label: 'Gelöst', cls: 'border-border' }, + closed: { label: 'Geschlossen', cls: 'border-border text-muted-foreground' }, +} + +const PRIORITY_META: Record = { + low: { label: 'Niedrig', cls: 'border-border' }, + normal: { label: 'Normal', cls: 'border-foreground/30' }, + high: { label: 'Hoch', cls: 'border-foreground/50' }, + urgent: { label: 'Dringend', cls: 'border-foreground bg-foreground/5' }, +} + +/* ----------------------------- view ----------------------------- */ + +export function ResellerPortalView() { + const { data: agencyData, loading: agencyLoading } = useFetch('/api/aos/agency') + const [agencyId, setAgencyId] = useState(null) + const [tab, setTab] = useState<'clients' | 'licenses' | 'tickets'>('clients') + + const agencies = agencyData?.agencies ?? [] + + // Auto-select the first approved agency on first load. + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + if (!agencyId && agencies[0]) setAgencyId(agencies[0].id) + }, [agencyId, agencies]) + + const selectedAgency = agencies.find((a) => a.id === agencyId) ?? null + + if (agencyLoading && !agencyData) { + return ( +
+ + + +
+ ) + } + + return ( +
+ + + + + {agencyId ? ( + setTab(v as 'clients' | 'licenses' | 'tickets')}> + + + + Kunden + + + + Lizenzen + + + + Tickets + + + + + + + + + + + + + + ) : ( + + )} +
+ ) +} + +/* ----------------------------- agency selector ----------------------------- */ + +function AgencySelector({ + agencies, + value, + onChange, + selected, +}: { + agencies: AgencyCompany[] + value: string | null + onChange: (id: string | null) => void + selected: AgencyCompany | null +}) { + return ( + +
+
+
+ +
+
+

Reseller auswählen

+

+ Du agierst im Namen dieses genehmigten Resellers. Alle Aktionen (Kunden anlegen, + Lizenzen ausstellen, Tickets) werden diesem Reseller zugeordnet. +

+
+
+
+ + {selected && ( +
+ + + {selected.agencyDiscountPct}% Rabatt + + {selected.email && ( + + + {selected.email} + + )} + {selected.city && ( + + + {selected.city} + + )} +
+ )} +
+
+
+ ) +} + +/* ----------------------------- clients tab ----------------------------- */ + +function ClientsTab({ agencyId }: { agencyId: string }) { + const url = `/api/aos/clients?agencyId=${encodeURIComponent(agencyId)}` + const { data, loading, refetch } = useFetch(url) + const [createOpen, setCreateOpen] = useState(false) + + if (loading && !data) { + return ( +
+
+ +
+ +
+ ) + } + + const clients = data?.clients ?? [] + + return ( +
+
+

+ Kunden des Resellers + + {clients.length} {clients.length === 1 ? 'Kunde' : 'Kunden'} + +

+ +
+ + {clients.length === 0 ? ( + setCreateOpen(true)}> + + Neuer Kunde + + } + /> + ) : ( + + + Kunden ({clients.length}) + + +
+ + + + Name + E-Mail + Stadt + Lizenzen + Tickets + Erstellt + + + + {clients.map((c) => ( + + +
+
+ +
+
+

{c.name}

+ {c.website && ( +

+ {c.website} +

+ )} +
+
+
+ + {c.email ? ( + + + {c.email} + + ) : ( + — + )} + + + {c.city ? ( + + + {c.city} + + ) : ( + — + )} + + + {c._count.licenses} + + + {c._count.tickets} + + + {formatDate(c.createdAt)} + +
+ ))} +
+
+
+
+
+ )} + + +
+ ) +} + +function CreateClientDialog({ + open, + onOpenChange, + agencyId, + onCreated, +}: { + open: boolean + onOpenChange: (o: boolean) => void + agencyId: string + onCreated: () => void +}) { + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [phone, setPhone] = useState('') + const [city, setCity] = useState('') + const [zip, setZip] = useState('') + const [street, setStreet] = useState('') + const [website, setWebsite] = useState('') + const [saving, setSaving] = useState(false) + + // Reset on close. + useEffect(() => { + if (open) return + setName('') + setEmail('') + setPhone('') + setCity('') + setZip('') + setStreet('') + setWebsite('') + }, [open]) + + const submit = async () => { + if (!name.trim()) { + toast.error('Name ist erforderlich') + return + } + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + toast.error('Ungültige E-Mail-Adresse') + return + } + setSaving(true) + const t = toast.loading('Kunde wird angelegt…') + try { + const body: Record = { + name: name.trim(), + parentAgencyId: agencyId, + } + if (email.trim()) body.email = email.trim() + if (phone.trim()) body.phone = phone.trim() + if (city.trim()) body.city = city.trim() + if (zip.trim()) body.zip = zip.trim() + if (street.trim()) body.street = street.trim() + if (website.trim()) body.website = website.trim() + + const r = await fetch('/api/aos/clients', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + const res = await r.json() + if (!r.ok) throw new Error(res.error || 'create failed') + toast.success(`Kunde „${name.trim()}“ angelegt`, { id: t }) + onOpenChange(false) + onCreated() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Anlegen fehlgeschlagen', { id: t }) + } finally { + setSaving(false) + } + } + + return ( + + + + Neuer Kunde + + Der Kunde wird dem ausgewählten Reseller zugeordnet. Tickets dieses Kunden + werden automatisch an den Reseller geroutet. + + +
+
+ + setName(e.target.value)} + placeholder="z. B. Restaurant Athena GmbH" + autoFocus + /> +
+
+
+ + setEmail(e.target.value)} + placeholder="kontakt@kunde.de" + /> +
+
+ + setPhone(e.target.value)} + placeholder="+49 …" + /> +
+
+
+
+ + setZip(e.target.value)} + placeholder="10115" + /> +
+
+ + setCity(e.target.value)} + placeholder="Berlin" + /> +
+
+
+ + setStreet(e.target.value)} + placeholder="Musterstraße 1" + /> +
+
+ + setWebsite(e.target.value)} + placeholder="https://kunde.de" + /> +
+
+ + + + +
+
+ ) +} + +/* ----------------------------- licenses tab ----------------------------- */ + +function LicensesTab({ + agencyId, + agencyDiscountPct, +}: { + agencyId: string + agencyDiscountPct: number +}) { + const { data, loading, refetch } = useFetch('/api/aos/licenses') + const { data: clientsData } = useFetch( + `/api/aos/clients?agencyId=${encodeURIComponent(agencyId)}`, + ) + const [createOpen, setCreateOpen] = useState(false) + + if (loading && !data) { + return ( +
+
+ +
+ +
+ ) + } + + // Filter client-side to licenses belonging to this agency's clients. + const allLicenses = data?.licenses ?? [] + const licenses = allLicenses.filter((l) => l.company.parentAgencyId === agencyId) + const clients = clientsData?.clients ?? [] + + return ( +
+
+

+ Lizenzen der Reseller-Kunden + + {licenses.length} {licenses.length === 1 ? 'Lizenz' : 'Lizenzen'} + +

+ +
+ + {licenses.length === 0 ? ( + 0 ? ( + + ) : undefined + } + /> + ) : ( + + + Lizenzen ({licenses.length}) + + +
+ + + + Schlüssel + Produkt / Plan + Kunde + Status + Nettopreis + Gültig bis + + + + {licenses.map((l) => { + const st = LIC_STATUS[l.status] ?? LIC_STATUS.active + const SIcon = st.icon + const isModule = !!l.parentId + const cancelled = !!l.cancelledAt || l.status === 'cancelled' + const inTrial = !!l.trialEndsAt && new Date(l.trialEndsAt) > new Date() + const valid = l.validUntil ? daysUntil(l.validUntil) : null + const cycleLabel = l.billingCycle === 'yearly' ? 'Jahr' : 'Mon.' + return ( + + + + {l.key} + + {isModule && ( + Modul + )} + {l.environment === 'demo' && ( + Demo + )} + + +

{l.product}

+ + {l.plan} + +
+ {l.company.name} + +
+ + + {st.label} + + {inTrial && ( + + + Trial + + )} + {l.netPriceCents > 0 && ( + + {l.billingCycle === 'yearly' ? 'Y' : 'M'} + + )} +
+
+ + {l.netPriceCents > 0 ? ( +
+ + {formatEuroCompact(l.netPriceCents)} + + + / {cycleLabel} + + {l.discountPct > 0 && ( + + −{l.discountPct}% + + )} +
+ ) : ( + — + )} +
+ + {l.validUntil ? ( + + {formatDate(l.validUntil)} + {valid !== null && ( + + {valid < 0 ? `(${Math.abs(valid)} d vorbei)` : `(${valid} d)`} + + )} + + ) : ( + — + )} + +
+ ) + })} +
+
+
+
+
+ )} + + +
+ ) +} + +function CreateLicenseDialog({ + open, + onOpenChange, + clients, + agencyDiscountPct, + onCreated, +}: { + open: boolean + onOpenChange: (o: boolean) => void + clients: AgencyClient[] + agencyDiscountPct: number + onCreated: () => void +}) { + const [clientId, setClientId] = useState('') + const [product, setProduct] = useState('restov1') + const [plan, setPlan] = useState<'starter' | 'pro' | 'enterprise'>('pro') + const [environment, setEnvironment] = useState<'demo' | 'production'>('production') + const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly') + const [saving, setSaving] = useState(false) + + // Pick first client when dialog opens. + useEffect(() => { + if (open && clients[0] && !clientId) setClientId(clients[0].id) + }, [open, clients, clientId]) + + // Reset on close. + useEffect(() => { + if (open) return + setProduct('restov1') + setPlan('pro') + setEnvironment('production') + setBillingCycle('monthly') + }, [open]) + + // Live pricing preview — mirrors the backend computePricing() formula. + const pricing = useMemo(() => { + const monthlyCents = PLAN_PRICES[plan] ?? 0 + const baseCents = billingCycle === 'yearly' ? monthlyCents * 12 : monthlyCents + const yearlyPct = billingCycle === 'yearly' ? YEARLY_DISCOUNT_PCT : 0 + const agencyPct = agencyDiscountPct + const totalPct = agencyPct + yearlyPct + const discountAmount = Math.round((baseCents * totalPct) / 100) + const netPriceCents = baseCents - discountAmount + return { monthlyCents, baseCents, agencyPct, yearlyPct, totalPct, discountAmount, netPriceCents } + }, [plan, billingCycle, agencyDiscountPct]) + + const submit = async () => { + if (!clientId) { + toast.error('Bitte einen Kunden auswählen') + return + } + if (!product.trim()) { + toast.error('Produkt eingeben') + return + } + setSaving(true) + const t = toast.loading('Lizenz wird ausgestellt…') + try { + const body: Record = { + companyId: clientId, + product: product.trim(), + plan, + environment, + billingCycle, + } + const r = await fetch('/api/aos/licenses', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + const res = await r.json() + if (!r.ok) throw new Error(res.error || 'create failed') + const pricingEcho = res.pricing + const cycleLabel = billingCycle === 'yearly' ? 'Jahr' : 'Monat' + const netCents = + typeof pricingEcho?.netPriceCents === 'number' + ? pricingEcho.netPriceCents + : pricing.netPriceCents + const discPct = + typeof pricingEcho?.totalDiscountPct === 'number' + ? pricingEcho.totalDiscountPct + : pricing.totalPct + const netEuro = (netCents / 100).toLocaleString('de-DE', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + toast.success( + `Lizenz ${res.license?.key ?? ''} ausgestellt — ${netEuro} €/${cycleLabel} (inkl. ${discPct}% Rabatt)`, + { id: t, duration: 6000 }, + ) + onOpenChange(false) + onCreated() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Ausstellung fehlgeschlagen', { id: t }) + } finally { + setSaving(false) + } + } + + return ( + + + + Neue Lizenz + + Stellt eine Lizenz für einen Kunden des Resellers aus. Der Agentur-Rabatt + ({agencyDiscountPct}%) wird vom Backend automatisch angewendet. + + +
+
+ + +
+ +
+
+ + setProduct(e.target.value)} + placeholder="z. B. restov1" + /> +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + {/* Live pricing preview */} +
+
+ + Preisvorschau +
+
+
+ + Basispreis ({billingCycle === 'yearly' ? 'Jahr' : 'Monat'}) + + {formatEuro(pricing.baseCents)} +
+ {pricing.agencyPct > 0 && ( +
+ + Agentur-Rabatt ({pricing.agencyPct}%) + + + −{formatEuro(Math.round((pricing.baseCents * pricing.agencyPct) / 100))} + +
+ )} + {pricing.yearlyPct > 0 && ( +
+ + Jahresrabatt ({pricing.yearlyPct}%) + + + −{formatEuro(Math.round((pricing.baseCents * pricing.yearlyPct) / 100))} + +
+ )} + {pricing.totalPct > 0 && ( +
+ Gesamtrabatt + {pricing.totalPct}% +
+ )} +
+ Netto / {billingCycle === 'yearly' ? 'Jahr' : 'Monat'} + {formatEuro(pricing.netPriceCents)} +
+
+
+
+ + + + +
+
+ ) +} + +/* ----------------------------- tickets tab ----------------------------- */ + +function TicketsTab({ agencyId }: { agencyId: string }) { + const url = `/api/aos/tickets?scope=agency&agencyId=${encodeURIComponent(agencyId)}` + const { data, loading, refetch } = useFetch(url) + const [escalateTarget, setEscalateTarget] = useState(null) + + if (loading && !data) { + return ( +
+ +
+ ) + } + + const tickets = data?.tickets ?? [] + + return ( +
+
+

+ Ticket-Postfach + + {tickets.length} {tickets.length === 1 ? 'Ticket' : 'Tickets'} + +

+
+ + {tickets.length === 0 ? ( + + ) : ( + + + Tickets ({tickets.length}) + + +
+ + + + Nummer + Betreff + Kunde + Status + Priorität + Letzte Nachricht + Aktion + + + + {tickets.map((t) => { + const st = TICKET_STATUS[t.status] ?? TICKET_STATUS.open + const pr = PRIORITY_META[t.priority] ?? PRIORITY_META.normal + const lastDate = t.lastMessage?.createdAt ?? t.updatedAt + return ( + + + {t.number} + + +

{t.subject}

+ {t.lastMessage && ( +

+ {t.lastMessage.fromName}: {t.lastMessage.body} +

+ )} +
+ {t.companyName} + + + {st.label} + + + + + {pr.label} + + + + {formatRelative(lastDate)} + + + {t.escalatedToAdmin ? ( + + + Eskaliert + + ) : ( + + )} + +
+ ) + })} +
+
+
+
+
+ )} + + !o && setEscalateTarget(null)} + onDone={refetch} + /> +
+ ) +} + +function EscalateDialog({ + ticket, + onOpenChange, + onDone, +}: { + ticket: AgencyTicket | null + onOpenChange: (o: boolean) => void + onDone: () => void +}) { + const [saving, setSaving] = useState(false) + + const submit = async () => { + if (!ticket) return + setSaving(true) + const t = toast.loading('Ticket wird eskaliert…') + try { + const r = await fetch(`/api/aos/tickets/${ticket.id}/escalate`, { + method: 'PATCH', + }) + const res = await r.json() + if (!r.ok) throw new Error(res.error || 'escalate failed') + toast.success(`Ticket ${ticket.number} an Admin eskaliert`, { id: t }) + onOpenChange(false) + onDone() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Eskalation fehlgeschlagen', { id: t }) + } finally { + setSaving(false) + } + } + + return ( + + + + + + Ticket an Admin eskalieren + + + Das Ticket wird an den Plattform-Admin weitergeleitet. Es verschwindet aus + deinem Postfach und taucht im Admin-Inbox auf. + + + {ticket && ( +
+
+ {ticket.number} + {ticket.priority} +
+

{ticket.subject}

+

Kunde: {ticket.companyName}

+
+ )} + + + + +
+
+ ) +} + +/* ----------------------------- shared empty state ----------------------------- */ + +function EmptyState({ + icon: Icon, + title, + description, + action, +}: { + icon: typeof Users + title: string + description?: string + action?: React.ReactNode +}) { + return ( +
+
+ +
+
+

{title}

+ {description &&

{description}

} +
+ {action} +
+ ) +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index a5a7d28..774fd15 100755 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -29,6 +29,7 @@ const de: Dict = { 'nav.support': 'Support', 'nav.licenses': 'Lizenzen', 'nav.agency': 'Reseller', + 'nav.reseller_portal': 'Reseller-Portal', 'nav.instances': 'Instanzen', 'nav.contracts': 'Verträge', 'nav.expenses': 'Ausgaben', @@ -87,6 +88,7 @@ const en: Dict = { 'nav.support': 'Support', 'nav.licenses': 'Licenses', 'nav.agency': 'Reseller', + 'nav.reseller_portal': 'Reseller Portal', 'nav.instances': 'Instances', 'nav.contracts': 'Contracts', 'nav.expenses': 'Expenses', @@ -141,6 +143,7 @@ const tr: Dict = { 'nav.support': 'Destek', 'nav.licenses': 'Lisanslar', 'nav.agency': 'Bayi', + 'nav.reseller_portal': 'Bayi Portal', 'nav.instances': 'Sunucular', 'nav.contracts': 'Sözleşmeler', 'nav.expenses': 'Giderler', diff --git a/src/store/aos.ts b/src/store/aos.ts index 871d48e..f15af1b 100755 --- a/src/store/aos.ts +++ b/src/store/aos.ts @@ -14,6 +14,7 @@ export type ViewId = | 'support' | 'licenses' | 'agency' + | 'reseller_portal' | 'instances' | 'contracts' | 'expenses' diff --git a/tool-results/read_1793230719374_07f1dc503692.txt b/tool-results/read_1793230719374_07f1dc503692.txt new file mode 100644 index 0000000..52d5fd9 --- /dev/null +++ b/tool-results/read_1793230719374_07f1dc503692.txt @@ -0,0 +1,876 @@ + 1→'use client' + 2→ + 3→import { useMemo, useState } from 'react' + 4→import { + 5→ Users, + 6→ Building2, + 7→ Globe, + 8→ MapPin, + 9→ Clock, + 10→ Percent, + 11→ CheckCircle2, + 12→ XCircle, + 13→ History, + 14→ Inbox, + 15→ Mail, + 16→ Loader2, + 17→ ChevronRight, + 18→ ShoppingBag, + 19→} from 'lucide-react' + 20→import { useFetch } from '@/hooks/use-fetch' + 21→import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' + 22→import { Badge } from '@/components/ui/badge' + 23→import { Button } from '@/components/ui/button' + 24→import { Input } from '@/components/ui/input' + 25→import { Label } from '@/components/ui/label' + 26→import { Textarea } from '@/components/ui/textarea' + 27→import { Skeleton } from '@/components/ui/skeleton' + 28→import { + 29→ Dialog, + 30→ DialogContent, + 31→ DialogDescription, + 32→ DialogFooter, + 33→ DialogHeader, + 34→ DialogTitle, + 35→} from '@/components/ui/dialog' + 36→import { + 37→ Sheet, + 38→ SheetContent, + 39→ SheetDescription, + 40→ SheetHeader, + 41→ SheetTitle, + 42→} from '@/components/ui/sheet' + 43→import { + 44→ Collapsible, + 45→ CollapsibleContent, + 46→ CollapsibleTrigger, + 47→} from '@/components/ui/collapsible' + 48→import { PageHeader } from '../primitives' + 49→import { FuseButton } from '@/components/FuseButton' + 50→import { formatRelative, formatDate, formatDateTime } from '@/lib/format' + 51→import { cn } from '@/lib/utils' + 52→import { toast } from 'sonner' + 53→ + 54→/* ----------------------------- types ----------------------------- */ + 55→ + 56→interface AgencyCompany { + 57→ id: string + 58→ name: string + 59→ email: string | null + 60→ website: string | null + 61→ city: string | null + 62→ isAgency: boolean + 63→ agencyStatus: string | null + 64→ agencyDiscountPct: number | null + 65→} + 66→ + 67→interface AgencyApplication { + 68→ id: string + 69→ companyId: string + 70→ status: 'pending' | 'approved' | 'rejected' + 71→ discountPct: number + 72→ businessModel?: string | null + 73→ expectedClientVolume?: string | null + 74→ referredBy?: string | null + 75→ reviewedBy?: string | null + 76→ reviewedAt?: string | null + 77→ rejectReason?: string | null + 78→ appliedAt: string + 79→ createdAt: string + 80→ updatedAt: string + 81→ company: AgencyCompany + 82→} + 83→ + 84→interface AgencyClient { + 85→ id: string + 86→ name: string + 87→ email: string | null + 88→} + 89→ + 90→interface ApprovedAgency extends AgencyCompany { + 91→ agencyDiscountPct: number + 92→ agencyApprovedAt: string + 93→ agencyClients: AgencyClient[] + 94→ _count: { agencyClients: number; licenses: number } + 95→} + 96→ + 97→interface AgencyData { + 98→ applications: { + 99→ pending: AgencyApplication[] + 100→ reviewed: AgencyApplication[] + 101→ } + 102→ agencies: ApprovedAgency[] + 103→ stats: { pending: number; approved: number; totalApplications: number } + 104→} + 105→ + 106→const BASE_PRICE_EUR = 50 // 50 €/Monat base price for preview + 107→ + 108→/* ----------------------------- view ----------------------------- */ + 109→ + 110→export function AgencyView() { + 111→ const { data, loading, refetch } = useFetch('/api/aos/agency') + 112→ + 113→ if (loading || !data) { + 114→ return ( + 115→
+ 116→ + 117→
+ 118→ {Array.from({ length: 4 }).map((_, i) => ( + 119→ + 120→ ))} + 121→
+ 122→ + 123→
+ 124→ ) + 125→ } + 126→ + 127→ const { applications, agencies, stats } = data + 128→ const avgDiscount = + 129→ agencies.length > 0 + 130→ ? Math.round( + 131→ agencies.reduce((sum, a) => sum + (a.agencyDiscountPct ?? 0), 0) / agencies.length, + 132→ ) + 133→ : 0 + 134→ + 135→ return ( + 136→
+ 137→ + 141→ + 142→ {/* Stats */} + 143→
+ 144→ + 150→ + 156→ + 162→ + 168→
+ 169→ + 170→ + 171→ + 172→ + 173→
+ 174→ ) + 175→} + 176→ + 177→/* ----------------------------- stats card ----------------------------- */ + 178→ + 179→function StatCard({ + 180→ label, + 181→ value, + 182→ icon: Icon, + 183→ hint, + 184→}: { + 185→ label: string + 186→ value: number | string + 187→ icon: typeof Inbox + 188→ hint?: string + 189→}) { + 190→ return ( + 191→ + 192→
+ 193→

{label}

+ 194→ + 195→
+ 196→

{value}

+ 197→ {hint &&

{hint}

} + 198→
+ 199→ ) + 200→} + 201→ + 202→/* ----------------------------- pending applications ----------------------------- */ + 203→ + 204→function PendingApplicationsSection({ + 205→ applications, + 206→ onChanged, + 207→}: { + 208→ applications: AgencyApplication[] + 209→ onChanged: () => void + 210→}) { + 211→ const [approveTarget, setApproveTarget] = useState(null) + 212→ const [rejectTarget, setRejectTarget] = useState(null) + 213→ + 214→ return ( + 215→
+ 216→
+ 217→

+ 218→ Bewerbende Reseller + 219→ + 220→ {applications.length} offen + 221→ + 222→

+ 223→
+ 224→ + 225→ {applications.length === 0 ? ( + 226→
+ 227→
+ 228→ + 229→
+ 230→
+ 231→

Keine bewerbenden Anfragen

+ 232→

+ 233→ Neue Reseller-Bewerbungen erscheinen hier. + 234→

+ 235→
+ 236→
+ 237→ ) : ( + 238→
+ 239→ {applications.map((app) => ( + 240→ + 241→ + 242→ {/* header */} + 243→
+ 244→
+ 245→

{app.company.name}

+ 246→
+ 247→ {app.company.email && ( + 248→ + 249→ + 250→ {app.company.email} + 251→ + 252→ )} + 253→ {app.company.website && ( + 254→ + 255→ + 256→ {app.company.website} + 257→ + 258→ )} + 259→ {app.company.city && ( + 260→ + 261→ + 262→ {app.company.city} + 263→ + 264→ )} + 265→
+ 266→
+ 267→ + 268→ + 269→ {formatRelative(app.appliedAt)} + 270→ + 271→
+ 272→ + 273→ {/* application details */} + 274→ {(app.businessModel || app.expectedClientVolume || app.referredBy) && ( + 275→
+ 276→ {app.businessModel && ( + 277→ + 278→ )} + 279→ {app.expectedClientVolume && ( + 280→ + 281→ )} + 282→ {app.referredBy && ( + 283→ + 284→ )} + 285→
+ 286→ )} + 287→ + 288→ {/* actions */} + 289→
+ 290→ + 298→ + 305→
+ 306→
+ 307→
+ 308→ ))} + 309→
+ 310→ )} + 311→ + 312→ setApproveTarget(null)} + 315→ onChanged={onChanged} + 316→ /> + 317→ setRejectTarget(null)} + 320→ onChanged={onChanged} + 321→ /> + 322→
+ 323→ ) + 324→} + 325→ + 326→function Meta({ label, value }: { label: string; value: string }) { + 327→ return ( + 328→
+ 329→

{label}

+ 330→

{value}

+ 331→
+ 332→ ) + 333→} + 334→ + 335→/* ----------------------------- approve dialog ----------------------------- */ + 336→ + 337→function ApproveDialog({ + 338→ application, + 339→ onClose, + 340→ onChanged, + 341→}: { + 342→ application: AgencyApplication | null + 343→ onClose: () => void + 344→ onChanged: () => void + 345→}) { + 346→ const [discount, setDiscount] = useState(10) + 347→ const [committing, setCommitting] = useState(false) + 348→ + 349→ // Reset state when dialog opens for a new application + 350→ const handleOpenChange = (open: boolean) => { + 351→ if (open) setDiscount(10) + 352→ if (!open) { + 353→ if (!committing) onClose() + 354→ } + 355→ } + 356→ + 357→ const net = useMemo(() => { + 358→ const netEur = BASE_PRICE_EUR * (1 - discount / 100) + 359→ return netEur.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + 360→ }, [discount]) + 361→ + 362→ const handleCommit = async () => { + 363→ if (!application) return + 364→ setCommitting(true) + 365→ try { + 366→ const r = await fetch(`/api/aos/agency/${application.id}/approve`, { + 367→ method: 'PATCH', + 368→ headers: { 'content-type': 'application/json' }, + 369→ body: JSON.stringify({ discountPct: discount }), + 370→ }) + 371→ if (!r.ok) { + 372→ const err = await r.json().catch(() => ({})) + 373→ throw new Error(err.error || 'approve failed') + 374→ } + 375→ // Defer close + refetch so the FuseButton's done-state + toast can render. + 376→ setTimeout(() => { + 377→ onClose() + 378→ onChanged() + 379→ }, 350) + 380→ } catch (err) { + 381→ setCommitting(false) + 382→ throw err + 383→ } + 384→ // Note: setCommitting(false) intentionally skipped on success — dialog will + 385→ // close via the setTimeout above and the button's doneLabel will be shown. + 386→ } + 387→ + 388→ return ( + 389→ + 390→ + 391→ {application && ( + 392→ <> + 393→ + 394→ + 395→ + 396→ Reseller-Bewerbung genehmigen + 397→ + 398→ + 399→ {application.company.name} wird + 400→ als Reseller freigeschaltet. Der Rabatt wird auf alle vom Reseller angelegten Kundenlizenzierungen + 401→ angewendet. + 402→ + 403→ + 404→ + 405→
+ 406→
+ 407→
+ 408→ + 409→ 0 – 20 + 410→
+ 411→ { + 419→ const raw = parseInt(e.target.value, 10) + 420→ if (Number.isNaN(raw)) { + 421→ setDiscount(0) + 422→ return + 423→ } + 424→ setDiscount(Math.min(20, Math.max(0, raw))) + 425→ }} + 426→ className="tabular-nums" + 427→ /> + 428→
+ 429→ + 430→ {/* live preview */} + 431→
+ 432→

+ 433→ Preisvorschau + 434→

+ 435→

+ 436→ {BASE_PRICE_EUR} €/Monat + 437→ Basis + 438→ → + 439→ mit {discount}% Rabatt + 440→ = + 441→ + 442→ {net} € + 443→ + 444→

+ 445→
+ 446→
+ 447→ + 448→ + 449→ + 452→ + 459→ + 460→ + 461→ )} + 462→
+ 463→
+ 464→ ) + 465→} + 466→ + 467→/* ----------------------------- reject dialog ----------------------------- */ + 468→ + 469→function RejectDialog({ + 470→ application, + 471→ onClose, + 472→ onChanged, + 473→}: { + 474→ application: AgencyApplication | null + 475→ onClose: () => void + 476→ onChanged: () => void + 477→}) { + 478→ const [reason, setReason] = useState('') + 479→ const [submitting, setSubmitting] = useState(false) + 480→ + 481→ const handleOpenChange = (open: boolean) => { + 482→ if (!open) { + 483→ if (!submitting) { + 484→ setReason('') + 485→ onClose() + 486→ } + 487→ } + 488→ } + 489→ + 490→ const submit = async () => { + 491→ if (!application) return + 492→ if (!reason.trim()) { + 493→ toast.error('Bitte gib einen Ablehnungsgrund an.') + 494→ return + 495→ } + 496→ setSubmitting(true) + 497→ const t = toast.loading('Ablehnung wird gespeichert…') + 498→ try { + 499→ const r = await fetch(`/api/aos/agency/${application.id}/reject`, { + 500→ method: 'PATCH', + 501→ headers: { 'content-type': 'application/json' }, + 502→ body: JSON.stringify({ reason: reason.trim() }), + 503→ }) + 504→ if (!r.ok) { + 505→ const err = await r.json().catch(() => ({})) + 506→ throw new Error(err.error || 'reject failed') + 507→ } + 508→ toast.success(`${application.company.name} abgelehnt`, { id: t }) + 509→ setReason('') + 510→ onClose() + 511→ onChanged() + 512→ } catch (err) { + 513→ toast.error(err instanceof Error ? err.message : 'Ablehnung fehlgeschlagen', { id: t }) + 514→ } finally { + 515→ setSubmitting(false) + 516→ } + 517→ } + 518→ + 519→ return ( + 520→ + 521→ + 522→ {application && ( + 523→ <> + 524→ + 525→ + 526→ + 527→ Reseller-Bewerbung ablehnen + 528→ + 529→ + 530→ {application.company.name} + 531→ {' '}wird abgelehnt. Der Grund wird für den Bewerber sichtbar protokolliert. + 532→ + 533→ + 534→ + 535→
+ 536→ + 537→