876 lines
38 KiB
Plaintext
876 lines
38 KiB
Plaintext
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→ |