Files
AOS/tool-results/read_1793230719374_07f1dc503692.txt
T
Z User 39b3636333 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
2026-10-28 23:46:54 +00:00

876 lines
38 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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→