f6acc620-0a4c-41b3-9763-6681634541c0

This commit is contained in:
Z User committed 2026-10-28 22:14:58 +00:00
1 parent f2d4241a1c
commit e7185103f5
3 files changed
+1632

No files matched your search

@@ -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→