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
This commit is contained in:
1 parent
ec5e6b1629
commit
39b3636333
9 files changed
+3071
No files matched your search
@@ -19,6 +19,7 @@ import { TimesheetView } from './views/timesheet'
|
|||||||
import { SupportView } from './views/support'
|
import { SupportView } from './views/support'
|
||||||
import { LicensesView } from './views/licenses'
|
import { LicensesView } from './views/licenses'
|
||||||
import { AgencyView } from './views/agency'
|
import { AgencyView } from './views/agency'
|
||||||
|
import { ResellerPortalView } from './views/reseller-portal'
|
||||||
import { InstancesView } from './views/instances'
|
import { InstancesView } from './views/instances'
|
||||||
import { ContractsView } from './views/contracts'
|
import { ContractsView } from './views/contracts'
|
||||||
import { ExpensesView } from './views/expenses'
|
import { ExpensesView } from './views/expenses'
|
||||||
@@ -97,6 +98,7 @@ export function AppShell() {
|
|||||||
{view === 'support' && <SupportView />}
|
{view === 'support' && <SupportView />}
|
||||||
{view === 'licenses' && <LicensesView />}
|
{view === 'licenses' && <LicensesView />}
|
||||||
{view === 'agency' && <AgencyView />}
|
{view === 'agency' && <AgencyView />}
|
||||||
|
{view === 'reseller_portal' && <ResellerPortalView />}
|
||||||
{view === 'instances' && <InstancesView />}
|
{view === 'instances' && <InstancesView />}
|
||||||
{view === 'contracts' && <ContractsView />}
|
{view === 'contracts' && <ContractsView />}
|
||||||
{view === 'expenses' && <ExpensesView />}
|
{view === 'expenses' && <ExpensesView />}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
CalendarDays,
|
CalendarDays,
|
||||||
Database,
|
Database,
|
||||||
Server,
|
Server,
|
||||||
|
Store,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAos, type ViewId } from '@/store/aos'
|
import { useAos, type ViewId } from '@/store/aos'
|
||||||
import {
|
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: 'support', label: 'Support', icon: LifeBuoy, hint: 'Tickets & SLA' },
|
||||||
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' },
|
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' },
|
||||||
{ id: 'agency', label: 'Reseller', icon: Users, hint: 'Reseller-Bewerbungen & Rabatte' },
|
{ 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: 'instances', label: 'Instanzen', icon: Server, hint: 'Tenant-Instanzen & Provisioning' },
|
||||||
{ id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' },
|
{ id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' },
|
||||||
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' },
|
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' },
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Landmark,
|
Landmark,
|
||||||
Server,
|
Server,
|
||||||
|
Store,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAos, type ViewId } from '@/store/aos'
|
import { useAos, type ViewId } from '@/store/aos'
|
||||||
import { translate } from '@/lib/i18n'
|
import { translate } from '@/lib/i18n'
|
||||||
@@ -69,6 +70,7 @@ const NAV: {
|
|||||||
items: [
|
items: [
|
||||||
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' },
|
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' },
|
||||||
{ id: 'agency', label: 'Reseller', icon: Users, permission: 'admin' },
|
{ 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: 'instances', label: 'Instanzen', icon: Server, permission: 'admin' },
|
||||||
{ id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
|
{ id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
|
||||||
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' },
|
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' },
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
|||||||
support: { title: 'Support', crumb: 'Lieferung / Tickets' },
|
support: { title: 'Support', crumb: 'Lieferung / Tickets' },
|
||||||
licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' },
|
licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' },
|
||||||
agency: { title: 'Reseller', crumb: 'Betrieb / Reseller' },
|
agency: { title: 'Reseller', crumb: 'Betrieb / Reseller' },
|
||||||
|
reseller_portal: { title: 'Reseller-Portal', crumb: 'Betrieb / Reseller-Portal' },
|
||||||
instances: { title: 'Instanzen', crumb: 'Betrieb / Instanzen' },
|
instances: { title: 'Instanzen', crumb: 'Betrieb / Instanzen' },
|
||||||
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
|
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
|
||||||
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
|
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -29,6 +29,7 @@ const de: Dict = {
|
|||||||
'nav.support': 'Support',
|
'nav.support': 'Support',
|
||||||
'nav.licenses': 'Lizenzen',
|
'nav.licenses': 'Lizenzen',
|
||||||
'nav.agency': 'Reseller',
|
'nav.agency': 'Reseller',
|
||||||
|
'nav.reseller_portal': 'Reseller-Portal',
|
||||||
'nav.instances': 'Instanzen',
|
'nav.instances': 'Instanzen',
|
||||||
'nav.contracts': 'Verträge',
|
'nav.contracts': 'Verträge',
|
||||||
'nav.expenses': 'Ausgaben',
|
'nav.expenses': 'Ausgaben',
|
||||||
@@ -87,6 +88,7 @@ const en: Dict = {
|
|||||||
'nav.support': 'Support',
|
'nav.support': 'Support',
|
||||||
'nav.licenses': 'Licenses',
|
'nav.licenses': 'Licenses',
|
||||||
'nav.agency': 'Reseller',
|
'nav.agency': 'Reseller',
|
||||||
|
'nav.reseller_portal': 'Reseller Portal',
|
||||||
'nav.instances': 'Instances',
|
'nav.instances': 'Instances',
|
||||||
'nav.contracts': 'Contracts',
|
'nav.contracts': 'Contracts',
|
||||||
'nav.expenses': 'Expenses',
|
'nav.expenses': 'Expenses',
|
||||||
@@ -141,6 +143,7 @@ const tr: Dict = {
|
|||||||
'nav.support': 'Destek',
|
'nav.support': 'Destek',
|
||||||
'nav.licenses': 'Lisanslar',
|
'nav.licenses': 'Lisanslar',
|
||||||
'nav.agency': 'Bayi',
|
'nav.agency': 'Bayi',
|
||||||
|
'nav.reseller_portal': 'Bayi Portal',
|
||||||
'nav.instances': 'Sunucular',
|
'nav.instances': 'Sunucular',
|
||||||
'nav.contracts': 'Sözleşmeler',
|
'nav.contracts': 'Sözleşmeler',
|
||||||
'nav.expenses': 'Giderler',
|
'nav.expenses': 'Giderler',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type ViewId =
|
|||||||
| 'support'
|
| 'support'
|
||||||
| 'licenses'
|
| 'licenses'
|
||||||
| 'agency'
|
| 'agency'
|
||||||
|
| 'reseller_portal'
|
||||||
| 'instances'
|
| 'instances'
|
||||||
| 'contracts'
|
| 'contracts'
|
||||||
| 'expenses'
|
| 'expenses'
|
||||||
|
|||||||
@@ -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<AgencyData>('/api/aos/agency')
|
||||||
|
112→
|
||||||
|
113→ if (loading || !data) {
|
||||||
|
114→ return (
|
||||||
|
115→ <div className="space-y-6 p-4 md:p-6">
|
||||||
|
116→ <Skeleton className="h-9 w-32" />
|
||||||
|
117→ <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
118→ {Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
119→ <Skeleton key={i} className="h-24" />
|
||||||
|
120→ ))}
|
||||||
|
121→ </div>
|
||||||
|
122→ <Skeleton className="h-64" />
|
||||||
|
123→ </div>
|
||||||
|
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→ <div className="space-y-6 p-4 md:p-6">
|
||||||
|
137→ <PageHeader
|
||||||
|
138→ title="Reseller"
|
||||||
|
139→ description="Reseller-Verwaltung: Bewerbungen prüfen, Rabatt-Sätze festlegen (0–20 %) und genehmigte Reseller mit ihren Kunden einsehen."
|
||||||
|
140→ />
|
||||||
|
141→
|
||||||
|
142→ {/* Stats */}
|
||||||
|
143→ <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
144→ <StatCard
|
||||||
|
145→ label="Offene Bewerbungen"
|
||||||
|
146→ value={stats.pending}
|
||||||
|
147→ icon={Inbox}
|
||||||
|
148→ hint="wartet auf Freigabe"
|
||||||
|
149→ />
|
||||||
|
150→ <StatCard
|
||||||
|
151→ label="Genehmigte Reseller"
|
||||||
|
152→ value={stats.approved}
|
||||||
|
153→ icon={Users}
|
||||||
|
154→ hint="aktiv im System"
|
||||||
|
155→ />
|
||||||
|
156→ <StatCard
|
||||||
|
157→ label="Bewerbungen gesamt"
|
||||||
|
158→ value={stats.totalApplications}
|
||||||
|
159→ icon={History}
|
||||||
|
160→ hint="inkl. Verlauf"
|
||||||
|
161→ />
|
||||||
|
162→ <StatCard
|
||||||
|
163→ label="Ø Rabatt"
|
||||||
|
164→ value={`${avgDiscount}%`}
|
||||||
|
165→ icon={Percent}
|
||||||
|
166→ hint={agencies.length ? `über ${agencies.length} Reseller` : '—'}
|
||||||
|
167→ />
|
||||||
|
168→ </div>
|
||||||
|
169→
|
||||||
|
170→ <PendingApplicationsSection applications={applications.pending} onChanged={refetch} />
|
||||||
|
171→ <ApprovedAgenciesSection agencies={agencies} />
|
||||||
|
172→ <ReviewedSection applications={applications.reviewed} />
|
||||||
|
173→ </div>
|
||||||
|
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→ <Card className="p-4">
|
||||||
|
192→ <div className="flex items-center justify-between">
|
||||||
|
193→ <p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
194→ <Icon className="size-3.5 text-muted-foreground" />
|
||||||
|
195→ </div>
|
||||||
|
196→ <p className="mt-1 text-2xl font-semibold tabular-nums">{value}</p>
|
||||||
|
197→ {hint && <p className="text-[10px] text-muted-foreground">{hint}</p>}
|
||||||
|
198→ </Card>
|
||||||
|
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<AgencyApplication | null>(null)
|
||||||
|
212→ const [rejectTarget, setRejectTarget] = useState<AgencyApplication | null>(null)
|
||||||
|
213→
|
||||||
|
214→ return (
|
||||||
|
215→ <section className="space-y-3">
|
||||||
|
216→ <div className="flex items-center justify-between">
|
||||||
|
217→ <h2 className="text-sm font-semibold tracking-tight">
|
||||||
|
218→ Bewerbende Reseller
|
||||||
|
219→ <span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||||
|
220→ {applications.length} offen
|
||||||
|
221→ </span>
|
||||||
|
222→ </h2>
|
||||||
|
223→ </div>
|
||||||
|
224→
|
||||||
|
225→ {applications.length === 0 ? (
|
||||||
|
226→ <div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 px-6 py-14 text-center">
|
||||||
|
227→ <div className="flex size-12 items-center justify-center rounded-full border border-border bg-background">
|
||||||
|
228→ <Inbox className="size-5 text-muted-foreground" />
|
||||||
|
229→ </div>
|
||||||
|
230→ <div className="space-y-1">
|
||||||
|
231→ <p className="text-sm font-medium">Keine bewerbenden Anfragen</p>
|
||||||
|
232→ <p className="max-w-sm text-xs text-muted-foreground">
|
||||||
|
233→ Neue Reseller-Bewerbungen erscheinen hier.
|
||||||
|
234→ </p>
|
||||||
|
235→ </div>
|
||||||
|
236→ </div>
|
||||||
|
237→ ) : (
|
||||||
|
238→ <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
239→ {applications.map((app) => (
|
||||||
|
240→ <Card key={app.id} className="flex flex-col">
|
||||||
|
241→ <CardContent className="flex flex-1 flex-col gap-3 p-4">
|
||||||
|
242→ {/* header */}
|
||||||
|
243→ <div className="flex items-start justify-between gap-3">
|
||||||
|
244→ <div className="min-w-0 space-y-1">
|
||||||
|
245→ <p className="truncate text-sm font-semibold">{app.company.name}</p>
|
||||||
|
246→ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
|
||||||
|
247→ {app.company.email && (
|
||||||
|
248→ <span className="inline-flex items-center gap-1">
|
||||||
|
249→ <Mail className="size-3" />
|
||||||
|
250→ {app.company.email}
|
||||||
|
251→ </span>
|
||||||
|
252→ )}
|
||||||
|
253→ {app.company.website && (
|
||||||
|
254→ <span className="inline-flex items-center gap-1">
|
||||||
|
255→ <Globe className="size-3" />
|
||||||
|
256→ {app.company.website}
|
||||||
|
257→ </span>
|
||||||
|
258→ )}
|
||||||
|
259→ {app.company.city && (
|
||||||
|
260→ <span className="inline-flex items-center gap-1">
|
||||||
|
261→ <MapPin className="size-3" />
|
||||||
|
262→ {app.company.city}
|
||||||
|
263→ </span>
|
||||||
|
264→ )}
|
||||||
|
265→ </div>
|
||||||
|
266→ </div>
|
||||||
|
267→ <Badge variant="outline" className="gap-1 border-foreground/40 text-[10px]">
|
||||||
|
268→ <Clock className="size-2.5" />
|
||||||
|
269→ {formatRelative(app.appliedAt)}
|
||||||
|
270→ </Badge>
|
||||||
|
271→ </div>
|
||||||
|
272→
|
||||||
|
273→ {/* application details */}
|
||||||
|
274→ {(app.businessModel || app.expectedClientVolume || app.referredBy) && (
|
||||||
|
275→ <div className="grid grid-cols-1 gap-2 rounded-md border border-border bg-muted/30 p-3 text-xs sm:grid-cols-3">
|
||||||
|
276→ {app.businessModel && (
|
||||||
|
277→ <Meta label="Geschäftsmodell" value={app.businessModel} />
|
||||||
|
278→ )}
|
||||||
|
279→ {app.expectedClientVolume && (
|
||||||
|
280→ <Meta label="Erw. Kundenvolumen" value={app.expectedClientVolume} />
|
||||||
|
281→ )}
|
||||||
|
282→ {app.referredBy && (
|
||||||
|
283→ <Meta label="Empfohlen von" value={app.referredBy} />
|
||||||
|
284→ )}
|
||||||
|
285→ </div>
|
||||||
|
286→ )}
|
||||||
|
287→
|
||||||
|
288→ {/* actions */}
|
||||||
|
289→ <div className="mt-auto flex items-center justify-end gap-2 pt-1">
|
||||||
|
290→ <Button
|
||||||
|
291→ size="sm"
|
||||||
|
292→ variant="outline"
|
||||||
|
293→ onClick={() => setRejectTarget(app)}
|
||||||
|
294→ >
|
||||||
|
295→ <XCircle className="size-3.5" />
|
||||||
|
296→ Ablehnen
|
||||||
|
297→ </Button>
|
||||||
|
298→ <Button
|
||||||
|
299→ size="sm"
|
||||||
|
300→ onClick={() => setApproveTarget(app)}
|
||||||
|
301→ >
|
||||||
|
302→ <CheckCircle2 className="size-3.5" />
|
||||||
|
303→ Genehmigen
|
||||||
|
304→ </Button>
|
||||||
|
305→ </div>
|
||||||
|
306→ </CardContent>
|
||||||
|
307→ </Card>
|
||||||
|
308→ ))}
|
||||||
|
309→ </div>
|
||||||
|
310→ )}
|
||||||
|
311→
|
||||||
|
312→ <ApproveDialog
|
||||||
|
313→ application={approveTarget}
|
||||||
|
314→ onClose={() => setApproveTarget(null)}
|
||||||
|
315→ onChanged={onChanged}
|
||||||
|
316→ />
|
||||||
|
317→ <RejectDialog
|
||||||
|
318→ application={rejectTarget}
|
||||||
|
319→ onClose={() => setRejectTarget(null)}
|
||||||
|
320→ onChanged={onChanged}
|
||||||
|
321→ />
|
||||||
|
322→ </section>
|
||||||
|
323→ )
|
||||||
|
324→}
|
||||||
|
325→
|
||||||
|
326→function Meta({ label, value }: { label: string; value: string }) {
|
||||||
|
327→ return (
|
||||||
|
328→ <div className="min-w-0">
|
||||||
|
329→ <p className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||||
|
330→ <p className="mt-0.5 truncate text-xs font-medium">{value}</p>
|
||||||
|
331→ </div>
|
||||||
|
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→ <Dialog open={!!application} onOpenChange={handleOpenChange}>
|
||||||
|
390→ <DialogContent className="sm:max-w-md">
|
||||||
|
391→ {application && (
|
||||||
|
392→ <>
|
||||||
|
393→ <DialogHeader>
|
||||||
|
394→ <DialogTitle className="flex items-center gap-2">
|
||||||
|
395→ <CheckCircle2 className="size-4" />
|
||||||
|
396→ Reseller-Bewerbung genehmigen
|
||||||
|
397→ </DialogTitle>
|
||||||
|
398→ <DialogDescription>
|
||||||
|
399→ <span className="font-medium text-foreground">{application.company.name}</span> wird
|
||||||
|
400→ als Reseller freigeschaltet. Der Rabatt wird auf alle vom Reseller angelegten Kundenlizenzierungen
|
||||||
|
401→ angewendet.
|
||||||
|
402→ </DialogDescription>
|
||||||
|
403→ </DialogHeader>
|
||||||
|
404→
|
||||||
|
405→ <div className="space-y-3">
|
||||||
|
406→ <div className="space-y-1.5">
|
||||||
|
407→ <div className="flex items-center justify-between">
|
||||||
|
408→ <Label htmlFor="discount-pct">Rabatt (%)</Label>
|
||||||
|
409→ <span className="text-[10px] text-muted-foreground">0 – 20</span>
|
||||||
|
410→ </div>
|
||||||
|
411→ <Input
|
||||||
|
412→ id="discount-pct"
|
||||||
|
413→ type="number"
|
||||||
|
414→ min={0}
|
||||||
|
415→ max={20}
|
||||||
|
416→ step={1}
|
||||||
|
417→ value={discount}
|
||||||
|
418→ onChange={(e) => {
|
||||||
|
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→ </div>
|
||||||
|
429→
|
||||||
|
430→ {/* live preview */}
|
||||||
|
431→ <div className="rounded-md border border-border bg-muted/40 p-3 text-xs">
|
||||||
|
432→ <p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
433→ Preisvorschau
|
||||||
|
434→ </p>
|
||||||
|
435→ <p className="mt-1 tabular-nums">
|
||||||
|
436→ <span className="font-medium">{BASE_PRICE_EUR} €/Monat</span>
|
||||||
|
437→ <span className="mx-1.5 text-muted-foreground">Basis</span>
|
||||||
|
438→ <span className="mx-1 text-muted-foreground">→</span>
|
||||||
|
439→ <span className="text-muted-foreground">mit {discount}% Rabatt</span>
|
||||||
|
440→ <span className="mx-1.5 text-muted-foreground">=</span>
|
||||||
|
441→ <span className="font-semibold border-foreground/40 px-1.5 py-0.5 rounded border">
|
||||||
|
442→ {net} €
|
||||||
|
443→ </span>
|
||||||
|
444→ </p>
|
||||||
|
445→ </div>
|
||||||
|
446→ </div>
|
||||||
|
447→
|
||||||
|
448→ <DialogFooter className="gap-2">
|
||||||
|
449→ <Button variant="outline" onClick={onClose} disabled={committing}>
|
||||||
|
450→ Abbrechen
|
||||||
|
451→ </Button>
|
||||||
|
452→ <FuseButton
|
||||||
|
453→ label="Genehmigen"
|
||||||
|
454→ doneLabel="Genehmigt"
|
||||||
|
455→ onCommit={handleCommit}
|
||||||
|
456→ disabled={committing}
|
||||||
|
457→ variant="default"
|
||||||
|
458→ />
|
||||||
|
459→ </DialogFooter>
|
||||||
|
460→ </>
|
||||||
|
461→ )}
|
||||||
|
462→ </DialogContent>
|
||||||
|
463→ </Dialog>
|
||||||
|
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→ <Dialog open={!!application} onOpenChange={handleOpenChange}>
|
||||||
|
521→ <DialogContent className="sm:max-w-md">
|
||||||
|
522→ {application && (
|
||||||
|
523→ <>
|
||||||
|
524→ <DialogHeader>
|
||||||
|
525→ <DialogTitle className="flex items-center gap-2">
|
||||||
|
526→ <XCircle className="size-4" />
|
||||||
|
527→ Reseller-Bewerbung ablehnen
|
||||||
|
528→ </DialogTitle>
|
||||||
|
529→ <DialogDescription>
|
||||||
|
530→ <span className="font-medium text-foreground">{application.company.name}</span>
|
||||||
|
531→ {' '}wird abgelehnt. Der Grund wird für den Bewerber sichtbar protokolliert.
|
||||||
|
532→ </DialogDescription>
|
||||||
|
533→ </DialogHeader>
|
||||||
|
534→
|
||||||
|
535→ <div className="space-y-1.5">
|
||||||
|
536→ <Label htmlFor="reject-reason">Ablehnungsgrund</Label>
|
||||||
|
537→ <Textarea
|
||||||
|
538→ id="reject-reason"
|
||||||
|
539→ placeholder="z. B. Unvollständige Angaben, kein passendes Geschäftsmodell…"
|
||||||
|
540→ value={reason}
|
||||||
|
541→ onChange={(e) => setReason(e.target.value)}
|
||||||
|
542→ rows={4}
|
||||||
|
543→ maxLength={500}
|
||||||
|
544→ disabled={submitting}
|
||||||
|
545→ />
|
||||||
|
546→ <p className="text-right text-[10px] text-muted-foreground">
|
||||||
|
547→ {reason.length} / 500
|
||||||
|
548→ </p>
|
||||||
|
549→ </div>
|
||||||
|
550→
|
||||||
|
551→ <DialogFooter className="gap-2">
|
||||||
|
552→ <Button variant="outline" onClick={onClose} disabled={submitting}>
|
||||||
|
553→ Abbrechen
|
||||||
|
554→ </Button>
|
||||||
|
555→ <Button variant="destructive" onClick={submit} disabled={submitting}>
|
||||||
|
556→ {submitting ? (
|
||||||
|
557→ <Loader2 className="size-3.5 animate-spin" />
|
||||||
|
558→ ) : (
|
||||||
|
559→ <XCircle className="size-3.5" />
|
||||||
|
560→ )}
|
||||||
|
561→ Ablehnen
|
||||||
|
562→ </Button>
|
||||||
|
563→ </DialogFooter>
|
||||||
|
564→ </>
|
||||||
|
565→ )}
|
||||||
|
566→ </DialogContent>
|
||||||
|
567→ </Dialog>
|
||||||
|
568→ )
|
||||||
|
569→}
|
||||||
|
570→
|
||||||
|
571→/* ----------------------------- approved agencies ----------------------------- */
|
||||||
|
572→
|
||||||
|
573→function ApprovedAgenciesSection({ agencies }: { agencies: ApprovedAgency[] }) {
|
||||||
|
574→ const [openAgency, setOpenAgency] = useState<ApprovedAgency | null>(null)
|
||||||
|
575→
|
||||||
|
576→ return (
|
||||||
|
577→ <section className="space-y-3">
|
||||||
|
578→ <div className="flex items-center justify-between">
|
||||||
|
579→ <h2 className="text-sm font-semibold tracking-tight">
|
||||||
|
580→ Genehmigte Reseller
|
||||||
|
581→ <span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||||
|
582→ {agencies.length} aktiv
|
||||||
|
583→ </span>
|
||||||
|
584→ </h2>
|
||||||
|
585→ </div>
|
||||||
|
586→
|
||||||
|
587→ {agencies.length === 0 ? (
|
||||||
|
588→ <div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 px-6 py-14 text-center">
|
||||||
|
589→ <div className="flex size-12 items-center justify-center rounded-full border border-border bg-background">
|
||||||
|
590→ <Users className="size-5 text-muted-foreground" />
|
||||||
|
591→ </div>
|
||||||
|
592→ <div className="space-y-1">
|
||||||
|
593→ <p className="text-sm font-medium">Noch keine Reseller genehmigt</p>
|
||||||
|
594→ <p className="max-w-sm text-xs text-muted-foreground">
|
||||||
|
595→ Genehmigte Reseller-Bewerbungen erscheinen hier mit ihren Kunden und Lizenzen.
|
||||||
|
596→ </p>
|
||||||
|
597→ </div>
|
||||||
|
598→ </div>
|
||||||
|
599→ ) : (
|
||||||
|
600→ <Card>
|
||||||
|
601→ <CardHeader className="pb-2">
|
||||||
|
602→ <CardTitle className="text-sm">Reseller ({agencies.length})</CardTitle>
|
||||||
|
603→ </CardHeader>
|
||||||
|
604→ <CardContent className="p-0">
|
||||||
|
605→ <div className="overflow-x-auto">
|
||||||
|
606→ <table className="w-full text-sm">
|
||||||
|
607→ <thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
608→ <tr>
|
||||||
|
609→ <th className="px-4 py-2 font-medium">Reseller</th>
|
||||||
|
610→ <th className="px-4 py-2 font-medium">Rabatt</th>
|
||||||
|
611→ <th className="px-4 py-2 font-medium text-right">Kunden</th>
|
||||||
|
612→ <th className="px-4 py-2 font-medium text-right">Lizenzen</th>
|
||||||
|
613→ <th className="px-4 py-2 font-medium">Genehmigt am</th>
|
||||||
|
614→ <th className="px-4 py-2 font-medium text-right">Aktion</th>
|
||||||
|
615→ </tr>
|
||||||
|
616→ </thead>
|
||||||
|
617→ <tbody className="divide-y divide-border">
|
||||||
|
618→ {agencies.map((a) => (
|
||||||
|
619→ <tr key={a.id} className="transition hover:bg-muted/40">
|
||||||
|
620→ <td className="px-4 py-2.5">
|
||||||
|
621→ <div className="flex items-center gap-2">
|
||||||
|
622→ <div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border">
|
||||||
|
623→ <Building2 className="size-3.5 text-muted-foreground" />
|
||||||
|
624→ </div>
|
||||||
|
625→ <div className="min-w-0">
|
||||||
|
626→ <p className="truncate text-xs font-medium">{a.name}</p>
|
||||||
|
627→ {a.email && (
|
||||||
|
628→ <p className="truncate text-[10px] text-muted-foreground">{a.email}</p>
|
||||||
|
629→ )}
|
||||||
|
630→ </div>
|
||||||
|
631→ </div>
|
||||||
|
632→ </td>
|
||||||
|
633→ <td className="px-4 py-2.5">
|
||||||
|
634→ <Badge
|
||||||
|
635→ variant="outline"
|
||||||
|
636→ className="border-foreground/40 text-[10px] tabular-nums"
|
||||||
|
637→ >
|
||||||
|
638→ <Percent className="size-2.5" />
|
||||||
|
639→ {a.agencyDiscountPct}%
|
||||||
|
640→ </Badge>
|
||||||
|
641→ </td>
|
||||||
|
642→ <td className="px-4 py-2.5 text-right text-xs tabular-nums">
|
||||||
|
643→ {a._count.agencyClients}
|
||||||
|
644→ </td>
|
||||||
|
645→ <td className="px-4 py-2.5 text-right text-xs tabular-nums">
|
||||||
|
646→ {a._count.licenses}
|
||||||
|
647→ </td>
|
||||||
|
648→ <td className="px-4 py-2.5 text-xs">
|
||||||
|
649→ {formatDate(a.agencyApprovedAt)}
|
||||||
|
650→ </td>
|
||||||
|
651→ <td className="px-4 py-2.5 text-right">
|
||||||
|
652→ <Button
|
||||||
|
653→ size="sm"
|
||||||
|
654→ variant="ghost"
|
||||||
|
655→ onClick={() => setOpenAgency(a)}
|
||||||
|
656→ className="gap-1"
|
||||||
|
657→ >
|
||||||
|
658→ <ShoppingBag className="size-3.5" />
|
||||||
|
659→ Kunden ansehen
|
||||||
|
660→ <ChevronRight className="size-3" />
|
||||||
|
661→ </Button>
|
||||||
|
662→ </td>
|
||||||
|
663→ </tr>
|
||||||
|
664→ ))}
|
||||||
|
665→ </tbody>
|
||||||
|
666→ </table>
|
||||||
|
667→ </div>
|
||||||
|
668→ </CardContent>
|
||||||
|
669→ </Card>
|
||||||
|
670→ )}
|
||||||
|
671→
|
||||||
|
672→ <ClientsSheet agency={openAgency} onClose={() => setOpenAgency(null)} />
|
||||||
|
673→ </section>
|
||||||
|
674→ )
|
||||||
|
675→}
|
||||||
|
676→
|
||||||
|
677→function ClientsSheet({
|
||||||
|
678→ agency,
|
||||||
|
679→ onClose,
|
||||||
|
680→}: {
|
||||||
|
681→ agency: ApprovedAgency | null
|
||||||
|
682→ onClose: () => void
|
||||||
|
683→}) {
|
||||||
|
684→ return (
|
||||||
|
685→ <Sheet open={!!agency} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
686→ <SheetContent side="right" className="w-full gap-0 sm:max-w-md">
|
||||||
|
687→ {agency && (
|
||||||
|
688→ <>
|
||||||
|
689→ <SheetHeader className="border-b border-border">
|
||||||
|
690→ <SheetTitle className="flex items-center gap-2">
|
||||||
|
691→ <Building2 className="size-4 text-muted-foreground" />
|
||||||
|
692→ {agency.name}
|
||||||
|
693→ </SheetTitle>
|
||||||
|
694→ <SheetDescription>
|
||||||
|
695→ Reseller-Kundenverwaltung — {agency._count.agencyClients} Kunde(n),{' '}
|
||||||
|
696→ {agency._count.licenses} Lizenz(en).
|
||||||
|
697→ </SheetDescription>
|
||||||
|
698→ </SheetHeader>
|
||||||
|
699→
|
||||||
|
700→ <div className="flex-1 overflow-y-auto px-4 py-3">
|
||||||
|
701→ {/* routing note */}
|
||||||
|
702→ <div className="mb-3 flex items-start gap-2 rounded-md border border-border bg-muted/40 p-3 text-[11px]">
|
||||||
|
703→ <Mail className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
704→ <p className="text-muted-foreground">
|
||||||
|
705→ Reseller kann eigene Kunden anlegen und lizenzieren. Tickets von dessen Kunden werden
|
||||||
|
706→ an den Reseller geroutet, nicht an uns.
|
||||||
|
707→ </p>
|
||||||
|
708→ </div>
|
||||||
|
709→
|
||||||
|
710→ {/* meta */}
|
||||||
|
711→ <dl className="mb-3 grid grid-cols-2 gap-2 text-xs">
|
||||||
|
712→ <MetaItem label="Rabatt" value={`${agency.agencyDiscountPct}%`} />
|
||||||
|
713→ <MetaItem label="Genehmigt" value={formatDate(agency.agencyApprovedAt)} />
|
||||||
|
714→ {agency.email && (
|
||||||
|
715→ <MetaItem label="E-Mail" value={agency.email} full />
|
||||||
|
716→ )}
|
||||||
|
717→ {agency.city && <MetaItem label="Stadt" value={agency.city} />}
|
||||||
|
718→ {agency.website && <MetaItem label="Website" value={agency.website} />}
|
||||||
|
719→ </dl>
|
||||||
|
720→
|
||||||
|
721→ {/* clients list */}
|
||||||
|
722→ <p className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
723→ Kunden ({agency.agencyClients.length})
|
||||||
|
724→ </p>
|
||||||
|
725→ {agency.agencyClients.length === 0 ? (
|
||||||
|
726→ <div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
||||||
|
727→ <p className="text-xs text-muted-foreground">
|
||||||
|
728→ Noch keine Kunden angelegt.
|
||||||
|
729→ </p>
|
||||||
|
730→ </div>
|
||||||
|
731→ ) : (
|
||||||
|
732→ <ul className="space-y-1">
|
||||||
|
733→ {agency.agencyClients.map((c) => (
|
||||||
|
734→ <li
|
||||||
|
735→ key={c.id}
|
||||||
|
736→ className="flex items-center gap-3 rounded-md border border-border bg-background px-3 py-2"
|
||||||
|
737→ >
|
||||||
|
738→ <div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-semibold">
|
||||||
|
739→ {c.name.slice(0, 2).toUpperCase()}
|
||||||
|
740→ </div>
|
||||||
|
741→ <div className="min-w-0 flex-1">
|
||||||
|
742→ <p className="truncate text-xs font-medium">{c.name}</p>
|
||||||
|
743→ {c.email && (
|
||||||
|
744→ <p className="truncate text-[10px] text-muted-foreground">{c.email}</p>
|
||||||
|
745→ )}
|
||||||
|
746→ </div>
|
||||||
|
747→ </li>
|
||||||
|
748→ ))}
|
||||||
|
749→ </ul>
|
||||||
|
750→ )}
|
||||||
|
751→ </div>
|
||||||
|
752→ </>
|
||||||
|
753→ )}
|
||||||
|
754→ </SheetContent>
|
||||||
|
755→ </Sheet>
|
||||||
|
756→ )
|
||||||
|
757→}
|
||||||
|
758→
|
||||||
|
759→function MetaItem({
|
||||||
|
760→ label,
|
||||||
|
761→ value,
|
||||||
|
762→ full,
|
||||||
|
763→}: {
|
||||||
|
764→ label: string
|
||||||
|
765→ value: string
|
||||||
|
766→ full?: boolean
|
||||||
|
767→}) {
|
||||||
|
768→ return (
|
||||||
|
769→ <div className={cn('min-w-0', full && 'col-span-2')}>
|
||||||
|
770→ <dt className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</dt>
|
||||||
|
771→ <dd className="mt-0.5 truncate text-xs font-medium">{value}</dd>
|
||||||
|
772→ </div>
|
||||||
|
773→ )
|
||||||
|
774→}
|
||||||
|
775→
|
||||||
|
776→/* ----------------------------- reviewed (history) ----------------------------- */
|
||||||
|
777→
|
||||||
|
778→function ReviewedSection({ applications }: { applications: AgencyApplication[] }) {
|
||||||
|
779→ const [open, setOpen] = useState(false)
|
||||||
|
780→
|
||||||
|
781→ return (
|
||||||
|
782→ <section className="space-y-2">
|
||||||
|
783→ <Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
784→ <Card className="bg-muted/30">
|
||||||
|
785→ <CollapsibleTrigger asChild>
|
||||||
|
786→ <button
|
||||||
|
787→ type="button"
|
||||||
|
788→ className="flex w-full items-center justify-between p-3 text-left"
|
||||||
|
789→ >
|
||||||
|
790→ <div className="flex items-center gap-2">
|
||||||
|
791→ <History className="size-3.5 text-muted-foreground" />
|
||||||
|
792→ <span className="text-xs font-semibold">Verlauf</span>
|
||||||
|
793→ <Badge variant="secondary" className="text-[10px] tabular-nums">
|
||||||
|
794→ {applications.length}
|
||||||
|
795→ </Badge>
|
||||||
|
796→ </div>
|
||||||
|
797→ <span className="text-[10px] text-muted-foreground">
|
||||||
|
798→ {open ? 'ausblenden' : 'einblenden'}
|
||||||
|
799→ </span>
|
||||||
|
800→ </button>
|
||||||
|
801→ </CollapsibleTrigger>
|
||||||
|
802→
|
||||||
|
803→ <CollapsibleContent>
|
||||||
|
804→ {applications.length === 0 ? (
|
||||||
|
805→ <div className="border-t border-border px-4 py-6 text-center text-xs text-muted-foreground">
|
||||||
|
806→ Noch keine bewerteten Bewerbungen.
|
||||||
|
807→ </div>
|
||||||
|
808→ ) : (
|
||||||
|
809→ <div className="border-t border-border">
|
||||||
|
810→ <ul className="divide-y divide-border">
|
||||||
|
811→ {applications.map((a) => (
|
||||||
|
812→ <li
|
||||||
|
813→ key={a.id}
|
||||||
|
814→ className="flex flex-col gap-1 px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
815→ >
|
||||||
|
816→ <div className="min-w-0 space-y-0.5">
|
||||||
|
817→ <div className="flex items-center gap-2">
|
||||||
|
818→ <span className="truncate text-xs font-medium">{a.company.name}</span>
|
||||||
|
819→ <StatusBadge status={a.status} />
|
||||||
|
820→ </div>
|
||||||
|
821→ {a.status === 'rejected' && a.rejectReason && (
|
||||||
|
822→ <p className="truncate text-[11px] text-muted-foreground">
|
||||||
|
823→ Grund: {a.rejectReason}
|
||||||
|
824→ </p>
|
||||||
|
825→ )}
|
||||||
|
826→ {a.status === 'approved' && (
|
||||||
|
827→ <p className="text-[11px] text-muted-foreground">
|
||||||
|
828→ Rabatt: <span className="tabular-nums">{a.discountPct}%</span>
|
||||||
|
829→ </p>
|
||||||
|
830→ )}
|
||||||
|
831→ </div>
|
||||||
|
832→ <div className="shrink-0 text-right text-[10px] text-muted-foreground">
|
||||||
|
833→ {a.reviewedBy && <p>reviewed by {a.reviewedBy}</p>}
|
||||||
|
834→ {a.reviewedAt && (
|
||||||
|
835→ <p className="tabular-nums">{formatDateTime(a.reviewedAt)}</p>
|
||||||
|
836→ )}
|
||||||
|
837→ </div>
|
||||||
|
838→ </li>
|
||||||
|
839→ ))}
|
||||||
|
840→ </ul>
|
||||||
|
841→ </div>
|
||||||
|
842→ )}
|
||||||
|
843→ </CollapsibleContent>
|
||||||
|
844→ </Card>
|
||||||
|
845→ </Collapsible>
|
||||||
|
846→ </section>
|
||||||
|
847→ )
|
||||||
|
848→}
|
||||||
|
849→
|
||||||
|
850→function StatusBadge({ status }: { status: 'approved' | 'rejected' | 'pending' }) {
|
||||||
|
851→ if (status === 'approved') {
|
||||||
|
852→ return (
|
||||||
|
853→ <Badge
|
||||||
|
854→ variant="outline"
|
||||||
|
855→ className="gap-1 border-foreground/40 text-[10px]"
|
||||||
|
856→ >
|
||||||
|
857→ <CheckCircle2 className="size-2.5" />
|
||||||
|
858→ Genehmigt
|
||||||
|
859→ </Badge>
|
||||||
|
860→ )
|
||||||
|
861→ }
|
||||||
|
862→ if (status === 'rejected') {
|
||||||
|
863→ return (
|
||||||
|
864→ <Badge variant="outline" className="gap-1 text-[10px] text-muted-foreground">
|
||||||
|
865→ <XCircle className="size-2.5" />
|
||||||
|
866→ Abgelehnt
|
||||||
|
867→ </Badge>
|
||||||
|
868→ )
|
||||||
|
869→ }
|
||||||
|
870→ return (
|
||||||
|
871→ <Badge variant="outline" className="text-[10px] text-muted-foreground">
|
||||||
|
872→ Offen
|
||||||
|
873→ </Badge>
|
||||||
|
874→ )
|
||||||
|
875→}
|
||||||
|
876→
|
||||||
@@ -0,0 +1,876 @@
|
|||||||
|
1→ 1→'use client'
|
||||||
|
2→ 2→
|
||||||
|
3→ 3→import { useMemo, useState } from 'react'
|
||||||
|
4→ 4→import {
|
||||||
|
5→ 5→ Users,
|
||||||
|
6→ 6→ Building2,
|
||||||
|
7→ 7→ Globe,
|
||||||
|
8→ 8→ MapPin,
|
||||||
|
9→ 9→ Clock,
|
||||||
|
10→ 10→ Percent,
|
||||||
|
11→ 11→ CheckCircle2,
|
||||||
|
12→ 12→ XCircle,
|
||||||
|
13→ 13→ History,
|
||||||
|
14→ 14→ Inbox,
|
||||||
|
15→ 15→ Mail,
|
||||||
|
16→ 16→ Loader2,
|
||||||
|
17→ 17→ ChevronRight,
|
||||||
|
18→ 18→ ShoppingBag,
|
||||||
|
19→ 19→} from 'lucide-react'
|
||||||
|
20→ 20→import { useFetch } from '@/hooks/use-fetch'
|
||||||
|
21→ 21→import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
22→ 22→import { Badge } from '@/components/ui/badge'
|
||||||
|
23→ 23→import { Button } from '@/components/ui/button'
|
||||||
|
24→ 24→import { Input } from '@/components/ui/input'
|
||||||
|
25→ 25→import { Label } from '@/components/ui/label'
|
||||||
|
26→ 26→import { Textarea } from '@/components/ui/textarea'
|
||||||
|
27→ 27→import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
28→ 28→import {
|
||||||
|
29→ 29→ Dialog,
|
||||||
|
30→ 30→ DialogContent,
|
||||||
|
31→ 31→ DialogDescription,
|
||||||
|
32→ 32→ DialogFooter,
|
||||||
|
33→ 33→ DialogHeader,
|
||||||
|
34→ 34→ DialogTitle,
|
||||||
|
35→ 35→} from '@/components/ui/dialog'
|
||||||
|
36→ 36→import {
|
||||||
|
37→ 37→ Sheet,
|
||||||
|
38→ 38→ SheetContent,
|
||||||
|
39→ 39→ SheetDescription,
|
||||||
|
40→ 40→ SheetHeader,
|
||||||
|
41→ 41→ SheetTitle,
|
||||||
|
42→ 42→} from '@/components/ui/sheet'
|
||||||
|
43→ 43→import {
|
||||||
|
44→ 44→ Collapsible,
|
||||||
|
45→ 45→ CollapsibleContent,
|
||||||
|
46→ 46→ CollapsibleTrigger,
|
||||||
|
47→ 47→} from '@/components/ui/collapsible'
|
||||||
|
48→ 48→import { PageHeader } from '../primitives'
|
||||||
|
49→ 49→import { FuseButton } from '@/components/FuseButton'
|
||||||
|
50→ 50→import { formatRelative, formatDate, formatDateTime } from '@/lib/format'
|
||||||
|
51→ 51→import { cn } from '@/lib/utils'
|
||||||
|
52→ 52→import { toast } from 'sonner'
|
||||||
|
53→ 53→
|
||||||
|
54→ 54→/* ----------------------------- types ----------------------------- */
|
||||||
|
55→ 55→
|
||||||
|
56→ 56→interface AgencyCompany {
|
||||||
|
57→ 57→ id: string
|
||||||
|
58→ 58→ name: string
|
||||||
|
59→ 59→ email: string | null
|
||||||
|
60→ 60→ website: string | null
|
||||||
|
61→ 61→ city: string | null
|
||||||
|
62→ 62→ isAgency: boolean
|
||||||
|
63→ 63→ agencyStatus: string | null
|
||||||
|
64→ 64→ agencyDiscountPct: number | null
|
||||||
|
65→ 65→}
|
||||||
|
66→ 66→
|
||||||
|
67→ 67→interface AgencyApplication {
|
||||||
|
68→ 68→ id: string
|
||||||
|
69→ 69→ companyId: string
|
||||||
|
70→ 70→ status: 'pending' | 'approved' | 'rejected'
|
||||||
|
71→ 71→ discountPct: number
|
||||||
|
72→ 72→ businessModel?: string | null
|
||||||
|
73→ 73→ expectedClientVolume?: string | null
|
||||||
|
74→ 74→ referredBy?: string | null
|
||||||
|
75→ 75→ reviewedBy?: string | null
|
||||||
|
76→ 76→ reviewedAt?: string | null
|
||||||
|
77→ 77→ rejectReason?: string | null
|
||||||
|
78→ 78→ appliedAt: string
|
||||||
|
79→ 79→ createdAt: string
|
||||||
|
80→ 80→ updatedAt: string
|
||||||
|
81→ 81→ company: AgencyCompany
|
||||||
|
82→ 82→}
|
||||||
|
83→ 83→
|
||||||
|
84→ 84→interface AgencyClient {
|
||||||
|
85→ 85→ id: string
|
||||||
|
86→ 86→ name: string
|
||||||
|
87→ 87→ email: string | null
|
||||||
|
88→ 88→}
|
||||||
|
89→ 89→
|
||||||
|
90→ 90→interface ApprovedAgency extends AgencyCompany {
|
||||||
|
91→ 91→ agencyDiscountPct: number
|
||||||
|
92→ 92→ agencyApprovedAt: string
|
||||||
|
93→ 93→ agencyClients: AgencyClient[]
|
||||||
|
94→ 94→ _count: { agencyClients: number; licenses: number }
|
||||||
|
95→ 95→}
|
||||||
|
96→ 96→
|
||||||
|
97→ 97→interface AgencyData {
|
||||||
|
98→ 98→ applications: {
|
||||||
|
99→ 99→ pending: AgencyApplication[]
|
||||||
|
100→ 100→ reviewed: AgencyApplication[]
|
||||||
|
101→ 101→ }
|
||||||
|
102→ 102→ agencies: ApprovedAgency[]
|
||||||
|
103→ 103→ stats: { pending: number; approved: number; totalApplications: number }
|
||||||
|
104→ 104→}
|
||||||
|
105→ 105→
|
||||||
|
106→ 106→const BASE_PRICE_EUR = 50 // 50 €/Monat base price for preview
|
||||||
|
107→ 107→
|
||||||
|
108→ 108→/* ----------------------------- view ----------------------------- */
|
||||||
|
109→ 109→
|
||||||
|
110→ 110→export function AgencyView() {
|
||||||
|
111→ 111→ const { data, loading, refetch } = useFetch<AgencyData>('/api/aos/agency')
|
||||||
|
112→ 112→
|
||||||
|
113→ 113→ if (loading || !data) {
|
||||||
|
114→ 114→ return (
|
||||||
|
115→ 115→ <div className="space-y-6 p-4 md:p-6">
|
||||||
|
116→ 116→ <Skeleton className="h-9 w-32" />
|
||||||
|
117→ 117→ <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
118→ 118→ {Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
119→ 119→ <Skeleton key={i} className="h-24" />
|
||||||
|
120→ 120→ ))}
|
||||||
|
121→ 121→ </div>
|
||||||
|
122→ 122→ <Skeleton className="h-64" />
|
||||||
|
123→ 123→ </div>
|
||||||
|
124→ 124→ )
|
||||||
|
125→ 125→ }
|
||||||
|
126→ 126→
|
||||||
|
127→ 127→ const { applications, agencies, stats } = data
|
||||||
|
128→ 128→ const avgDiscount =
|
||||||
|
129→ 129→ agencies.length > 0
|
||||||
|
130→ 130→ ? Math.round(
|
||||||
|
131→ 131→ agencies.reduce((sum, a) => sum + (a.agencyDiscountPct ?? 0), 0) / agencies.length,
|
||||||
|
132→ 132→ )
|
||||||
|
133→ 133→ : 0
|
||||||
|
134→ 134→
|
||||||
|
135→ 135→ return (
|
||||||
|
136→ 136→ <div className="space-y-6 p-4 md:p-6">
|
||||||
|
137→ 137→ <PageHeader
|
||||||
|
138→ 138→ title="Reseller"
|
||||||
|
139→ 139→ description="Reseller-Verwaltung: Bewerbungen prüfen, Rabatt-Sätze festlegen (0–20 %) und genehmigte Reseller mit ihren Kunden einsehen."
|
||||||
|
140→ 140→ />
|
||||||
|
141→ 141→
|
||||||
|
142→ 142→ {/* Stats */}
|
||||||
|
143→ 143→ <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
144→ 144→ <StatCard
|
||||||
|
145→ 145→ label="Offene Bewerbungen"
|
||||||
|
146→ 146→ value={stats.pending}
|
||||||
|
147→ 147→ icon={Inbox}
|
||||||
|
148→ 148→ hint="wartet auf Freigabe"
|
||||||
|
149→ 149→ />
|
||||||
|
150→ 150→ <StatCard
|
||||||
|
151→ 151→ label="Genehmigte Reseller"
|
||||||
|
152→ 152→ value={stats.approved}
|
||||||
|
153→ 153→ icon={Users}
|
||||||
|
154→ 154→ hint="aktiv im System"
|
||||||
|
155→ 155→ />
|
||||||
|
156→ 156→ <StatCard
|
||||||
|
157→ 157→ label="Bewerbungen gesamt"
|
||||||
|
158→ 158→ value={stats.totalApplications}
|
||||||
|
159→ 159→ icon={History}
|
||||||
|
160→ 160→ hint="inkl. Verlauf"
|
||||||
|
161→ 161→ />
|
||||||
|
162→ 162→ <StatCard
|
||||||
|
163→ 163→ label="Ø Rabatt"
|
||||||
|
164→ 164→ value={`${avgDiscount}%`}
|
||||||
|
165→ 165→ icon={Percent}
|
||||||
|
166→ 166→ hint={agencies.length ? `über ${agencies.length} Reseller` : '—'}
|
||||||
|
167→ 167→ />
|
||||||
|
168→ 168→ </div>
|
||||||
|
169→ 169→
|
||||||
|
170→ 170→ <PendingApplicationsSection applications={applications.pending} onChanged={refetch} />
|
||||||
|
171→ 171→ <ApprovedAgenciesSection agencies={agencies} />
|
||||||
|
172→ 172→ <ReviewedSection applications={applications.reviewed} />
|
||||||
|
173→ 173→ </div>
|
||||||
|
174→ 174→ )
|
||||||
|
175→ 175→}
|
||||||
|
176→ 176→
|
||||||
|
177→ 177→/* ----------------------------- stats card ----------------------------- */
|
||||||
|
178→ 178→
|
||||||
|
179→ 179→function StatCard({
|
||||||
|
180→ 180→ label,
|
||||||
|
181→ 181→ value,
|
||||||
|
182→ 182→ icon: Icon,
|
||||||
|
183→ 183→ hint,
|
||||||
|
184→ 184→}: {
|
||||||
|
185→ 185→ label: string
|
||||||
|
186→ 186→ value: number | string
|
||||||
|
187→ 187→ icon: typeof Inbox
|
||||||
|
188→ 188→ hint?: string
|
||||||
|
189→ 189→}) {
|
||||||
|
190→ 190→ return (
|
||||||
|
191→ 191→ <Card className="p-4">
|
||||||
|
192→ 192→ <div className="flex items-center justify-between">
|
||||||
|
193→ 193→ <p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
194→ 194→ <Icon className="size-3.5 text-muted-foreground" />
|
||||||
|
195→ 195→ </div>
|
||||||
|
196→ 196→ <p className="mt-1 text-2xl font-semibold tabular-nums">{value}</p>
|
||||||
|
197→ 197→ {hint && <p className="text-[10px] text-muted-foreground">{hint}</p>}
|
||||||
|
198→ 198→ </Card>
|
||||||
|
199→ 199→ )
|
||||||
|
200→ 200→}
|
||||||
|
201→ 201→
|
||||||
|
202→ 202→/* ----------------------------- pending applications ----------------------------- */
|
||||||
|
203→ 203→
|
||||||
|
204→ 204→function PendingApplicationsSection({
|
||||||
|
205→ 205→ applications,
|
||||||
|
206→ 206→ onChanged,
|
||||||
|
207→ 207→}: {
|
||||||
|
208→ 208→ applications: AgencyApplication[]
|
||||||
|
209→ 209→ onChanged: () => void
|
||||||
|
210→ 210→}) {
|
||||||
|
211→ 211→ const [approveTarget, setApproveTarget] = useState<AgencyApplication | null>(null)
|
||||||
|
212→ 212→ const [rejectTarget, setRejectTarget] = useState<AgencyApplication | null>(null)
|
||||||
|
213→ 213→
|
||||||
|
214→ 214→ return (
|
||||||
|
215→ 215→ <section className="space-y-3">
|
||||||
|
216→ 216→ <div className="flex items-center justify-between">
|
||||||
|
217→ 217→ <h2 className="text-sm font-semibold tracking-tight">
|
||||||
|
218→ 218→ Bewerbende Reseller
|
||||||
|
219→ 219→ <span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||||
|
220→ 220→ {applications.length} offen
|
||||||
|
221→ 221→ </span>
|
||||||
|
222→ 222→ </h2>
|
||||||
|
223→ 223→ </div>
|
||||||
|
224→ 224→
|
||||||
|
225→ 225→ {applications.length === 0 ? (
|
||||||
|
226→ 226→ <div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 px-6 py-14 text-center">
|
||||||
|
227→ 227→ <div className="flex size-12 items-center justify-center rounded-full border border-border bg-background">
|
||||||
|
228→ 228→ <Inbox className="size-5 text-muted-foreground" />
|
||||||
|
229→ 229→ </div>
|
||||||
|
230→ 230→ <div className="space-y-1">
|
||||||
|
231→ 231→ <p className="text-sm font-medium">Keine bewerbenden Anfragen</p>
|
||||||
|
232→ 232→ <p className="max-w-sm text-xs text-muted-foreground">
|
||||||
|
233→ 233→ Neue Reseller-Bewerbungen erscheinen hier.
|
||||||
|
234→ 234→ </p>
|
||||||
|
235→ 235→ </div>
|
||||||
|
236→ 236→ </div>
|
||||||
|
237→ 237→ ) : (
|
||||||
|
238→ 238→ <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
239→ 239→ {applications.map((app) => (
|
||||||
|
240→ 240→ <Card key={app.id} className="flex flex-col">
|
||||||
|
241→ 241→ <CardContent className="flex flex-1 flex-col gap-3 p-4">
|
||||||
|
242→ 242→ {/* header */}
|
||||||
|
243→ 243→ <div className="flex items-start justify-between gap-3">
|
||||||
|
244→ 244→ <div className="min-w-0 space-y-1">
|
||||||
|
245→ 245→ <p className="truncate text-sm font-semibold">{app.company.name}</p>
|
||||||
|
246→ 246→ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
|
||||||
|
247→ 247→ {app.company.email && (
|
||||||
|
248→ 248→ <span className="inline-flex items-center gap-1">
|
||||||
|
249→ 249→ <Mail className="size-3" />
|
||||||
|
250→ 250→ {app.company.email}
|
||||||
|
251→ 251→ </span>
|
||||||
|
252→ 252→ )}
|
||||||
|
253→ 253→ {app.company.website && (
|
||||||
|
254→ 254→ <span className="inline-flex items-center gap-1">
|
||||||
|
255→ 255→ <Globe className="size-3" />
|
||||||
|
256→ 256→ {app.company.website}
|
||||||
|
257→ 257→ </span>
|
||||||
|
258→ 258→ )}
|
||||||
|
259→ 259→ {app.company.city && (
|
||||||
|
260→ 260→ <span className="inline-flex items-center gap-1">
|
||||||
|
261→ 261→ <MapPin className="size-3" />
|
||||||
|
262→ 262→ {app.company.city}
|
||||||
|
263→ 263→ </span>
|
||||||
|
264→ 264→ )}
|
||||||
|
265→ 265→ </div>
|
||||||
|
266→ 266→ </div>
|
||||||
|
267→ 267→ <Badge variant="outline" className="gap-1 border-foreground/40 text-[10px]">
|
||||||
|
268→ 268→ <Clock className="size-2.5" />
|
||||||
|
269→ 269→ {formatRelative(app.appliedAt)}
|
||||||
|
270→ 270→ </Badge>
|
||||||
|
271→ 271→ </div>
|
||||||
|
272→ 272→
|
||||||
|
273→ 273→ {/* application details */}
|
||||||
|
274→ 274→ {(app.businessModel || app.expectedClientVolume || app.referredBy) && (
|
||||||
|
275→ 275→ <div className="grid grid-cols-1 gap-2 rounded-md border border-border bg-muted/30 p-3 text-xs sm:grid-cols-3">
|
||||||
|
276→ 276→ {app.businessModel && (
|
||||||
|
277→ 277→ <Meta label="Geschäftsmodell" value={app.businessModel} />
|
||||||
|
278→ 278→ )}
|
||||||
|
279→ 279→ {app.expectedClientVolume && (
|
||||||
|
280→ 280→ <Meta label="Erw. Kundenvolumen" value={app.expectedClientVolume} />
|
||||||
|
281→ 281→ )}
|
||||||
|
282→ 282→ {app.referredBy && (
|
||||||
|
283→ 283→ <Meta label="Empfohlen von" value={app.referredBy} />
|
||||||
|
284→ 284→ )}
|
||||||
|
285→ 285→ </div>
|
||||||
|
286→ 286→ )}
|
||||||
|
287→ 287→
|
||||||
|
288→ 288→ {/* actions */}
|
||||||
|
289→ 289→ <div className="mt-auto flex items-center justify-end gap-2 pt-1">
|
||||||
|
290→ 290→ <Button
|
||||||
|
291→ 291→ size="sm"
|
||||||
|
292→ 292→ variant="outline"
|
||||||
|
293→ 293→ onClick={() => setRejectTarget(app)}
|
||||||
|
294→ 294→ >
|
||||||
|
295→ 295→ <XCircle className="size-3.5" />
|
||||||
|
296→ 296→ Ablehnen
|
||||||
|
297→ 297→ </Button>
|
||||||
|
298→ 298→ <Button
|
||||||
|
299→ 299→ size="sm"
|
||||||
|
300→ 300→ onClick={() => setApproveTarget(app)}
|
||||||
|
301→ 301→ >
|
||||||
|
302→ 302→ <CheckCircle2 className="size-3.5" />
|
||||||
|
303→ 303→ Genehmigen
|
||||||
|
304→ 304→ </Button>
|
||||||
|
305→ 305→ </div>
|
||||||
|
306→ 306→ </CardContent>
|
||||||
|
307→ 307→ </Card>
|
||||||
|
308→ 308→ ))}
|
||||||
|
309→ 309→ </div>
|
||||||
|
310→ 310→ )}
|
||||||
|
311→ 311→
|
||||||
|
312→ 312→ <ApproveDialog
|
||||||
|
313→ 313→ application={approveTarget}
|
||||||
|
314→ 314→ onClose={() => setApproveTarget(null)}
|
||||||
|
315→ 315→ onChanged={onChanged}
|
||||||
|
316→ 316→ />
|
||||||
|
317→ 317→ <RejectDialog
|
||||||
|
318→ 318→ application={rejectTarget}
|
||||||
|
319→ 319→ onClose={() => setRejectTarget(null)}
|
||||||
|
320→ 320→ onChanged={onChanged}
|
||||||
|
321→ 321→ />
|
||||||
|
322→ 322→ </section>
|
||||||
|
323→ 323→ )
|
||||||
|
324→ 324→}
|
||||||
|
325→ 325→
|
||||||
|
326→ 326→function Meta({ label, value }: { label: string; value: string }) {
|
||||||
|
327→ 327→ return (
|
||||||
|
328→ 328→ <div className="min-w-0">
|
||||||
|
329→ 329→ <p className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||||
|
330→ 330→ <p className="mt-0.5 truncate text-xs font-medium">{value}</p>
|
||||||
|
331→ 331→ </div>
|
||||||
|
332→ 332→ )
|
||||||
|
333→ 333→}
|
||||||
|
334→ 334→
|
||||||
|
335→ 335→/* ----------------------------- approve dialog ----------------------------- */
|
||||||
|
336→ 336→
|
||||||
|
337→ 337→function ApproveDialog({
|
||||||
|
338→ 338→ application,
|
||||||
|
339→ 339→ onClose,
|
||||||
|
340→ 340→ onChanged,
|
||||||
|
341→ 341→}: {
|
||||||
|
342→ 342→ application: AgencyApplication | null
|
||||||
|
343→ 343→ onClose: () => void
|
||||||
|
344→ 344→ onChanged: () => void
|
||||||
|
345→ 345→}) {
|
||||||
|
346→ 346→ const [discount, setDiscount] = useState(10)
|
||||||
|
347→ 347→ const [committing, setCommitting] = useState(false)
|
||||||
|
348→ 348→
|
||||||
|
349→ 349→ // Reset state when dialog opens for a new application
|
||||||
|
350→ 350→ const handleOpenChange = (open: boolean) => {
|
||||||
|
351→ 351→ if (open) setDiscount(10)
|
||||||
|
352→ 352→ if (!open) {
|
||||||
|
353→ 353→ if (!committing) onClose()
|
||||||
|
354→ 354→ }
|
||||||
|
355→ 355→ }
|
||||||
|
356→ 356→
|
||||||
|
357→ 357→ const net = useMemo(() => {
|
||||||
|
358→ 358→ const netEur = BASE_PRICE_EUR * (1 - discount / 100)
|
||||||
|
359→ 359→ return netEur.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
360→ 360→ }, [discount])
|
||||||
|
361→ 361→
|
||||||
|
362→ 362→ const handleCommit = async () => {
|
||||||
|
363→ 363→ if (!application) return
|
||||||
|
364→ 364→ setCommitting(true)
|
||||||
|
365→ 365→ try {
|
||||||
|
366→ 366→ const r = await fetch(`/api/aos/agency/${application.id}/approve`, {
|
||||||
|
367→ 367→ method: 'PATCH',
|
||||||
|
368→ 368→ headers: { 'content-type': 'application/json' },
|
||||||
|
369→ 369→ body: JSON.stringify({ discountPct: discount }),
|
||||||
|
370→ 370→ })
|
||||||
|
371→ 371→ if (!r.ok) {
|
||||||
|
372→ 372→ const err = await r.json().catch(() => ({}))
|
||||||
|
373→ 373→ throw new Error(err.error || 'approve failed')
|
||||||
|
374→ 374→ }
|
||||||
|
375→ 375→ // Defer close + refetch so the FuseButton's done-state + toast can render.
|
||||||
|
376→ 376→ setTimeout(() => {
|
||||||
|
377→ 377→ onClose()
|
||||||
|
378→ 378→ onChanged()
|
||||||
|
379→ 379→ }, 350)
|
||||||
|
380→ 380→ } catch (err) {
|
||||||
|
381→ 381→ setCommitting(false)
|
||||||
|
382→ 382→ throw err
|
||||||
|
383→ 383→ }
|
||||||
|
384→ 384→ // Note: setCommitting(false) intentionally skipped on success — dialog will
|
||||||
|
385→ 385→ // close via the setTimeout above and the button's doneLabel will be shown.
|
||||||
|
386→ 386→ }
|
||||||
|
387→ 387→
|
||||||
|
388→ 388→ return (
|
||||||
|
389→ 389→ <Dialog open={!!application} onOpenChange={handleOpenChange}>
|
||||||
|
390→ 390→ <DialogContent className="sm:max-w-md">
|
||||||
|
391→ 391→ {application && (
|
||||||
|
392→ 392→ <>
|
||||||
|
393→ 393→ <DialogHeader>
|
||||||
|
394→ 394→ <DialogTitle className="flex items-center gap-2">
|
||||||
|
395→ 395→ <CheckCircle2 className="size-4" />
|
||||||
|
396→ 396→ Reseller-Bewerbung genehmigen
|
||||||
|
397→ 397→ </DialogTitle>
|
||||||
|
398→ 398→ <DialogDescription>
|
||||||
|
399→ 399→ <span className="font-medium text-foreground">{application.company.name}</span> wird
|
||||||
|
400→ 400→ als Reseller freigeschaltet. Der Rabatt wird auf alle vom Reseller angelegten Kundenlizenzierungen
|
||||||
|
401→ 401→ angewendet.
|
||||||
|
402→ 402→ </DialogDescription>
|
||||||
|
403→ 403→ </DialogHeader>
|
||||||
|
404→ 404→
|
||||||
|
405→ 405→ <div className="space-y-3">
|
||||||
|
406→ 406→ <div className="space-y-1.5">
|
||||||
|
407→ 407→ <div className="flex items-center justify-between">
|
||||||
|
408→ 408→ <Label htmlFor="discount-pct">Rabatt (%)</Label>
|
||||||
|
409→ 409→ <span className="text-[10px] text-muted-foreground">0 – 20</span>
|
||||||
|
410→ 410→ </div>
|
||||||
|
411→ 411→ <Input
|
||||||
|
412→ 412→ id="discount-pct"
|
||||||
|
413→ 413→ type="number"
|
||||||
|
414→ 414→ min={0}
|
||||||
|
415→ 415→ max={20}
|
||||||
|
416→ 416→ step={1}
|
||||||
|
417→ 417→ value={discount}
|
||||||
|
418→ 418→ onChange={(e) => {
|
||||||
|
419→ 419→ const raw = parseInt(e.target.value, 10)
|
||||||
|
420→ 420→ if (Number.isNaN(raw)) {
|
||||||
|
421→ 421→ setDiscount(0)
|
||||||
|
422→ 422→ return
|
||||||
|
423→ 423→ }
|
||||||
|
424→ 424→ setDiscount(Math.min(20, Math.max(0, raw)))
|
||||||
|
425→ 425→ }}
|
||||||
|
426→ 426→ className="tabular-nums"
|
||||||
|
427→ 427→ />
|
||||||
|
428→ 428→ </div>
|
||||||
|
429→ 429→
|
||||||
|
430→ 430→ {/* live preview */}
|
||||||
|
431→ 431→ <div className="rounded-md border border-border bg-muted/40 p-3 text-xs">
|
||||||
|
432→ 432→ <p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
433→ 433→ Preisvorschau
|
||||||
|
434→ 434→ </p>
|
||||||
|
435→ 435→ <p className="mt-1 tabular-nums">
|
||||||
|
436→ 436→ <span className="font-medium">{BASE_PRICE_EUR} €/Monat</span>
|
||||||
|
437→ 437→ <span className="mx-1.5 text-muted-foreground">Basis</span>
|
||||||
|
438→ 438→ <span className="mx-1 text-muted-foreground">→</span>
|
||||||
|
439→ 439→ <span className="text-muted-foreground">mit {discount}% Rabatt</span>
|
||||||
|
440→ 440→ <span className="mx-1.5 text-muted-foreground">=</span>
|
||||||
|
441→ 441→ <span className="font-semibold border-foreground/40 px-1.5 py-0.5 rounded border">
|
||||||
|
442→ 442→ {net} €
|
||||||
|
443→ 443→ </span>
|
||||||
|
444→ 444→ </p>
|
||||||
|
445→ 445→ </div>
|
||||||
|
446→ 446→ </div>
|
||||||
|
447→ 447→
|
||||||
|
448→ 448→ <DialogFooter className="gap-2">
|
||||||
|
449→ 449→ <Button variant="outline" onClick={onClose} disabled={committing}>
|
||||||
|
450→ 450→ Abbrechen
|
||||||
|
451→ 451→ </Button>
|
||||||
|
452→ 452→ <FuseButton
|
||||||
|
453→ 453→ label="Genehmigen"
|
||||||
|
454→ 454→ doneLabel="Genehmigt"
|
||||||
|
455→ 455→ onCommit={handleCommit}
|
||||||
|
456→ 456→ disabled={committing}
|
||||||
|
457→ 457→ variant="default"
|
||||||
|
458→ 458→ />
|
||||||
|
459→ 459→ </DialogFooter>
|
||||||
|
460→ 460→ </>
|
||||||
|
461→ 461→ )}
|
||||||
|
462→ 462→ </DialogContent>
|
||||||
|
463→ 463→ </Dialog>
|
||||||
|
464→ 464→ )
|
||||||
|
465→ 465→}
|
||||||
|
466→ 466→
|
||||||
|
467→ 467→/* ----------------------------- reject dialog ----------------------------- */
|
||||||
|
468→ 468→
|
||||||
|
469→ 469→function RejectDialog({
|
||||||
|
470→ 470→ application,
|
||||||
|
471→ 471→ onClose,
|
||||||
|
472→ 472→ onChanged,
|
||||||
|
473→ 473→}: {
|
||||||
|
474→ 474→ application: AgencyApplication | null
|
||||||
|
475→ 475→ onClose: () => void
|
||||||
|
476→ 476→ onChanged: () => void
|
||||||
|
477→ 477→}) {
|
||||||
|
478→ 478→ const [reason, setReason] = useState('')
|
||||||
|
479→ 479→ const [submitting, setSubmitting] = useState(false)
|
||||||
|
480→ 480→
|
||||||
|
481→ 481→ const handleOpenChange = (open: boolean) => {
|
||||||
|
482→ 482→ if (!open) {
|
||||||
|
483→ 483→ if (!submitting) {
|
||||||
|
484→ 484→ setReason('')
|
||||||
|
485→ 485→ onClose()
|
||||||
|
486→ 486→ }
|
||||||
|
487→ 487→ }
|
||||||
|
488→ 488→ }
|
||||||
|
489→ 489→
|
||||||
|
490→ 490→ const submit = async () => {
|
||||||
|
491→ 491→ if (!application) return
|
||||||
|
492→ 492→ if (!reason.trim()) {
|
||||||
|
493→ 493→ toast.error('Bitte gib einen Ablehnungsgrund an.')
|
||||||
|
494→ 494→ return
|
||||||
|
495→ 495→ }
|
||||||
|
496→ 496→ setSubmitting(true)
|
||||||
|
497→ 497→ const t = toast.loading('Ablehnung wird gespeichert…')
|
||||||
|
498→ 498→ try {
|
||||||
|
499→ 499→ const r = await fetch(`/api/aos/agency/${application.id}/reject`, {
|
||||||
|
500→ 500→ method: 'PATCH',
|
||||||
|
501→ 501→ headers: { 'content-type': 'application/json' },
|
||||||
|
502→ 502→ body: JSON.stringify({ reason: reason.trim() }),
|
||||||
|
503→ 503→ })
|
||||||
|
504→ 504→ if (!r.ok) {
|
||||||
|
505→ 505→ const err = await r.json().catch(() => ({}))
|
||||||
|
506→ 506→ throw new Error(err.error || 'reject failed')
|
||||||
|
507→ 507→ }
|
||||||
|
508→ 508→ toast.success(`${application.company.name} abgelehnt`, { id: t })
|
||||||
|
509→ 509→ setReason('')
|
||||||
|
510→ 510→ onClose()
|
||||||
|
511→ 511→ onChanged()
|
||||||
|
512→ 512→ } catch (err) {
|
||||||
|
513→ 513→ toast.error(err instanceof Error ? err.message : 'Ablehnung fehlgeschlagen', { id: t })
|
||||||
|
514→ 514→ } finally {
|
||||||
|
515→ 515→ setSubmitting(false)
|
||||||
|
516→ 516→ }
|
||||||
|
517→ 517→ }
|
||||||
|
518→ 518→
|
||||||
|
519→ 519→ return (
|
||||||
|
520→ 520→ <Dialog open={!!application} onOpenChange={handleOpenChange}>
|
||||||
|
521→ 521→ <DialogContent className="sm:max-w-md">
|
||||||
|
522→ 522→ {application && (
|
||||||
|
523→ 523→ <>
|
||||||
|
524→ 524→ <DialogHeader>
|
||||||
|
525→ 525→ <DialogTitle className="flex items-center gap-2">
|
||||||
|
526→ 526→ <XCircle className="size-4" />
|
||||||
|
527→ 527→ Reseller-Bewerbung ablehnen
|
||||||
|
528→ 528→ </DialogTitle>
|
||||||
|
529→ 529→ <DialogDescription>
|
||||||
|
530→ 530→ <span className="font-medium text-foreground">{application.company.name}</span>
|
||||||
|
531→ 531→ {' '}wird abgelehnt. Der Grund wird für den Bewerber sichtbar protokolliert.
|
||||||
|
532→ 532→ </DialogDescription>
|
||||||
|
533→ 533→ </DialogHeader>
|
||||||
|
534→ 534→
|
||||||
|
535→ 535→ <div className="space-y-1.5">
|
||||||
|
536→ 536→ <Label htmlFor="reject-reason">Ablehnungsgrund</Label>
|
||||||
|
537→ 537→ <Textarea
|
||||||
|
538→ 538→ id="reject-reason"
|
||||||
|
539→ 539→ placeholder="z. B. Unvollständige Angaben, kein passendes Geschäftsmodell…"
|
||||||
|
540→ 540→ value={reason}
|
||||||
|
541→ 541→ onChange={(e) => setReason(e.target.value)}
|
||||||
|
542→ 542→ rows={4}
|
||||||
|
543→ 543→ maxLength={500}
|
||||||
|
544→ 544→ disabled={submitting}
|
||||||
|
545→ 545→ />
|
||||||
|
546→ 546→ <p className="text-right text-[10px] text-muted-foreground">
|
||||||
|
547→ 547→ {reason.length} / 500
|
||||||
|
548→ 548→ </p>
|
||||||
|
549→ 549→ </div>
|
||||||
|
550→ 550→
|
||||||
|
551→ 551→ <DialogFooter className="gap-2">
|
||||||
|
552→ 552→ <Button variant="outline" onClick={onClose} disabled={submitting}>
|
||||||
|
553→ 553→ Abbrechen
|
||||||
|
554→ 554→ </Button>
|
||||||
|
555→ 555→ <Button variant="destructive" onClick={submit} disabled={submitting}>
|
||||||
|
556→ 556→ {submitting ? (
|
||||||
|
557→ 557→ <Loader2 className="size-3.5 animate-spin" />
|
||||||
|
558→ 558→ ) : (
|
||||||
|
559→ 559→ <XCircle className="size-3.5" />
|
||||||
|
560→ 560→ )}
|
||||||
|
561→ 561→ Ablehnen
|
||||||
|
562→ 562→ </Button>
|
||||||
|
563→ 563→ </DialogFooter>
|
||||||
|
564→ 564→ </>
|
||||||
|
565→ 565→ )}
|
||||||
|
566→ 566→ </DialogContent>
|
||||||
|
567→ 567→ </Dialog>
|
||||||
|
568→ 568→ )
|
||||||
|
569→ 569→}
|
||||||
|
570→ 570→
|
||||||
|
571→ 571→/* ----------------------------- approved agencies ----------------------------- */
|
||||||
|
572→ 572→
|
||||||
|
573→ 573→function ApprovedAgenciesSection({ agencies }: { agencies: ApprovedAgency[] }) {
|
||||||
|
574→ 574→ const [openAgency, setOpenAgency] = useState<ApprovedAgency | null>(null)
|
||||||
|
575→ 575→
|
||||||
|
576→ 576→ return (
|
||||||
|
577→ 577→ <section className="space-y-3">
|
||||||
|
578→ 578→ <div className="flex items-center justify-between">
|
||||||
|
579→ 579→ <h2 className="text-sm font-semibold tracking-tight">
|
||||||
|
580→ 580→ Genehmigte Reseller
|
||||||
|
581→ 581→ <span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||||
|
582→ 582→ {agencies.length} aktiv
|
||||||
|
583→ 583→ </span>
|
||||||
|
584→ 584→ </h2>
|
||||||
|
585→ 585→ </div>
|
||||||
|
586→ 586→
|
||||||
|
587→ 587→ {agencies.length === 0 ? (
|
||||||
|
588→ 588→ <div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 px-6 py-14 text-center">
|
||||||
|
589→ 589→ <div className="flex size-12 items-center justify-center rounded-full border border-border bg-background">
|
||||||
|
590→ 590→ <Users className="size-5 text-muted-foreground" />
|
||||||
|
591→ 591→ </div>
|
||||||
|
592→ 592→ <div className="space-y-1">
|
||||||
|
593→ 593→ <p className="text-sm font-medium">Noch keine Reseller genehmigt</p>
|
||||||
|
594→ 594→ <p className="max-w-sm text-xs text-muted-foreground">
|
||||||
|
595→ 595→ Genehmigte Reseller-Bewerbungen erscheinen hier mit ihren Kunden und Lizenzen.
|
||||||
|
596→ 596→ </p>
|
||||||
|
597→ 597→ </div>
|
||||||
|
598→ 598→ </div>
|
||||||
|
599→ 599→ ) : (
|
||||||
|
600→ 600→ <Card>
|
||||||
|
601→ 601→ <CardHeader className="pb-2">
|
||||||
|
602→ 602→ <CardTitle className="text-sm">Reseller ({agencies.length})</CardTitle>
|
||||||
|
603→ 603→ </CardHeader>
|
||||||
|
604→ 604→ <CardContent className="p-0">
|
||||||
|
605→ 605→ <div className="overflow-x-auto">
|
||||||
|
606→ 606→ <table className="w-full text-sm">
|
||||||
|
607→ 607→ <thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
608→ 608→ <tr>
|
||||||
|
609→ 609→ <th className="px-4 py-2 font-medium">Reseller</th>
|
||||||
|
610→ 610→ <th className="px-4 py-2 font-medium">Rabatt</th>
|
||||||
|
611→ 611→ <th className="px-4 py-2 font-medium text-right">Kunden</th>
|
||||||
|
612→ 612→ <th className="px-4 py-2 font-medium text-right">Lizenzen</th>
|
||||||
|
613→ 613→ <th className="px-4 py-2 font-medium">Genehmigt am</th>
|
||||||
|
614→ 614→ <th className="px-4 py-2 font-medium text-right">Aktion</th>
|
||||||
|
615→ 615→ </tr>
|
||||||
|
616→ 616→ </thead>
|
||||||
|
617→ 617→ <tbody className="divide-y divide-border">
|
||||||
|
618→ 618→ {agencies.map((a) => (
|
||||||
|
619→ 619→ <tr key={a.id} className="transition hover:bg-muted/40">
|
||||||
|
620→ 620→ <td className="px-4 py-2.5">
|
||||||
|
621→ 621→ <div className="flex items-center gap-2">
|
||||||
|
622→ 622→ <div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border">
|
||||||
|
623→ 623→ <Building2 className="size-3.5 text-muted-foreground" />
|
||||||
|
624→ 624→ </div>
|
||||||
|
625→ 625→ <div className="min-w-0">
|
||||||
|
626→ 626→ <p className="truncate text-xs font-medium">{a.name}</p>
|
||||||
|
627→ 627→ {a.email && (
|
||||||
|
628→ 628→ <p className="truncate text-[10px] text-muted-foreground">{a.email}</p>
|
||||||
|
629→ 629→ )}
|
||||||
|
630→ 630→ </div>
|
||||||
|
631→ 631→ </div>
|
||||||
|
632→ 632→ </td>
|
||||||
|
633→ 633→ <td className="px-4 py-2.5">
|
||||||
|
634→ 634→ <Badge
|
||||||
|
635→ 635→ variant="outline"
|
||||||
|
636→ 636→ className="border-foreground/40 text-[10px] tabular-nums"
|
||||||
|
637→ 637→ >
|
||||||
|
638→ 638→ <Percent className="size-2.5" />
|
||||||
|
639→ 639→ {a.agencyDiscountPct}%
|
||||||
|
640→ 640→ </Badge>
|
||||||
|
641→ 641→ </td>
|
||||||
|
642→ 642→ <td className="px-4 py-2.5 text-right text-xs tabular-nums">
|
||||||
|
643→ 643→ {a._count.agencyClients}
|
||||||
|
644→ 644→ </td>
|
||||||
|
645→ 645→ <td className="px-4 py-2.5 text-right text-xs tabular-nums">
|
||||||
|
646→ 646→ {a._count.licenses}
|
||||||
|
647→ 647→ </td>
|
||||||
|
648→ 648→ <td className="px-4 py-2.5 text-xs">
|
||||||
|
649→ 649→ {formatDate(a.agencyApprovedAt)}
|
||||||
|
650→ 650→ </td>
|
||||||
|
651→ 651→ <td className="px-4 py-2.5 text-right">
|
||||||
|
652→ 652→ <Button
|
||||||
|
653→ 653→ size="sm"
|
||||||
|
654→ 654→ variant="ghost"
|
||||||
|
655→ 655→ onClick={() => setOpenAgency(a)}
|
||||||
|
656→ 656→ className="gap-1"
|
||||||
|
657→ 657→ >
|
||||||
|
658→ 658→ <ShoppingBag className="size-3.5" />
|
||||||
|
659→ 659→ Kunden ansehen
|
||||||
|
660→ 660→ <ChevronRight className="size-3" />
|
||||||
|
661→ 661→ </Button>
|
||||||
|
662→ 662→ </td>
|
||||||
|
663→ 663→ </tr>
|
||||||
|
664→ 664→ ))}
|
||||||
|
665→ 665→ </tbody>
|
||||||
|
666→ 666→ </table>
|
||||||
|
667→ 667→ </div>
|
||||||
|
668→ 668→ </CardContent>
|
||||||
|
669→ 669→ </Card>
|
||||||
|
670→ 670→ )}
|
||||||
|
671→ 671→
|
||||||
|
672→ 672→ <ClientsSheet agency={openAgency} onClose={() => setOpenAgency(null)} />
|
||||||
|
673→ 673→ </section>
|
||||||
|
674→ 674→ )
|
||||||
|
675→ 675→}
|
||||||
|
676→ 676→
|
||||||
|
677→ 677→function ClientsSheet({
|
||||||
|
678→ 678→ agency,
|
||||||
|
679→ 679→ onClose,
|
||||||
|
680→ 680→}: {
|
||||||
|
681→ 681→ agency: ApprovedAgency | null
|
||||||
|
682→ 682→ onClose: () => void
|
||||||
|
683→ 683→}) {
|
||||||
|
684→ 684→ return (
|
||||||
|
685→ 685→ <Sheet open={!!agency} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
686→ 686→ <SheetContent side="right" className="w-full gap-0 sm:max-w-md">
|
||||||
|
687→ 687→ {agency && (
|
||||||
|
688→ 688→ <>
|
||||||
|
689→ 689→ <SheetHeader className="border-b border-border">
|
||||||
|
690→ 690→ <SheetTitle className="flex items-center gap-2">
|
||||||
|
691→ 691→ <Building2 className="size-4 text-muted-foreground" />
|
||||||
|
692→ 692→ {agency.name}
|
||||||
|
693→ 693→ </SheetTitle>
|
||||||
|
694→ 694→ <SheetDescription>
|
||||||
|
695→ 695→ Reseller-Kundenverwaltung — {agency._count.agencyClients} Kunde(n),{' '}
|
||||||
|
696→ 696→ {agency._count.licenses} Lizenz(en).
|
||||||
|
697→ 697→ </SheetDescription>
|
||||||
|
698→ 698→ </SheetHeader>
|
||||||
|
699→ 699→
|
||||||
|
700→ 700→ <div className="flex-1 overflow-y-auto px-4 py-3">
|
||||||
|
701→ 701→ {/* routing note */}
|
||||||
|
702→ 702→ <div className="mb-3 flex items-start gap-2 rounded-md border border-border bg-muted/40 p-3 text-[11px]">
|
||||||
|
703→ 703→ <Mail className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
704→ 704→ <p className="text-muted-foreground">
|
||||||
|
705→ 705→ Reseller kann eigene Kunden anlegen und lizenzieren. Tickets von dessen Kunden werden
|
||||||
|
706→ 706→ an den Reseller geroutet, nicht an uns.
|
||||||
|
707→ 707→ </p>
|
||||||
|
708→ 708→ </div>
|
||||||
|
709→ 709→
|
||||||
|
710→ 710→ {/* meta */}
|
||||||
|
711→ 711→ <dl className="mb-3 grid grid-cols-2 gap-2 text-xs">
|
||||||
|
712→ 712→ <MetaItem label="Rabatt" value={`${agency.agencyDiscountPct}%`} />
|
||||||
|
713→ 713→ <MetaItem label="Genehmigt" value={formatDate(agency.agencyApprovedAt)} />
|
||||||
|
714→ 714→ {agency.email && (
|
||||||
|
715→ 715→ <MetaItem label="E-Mail" value={agency.email} full />
|
||||||
|
716→ 716→ )}
|
||||||
|
717→ 717→ {agency.city && <MetaItem label="Stadt" value={agency.city} />}
|
||||||
|
718→ 718→ {agency.website && <MetaItem label="Website" value={agency.website} />}
|
||||||
|
719→ 719→ </dl>
|
||||||
|
720→ 720→
|
||||||
|
721→ 721→ {/* clients list */}
|
||||||
|
722→ 722→ <p className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
723→ 723→ Kunden ({agency.agencyClients.length})
|
||||||
|
724→ 724→ </p>
|
||||||
|
725→ 725→ {agency.agencyClients.length === 0 ? (
|
||||||
|
726→ 726→ <div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
||||||
|
727→ 727→ <p className="text-xs text-muted-foreground">
|
||||||
|
728→ 728→ Noch keine Kunden angelegt.
|
||||||
|
729→ 729→ </p>
|
||||||
|
730→ 730→ </div>
|
||||||
|
731→ 731→ ) : (
|
||||||
|
732→ 732→ <ul className="space-y-1">
|
||||||
|
733→ 733→ {agency.agencyClients.map((c) => (
|
||||||
|
734→ 734→ <li
|
||||||
|
735→ 735→ key={c.id}
|
||||||
|
736→ 736→ className="flex items-center gap-3 rounded-md border border-border bg-background px-3 py-2"
|
||||||
|
737→ 737→ >
|
||||||
|
738→ 738→ <div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-semibold">
|
||||||
|
739→ 739→ {c.name.slice(0, 2).toUpperCase()}
|
||||||
|
740→ 740→ </div>
|
||||||
|
741→ 741→ <div className="min-w-0 flex-1">
|
||||||
|
742→ 742→ <p className="truncate text-xs font-medium">{c.name}</p>
|
||||||
|
743→ 743→ {c.email && (
|
||||||
|
744→ 744→ <p className="truncate text-[10px] text-muted-foreground">{c.email}</p>
|
||||||
|
745→ 745→ )}
|
||||||
|
746→ 746→ </div>
|
||||||
|
747→ 747→ </li>
|
||||||
|
748→ 748→ ))}
|
||||||
|
749→ 749→ </ul>
|
||||||
|
750→ 750→ )}
|
||||||
|
751→ 751→ </div>
|
||||||
|
752→ 752→ </>
|
||||||
|
753→ 753→ )}
|
||||||
|
754→ 754→ </SheetContent>
|
||||||
|
755→ 755→ </Sheet>
|
||||||
|
756→ 756→ )
|
||||||
|
757→ 757→}
|
||||||
|
758→ 758→
|
||||||
|
759→ 759→function MetaItem({
|
||||||
|
760→ 760→ label,
|
||||||
|
761→ 761→ value,
|
||||||
|
762→ 762→ full,
|
||||||
|
763→ 763→}: {
|
||||||
|
764→ 764→ label: string
|
||||||
|
765→ 765→ value: string
|
||||||
|
766→ 766→ full?: boolean
|
||||||
|
767→ 767→}) {
|
||||||
|
768→ 768→ return (
|
||||||
|
769→ 769→ <div className={cn('min-w-0', full && 'col-span-2')}>
|
||||||
|
770→ 770→ <dt className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</dt>
|
||||||
|
771→ 771→ <dd className="mt-0.5 truncate text-xs font-medium">{value}</dd>
|
||||||
|
772→ 772→ </div>
|
||||||
|
773→ 773→ )
|
||||||
|
774→ 774→}
|
||||||
|
775→ 775→
|
||||||
|
776→ 776→/* ----------------------------- reviewed (history) ----------------------------- */
|
||||||
|
777→ 777→
|
||||||
|
778→ 778→function ReviewedSection({ applications }: { applications: AgencyApplication[] }) {
|
||||||
|
779→ 779→ const [open, setOpen] = useState(false)
|
||||||
|
780→ 780→
|
||||||
|
781→ 781→ return (
|
||||||
|
782→ 782→ <section className="space-y-2">
|
||||||
|
783→ 783→ <Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
784→ 784→ <Card className="bg-muted/30">
|
||||||
|
785→ 785→ <CollapsibleTrigger asChild>
|
||||||
|
786→ 786→ <button
|
||||||
|
787→ 787→ type="button"
|
||||||
|
788→ 788→ className="flex w-full items-center justify-between p-3 text-left"
|
||||||
|
789→ 789→ >
|
||||||
|
790→ 790→ <div className="flex items-center gap-2">
|
||||||
|
791→ 791→ <History className="size-3.5 text-muted-foreground" />
|
||||||
|
792→ 792→ <span className="text-xs font-semibold">Verlauf</span>
|
||||||
|
793→ 793→ <Badge variant="secondary" className="text-[10px] tabular-nums">
|
||||||
|
794→ 794→ {applications.length}
|
||||||
|
795→ 795→ </Badge>
|
||||||
|
796→ 796→ </div>
|
||||||
|
797→ 797→ <span className="text-[10px] text-muted-foreground">
|
||||||
|
798→ 798→ {open ? 'ausblenden' : 'einblenden'}
|
||||||
|
799→ 799→ </span>
|
||||||
|
800→ 800→ </button>
|
||||||
|
801→ 801→ </CollapsibleTrigger>
|
||||||
|
802→ 802→
|
||||||
|
803→ 803→ <CollapsibleContent>
|
||||||
|
804→ 804→ {applications.length === 0 ? (
|
||||||
|
805→ 805→ <div className="border-t border-border px-4 py-6 text-center text-xs text-muted-foreground">
|
||||||
|
806→ 806→ Noch keine bewerteten Bewerbungen.
|
||||||
|
807→ 807→ </div>
|
||||||
|
808→ 808→ ) : (
|
||||||
|
809→ 809→ <div className="border-t border-border">
|
||||||
|
810→ 810→ <ul className="divide-y divide-border">
|
||||||
|
811→ 811→ {applications.map((a) => (
|
||||||
|
812→ 812→ <li
|
||||||
|
813→ 813→ key={a.id}
|
||||||
|
814→ 814→ className="flex flex-col gap-1 px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
815→ 815→ >
|
||||||
|
816→ 816→ <div className="min-w-0 space-y-0.5">
|
||||||
|
817→ 817→ <div className="flex items-center gap-2">
|
||||||
|
818→ 818→ <span className="truncate text-xs font-medium">{a.company.name}</span>
|
||||||
|
819→ 819→ <StatusBadge status={a.status} />
|
||||||
|
820→ 820→ </div>
|
||||||
|
821→ 821→ {a.status === 'rejected' && a.rejectReason && (
|
||||||
|
822→ 822→ <p className="truncate text-[11px] text-muted-foreground">
|
||||||
|
823→ 823→ Grund: {a.rejectReason}
|
||||||
|
824→ 824→ </p>
|
||||||
|
825→ 825→ )}
|
||||||
|
826→ 826→ {a.status === 'approved' && (
|
||||||
|
827→ 827→ <p className="text-[11px] text-muted-foreground">
|
||||||
|
828→ 828→ Rabatt: <span className="tabular-nums">{a.discountPct}%</span>
|
||||||
|
829→ 829→ </p>
|
||||||
|
830→ 830→ )}
|
||||||
|
831→ 831→ </div>
|
||||||
|
832→ 832→ <div className="shrink-0 text-right text-[10px] text-muted-foreground">
|
||||||
|
833→ 833→ {a.reviewedBy && <p>reviewed by {a.reviewedBy}</p>}
|
||||||
|
834→ 834→ {a.reviewedAt && (
|
||||||
|
835→ 835→ <p className="tabular-nums">{formatDateTime(a.reviewedAt)}</p>
|
||||||
|
836→ 836→ )}
|
||||||
|
837→ 837→ </div>
|
||||||
|
838→ 838→ </li>
|
||||||
|
839→ 839→ ))}
|
||||||
|
840→ 840→ </ul>
|
||||||
|
841→ 841→ </div>
|
||||||
|
842→ 842→ )}
|
||||||
|
843→ 843→ </CollapsibleContent>
|
||||||
|
844→ 844→ </Card>
|
||||||
|
845→ 845→ </Collapsible>
|
||||||
|
846→ 846→ </section>
|
||||||
|
847→ 847→ )
|
||||||
|
848→ 848→}
|
||||||
|
849→ 849→
|
||||||
|
850→ 850→function StatusBadge({ status }: { status: 'approved' | 'rejected' | 'pending' }) {
|
||||||
|
851→ 851→ if (status === 'approved') {
|
||||||
|
852→ 852→ return (
|
||||||
|
853→ 853→ <Badge
|
||||||
|
854→ 854→ variant="outline"
|
||||||
|
855→ 855→ className="gap-1 border-foreground/40 text-[10px]"
|
||||||
|
856→ 856→ >
|
||||||
|
857→ 857→ <CheckCircle2 className="size-2.5" />
|
||||||
|
858→ 858→ Genehmigt
|
||||||
|
859→ 859→ </Badge>
|
||||||
|
860→ 860→ )
|
||||||
|
861→ 861→ }
|
||||||
|
862→ 862→ if (status === 'rejected') {
|
||||||
|
863→ 863→ return (
|
||||||
|
864→ 864→ <Badge variant="outline" className="gap-1 text-[10px] text-muted-foreground">
|
||||||
|
865→ 865→ <XCircle className="size-2.5" />
|
||||||
|
866→ 866→ Abgelehnt
|
||||||
|
867→ 867→ </Badge>
|
||||||
|
868→ 868→ )
|
||||||
|
869→ 869→ }
|
||||||
|
870→ 870→ return (
|
||||||
|
871→ 871→ <Badge variant="outline" className="text-[10px] text-muted-foreground">
|
||||||
|
872→ 872→ Offen
|
||||||
|
873→ 873→ </Badge>
|
||||||
|
874→ 874→ )
|
||||||
|
875→ 875→}
|
||||||
|
876→ 876→
|
||||||
Reference in new issue
Block a user