a6c0f3de-eeb0-4d76-a57f-f600dd133c06
This commit is contained in:
1 parent
2aa3496a90
commit
762f3d7373
141 files changed
+4485
-69
No files matched your search
@@ -17,6 +17,7 @@ import { ProjectsView } from './views/projects'
|
||||
import { TimesheetView } from './views/timesheet'
|
||||
import { SupportView } from './views/support'
|
||||
import { LicensesView } from './views/licenses'
|
||||
import { AgencyView } from './views/agency'
|
||||
import { ContractsView } from './views/contracts'
|
||||
import { ExpensesView } from './views/expenses'
|
||||
import { FinanceView } from './views/finance'
|
||||
@@ -71,6 +72,7 @@ export function AppShell() {
|
||||
{view === 'timesheet' && <TimesheetView />}
|
||||
{view === 'support' && <SupportView />}
|
||||
{view === 'licenses' && <LicensesView />}
|
||||
{view === 'agency' && <AgencyView />}
|
||||
{view === 'contracts' && <ContractsView />}
|
||||
{view === 'expenses' && <ExpensesView />}
|
||||
{view === 'finance' && <FinanceView />}
|
||||
|
||||
@@ -49,6 +49,7 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st
|
||||
{ id: 'projects', label: 'Projekte', icon: FolderKanban, hint: 'Aufgaben & Zeit' },
|
||||
{ id: 'support', label: 'Support', icon: LifeBuoy, hint: 'Tickets & SLA' },
|
||||
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' },
|
||||
{ id: 'agency', label: 'Bayis', icon: Users, hint: 'Reseller-Bewerbungen & Rabatte' },
|
||||
{ id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' },
|
||||
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' },
|
||||
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
|
||||
|
||||
@@ -67,6 +67,7 @@ const NAV: {
|
||||
group: 'nav.operations',
|
||||
items: [
|
||||
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' },
|
||||
{ id: 'agency', label: 'Bayis', icon: Users, permission: 'admin' },
|
||||
{ id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
|
||||
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' },
|
||||
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
|
||||
|
||||
@@ -28,6 +28,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
||||
timesheet: { title: 'Zeiterfassung', crumb: 'Lieferung / Zeiterfassung' },
|
||||
support: { title: 'Support', crumb: 'Lieferung / Tickets' },
|
||||
licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' },
|
||||
agency: { title: 'Bayis', crumb: 'Betrieb / Bayis' },
|
||||
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
|
||||
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
|
||||
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
|
||||
|
||||
@@ -0,0 +1,875 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
Users,
|
||||
Building2,
|
||||
Globe,
|
||||
MapPin,
|
||||
Clock,
|
||||
Percent,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
History,
|
||||
Inbox,
|
||||
Mail,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
ShoppingBag,
|
||||
} from 'lucide-react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { PageHeader } from '../primitives'
|
||||
import { FuseButton } from '@/components/FuseButton'
|
||||
import { formatRelative, formatDate, formatDateTime } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
/* ----------------------------- types ----------------------------- */
|
||||
|
||||
interface AgencyCompany {
|
||||
id: string
|
||||
name: string
|
||||
email: string | null
|
||||
website: string | null
|
||||
city: string | null
|
||||
isAgency: boolean
|
||||
agencyStatus: string | null
|
||||
agencyDiscountPct: number | null
|
||||
}
|
||||
|
||||
interface AgencyApplication {
|
||||
id: string
|
||||
companyId: string
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
discountPct: number
|
||||
businessModel?: string | null
|
||||
expectedClientVolume?: string | null
|
||||
referredBy?: string | null
|
||||
reviewedBy?: string | null
|
||||
reviewedAt?: string | null
|
||||
rejectReason?: string | null
|
||||
appliedAt: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
company: AgencyCompany
|
||||
}
|
||||
|
||||
interface AgencyClient {
|
||||
id: string
|
||||
name: string
|
||||
email: string | null
|
||||
}
|
||||
|
||||
interface ApprovedAgency extends AgencyCompany {
|
||||
agencyDiscountPct: number
|
||||
agencyApprovedAt: string
|
||||
agencyClients: AgencyClient[]
|
||||
_count: { agencyClients: number; licenses: number }
|
||||
}
|
||||
|
||||
interface AgencyData {
|
||||
applications: {
|
||||
pending: AgencyApplication[]
|
||||
reviewed: AgencyApplication[]
|
||||
}
|
||||
agencies: ApprovedAgency[]
|
||||
stats: { pending: number; approved: number; totalApplications: number }
|
||||
}
|
||||
|
||||
const BASE_PRICE_EUR = 50 // 50 €/Monat base price for preview
|
||||
|
||||
/* ----------------------------- view ----------------------------- */
|
||||
|
||||
export function AgencyView() {
|
||||
const { data, loading, refetch } = useFetch<AgencyData>('/api/aos/agency')
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
<Skeleton className="h-9 w-32" />
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-64" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { applications, agencies, stats } = data
|
||||
const avgDiscount =
|
||||
agencies.length > 0
|
||||
? Math.round(
|
||||
agencies.reduce((sum, a) => sum + (a.agencyDiscountPct ?? 0), 0) / agencies.length,
|
||||
)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Bayis"
|
||||
description="Reseller-Verwaltung: Bayi-Bewerbungen prüfen, Rabatt-Sätze festlegen (0–20 %) und genehmigte Bayis mit ihren Kunden einsehen."
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<StatCard
|
||||
label="Offene Bewerbungen"
|
||||
value={stats.pending}
|
||||
icon={Inbox}
|
||||
hint="wartet auf Freigabe"
|
||||
/>
|
||||
<StatCard
|
||||
label="Genehmigte Bayis"
|
||||
value={stats.approved}
|
||||
icon={Users}
|
||||
hint="aktiv im System"
|
||||
/>
|
||||
<StatCard
|
||||
label="Bewerbungen gesamt"
|
||||
value={stats.totalApplications}
|
||||
icon={History}
|
||||
hint="inkl. Verlauf"
|
||||
/>
|
||||
<StatCard
|
||||
label="Ø Rabatt"
|
||||
value={`${avgDiscount}%`}
|
||||
icon={Percent}
|
||||
hint={agencies.length ? `über ${agencies.length} Bayis` : '—'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PendingApplicationsSection applications={applications.pending} onChanged={refetch} />
|
||||
<ApprovedAgenciesSection agencies={agencies} />
|
||||
<ReviewedSection applications={applications.reviewed} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ----------------------------- stats card ----------------------------- */
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
hint,
|
||||
}: {
|
||||
label: string
|
||||
value: number | string
|
||||
icon: typeof Inbox
|
||||
hint?: string
|
||||
}) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<Icon className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{value}</p>
|
||||
{hint && <p className="text-[10px] text-muted-foreground">{hint}</p>}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/* ----------------------------- pending applications ----------------------------- */
|
||||
|
||||
function PendingApplicationsSection({
|
||||
applications,
|
||||
onChanged,
|
||||
}: {
|
||||
applications: AgencyApplication[]
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const [approveTarget, setApproveTarget] = useState<AgencyApplication | null>(null)
|
||||
const [rejectTarget, setRejectTarget] = useState<AgencyApplication | null>(null)
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold tracking-tight">
|
||||
Bewerbende Bayis
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
{applications.length} offen
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{applications.length === 0 ? (
|
||||
<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">
|
||||
<div className="flex size-12 items-center justify-center rounded-full border border-border bg-background">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Keine bewerbenden Anfragen</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
Neue Bayi-Bewerbungen erscheinen hier.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{applications.map((app) => (
|
||||
<Card key={app.id} className="flex flex-col">
|
||||
<CardContent className="flex flex-1 flex-col gap-3 p-4">
|
||||
{/* header */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="truncate text-sm font-semibold">{app.company.name}</p>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
|
||||
{app.company.email && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Mail className="size-3" />
|
||||
{app.company.email}
|
||||
</span>
|
||||
)}
|
||||
{app.company.website && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Globe className="size-3" />
|
||||
{app.company.website}
|
||||
</span>
|
||||
)}
|
||||
{app.company.city && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="size-3" />
|
||||
{app.company.city}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="gap-1 border-foreground/40 text-[10px]">
|
||||
<Clock className="size-2.5" />
|
||||
{formatRelative(app.appliedAt)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* application details */}
|
||||
{(app.businessModel || app.expectedClientVolume || app.referredBy) && (
|
||||
<div className="grid grid-cols-1 gap-2 rounded-md border border-border bg-muted/30 p-3 text-xs sm:grid-cols-3">
|
||||
{app.businessModel && (
|
||||
<Meta label="Geschäftsmodell" value={app.businessModel} />
|
||||
)}
|
||||
{app.expectedClientVolume && (
|
||||
<Meta label="Erw. Kundenvolumen" value={app.expectedClientVolume} />
|
||||
)}
|
||||
{app.referredBy && (
|
||||
<Meta label="Empfohlen von" value={app.referredBy} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* actions */}
|
||||
<div className="mt-auto flex items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setRejectTarget(app)}
|
||||
>
|
||||
<XCircle className="size-3.5" />
|
||||
Ablehnen
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setApproveTarget(app)}
|
||||
>
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
Genehmigen
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ApproveDialog
|
||||
application={approveTarget}
|
||||
onClose={() => setApproveTarget(null)}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
<RejectDialog
|
||||
application={rejectTarget}
|
||||
onClose={() => setRejectTarget(null)}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Meta({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||
<p className="mt-0.5 truncate text-xs font-medium">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ----------------------------- approve dialog ----------------------------- */
|
||||
|
||||
function ApproveDialog({
|
||||
application,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
application: AgencyApplication | null
|
||||
onClose: () => void
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const [discount, setDiscount] = useState(10)
|
||||
const [committing, setCommitting] = useState(false)
|
||||
|
||||
// Reset state when dialog opens for a new application
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open) setDiscount(10)
|
||||
if (!open) {
|
||||
if (!committing) onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const net = useMemo(() => {
|
||||
const netEur = BASE_PRICE_EUR * (1 - discount / 100)
|
||||
return netEur.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}, [discount])
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!application) return
|
||||
setCommitting(true)
|
||||
try {
|
||||
const r = await fetch(`/api/aos/agency/${application.id}/approve`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ discountPct: discount }),
|
||||
})
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}))
|
||||
throw new Error(err.error || 'approve failed')
|
||||
}
|
||||
// Defer close + refetch so the FuseButton's done-state + toast can render.
|
||||
setTimeout(() => {
|
||||
onClose()
|
||||
onChanged()
|
||||
}, 350)
|
||||
} catch (err) {
|
||||
setCommitting(false)
|
||||
throw err
|
||||
}
|
||||
// Note: setCommitting(false) intentionally skipped on success — dialog will
|
||||
// close via the setTimeout above and the button's doneLabel will be shown.
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!application} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{application && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="size-4" />
|
||||
Bayi-Bewerbung genehmigen
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{application.company.name}</span> wird
|
||||
als Bayi freigeschaltet. Der Rabatt wird auf alle vom Bayi angelegten Kundenlizenzierungen
|
||||
angewendet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="discount-pct">Rabatt (%)</Label>
|
||||
<span className="text-[10px] text-muted-foreground">0 – 20</span>
|
||||
</div>
|
||||
<Input
|
||||
id="discount-pct"
|
||||
type="number"
|
||||
min={0}
|
||||
max={20}
|
||||
step={1}
|
||||
value={discount}
|
||||
onChange={(e) => {
|
||||
const raw = parseInt(e.target.value, 10)
|
||||
if (Number.isNaN(raw)) {
|
||||
setDiscount(0)
|
||||
return
|
||||
}
|
||||
setDiscount(Math.min(20, Math.max(0, raw)))
|
||||
}}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* live preview */}
|
||||
<div className="rounded-md border border-border bg-muted/40 p-3 text-xs">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Preisvorschau
|
||||
</p>
|
||||
<p className="mt-1 tabular-nums">
|
||||
<span className="font-medium">{BASE_PRICE_EUR} €/Monat</span>
|
||||
<span className="mx-1.5 text-muted-foreground">Basis</span>
|
||||
<span className="mx-1 text-muted-foreground">→</span>
|
||||
<span className="text-muted-foreground">mit {discount}% Rabatt</span>
|
||||
<span className="mx-1.5 text-muted-foreground">=</span>
|
||||
<span className="font-semibold border-foreground/40 px-1.5 py-0.5 rounded border">
|
||||
{net} €
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={onClose} disabled={committing}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<FuseButton
|
||||
label="Genehmigen"
|
||||
doneLabel="Genehmigt"
|
||||
onCommit={handleCommit}
|
||||
disabled={committing}
|
||||
variant="default"
|
||||
/>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/* ----------------------------- reject dialog ----------------------------- */
|
||||
|
||||
function RejectDialog({
|
||||
application,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
application: AgencyApplication | null
|
||||
onClose: () => void
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const [reason, setReason] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
if (!submitting) {
|
||||
setReason('')
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!application) return
|
||||
if (!reason.trim()) {
|
||||
toast.error('Bitte gib einen Ablehnungsgrund an.')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
const t = toast.loading('Ablehnung wird gespeichert…')
|
||||
try {
|
||||
const r = await fetch(`/api/aos/agency/${application.id}/reject`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ reason: reason.trim() }),
|
||||
})
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}))
|
||||
throw new Error(err.error || 'reject failed')
|
||||
}
|
||||
toast.success(`${application.company.name} abgelehnt`, { id: t })
|
||||
setReason('')
|
||||
onClose()
|
||||
onChanged()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Ablehnung fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!application} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{application && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<XCircle className="size-4" />
|
||||
Bayi-Bewerbung ablehnen
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium text-foreground">{application.company.name}</span>
|
||||
{' '}wird abgelehnt. Der Grund wird für den Bewerber sichtbar protokolliert.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="reject-reason">Ablehnungsgrund</Label>
|
||||
<Textarea
|
||||
id="reject-reason"
|
||||
placeholder="z. B. Unvollständige Angaben, kein passendes Geschäftsmodell…"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<p className="text-right text-[10px] text-muted-foreground">
|
||||
{reason.length} / 500
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={onClose} disabled={submitting}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={submit} disabled={submitting}>
|
||||
{submitting ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<XCircle className="size-3.5" />
|
||||
)}
|
||||
Ablehnen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/* ----------------------------- approved agencies ----------------------------- */
|
||||
|
||||
function ApprovedAgenciesSection({ agencies }: { agencies: ApprovedAgency[] }) {
|
||||
const [openAgency, setOpenAgency] = useState<ApprovedAgency | null>(null)
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold tracking-tight">
|
||||
Genehmigte Bayis
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
{agencies.length} aktiv
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{agencies.length === 0 ? (
|
||||
<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">
|
||||
<div className="flex size-12 items-center justify-center rounded-full border border-border bg-background">
|
||||
<Users className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Noch keine Bayis genehmigt</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
Genehmigte Bayi-Bewerbungen erscheinen hier mit ihren Kunden und Lizenzen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Bayis ({agencies.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Bayi</th>
|
||||
<th className="px-4 py-2 font-medium">Rabatt</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Kunden</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Lizenzen</th>
|
||||
<th className="px-4 py-2 font-medium">Genehmigt am</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{agencies.map((a) => (
|
||||
<tr key={a.id} className="transition hover:bg-muted/40">
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border">
|
||||
<Building2 className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium">{a.name}</p>
|
||||
{a.email && (
|
||||
<p className="truncate text-[10px] text-muted-foreground">{a.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-foreground/40 text-[10px] tabular-nums"
|
||||
>
|
||||
<Percent className="size-2.5" />
|
||||
{a.agencyDiscountPct}%
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-xs tabular-nums">
|
||||
{a._count.agencyClients}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-xs tabular-nums">
|
||||
{a._count.licenses}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs">
|
||||
{formatDate(a.agencyApprovedAt)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setOpenAgency(a)}
|
||||
className="gap-1"
|
||||
>
|
||||
<ShoppingBag className="size-3.5" />
|
||||
Kunden ansehen
|
||||
<ChevronRight className="size-3" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ClientsSheet agency={openAgency} onClose={() => setOpenAgency(null)} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ClientsSheet({
|
||||
agency,
|
||||
onClose,
|
||||
}: {
|
||||
agency: ApprovedAgency | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<Sheet open={!!agency} onOpenChange={(o) => !o && onClose()}>
|
||||
<SheetContent side="right" className="w-full gap-0 sm:max-w-md">
|
||||
{agency && (
|
||||
<>
|
||||
<SheetHeader className="border-b border-border">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Building2 className="size-4 text-muted-foreground" />
|
||||
{agency.name}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Bayi-Kundenverwaltung — {agency._count.agencyClients} Kunde(n),{' '}
|
||||
{agency._count.licenses} Lizenz(en).
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{/* routing note */}
|
||||
<div className="mb-3 flex items-start gap-2 rounded-md border border-border bg-muted/40 p-3 text-[11px]">
|
||||
<Mail className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
Bayi kann eigene Kunden anlegen und lizenzieren. Tickets von dessen Kunden werden
|
||||
an den Bayi geroutet, nicht an uns.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* meta */}
|
||||
<dl className="mb-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<MetaItem label="Rabatt" value={`${agency.agencyDiscountPct}%`} />
|
||||
<MetaItem label="Genehmigt" value={formatDate(agency.agencyApprovedAt)} />
|
||||
{agency.email && (
|
||||
<MetaItem label="E-Mail" value={agency.email} full />
|
||||
)}
|
||||
{agency.city && <MetaItem label="Stadt" value={agency.city} />}
|
||||
{agency.website && <MetaItem label="Website" value={agency.website} />}
|
||||
</dl>
|
||||
|
||||
{/* clients list */}
|
||||
<p className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Kunden ({agency.agencyClients.length})
|
||||
</p>
|
||||
{agency.agencyClients.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Noch keine Kunden angelegt.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{agency.agencyClients.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className="flex items-center gap-3 rounded-md border border-border bg-background px-3 py-2"
|
||||
>
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-semibold">
|
||||
{c.name.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium">{c.name}</p>
|
||||
{c.email && (
|
||||
<p className="truncate text-[10px] text-muted-foreground">{c.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function MetaItem({
|
||||
label,
|
||||
value,
|
||||
full,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
full?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('min-w-0', full && 'col-span-2')}>
|
||||
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</dt>
|
||||
<dd className="mt-0.5 truncate text-xs font-medium">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ----------------------------- reviewed (history) ----------------------------- */
|
||||
|
||||
function ReviewedSection({ applications }: { applications: AgencyApplication[] }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<Card className="bg-muted/30">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between p-3 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold">Verlauf</span>
|
||||
<Badge variant="secondary" className="text-[10px] tabular-nums">
|
||||
{applications.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{open ? 'ausblenden' : 'einblenden'}
|
||||
</span>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
{applications.length === 0 ? (
|
||||
<div className="border-t border-border px-4 py-6 text-center text-xs text-muted-foreground">
|
||||
Noch keine bewerteten Bewerbungen.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t border-border">
|
||||
<ul className="divide-y divide-border">
|
||||
{applications.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex flex-col gap-1 px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-xs font-medium">{a.company.name}</span>
|
||||
<StatusBadge status={a.status} />
|
||||
</div>
|
||||
{a.status === 'rejected' && a.rejectReason && (
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
Grund: {a.rejectReason}
|
||||
</p>
|
||||
)}
|
||||
{a.status === 'approved' && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Rabatt: <span className="tabular-nums">{a.discountPct}%</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-[10px] text-muted-foreground">
|
||||
{a.reviewedBy && <p>reviewed by {a.reviewedBy}</p>}
|
||||
{a.reviewedAt && (
|
||||
<p className="tabular-nums">{formatDateTime(a.reviewedAt)}</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'approved' | 'rejected' | 'pending' }) {
|
||||
if (status === 'approved') {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-foreground/40 text-[10px]"
|
||||
>
|
||||
<CheckCircle2 className="size-2.5" />
|
||||
Genehmigt
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (status === 'rejected') {
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 text-[10px] text-muted-foreground">
|
||||
<XCircle className="size-2.5" />
|
||||
Abgelehnt
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="text-[10px] text-muted-foreground">
|
||||
Offen
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user