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('/api/aos/agency') 112→ 112→ 113→ 113→ if (loading || !data) { 114→ 114→ return ( 115→ 115→
116→ 116→ 117→ 117→
118→ 118→ {Array.from({ length: 4 }).map((_, i) => ( 119→ 119→ 120→ 120→ ))} 121→ 121→
122→ 122→ 123→ 123→
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→
137→ 137→ 141→ 141→ 142→ 142→ {/* Stats */} 143→ 143→
144→ 144→ 150→ 150→ 156→ 156→ 162→ 162→ 168→ 168→
169→ 169→ 170→ 170→ 171→ 171→ 172→ 172→ 173→ 173→
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→ 192→ 192→
193→ 193→

{label}

194→ 194→ 195→ 195→
196→ 196→

{value}

197→ 197→ {hint &&

{hint}

} 198→ 198→
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(null) 212→ 212→ const [rejectTarget, setRejectTarget] = useState(null) 213→ 213→ 214→ 214→ return ( 215→ 215→
216→ 216→
217→ 217→

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

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

Keine bewerbenden Anfragen

232→ 232→

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

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

{app.company.name}

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

{label}

330→ 330→

{value}

331→ 331→
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→ 390→ 390→ 391→ 391→ {application && ( 392→ 392→ <> 393→ 393→ 394→ 394→ 395→ 395→ 396→ 396→ Reseller-Bewerbung genehmigen 397→ 397→ 398→ 398→ 399→ 399→ {application.company.name} wird 400→ 400→ als Reseller freigeschaltet. Der Rabatt wird auf alle vom Reseller angelegten Kundenlizenzierungen 401→ 401→ angewendet. 402→ 402→ 403→ 403→ 404→ 404→ 405→ 405→
406→ 406→
407→ 407→
408→ 408→ 409→ 409→ 0 – 20 410→ 410→
411→ 411→ { 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→
429→ 429→ 430→ 430→ {/* live preview */} 431→ 431→
432→ 432→

433→ 433→ Preisvorschau 434→ 434→

435→ 435→

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

445→ 445→
446→ 446→
447→ 447→ 448→ 448→ 449→ 449→ 452→ 452→ 459→ 459→ 460→ 460→ 461→ 461→ )} 462→ 462→
463→ 463→
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→ 521→ 521→ 522→ 522→ {application && ( 523→ 523→ <> 524→ 524→ 525→ 525→ 526→ 526→ 527→ 527→ Reseller-Bewerbung ablehnen 528→ 528→ 529→ 529→ 530→ 530→ {application.company.name} 531→ 531→ {' '}wird abgelehnt. Der Grund wird für den Bewerber sichtbar protokolliert. 532→ 532→ 533→ 533→ 534→ 534→ 535→ 535→
536→ 536→ 537→ 537→