All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 2m3s
Sortierung: Erklaerungen erscheinen im Admin wie im oeffentlichen
Bereich alphabetisch nach Titel. Bewusst in JavaScript statt ueber die
Datenbank - SQLite vergleicht binaer, dort landeten "Aenderungs-
mitteilung" und "Uebernahme" hinter "Zaehlerstandsablesung". Intl mit
deutscher Kollation sortiert Ae bei A. Gemeinsame Regel in
lib/policy-sort.ts, benutzt von der Server-Komponente, der API und der
Admin-Tabelle; die Spaltenkoepfe bleiben umschaltbar.
Overlay: @radix-ui/react-select verlangte react-dismissable-layer
1.1.11, alle anderen Radix-Pakete 1.1.19. Dieses Paket setzt und
entfernt pointer-events auf dem body und fuehrt dazu eine Liste offener
Layer im Modulzustand - bei zwei Kopien zwei getrennte Listen. Ein
Select im Dialog liess deshalb beim Schliessen das Overlay des Dialogs
verschwinden und pointer-events: none stehen. Die Seite war danach bis
zum Neuladen tot.
Belegt im Browser mit einer Wegwerfseite (Select im Dialog):
zwei Versionen -> Dialog nach Select-Schliessen nicht mehr bedienbar,
body pointer-events bleibt "none"
eine Version -> body wieder leer, Seite klickbar
react-select, -label, -slot und -tabs auf aktuelle Versionen gehoben;
damit liegt jedes Radix-Kernpaket nur noch einmal im Baum.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
564 lines
20 KiB
TypeScript
564 lines
20 KiB
TypeScript
"use client"
|
||
|
||
import { useMemo, useState } from "react"
|
||
import {
|
||
ArrowDown,
|
||
ArrowUp,
|
||
Eye,
|
||
EyeOff,
|
||
FileText,
|
||
FileType2,
|
||
Scale,
|
||
LogOut,
|
||
MoreHorizontal,
|
||
Pencil,
|
||
Plus,
|
||
Search,
|
||
TriangleAlert,
|
||
Trash2,
|
||
Users,
|
||
X,
|
||
} from "lucide-react"
|
||
import { signOut, useSession } from "next-auth/react"
|
||
import { toast } from "sonner"
|
||
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from "@/components/ui/alert-dialog"
|
||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Button } from "@/components/ui/button"
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuLabel,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Skeleton } from "@/components/ui/skeleton"
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table"
|
||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||
import { Brand } from "@/components/brand"
|
||
import { PolicyDetail } from "@/components/policy-detail"
|
||
import { PolicyDefaultsForm } from "@/components/policy-defaults-form"
|
||
import { PolicyForm } from "@/components/policy-form"
|
||
import { SitePagesForm } from "@/components/site-pages-form"
|
||
import { ThemeToggle } from "@/components/theme-toggle"
|
||
import { UserManagement } from "@/components/user-management"
|
||
import { deletePolicy, usePrivacyPolicies } from "@/hooks/use-privacy-policies"
|
||
import { usePolicyDefaults } from "@/hooks/use-policy-defaults"
|
||
import { describeMissing, missingRequiredFields } from "@/lib/policy-completeness"
|
||
import { formatDateTime, initials } from "@/lib/format"
|
||
import { compareByTitle } from "@/lib/policy-sort"
|
||
import { PrivacyPolicy } from "@/types/privacy-policy"
|
||
|
||
type SortKey = "title" | "createdAt" | "updatedAt"
|
||
|
||
export default function AdminDashboard() {
|
||
const { data: session, status } = useSession()
|
||
const { policies, isLoading, error } = usePrivacyPolicies()
|
||
const { defaults } = usePolicyDefaults()
|
||
|
||
const [searchTerm, setSearchTerm] = useState("")
|
||
// Vorgabe: alphabetisch nach Titel. Die Spaltenköpfe bleiben umschaltbar.
|
||
const [sortBy, setSortBy] = useState<SortKey>("title")
|
||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc")
|
||
const [isFormOpen, setIsFormOpen] = useState(false)
|
||
const [editingPolicy, setEditingPolicy] = useState<PrivacyPolicy | null>(null)
|
||
const [previewPolicy, setPreviewPolicy] = useState<PrivacyPolicy | null>(null)
|
||
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
|
||
const [policyToDelete, setPolicyToDelete] = useState<PrivacyPolicy | null>(null)
|
||
const [isDeleting, setIsDeleting] = useState(false)
|
||
const [isUserManagementOpen, setIsUserManagementOpen] = useState(false)
|
||
const [isDefaultsOpen, setIsDefaultsOpen] = useState(false)
|
||
const [isSitePagesOpen, setIsSitePagesOpen] = useState(false)
|
||
|
||
const visiblePolicies = useMemo(() => {
|
||
const term = searchTerm.trim().toLowerCase()
|
||
|
||
return policies
|
||
.filter(
|
||
(policy) =>
|
||
!term ||
|
||
policy.title.toLowerCase().includes(term) ||
|
||
policy.responsible.toLowerCase().includes(term)
|
||
)
|
||
.sort((a, b) => {
|
||
if (sortBy === "title") {
|
||
return sortOrder === "asc" ? compareByTitle(a, b) : compareByTitle(b, a)
|
||
}
|
||
const aDate = new Date(a[sortBy]).getTime()
|
||
const bDate = new Date(b[sortBy]).getTime()
|
||
return sortOrder === "asc" ? aDate - bDate : bDate - aDate
|
||
})
|
||
}, [policies, searchTerm, sortBy, sortOrder])
|
||
|
||
const toggleSort = (key: SortKey) => {
|
||
if (sortBy === key) {
|
||
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
|
||
} else {
|
||
setSortBy(key)
|
||
setSortOrder(key === "title" ? "asc" : "desc")
|
||
}
|
||
}
|
||
|
||
const handleAddNew = () => {
|
||
setEditingPolicy(null)
|
||
setIsFormOpen(true)
|
||
}
|
||
|
||
const handlePreview = (policy: PrivacyPolicy) => {
|
||
setPreviewPolicy(policy)
|
||
setIsPreviewOpen(true)
|
||
}
|
||
|
||
const handleEdit = (policy: PrivacyPolicy) => {
|
||
setEditingPolicy(policy)
|
||
setIsFormOpen(true)
|
||
}
|
||
|
||
const handleDelete = async () => {
|
||
if (!policyToDelete) return
|
||
|
||
setIsDeleting(true)
|
||
try {
|
||
await deletePolicy(policyToDelete.id)
|
||
toast.success("Datenschutzerklärung gelöscht")
|
||
setPolicyToDelete(null)
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "Fehler beim Löschen")
|
||
} finally {
|
||
setIsDeleting(false)
|
||
}
|
||
}
|
||
|
||
const publicCount = policies.filter((policy) => policy.isPublic).length
|
||
|
||
/**
|
||
* Fehlende Pflichtangaben nach Art. 13/14 DSGVO je Erklärung.
|
||
*
|
||
* Die API liefert die Bausteine bereits mit eingesetztem Standardtext – was
|
||
* hier als fehlend gilt, fehlt also tatsächlich und nicht nur im Datensatz.
|
||
*/
|
||
const missingByPolicy = useMemo(() => {
|
||
const map = new Map<number, string[]>()
|
||
|
||
for (const policy of policies) {
|
||
const missing = missingRequiredFields(
|
||
policy as unknown as Record<string, unknown>,
|
||
defaults
|
||
)
|
||
if (missing.length > 0) map.set(policy.id, missing.map(describeMissing))
|
||
}
|
||
|
||
return map
|
||
}, [policies, defaults])
|
||
|
||
const incompleteCount = missingByPolicy.size
|
||
|
||
const SortableHead = ({
|
||
column,
|
||
children,
|
||
className,
|
||
}: {
|
||
column: SortKey
|
||
children: React.ReactNode
|
||
className?: string
|
||
}) => (
|
||
<TableHead className={className}>
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleSort(column)}
|
||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-sm transition-colors hover:text-foreground"
|
||
>
|
||
{children}
|
||
{sortBy === column &&
|
||
(sortOrder === "asc" ? (
|
||
<ArrowUp className="size-3.5" />
|
||
) : (
|
||
<ArrowDown className="size-3.5" />
|
||
))}
|
||
</button>
|
||
</TableHead>
|
||
)
|
||
|
||
if (status === "loading") {
|
||
return (
|
||
<div className="flex min-h-screen items-center justify-center">
|
||
<Skeleton className="h-5 w-40" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (status === "unauthenticated") {
|
||
return (
|
||
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
|
||
Nicht angemeldet – Weiterleitung…
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-background">
|
||
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between gap-4 px-6">
|
||
<div className="flex items-center gap-3">
|
||
<Brand />
|
||
<Badge variant="secondary">Admin</Badge>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => window.open("/", "_blank")}
|
||
>
|
||
<Eye />
|
||
<span className="sr-only">Öffentliche Ansicht</span>
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Öffentliche Ansicht</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => setIsDefaultsOpen(true)}
|
||
>
|
||
<FileType2 />
|
||
<span className="sr-only">Standardtexte</span>
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Standardtexte</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => setIsUserManagementOpen(true)}
|
||
>
|
||
<Users />
|
||
<span className="sr-only">Benutzerverwaltung</span>
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>Benutzer</TooltipContent>
|
||
</Tooltip>
|
||
|
||
<ThemeToggle />
|
||
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" size="icon" className="ml-1">
|
||
<Avatar>
|
||
<AvatarFallback>
|
||
{initials(session?.user?.name || session?.user?.email || "?")}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="min-w-56">
|
||
<DropdownMenuLabel className="font-normal">
|
||
<span className="block truncate font-medium">
|
||
{session?.user?.name || "Angemeldet"}
|
||
</span>
|
||
<span className="block truncate text-xs text-muted-foreground">
|
||
{session?.user?.email}
|
||
</span>
|
||
</DropdownMenuLabel>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem onSelect={() => setIsDefaultsOpen(true)}>
|
||
<FileType2 />
|
||
Standardtexte pflegen
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onSelect={() => setIsSitePagesOpen(true)}>
|
||
<Scale />
|
||
Rechtliche Seiten
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onSelect={() => setIsUserManagementOpen(true)}>
|
||
<Users />
|
||
Benutzer verwalten
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
variant="destructive"
|
||
onSelect={() => signOut({ callbackUrl: "/auth/signin" })}
|
||
>
|
||
<LogOut />
|
||
Abmelden
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="mx-auto max-w-7xl px-6 py-8">
|
||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||
<div className="space-y-1.5">
|
||
<h1 className="text-2xl font-semibold tracking-tight">
|
||
Datenschutzerklärungen
|
||
</h1>
|
||
<p className="text-sm text-muted-foreground">
|
||
{policies.length} gesamt · {publicCount} öffentlich ·{" "}
|
||
{policies.length - publicCount} privat
|
||
{incompleteCount > 0 && (
|
||
<>
|
||
{" · "}
|
||
<span className="text-amber-600 dark:text-amber-500">
|
||
{incompleteCount} unvollständig
|
||
</span>
|
||
</>
|
||
)}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<div className="relative flex-1 sm:w-64 sm:flex-none">
|
||
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
placeholder="Suchen…"
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="px-9"
|
||
/>
|
||
{searchTerm && (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={() => setSearchTerm("")}
|
||
className="absolute top-1/2 right-0.5 -translate-y-1/2 text-muted-foreground"
|
||
>
|
||
<X />
|
||
<span className="sr-only">Suche zurücksetzen</span>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
<Button onClick={handleAddNew}>
|
||
<Plus />
|
||
Neu
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{error ? (
|
||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||
Fehler beim Laden der Daten: {error.message}
|
||
</div>
|
||
) : isLoading ? (
|
||
<div className="space-y-3 rounded-lg border p-4">
|
||
{Array.from({ length: 5 }).map((_, index) => (
|
||
<Skeleton key={index} className="h-10 w-full" />
|
||
))}
|
||
</div>
|
||
) : visiblePolicies.length === 0 ? (
|
||
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed px-6 py-16 text-center">
|
||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||
<FileText className="size-6 text-muted-foreground" />
|
||
</div>
|
||
<p className="font-medium">
|
||
{searchTerm ? "Keine Treffer" : "Noch keine Datenschutzerklärungen"}
|
||
</p>
|
||
<p className="max-w-sm text-sm text-muted-foreground">
|
||
{searchTerm
|
||
? "Passen Sie den Suchbegriff an."
|
||
: "Legen Sie die erste DSGVO-Erklärung an."}
|
||
</p>
|
||
{!searchTerm && (
|
||
<Button onClick={handleAddNew}>
|
||
<Plus />
|
||
Erste Erklärung anlegen
|
||
</Button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="overflow-hidden rounded-lg border bg-card">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="hover:bg-transparent">
|
||
<SortableHead column="title">Titel</SortableHead>
|
||
<TableHead className="hidden md:table-cell">
|
||
Verantwortlicher
|
||
</TableHead>
|
||
<TableHead className="w-32">Status</TableHead>
|
||
<SortableHead column="createdAt" className="hidden w-40 lg:table-cell">
|
||
Erstellt
|
||
</SortableHead>
|
||
<SortableHead column="updatedAt" className="w-40">
|
||
Geändert
|
||
</SortableHead>
|
||
<TableHead className="w-12" />
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{visiblePolicies.map((policy) => (
|
||
<TableRow key={policy.id}>
|
||
<TableCell className="max-w-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => handlePreview(policy)}
|
||
className="block w-full cursor-pointer truncate text-left font-medium hover:text-brand"
|
||
>
|
||
{policy.title}
|
||
</button>
|
||
</TableCell>
|
||
<TableCell className="hidden max-w-0 md:table-cell">
|
||
<span className="block truncate text-muted-foreground">
|
||
{policy.responsible.replace(/[*_#`]/g, "").split("\n")[0]}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
{policy.isPublic ? (
|
||
<Badge variant="success">
|
||
<Eye />
|
||
Öffentlich
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="outline">
|
||
<EyeOff />
|
||
Privat
|
||
</Badge>
|
||
)}
|
||
|
||
{missingByPolicy.has(policy.id) && (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Badge
|
||
variant="outline"
|
||
className="cursor-help border-amber-500/40 text-amber-600 dark:text-amber-500"
|
||
>
|
||
<TriangleAlert />
|
||
{missingByPolicy.get(policy.id)!.length}
|
||
</Badge>
|
||
</TooltipTrigger>
|
||
<TooltipContent className="max-w-xs">
|
||
<p className="mb-1 font-medium">
|
||
Fehlende Pflichtangaben
|
||
</p>
|
||
<ul className="space-y-0.5">
|
||
{missingByPolicy.get(policy.id)!.map((text) => (
|
||
<li key={text}>· {text}</li>
|
||
))}
|
||
</ul>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="hidden whitespace-nowrap text-muted-foreground lg:table-cell">
|
||
{formatDateTime(policy.createdAt)}
|
||
</TableCell>
|
||
<TableCell className="whitespace-nowrap text-muted-foreground">
|
||
{formatDateTime(policy.updatedAt)}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" size="icon-sm">
|
||
<MoreHorizontal />
|
||
<span className="sr-only">Aktionen</span>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem onSelect={() => handlePreview(policy)}>
|
||
<Eye />
|
||
Vorschau
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onSelect={() => handleEdit(policy)}>
|
||
<Pencil />
|
||
Bearbeiten
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
variant="destructive"
|
||
onSelect={() => setPolicyToDelete(policy)}
|
||
>
|
||
<Trash2 />
|
||
Löschen
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
)}
|
||
</main>
|
||
|
||
<PolicyForm
|
||
open={isFormOpen}
|
||
onOpenChange={setIsFormOpen}
|
||
policy={editingPolicy}
|
||
/>
|
||
|
||
<PolicyDetail
|
||
policy={previewPolicy}
|
||
open={isPreviewOpen}
|
||
onOpenChange={setIsPreviewOpen}
|
||
/>
|
||
|
||
<PolicyDefaultsForm open={isDefaultsOpen} onOpenChange={setIsDefaultsOpen} />
|
||
|
||
<SitePagesForm open={isSitePagesOpen} onOpenChange={setIsSitePagesOpen} />
|
||
|
||
<UserManagement
|
||
isOpen={isUserManagementOpen}
|
||
onClose={() => setIsUserManagementOpen(false)}
|
||
/>
|
||
|
||
<AlertDialog
|
||
open={policyToDelete !== null}
|
||
onOpenChange={(open) => !open && setPolicyToDelete(null)}
|
||
>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Datenschutzerklärung löschen?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
„{policyToDelete?.title}“ wird unwiderruflich entfernt. Diese Aktion
|
||
kann nicht rückgängig gemacht werden.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel disabled={isDeleting}>Abbrechen</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
handleDelete()
|
||
}}
|
||
disabled={isDeleting}
|
||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||
>
|
||
{isDeleting ? "Löschen…" : "Löschen"}
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</div>
|
||
)
|
||
}
|