new clients functionality

This commit is contained in:
Rajpreet0 2026-09-27 18:57:56 +02:00
parent 9dbacf4d45
commit 5edd697056
25 changed files with 1268 additions and 56 deletions

View file

@ -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<CreateClientState> {
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 }
}

View file

@ -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
}

View file

@ -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 (
<>
<DialogHeader className="mb-4">
<DialogTitle>Client angelegt 🎉</DialogTitle>
<DialogDescription>
Gib die Zugangsdaten an den Client weiter. Das Passwort wird nur jetzt angezeigt.
</DialogDescription>
</DialogHeader>
<FieldGroup>
<CopyField label="Email" value={state.email} />
<CopyField label="Passwort" value={state.password} />
</FieldGroup>
<DialogFooter className="mt-6">
<Button type="button" onClick={onDone}>Fertig</Button>
</DialogFooter>
</>
)
}
return (
<>
<DialogHeader className="mb-4">
<DialogTitle>Neuen Clienten anlegen</DialogTitle>
</DialogHeader>
<form action={formAction}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="client-name"><UserRound className="w-4 h-4"/> Name <span className="text-destructive">*</span></FieldLabel>
<Input id="client-name" name="name" required className="focus-visible:ring-0"/>
</Field>
<Field>
<FieldLabel htmlFor="client-email"><Mail className="w-4 h-4"/> Email <span className="text-destructive">*</span></FieldLabel>
<Input id="client-email" name="email" type="email" required className="focus-visible:ring-0"/>
</Field>
{state && "error" in state && (
<p className="text-sm text-destructive">{state.error}</p>
)}
</FieldGroup>
<DialogFooter className="mt-6">
<Button type="submit" disabled={pending}>
{pending ? "Wird angelegt..." : "Anlegen"}
</Button>
</DialogFooter>
</form>
</>
)
}
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={<Button className="cursor-pointer"/>}>
<PlusIcon/>
Add a new Client
</DialogTrigger>
<DialogContent>
<AddClientDialogContent key={instanceKey} onDone={() => handleOpenChange(false)} />
</DialogContent>
</Dialog>
)
}

View file

@ -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 (
<div className="flex flex-col gap-8 w-full">
<div className="flex flex-row items-center justify-between gap-2 w-full p-2">
<InputGroup className="max-w-lg bg-white rounded-md focus-visible:ring-0">
<InputGroupInput
placeholder="Search..."
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<InputGroupAddon>
<Search />
</InputGroupAddon>
</InputGroup>
<AddClientDialog />
</div>
{filteredClients.length === 0 ? (
<p className="p-8 text-center text-muted-foreground">
{clients.length === 0 ? "Noch keine Clients vorhanden." : "Keine Clients gefunden."}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Client</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-16" />
</TableRow>
</TableHeader>
<TableBody>
{filteredClients.map((client) => {
const index = clients.indexOf(client);
const status = STATUS_CONFIG[getPlaceholderStatus(index)];
return (
<TableRow key={client.id}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar size="lg" className="shrink-0">
<AvatarFallback>
<Building2Icon className="size-4.5 text-muted-foreground" />
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium">{client.name}</span>
<span className="truncate text-small text-muted-foreground">{client.email}</span>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={status.variant}>{status.label}</Badge>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon-sm"
nativeButton={false}
render={<Link href={`/admin/clients/${client.id}`} />}
aria-label={`${client.name} öffnen`}
>
<ArrowRightIcon />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
);
}

View file

@ -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 (
<Field>
<FieldLabel>{label}</FieldLabel>
<div className="flex items-center gap-2">
<Input value={value} readOnly className="focus-visible:ring-0" />
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0 cursor-pointer"
onClick={handleCopy}
aria-label={`${label} kopieren`}
>
{copied ? <CheckIcon className="text-signal-success" /> : <CopyIcon />}
</Button>
</div>
</Field>
)
}

View file

@ -0,0 +1,15 @@
import { ClientsTable } from "../components/clients-table"
import { getClients } from "../../queries/clients"
const ClientListViewPage = async () => {
const clients = await getClients()
return (
<div className="flex flex-col gap-8 w-full">
<h1 className="text-3xl font-semibold">Clients</h1>
<ClientsTable clients={clients} />
</div>
)
}
export default ClientListViewPage

View file

@ -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
}
]
]
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
}
]
}

View file

@ -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 }) {
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu className="gap-1.5">
{menu.map((item) => (
<SidebarMenuLink
key={item.url}
title={item.title}
url={item.url}
icon={<item.icon className="size-4.5" />}
/>
))}
{menu.map((item) =>
item.url === "/admin/clients" ? (
<ClientsMenuItem key={item.url} />
) : (
<SidebarMenuLink
key={item.url}
title={item.title}
url={item.url}
icon={<item.icon className="size-4.5" />}
/>
)
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="gap-3 p-4">
<p className="truncate text-xs text-muted-foreground">{session.email}</p>
<LogoutButton />
<div className="flex items-center gap-2">
<LogoutButton />
<ThemeToggle />
</div>
</SidebarFooter>
<SidebarRail />
</Sidebar>

View file

@ -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 (
<SidebarMenuItem>
<SidebarMenuButton
render={<Link href="/admin/clients" onClick={() => setOpenMobile(false)} />}
isActive={isActive}
className="h-10 text-[0.95rem]"
>
<UsersRoundIcon className="size-4.5" />
<span>Clients</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
const menu = getClientDetailMenu(clientId);
return (
<Collapsible defaultOpen>
<SidebarMenuItem>
<div className="relative">
<SidebarMenuButton
render={<Link href="/admin/clients" onClick={() => setOpenMobile(false)} />}
className="h-10 pr-9 text-[0.95rem]"
>
<UsersRoundIcon className="size-4.5" />
<span>Clients</span>
</SidebarMenuButton>
<CollapsibleTrigger
render={
<button
type="button"
aria-label="Client-Menü umschalten"
className="group absolute top-1/2 right-1.5 -translate-y-1/2 rounded-md p-1.5 hover:bg-sidebar-accent"
/>
}
>
<ChevronRightIcon className="size-4 transition-transform group-data-panel-open:rotate-90" />
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<SidebarMenuSub>
{menu.map((item) => {
const isActive = pathname === item.url;
return (
<SidebarMenuSubItem key={item.url}>
<SidebarMenuSubButton
render={<Link href={item.url} onClick={() => setOpenMobile(false)} />}
isActive={isActive}
>
<item.icon className="size-4" />
<span>{item.title}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
}

View file

@ -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 <Button variant="outline" size="icon" className="shrink-0" disabled />;
}
const isDark = resolvedTheme === "dark";
return (
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0 cursor-pointer"
onClick={() => setTheme(isDark ? "light" : "dark")}
aria-label={isDark ? "Zu hellem Modus wechseln" : "Zu dunklem Modus wechseln"}
>
{isDark ? <SunIcon /> : <MoonIcon />}
</Button>
)
}