diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
deleted file mode 100644
index ea15abd..0000000
--- a/.github/workflows/ci.yml
+++ /dev/null
@@ -1,36 +0,0 @@
-name: CI
-
-on:
- pull_request:
- branches: [dev]
- push:
- branches: [main]
-
-jobs:
- ci:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Setup Node
- uses: actions/setup-node@v4
- with:
- node-version: 22.12.0
- cache: npm
-
- - name: Install dependencies
- run: npm ci
-
- - name: Lint
- run: npm run lint
-
- - name: Type check
- run: npx tsc --noEmit
-
- - name: Build
- run: npm run build
- env:
- NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
- NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY }}
- SUPABASE_SECRET_KEY: ${{ secrets.SUPABASE_SECRET_KEY }}
\ No newline at end of file
diff --git a/app/admin/clients/[id]/layout.tsx b/app/admin/clients/[id]/layout.tsx
new file mode 100644
index 0000000..771e83d
--- /dev/null
+++ b/app/admin/clients/[id]/layout.tsx
@@ -0,0 +1,29 @@
+import { notFound, redirect } from "next/navigation";
+import { createAdminClient } from "@/lib/supabase/admin";
+import { verifySession } from "@/lib/supabase/dal";
+
+type Params = Promise<{ id: string }>;
+
+export default async function ClientDetailLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode;
+ params: Params;
+}) {
+ const session = await verifySession();
+ if (!session.isAdmin) redirect("/client/dashboard");
+
+ const { id } = await params;
+ const supabase = createAdminClient();
+ const { data: client } = await supabase
+ .from("profiles")
+ .select("id")
+ .eq("id", id)
+ .eq("role", "client")
+ .single();
+
+ if (!client) notFound();
+
+ return children;
+}
diff --git a/app/admin/clients/[id]/page.tsx b/app/admin/clients/[id]/page.tsx
new file mode 100644
index 0000000..1de47bb
--- /dev/null
+++ b/app/admin/clients/[id]/page.tsx
@@ -0,0 +1,26 @@
+import { notFound } from "next/navigation";
+import { createAdminClient } from "@/lib/supabase/admin";
+
+type Params = Promise<{ id: string }>;
+
+const ClientDetailPage = async ({ params }: { params: Params }) => {
+ const { id } = await params;
+ const supabase = createAdminClient();
+ const { data: client } = await supabase
+ .from("profiles")
+ .select("id, name, email")
+ .eq("id", id)
+ .eq("role", "client")
+ .single();
+
+ if (!client) notFound();
+
+ return (
+
+
{client.name}
+
{client.email}
+
+ )
+}
+
+export default ClientDetailPage
diff --git a/app/admin/clients/page.tsx b/app/admin/clients/page.tsx
new file mode 100644
index 0000000..2ecb934
--- /dev/null
+++ b/app/admin/clients/page.tsx
@@ -0,0 +1,12 @@
+import { verifySession } from "@/lib/supabase/dal"
+import ClientListViewPage from "@/modules/admin/ui/views/client-list-view";
+import { redirect } from "next/navigation";
+
+const Clients = async () => {
+ const session = await verifySession();
+ if (!session.isAdmin) redirect("/client/dashboard");
+
+ return
+}
+
+export default Clients
\ No newline at end of file
diff --git a/app/layout.tsx b/app/layout.tsx
index 8a7c3be..f44301c 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
+import { ThemeProvider } from "@/components/theme-provider";
import "./globals.css";
const inter = Inter({
@@ -22,8 +23,11 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
- {children}
+
+ {children}
+
);
}
diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx
new file mode 100644
index 0000000..f401999
--- /dev/null
+++ b/components/theme-provider.tsx
@@ -0,0 +1,15 @@
+"use client"
+
+import { ThemeProvider as NextThemesProvider } from "next-themes"
+
+export function ThemeProvider({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
index fdc1736..9eaf3f9 100644
--- a/components/ui/button.tsx
+++ b/components/ui/button.tsx
@@ -3,11 +3,11 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const buttonVariants = cva(
- "group/button inline-flex shrink-0 items-center justify-center gap-2 rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-[background-color,border-color,color] duration-[var(--duration-fast)] ease-out outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "group/button inline-flex shrink-0 items-center justify-center gap-2 cursor-pointer rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-[background-color,border-color,color] duration-[var(--duration-fast)] ease-out outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
- primary: "bg-primary text-primary-foreground hover:bg-accent-600",
+ primary: "bg-primary text-primary-foreground hover:bg-accent-600 dark:hover:bg-ink-100",
secondary:
"bg-secondary text-secondary-foreground border-border hover:bg-ink-100 dark:hover:bg-ink-800",
outline:
diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx
new file mode 100644
index 0000000..488fb33
--- /dev/null
+++ b/components/ui/collapsible.tsx
@@ -0,0 +1,21 @@
+"use client"
+
+import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
+
+function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
+ return
+}
+
+function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
+ return (
+
+ )
+}
+
+function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
+ return (
+
+ )
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }
diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx
new file mode 100644
index 0000000..5cadf3f
--- /dev/null
+++ b/components/ui/dialog.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+import { cn } from "cn"
+
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..cafe6ba
--- /dev/null
+++ b/components/ui/dropdown-menu.tsx
@@ -0,0 +1,267 @@
+"use client"
+
+import * as React from "react"
+import { Menu as MenuPrimitive } from "@base-ui/react/menu"
+import { cn } from "cn"
+import { ChevronRightIcon, CheckIcon } from "lucide-react"
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/components/ui/input-group.tsx b/components/ui/input-group.tsx
new file mode 100644
index 0000000..0adaf5f
--- /dev/null
+++ b/components/ui/input-group.tsx
@@ -0,0 +1,158 @@
+"use client"
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "cn"
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+
+function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ [data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+const inputGroupAddonVariants = cva(
+ "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ align: {
+ "inline-start":
+ "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
+ "inline-end":
+ "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
+ "block-start":
+ "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
+ "block-end":
+ "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
+ },
+ },
+ defaultVariants: {
+ align: "inline-start",
+ },
+ }
+)
+
+function InputGroupAddon({
+ className,
+ align = "inline-start",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps
) {
+ return (
+ {
+ if ((e.target as HTMLElement).closest("button")) {
+ return
+ }
+ e.currentTarget.parentElement?.querySelector("input")?.focus()
+ }}
+ {...props}
+ />
+ )
+}
+
+const inputGroupButtonVariants = cva(
+ "flex items-center gap-2 text-sm shadow-none",
+ {
+ variants: {
+ size: {
+ xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
+ sm: "",
+ "icon-xs":
+ "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
+ "icon-sm": "size-8 p-0 has-[>svg]:p-0",
+ },
+ },
+ defaultVariants: {
+ size: "xs",
+ },
+ }
+)
+
+function InputGroupButton({
+ className,
+ type = "button",
+ variant = "ghost",
+ size = "xs",
+ ...props
+}: Omit
, "size" | "type"> &
+ VariantProps & {
+ type?: "button" | "submit" | "reset"
+ }) {
+ return (
+
+ )
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function InputGroupInput({
+ className,
+ ...props
+}: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+function InputGroupTextarea({
+ className,
+ ...props
+}: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupText,
+ InputGroupInput,
+ InputGroupTextarea,
+}
diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx
new file mode 100644
index 0000000..5141de7
--- /dev/null
+++ b/components/ui/textarea.tsx
@@ -0,0 +1,17 @@
+import * as React from "react"
+import { cn } from "cn"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/lib/generate-password.ts b/lib/generate-password.ts
new file mode 100644
index 0000000..0a65f29
--- /dev/null
+++ b/lib/generate-password.ts
@@ -0,0 +1,29 @@
+import { randomInt } from "crypto"
+
+const LOWERCASE = "abcdefghijkmnpqrstuvwxyz"
+const UPPERCASE = "ABCDEFGHJKLMNPQRSTUVWXYZ"
+const DIGITS = "23456789"
+const SYMBOLS = "!@#$%&*"
+const ALL_CHARS = LOWERCASE + UPPERCASE + DIGITS + SYMBOLS
+
+export function generatePassword(length = 14) {
+ const required = [
+ LOWERCASE[randomInt(LOWERCASE.length)],
+ UPPERCASE[randomInt(UPPERCASE.length)],
+ DIGITS[randomInt(DIGITS.length)],
+ SYMBOLS[randomInt(SYMBOLS.length)],
+ ]
+
+ const rest = Array.from({ length: length - required.length }, () =>
+ ALL_CHARS[randomInt(ALL_CHARS.length)]
+ )
+
+ const password = [...required, ...rest]
+
+ for (let i = password.length - 1; i > 0; i--) {
+ const j = randomInt(i + 1)
+ ;[password[i], password[j]] = [password[j], password[i]]
+ }
+
+ return password.join("")
+}
diff --git a/modules/admin/actions/client.ts b/modules/admin/actions/client.ts
new file mode 100644
index 0000000..79fc7e3
--- /dev/null
+++ b/modules/admin/actions/client.ts
@@ -0,0 +1,50 @@
+"use server";
+import { createAdminClient } from "@/lib/supabase/admin";
+import { generatePassword } from "@/lib/generate-password";
+import * as z from "zod";
+
+
+
+const CreateClientSchema = z.object({
+ name: z.string().min(3, {error: "Mindestens drei Zeichen eingeben"}),
+ email: z.email({ error: "Bitte eine gültige Email eingeben." })
+})
+
+export type CreateClientState =
+ | { error: string }
+ | { success: true; email: string; password: string }
+ | undefined;
+
+export async function createClient(
+ _prevState: CreateClientState,
+ formData: FormData
+): Promise {
+
+ const validatedFields = CreateClientSchema.safeParse({
+ name: formData.get("name"),
+ email: formData.get("email")
+ });
+
+ if (!validatedFields.success) {
+ const { fieldErrors } = z.flattenError(validatedFields.error);
+ const message = fieldErrors.email?.[0] ?? fieldErrors.name?.[0] ?? "Bitte Name und Email ausfüllen.";
+ return { error: message }
+ }
+
+ const { name, email } = validatedFields.data;
+ const password = generatePassword();
+
+ const supabase = createAdminClient();
+ const { error } = await supabase.auth.admin.createUser({
+ email,
+ password,
+ email_confirm: true,
+ user_metadata: { name },
+ });
+
+ if (error) {
+ return { error: error.code === "email_exists" ? "Diese Email ist bereits vergeben." : "Client konnte nicht angelegt werden." }
+ }
+
+ return { success: true, email, password }
+}
\ No newline at end of file
diff --git a/modules/admin/queries/clients.ts b/modules/admin/queries/clients.ts
new file mode 100644
index 0000000..86bf4ad
--- /dev/null
+++ b/modules/admin/queries/clients.ts
@@ -0,0 +1,15 @@
+import "server-only"
+import { createAdminClient } from "@/lib/supabase/admin"
+
+export async function getClients() {
+ const supabase = createAdminClient()
+ const { data, error } = await supabase
+ .from("profiles")
+ .select("id, name, email")
+ .eq("role", "client")
+ .order("name")
+
+ if (error) return []
+
+ return data
+}
diff --git a/modules/admin/ui/components/add-client-dialog.tsx b/modules/admin/ui/components/add-client-dialog.tsx
new file mode 100644
index 0000000..e9ce5df
--- /dev/null
+++ b/modules/admin/ui/components/add-client-dialog.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import { useActionState, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, DialogDescription } from "@/components/ui/dialog";
+import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { Mail, PlusIcon, UserRound } from "lucide-react";
+import { createClient } from "@/modules/admin/actions/client";
+import { CopyField } from "./copy-field";
+
+function AddClientDialogContent({ onDone }: { onDone: () => void }) {
+ const [state, formAction, pending] = useActionState(createClient, undefined);
+ const success = state && "success" in state && state.success;
+
+ if (success) {
+ return (
+ <>
+
+ Client angelegt 🎉
+
+ Gib die Zugangsdaten an den Client weiter. Das Passwort wird nur jetzt angezeigt.
+
+
+
+
+
+
+
+
+
+ >
+ )
+ }
+
+ return (
+ <>
+
+ Neuen Clienten anlegen
+
+
+ >
+ )
+}
+
+export function AddClientDialog() {
+ const [open, setOpen] = useState(false);
+ const [instanceKey, setInstanceKey] = useState(0);
+
+ function handleOpenChange(nextOpen: boolean) {
+ setOpen(nextOpen);
+ if (!nextOpen) {
+ setInstanceKey((key) => key + 1);
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/modules/admin/ui/components/clients-table.tsx b/modules/admin/ui/components/clients-table.tsx
new file mode 100644
index 0000000..42f56d4
--- /dev/null
+++ b/modules/admin/ui/components/clients-table.tsx
@@ -0,0 +1,123 @@
+"use client"
+
+import { useMemo, useState } from "react";
+import Link from "next/link";
+import { ArrowRightIcon, Building2Icon, Search } from "lucide-react";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
+import { AddClientDialog } from "./add-client-dialog";
+
+type Client = {
+ id: string;
+ name: string;
+ email: string;
+};
+
+const STATUS_CONFIG = {
+ current: { label: "Current", variant: "warning" as const },
+ done: { label: "Done", variant: "success" as const },
+ upcoming: { label: "Upcoming", variant: "neutral" as const },
+};
+
+// TODO: Status kommt aktuell nicht aus der DB, sobald Projekte existieren durch echten Wert ersetzen.
+function getPlaceholderStatus(index: number): keyof typeof STATUS_CONFIG {
+ const statuses: (keyof typeof STATUS_CONFIG)[] = ["current", "done", "upcoming"];
+ return statuses[index % statuses.length];
+}
+
+export function ClientsTable({ clients }: { clients: Client[] }) {
+ const [query, setQuery] = useState("");
+
+ const filteredClients = useMemo(() => {
+ const normalizedQuery = query.trim().toLowerCase();
+ if (!normalizedQuery) return clients;
+
+ return clients.filter(
+ (client) =>
+ client.name.toLowerCase().includes(normalizedQuery) ||
+ client.email.toLowerCase().includes(normalizedQuery)
+ );
+ }, [clients, query]);
+
+ return (
+
+
+
+ setQuery(event.target.value)}
+ />
+
+
+
+
+
+
+
+ {filteredClients.length === 0 ? (
+
+ {clients.length === 0 ? "Noch keine Clients vorhanden." : "Keine Clients gefunden."}
+
+ ) : (
+
+
+
+ Client
+ Status
+
+
+
+
+ {filteredClients.map((client) => {
+ const index = clients.indexOf(client);
+ const status = STATUS_CONFIG[getPlaceholderStatus(index)];
+
+ return (
+
+
+
+
+
+
+
+
+
+ {client.name}
+ {client.email}
+
+
+
+
+ {status.label}
+
+
+ }
+ aria-label={`${client.name} öffnen`}
+ >
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/modules/admin/ui/components/copy-field.tsx b/modules/admin/ui/components/copy-field.tsx
new file mode 100644
index 0000000..1538510
--- /dev/null
+++ b/modules/admin/ui/components/copy-field.tsx
@@ -0,0 +1,36 @@
+"use client"
+
+import { useState } from "react";
+import { CheckIcon, CopyIcon } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Field, FieldLabel } from "@/components/ui/field";
+
+export function CopyField({ label, value }: { label: string; value: string }) {
+ const [copied, setCopied] = useState(false);
+
+ async function handleCopy() {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ }
+
+ return (
+
+ {label}
+
+
+
+
+
+ )
+}
diff --git a/modules/admin/ui/views/client-list-view.tsx b/modules/admin/ui/views/client-list-view.tsx
new file mode 100644
index 0000000..c55c58b
--- /dev/null
+++ b/modules/admin/ui/views/client-list-view.tsx
@@ -0,0 +1,15 @@
+import { ClientsTable } from "../components/clients-table"
+import { getClients } from "../../queries/clients"
+
+const ClientListViewPage = async () => {
+ const clients = await getClients()
+
+ return (
+
+
Clients
+
+
+ )
+}
+
+export default ClientListViewPage
\ No newline at end of file
diff --git a/modules/shared/data/sidebar-menu-content.ts b/modules/shared/data/sidebar-menu-content.ts
index 689f316..8651ddf 100644
--- a/modules/shared/data/sidebar-menu-content.ts
+++ b/modules/shared/data/sidebar-menu-content.ts
@@ -1,13 +1,14 @@
-import {
+import {
FileTextIcon,
FoldersIcon,
+ InfoIcon,
KeyIcon,
- LayoutDashboardIcon,
- LucideIcon,
- SettingsIcon,
- UserRoundIcon,
- UsersRoundIcon
- }
+ LayoutDashboardIcon,
+ LucideIcon,
+ SettingsIcon,
+ UserRoundIcon,
+ UsersRoundIcon
+ }
from "lucide-react";
export type MenuItem = {
@@ -60,4 +61,36 @@ export const clientMenu: MenuItem[] = [
url: "/client/storage",
icon: FoldersIcon
}
-]
\ No newline at end of file
+]
+
+export function getClientDetailMenu(clientId: string): MenuItem[] {
+ const base = `/admin/clients/${clientId}`
+
+ return [
+ {
+ title: "Info",
+ url: base,
+ icon: InfoIcon
+ },
+ {
+ title: "Stammdaten",
+ url: `${base}/data`,
+ icon: UserRoundIcon
+ },
+ {
+ title: "Rechnungen",
+ url: `${base}/billing`,
+ icon: FileTextIcon
+ },
+ {
+ title: "Passwörter",
+ url: `${base}/passwords`,
+ icon: KeyIcon
+ },
+ {
+ title: "Dokumente",
+ url: `${base}/storage`,
+ icon: FoldersIcon
+ }
+ ]
+}
\ No newline at end of file
diff --git a/modules/shared/ui/components/app-sidebar.tsx b/modules/shared/ui/components/app-sidebar.tsx
index b7a287b..fc140a0 100644
--- a/modules/shared/ui/components/app-sidebar.tsx
+++ b/modules/shared/ui/components/app-sidebar.tsx
@@ -11,8 +11,10 @@ import {
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Session } from "../../types/user";
import { adminMenu, clientMenu } from "../../data/sidebar-menu-content";
+import { ClientsMenuItem } from "./clients-menu-item";
import { LogoutButton } from "./logout-button";
import { SidebarMenuLink } from "./sidebar-menu-link";
+import { ThemeToggle } from "./theme-toggle";
import UserBadge from "./user-badge";
export function AppSidebar({ session }: { session: Session }) {
@@ -37,21 +39,28 @@ export function AppSidebar({ session }: { session: Session }) {
- {menu.map((item) => (
- }
- />
- ))}
+ {menu.map((item) =>
+ item.url === "/admin/clients" ? (
+
+ ) : (
+ }
+ />
+ )
+ )}
{session.email}
-
+
+
+
+
diff --git a/modules/shared/ui/components/clients-menu-item.tsx b/modules/shared/ui/components/clients-menu-item.tsx
new file mode 100644
index 0000000..2ab28c5
--- /dev/null
+++ b/modules/shared/ui/components/clients-menu-item.tsx
@@ -0,0 +1,92 @@
+"use client"
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { ChevronRightIcon, UsersRoundIcon } from "lucide-react";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import {
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ useSidebar,
+} from "@/components/ui/sidebar";
+import { getClientDetailMenu } from "@/modules/shared/data/sidebar-menu-content";
+
+export function ClientsMenuItem() {
+ const pathname = usePathname();
+ const { setOpenMobile } = useSidebar();
+
+ const detailMatch = pathname.match(/^\/admin\/clients\/([^/]+)/);
+ const clientId = detailMatch?.[1];
+
+ if (!clientId) {
+ const isActive = pathname === "/admin/clients";
+
+ return (
+
+ setOpenMobile(false)} />}
+ isActive={isActive}
+ className="h-10 text-[0.95rem]"
+ >
+
+ Clients
+
+
+ );
+ }
+
+ const menu = getClientDetailMenu(clientId);
+
+ return (
+
+
+
+ setOpenMobile(false)} />}
+ className="h-10 pr-9 text-[0.95rem]"
+ >
+
+ Clients
+
+
+ }
+ >
+
+
+
+
+
+ {menu.map((item) => {
+ const isActive = pathname === item.url;
+
+ return (
+
+ setOpenMobile(false)} />}
+ isActive={isActive}
+ >
+
+ {item.title}
+
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/modules/shared/ui/components/theme-toggle.tsx b/modules/shared/ui/components/theme-toggle.tsx
new file mode 100644
index 0000000..292fe58
--- /dev/null
+++ b/modules/shared/ui/components/theme-toggle.tsx
@@ -0,0 +1,38 @@
+"use client"
+
+import { useSyncExternalStore } from "react";
+import { useTheme } from "next-themes";
+import { MoonIcon, SunIcon } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+function subscribe() {
+ return () => {};
+}
+
+function useMounted() {
+ return useSyncExternalStore(subscribe, () => true, () => false);
+}
+
+export function ThemeToggle() {
+ const { resolvedTheme, setTheme } = useTheme();
+ const mounted = useMounted();
+
+ if (!mounted) {
+ return ;
+ }
+
+ const isDark = resolvedTheme === "dark";
+
+ return (
+
+ )
+}
diff --git a/package-lock.json b/package-lock.json
index 2ec0b5f..5eefef0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
"cn": "^0.4.0",
"lucide-react": "^1.48.0",
"next": "16.3.6",
+ "next-themes": "^0.4.6",
"react": "19.2.8",
"react-dom": "19.2.8",
"shadcn": "^4.21.0",
@@ -5945,6 +5946,16 @@
}
}
},
+ "node_modules/next-themes": {
+ "version": "0.4.6",
+ "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
+ "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
+ }
+ },
"node_modules/next/node_modules/postcss": {
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
diff --git a/package.json b/package.json
index 616f456..7505a94 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"cn": "^0.4.0",
"lucide-react": "^1.48.0",
"next": "16.3.6",
+ "next-themes": "^0.4.6",
"react": "19.2.8",
"react-dom": "19.2.8",
"shadcn": "^4.21.0",