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:
1 parent
c474e66106
commit
ce8d478ecd
8 files changed
+246
-151
No files matched your search
@@ -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,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 "Gelesen". 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">
|
||||
"{deleteTarget?.title}" wird für alle Owner entfernt. Diese Aktion kann nicht
|
||||
"{deleteTarget?.title}" wird für alle Halter entfernt. Diese Aktion kann nicht
|
||||
rückgängig gemacht werden.
|
||||
</p>
|
||||
<Input
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user