feat(admin): Verwaltungs-Seiten im neuen Design (Etappe 3, Paket 3)

- Team & Rollen: Personen mit Name/E-Mail statt Clerk-ID, Rollen-
  Erklärung, „Person hinzufügen“ per Suche nach Name/E-Mail statt
  eingefügter user_-ID, deutsche Dialoge; listModerators liefert Namen
- Mitteilungen, Rechtstexte, Begrüßung, Empfehlungen: einheitlicher
  Seitenkopf, „Halter“ statt „Owner“

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-05 08:56:10 +02:00
1 parent c474e66106
commit ce8d478ecd
8 files changed
+246 -151

No files matched your search

+21
View File
@@ -881,3 +881,24 @@ describe("admin.listReports", () => {
expect(mockPrisma.post.findMany).not.toHaveBeenCalled();
});
});
describe("admin.listModerators", () => {
it("adds names and e-mail from Clerk so the team page shows people instead of IDs", async () => {
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
const { caller, mockPrisma } = await createAdminCaller();
mockPrisma.adminRole.findMany.mockResolvedValue([
{ id: "r1", ownerId: "user_lea", role: "MODERATOR", grantedBy: "admin-1", grantedAt: new Date(), owner: { pets: [] } },
]);
mockGetUserList.mockResolvedValue({
data: [
{ id: "user_lea", firstName: "Lea", lastName: "M", primaryEmailAddressId: "e1", emailAddresses: [{ id: "e1", emailAddress: "lea@example.com" }] },
{ id: "admin-1", firstName: "Daniel", lastName: null, primaryEmailAddressId: null, emailAddresses: [] },
],
});
const [mod] = await caller.admin.listModerators();
expect(mod!.person).toEqual({ name: "Lea M", email: "lea@example.com" });
expect(mod!.grantedByName).toBe("Daniel");
});
});
+9 -12
View File
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { toast } from "sonner";
import { AdminPageHeader } from "@/components/admin/AdminSection";
function formatDate(d: string | Date) {
return new Date(d).toLocaleString("de-DE", {
@@ -34,7 +35,7 @@ export default function AdminBroadcastsPage() {
trpc.admin.createBroadcast.mutationOptions({
onSuccess: () => {
void qc.invalidateQueries({ queryKey: trpc.admin.listBroadcasts.queryKey() });
toast.success("Mitteilung gesendet — Owner sehen sie beim nächsten Laden der App");
toast.success("Mitteilung gesendet – Halter sehen sie beim nächsten Öffnen der App");
setTitle("");
setBody("");
},
@@ -65,15 +66,11 @@ export default function AdminBroadcastsPage() {
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Mitteilungen</h1>
<p className="text-sm text-muted-foreground mt-1">
System-weite Ankündigungen an alle Owner. Senden zeigt den Banner beim nächsten
App-Start; Owner bestätigen mit &quot;Gelesen&quot;. Verlauf zeigt, wie viele Owner die
Mitteilung schon erhalten bzw. gelesen haben.
</p>
</div>
<div className="mx-auto max-w-4xl space-y-6">
<AdminPageHeader
title="Mitteilungen"
description="Ankündigungen an alle Halter: Sie erscheinen beim nächsten Öffnen als Banner und werden mit „Gelesen“ bestätigt. Der Verlauf zeigt, wie viele sie schon gesehen haben."
/>
{/* Send form */}
<div className="rounded-xl border border-border bg-card p-4 space-y-3">
@@ -85,7 +82,7 @@ export default function AdminBroadcastsPage() {
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<Textarea
placeholder="Nachricht (wird 1:1 im Owner-Banner angezeigt)"
placeholder="Nachricht (erscheint genau so im Banner der Halter)"
value={body}
onChange={(e) => setBody(e.target.value)}
maxLength={2000}
@@ -156,7 +153,7 @@ export default function AdminBroadcastsPage() {
<DialogTitle>Mitteilung löschen</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
&quot;{deleteTarget?.title}&quot; wird für alle Owner entfernt. Diese Aktion kann nicht
&quot;{deleteTarget?.title}&quot; wird für alle Halter entfernt. Diese Aktion kann nicht
rückgängig gemacht werden.
</p>
<Input
+6 -7
View File
@@ -8,6 +8,7 @@ import { useTRPC } from "@/trpc/client";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { AdminPageHeader } from "@/components/admin/AdminSection";
const PLACEHOLDER = "{name}";
const PREVIEW_NAME = "Bubi";
@@ -30,13 +31,11 @@ export default function AdminCuratorPage() {
if (isLoading || !data) return <Loader2 className="mx-auto my-12 h-5 w-5 animate-spin text-muted-foreground" />;
return (
<div className="max-w-2xl space-y-8">
<div>
<h1 className="text-2xl font-semibold">Begrüßung</h1>
<p className="text-sm text-muted-foreground">
Was das Kurator-Konto „PawFeed Team“ bei jedem neu angelegten Tier automatisch macht.
</p>
</div>
<div className="mx-auto max-w-3xl space-y-6">
<AdminPageHeader
title="Begrüßung"
description="Was das Konto „PawFeed Team“ bei jedem neuen Tier automatisch macht."
/>
<section className="flex items-center gap-4 rounded-xl border p-4">
<Sprout className="h-6 w-6 shrink-0 text-emerald-600 dark:text-success" aria-hidden="true" />
+8 -11
View File
@@ -14,6 +14,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import { AdminPageHeader } from "@/components/admin/AdminSection";
const DOC_TYPE_LABELS: Record<string, string> = {
NUTZUNGSBEDINGUNGEN: "Nutzungsbedingungen",
@@ -42,7 +43,7 @@ export default function AdminLegalPage() {
trpc.legal.publish.mutationOptions({
onSuccess: () => {
void qc.invalidateQueries({ queryKey: trpc.legal.listVersions.queryKey() });
toast.success("Veröffentlicht — Owner sehen die Meldung beim nächsten Laden der App");
toast.success("Veröffentlicht – Halter sehen den Hinweis beim nächsten Öffnen der App");
setChangeSummary("");
},
// UNAUTHORIZED means the Clerk session cookie went stale (see
@@ -66,15 +67,11 @@ export default function AdminLegalPage() {
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Rechtstexte</h1>
<p className="text-sm text-muted-foreground mt-1">
Veröffentlichen ist gleichzeitig das Senden — jeder Owner sieht die Zusammenfassung
als Banner, bis er sie bestätigt. Ein erneutes Veröffentlichen (auch mit gleichem Text)
zeigt das Banner allen Owner erneut.
</p>
</div>
<div className="mx-auto max-w-4xl space-y-6">
<AdminPageHeader
title="Rechtstexte"
description="Veröffentlichen heißt zugleich Senden: Alle Halter sehen die Zusammenfassung als Banner, bis sie bestätigen. Erneutes Veröffentlichen zeigt das Banner noch einmal allen."
/>
{/* Publish form */}
<div className="rounded-xl border border-border bg-card p-4 space-y-3">
@@ -88,7 +85,7 @@ export default function AdminLegalPage() {
</SelectContent>
</Select>
<Textarea
placeholder="Was hat sich geändert? (wird 1:1 im Owner-Banner angezeigt)"
placeholder="Was hat sich geändert? (erscheint genau so im Banner der Halter)"
value={changeSummary}
onChange={(e) => setChangeSummary(e.target.value)}
maxLength={1000}
+170 -114
View File
@@ -1,183 +1,239 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { Plus, Trash2, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AdminRoleType } from "@prisma/client";
import { Check, Loader2, Plus, Search, Trash2, UserRound } from "lucide-react";
import { useTRPC } from "@/trpc/client";
import { useDebounce } from "@/hooks/use-debounce";
import { formatEventTime } from "@/lib/admin/format";
import { AdminPageHeader } from "@/components/admin/AdminSection";
import { StatusBadge, type StatusTone } from "@/components/admin/StatusBadge";
import { ADMIN_ROLE_LABEL } from "@/components/admin/admin-nav";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const ROLE_TONE: Record<AdminRoleType, StatusTone> = { SUPER_ADMIN: "warning", MODERATOR: "info", SUPPORT: "neutral" };
const ROLE_HINT: Record<AdminRoleType, string> = {
SUPPORT: "Meldungen, Halter, Beiträge und Nachrichten ansehen und bearbeiten",
MODERATOR: "Zusätzlich Verifizierung, Vermisst, Sperrliste, Protokoll und Community-Einstellungen",
SUPER_ADMIN: "Alles, inklusive Rollen vergeben, Wartungsmodus und Konten löschen",
};
const SEARCH_DEBOUNCE_MS = 250;
/**
* Team & Rollen: wer im Admin-Panel welche Rolle hat. Neue Personen werden per
* Name/E-Mail gesucht statt per eingefügter Clerk-ID. Vergeben/Entziehen dürfen
* nur Super-Admins (prüft der Server).
*/
export default function AdminModeratorsPage() {
const trpc = useTRPC();
const qc = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [newOwnerId, setNewOwnerId] = useState("");
const [query, setQuery] = useState("");
const [picked, setPicked] = useState<{ id: string; label: string } | null>(null);
const [newRole, setNewRole] = useState<AdminRoleType>(AdminRoleType.MODERATOR);
const [revokeTarget, setRevokeTarget] = useState<string | null>(null);
const [revokeTarget, setRevokeTarget] = useState<{ ownerId: string; label: string } | null>(null);
const debouncedQuery = useDebounce(query.trim(), SEARCH_DEBOUNCE_MS);
const { data: mods = [], isLoading } = useQuery(trpc.admin.listModerators.queryOptions());
const { data: throughput = [] } = useQuery(
trpc.admin.getModeratorThroughput.queryOptions({ days: 30 })
const { data: throughput = [] } = useQuery(trpc.admin.getModeratorThroughput.queryOptions({ days: 30 }));
const { data: searchResult, isFetching: isSearching } = useQuery(
trpc.adminOverview.search.queryOptions({ query: debouncedQuery }, { enabled: dialogOpen && debouncedQuery.length >= 2 && !picked })
);
const actionCountByModeratorId = new Map(throughput.map((t) => [t.moderatorId, t.actionCount]));
const invalidate = () =>
void qc.invalidateQueries({ queryKey: trpc.admin.listModerators.queryKey() });
const invalidate = () => void qc.invalidateQueries({ queryKey: trpc.admin.listModerators.queryKey() });
const closeGrant = () => {
setDialogOpen(false);
setQuery("");
setPicked(null);
setNewRole(AdminRoleType.MODERATOR);
};
const grantRole = useMutation(
trpc.admin.grantRole.mutationOptions({
onSuccess: () => {
invalidate();
setDialogOpen(false);
setNewOwnerId("");
setNewRole(AdminRoleType.MODERATOR);
toast.success("Rolle vergeben.");
closeGrant();
},
onError: (err) => toast.error(err.data?.code === "FORBIDDEN" ? "Rollen vergeben nur Super-Admins." : `Fehlgeschlagen: ${err.message}`),
})
);
const revokeRole = useMutation(
trpc.admin.revokeRole.mutationOptions({
onSuccess: () => {
invalidate();
toast.success("Rolle entzogen.");
setRevokeTarget(null);
},
onError: (err) => toast.error(err.data?.code === "FORBIDDEN" ? "Rollen entziehen nur Super-Admins." : `Fehlgeschlagen: ${err.message}`),
})
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Team &amp; Rollen</h1>
<p className="text-xs text-muted-foreground mt-0.5">Rollen vergeben nur Super-Admins.</p>
</div>
<Button size="sm" onClick={() => setDialogOpen(true)} className="bg-secondary hover:bg-accent">
<Plus className="h-4 w-4 mr-1.5" />
Grant role
</Button>
<div className="mx-auto max-w-5xl space-y-6">
<AdminPageHeader
title="Team & Rollen"
description="Wer im Admin-Panel mitarbeitet und was die jeweilige Rolle darf. Rollen vergeben nur Super-Admins."
actions={
<Button size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" aria-hidden />
Person hinzufügen
</Button>
}
/>
<div className="grid gap-3 sm:grid-cols-3">
{(Object.keys(ROLE_HINT) as AdminRoleType[]).map((role) => (
<div key={role} className="rounded-2xl border border-border bg-card p-4">
<StatusBadge tone={ROLE_TONE[role]}>{ADMIN_ROLE_LABEL[role]}</StatusBadge>
<p className="mt-2 text-sm text-muted-foreground">{ROLE_HINT[role]}</p>
</div>
))}
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" aria-label="Wird geladen" />
</div>
) : mods.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-12">No moderators yet</p>
<p className="rounded-2xl border border-dashed border-border py-12 text-center text-sm text-muted-foreground">Noch niemand im Team.</p>
) : (
<div className="space-y-2">
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
{mods.map((mod) => {
const pet = mod.owner.pets[0];
const label = mod.person.name ?? mod.person.email ?? mod.owner.pets[0]?.name ?? mod.ownerId;
const actions = actionCountByModeratorId.get(mod.ownerId) ?? 0;
return (
<div
key={mod.id}
className="flex items-center gap-4 rounded-xl border border-border bg-card px-4 py-3"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm text-foreground font-mono truncate">{mod.ownerId}</p>
<Badge
className={
mod.role === AdminRoleType.SUPER_ADMIN
? "bg-orange-700 text-orange-100 text-[10px]"
: mod.role === AdminRoleType.SUPPORT
? "bg-blue-700 text-blue-100 text-[10px]"
: "bg-secondary text-foreground text-[10px]"
}
>
{mod.role}
</Badge>
<li key={mod.id} className="flex items-center gap-4 px-5 py-3.5">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-accent text-accent-foreground" aria-hidden>
<UserRound className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-semibold text-card-foreground">{label}</p>
<StatusBadge tone={ROLE_TONE[mod.role]}>{ADMIN_ROLE_LABEL[mod.role]}</StatusBadge>
</div>
{pet && <p className="text-xs text-muted-foreground mt-0.5">Pet: {pet.name}</p>}
<p className="text-xs text-muted-foreground mt-0.5">
{actionCountByModeratorId.get(mod.ownerId) ?? 0} action
{actionCountByModeratorId.get(mod.ownerId) === 1 ? "" : "s"} (last 30 days)
</p>
<p className="text-xs text-muted-foreground/70 mt-0.5">
Granted {new Date(mod.grantedAt).toLocaleDateString()}
{mod.grantedBy && ` by ${mod.grantedBy.slice(0, 12)}…`}
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{mod.person.name && mod.person.email ? `${mod.person.email} · ` : ""}
{actions === 1 ? "1 Aktion" : `${actions} Aktionen`} in 30 Tagen · seit {formatEventTime(new Date(mod.grantedAt))}
{mod.grantedByName ? ` · vergeben von ${mod.grantedByName}` : ""}
</p>
</div>
<Button
size="sm"
variant="ghost"
className="text-destructive hover:opacity-80 shrink-0"
onClick={() => setRevokeTarget(mod.ownerId)}
className="shrink-0 text-destructive"
onClick={() => setRevokeTarget({ ownerId: mod.ownerId, label })}
aria-label={`Rolle von ${label} entziehen`}
>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-4 w-4" aria-hidden />
</Button>
</div>
</li>
);
})}
</div>
</ul>
)}
{/* Grant role dialog */}
<Dialog
open={dialogOpen}
onOpenChange={(o) => { if (!o) { setDialogOpen(false); setNewOwnerId(""); } }}
>
<DialogContent className="bg-card border-border text-foreground max-w-sm">
<Dialog open={dialogOpen} onOpenChange={(o) => !o && closeGrant()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Grant role</DialogTitle>
<DialogTitle>Person ins Team holen</DialogTitle>
<DialogDescription>Die Person braucht schon ein PawFeed-Konto. Nach Name oder E-Mail suchen und eine Rolle wählen.</DialogDescription>
</DialogHeader>
<p className="text-xs text-muted-foreground">
User must already have an account. Paste their Clerk user ID (starts with{" "}
<code className="text-primary">user_</code>).
</p>
<Input
placeholder="user_xxxxxxxxxxxxxxxxxxxx"
value={newOwnerId}
onChange={(e) => setNewOwnerId(e.target.value)}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground font-mono text-sm"
/>
<Select value={newRole} onValueChange={(v) => setNewRole(v as AdminRoleType)}>
<SelectTrigger className="bg-muted border-border text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-muted border-border">
<SelectItem value={AdminRoleType.SUPPORT}>Support</SelectItem>
<SelectItem value={AdminRoleType.MODERATOR}>Moderator</SelectItem>
<SelectItem value={AdminRoleType.SUPER_ADMIN}>Super Admin</SelectItem>
</SelectContent>
</Select>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-muted-foreground" onClick={() => setDialogOpen(false)}>
Cancel
{picked ? (
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/50 px-3 py-2.5">
<Check className="h-4 w-4 shrink-0 text-success" aria-hidden />
<span className="min-w-0 flex-1 truncate text-sm font-medium">{picked.label}</span>
<Button size="sm" variant="ghost" onClick={() => setPicked(null)}>
Ändern
</Button>
</div>
) : (
<div className="space-y-2">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Name oder E-Mail …"
aria-label="Person suchen"
className="h-9 w-full rounded-lg border border-input bg-card pl-9 pr-3 text-sm placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/30"
/>
</div>
{debouncedQuery.length >= 2 && (
<ul className="max-h-48 overflow-y-auto rounded-lg border border-border" aria-label="Gefundene Personen">
{isSearching && !searchResult ? (
<li className="flex justify-center py-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" aria-label="Suche läuft" />
</li>
) : !searchResult?.owners.length ? (
<li className="px-3 py-3 text-sm text-muted-foreground">Niemand gefunden.</li>
) : (
searchResult.owners.map((o) => (
<li key={o.id}>
<button
type="button"
onClick={() => setPicked({ id: o.id, label: o.name ? `${o.name}${o.email ? ` (${o.email})` : ""}` : (o.email ?? o.id) })}
className="w-full cursor-pointer px-3 py-2 text-left text-sm hover:bg-accent"
>
<span className="block font-medium">{o.name ?? o.email ?? o.id}</span>
{o.name && o.email && <span className="block text-xs text-muted-foreground">{o.email}</span>}
</button>
</li>
))
)}
</ul>
)}
</div>
)}
<label className="space-y-1.5 text-sm font-medium">
<span>Rolle</span>
<Select value={newRole} onValueChange={(v) => setNewRole((v ?? AdminRoleType.MODERATOR) as AdminRoleType)}>
<SelectTrigger className="h-9 w-full">
<SelectValue>{(v: AdminRoleType) => ADMIN_ROLE_LABEL[v]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(ROLE_HINT) as AdminRoleType[]).map((role) => (
<SelectItem key={role} value={role}>
{ADMIN_ROLE_LABEL[role]}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="block text-xs font-normal text-muted-foreground">{ROLE_HINT[newRole]}</span>
</label>
<DialogFooter>
<Button variant="ghost" onClick={closeGrant}>
Abbrechen
</Button>
<Button
onClick={() => grantRole.mutate({ ownerId: newOwnerId.trim(), role: newRole })}
disabled={grantRole.isPending || !newOwnerId.trim()}
className="bg-secondary hover:bg-accent"
>
{grantRole.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Grant
<Button onClick={() => picked && grantRole.mutate({ ownerId: picked.id, role: newRole })} disabled={grantRole.isPending || !picked}>
{grantRole.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />}
Rolle vergeben
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Revoke confirm */}
<Dialog open={!!revokeTarget} onOpenChange={(o) => { if (!o) setRevokeTarget(null); }}>
<DialogContent className="bg-card border-border text-foreground max-w-sm">
<Dialog open={!!revokeTarget} onOpenChange={(o) => !o && setRevokeTarget(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Revoke role</DialogTitle>
<DialogTitle>Rolle entziehen</DialogTitle>
<DialogDescription>
<span className="font-semibold text-foreground">{revokeTarget?.label}</span> hat danach keinen Zugang mehr zum Admin-Panel.
</DialogDescription>
</DialogHeader>
<p className="text-sm text-muted-foreground">Remove admin/moderator access for this user?</p>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-muted-foreground" onClick={() => setRevokeTarget(null)}>
Cancel
<DialogFooter>
<Button variant="ghost" onClick={() => setRevokeTarget(null)}>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={() => revokeRole.mutate({ ownerId: revokeTarget! })}
disabled={revokeRole.isPending}
>
{revokeRole.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Revoke
<Button variant="destructive" onClick={() => revokeTarget && revokeRole.mutate({ ownerId: revokeTarget.ownerId })} disabled={revokeRole.isPending}>
{revokeRole.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />}
Entziehen
</Button>
</DialogFooter>
</DialogContent>
+6 -5
View File
@@ -4,6 +4,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { RanksTab } from "@/components/admin/referrals/RanksTab";
import { SettingsTab } from "@/components/admin/referrals/SettingsTab";
import { OverviewTab } from "@/components/admin/referrals/OverviewTab";
import { AdminPageHeader } from "@/components/admin/AdminSection";
/**
* Empfehlungsprogramm verwalten: Ränge (Schwellen, Namen, Farben, Vorteile),
@@ -12,11 +13,11 @@ import { OverviewTab } from "@/components/admin/referrals/OverviewTab";
*/
export default function AdminReferralsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Empfehlungen</h1>
<p className="text-sm text-muted-foreground">Ränge, Einstellungen und Auswertung des Empfehlungsprogramms.</p>
</div>
<div className="mx-auto max-w-6xl space-y-6">
<AdminPageHeader
title="Empfehlungen"
description="Ränge, Einstellungen und Auswertung des Empfehlungsprogramms."
/>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Übersicht</TabsTrigger>
@@ -46,7 +46,7 @@ export function OverviewTab() {
{ label: "Eingelöst (7 / 30 Tage)", value: `${data.redeemed7} / ${data.redeemed30}` },
{ label: "Gezählt (7 / 30 Tage)", value: `${data.qualified7} / ${data.qualified30}` },
{ label: "Einladungsfaktor (30 Tage)", value: data.inviteFactor30.toFixed(2) },
{ label: "Owner mit Tier", value: String(data.ownersWithPet) },
{ label: "Halter mit Tier", value: String(data.ownersWithPet) },
];
const answered = data.sources.filter((s) => s.source).reduce((sum, s) => sum + s.count, 0);
+25 -1
View File
@@ -1700,7 +1700,7 @@ export const adminRouter = router({
// ─── Moderator management (grant/revoke stay super-admin only) ───────────────
listModerators: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
return ctx.prisma.adminRole.findMany({
const roles = await ctx.prisma.adminRole.findMany({
include: {
owner: {
include: { pets: { select: { name: true, nickname: true, avatarKey: true }, take: 1 } },
@@ -1708,6 +1708,30 @@ export const adminRouter = router({
},
orderBy: { grantedAt: "desc" },
});
// Namen + E-Mail für die Seite „Team & Rollen“ (statt nackter Clerk-IDs). Clerk-Ausfall → ohne Namen.
const ids = [...new Set(roles.flatMap((r) => [r.ownerId, r.grantedBy]).filter((id): id is string => !!id))];
let people = new Map<string, { name: string | null; email: string | null }>();
if (ids.length > 0) {
try {
const { data } = await (await clerkClient()).users.getUserList({ userId: ids, limit: ids.length });
people = new Map(
data.map((u) => [
u.id,
{
name: [u.firstName, u.lastName].filter(Boolean).join(" ") || null,
email: u.emailAddresses.find((e) => e.id === u.primaryEmailAddressId)?.emailAddress ?? null,
},
])
);
} catch {
// Liste bleibt nutzbar, nur ohne Namen.
}
}
return roles.map((r) => ({
...r,
person: people.get(r.ownerId) ?? { name: null, email: null },
grantedByName: r.grantedBy ? (people.get(r.grantedBy)?.name ?? null) : null,
}));
}),
grantRole: protectedProcedure