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

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

View file

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

View file

@ -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 (
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-semibold">{client.name}</h1>
<p className="text-muted-foreground">{client.email}</p>
</div>
)
}
export default ClientDetailPage

View file

@ -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 <ClientListViewPage/>
}
export default Clients

View file

@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google"; import { Inter, JetBrains_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import "./globals.css"; import "./globals.css";
const inter = Inter({ const inter = Inter({
@ -22,8 +23,11 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
<html <html
lang="en" lang="en"
className={`${inter.variable} ${jetbrainsMono.variable} h-full antialiased`} className={`${inter.variable} ${jetbrainsMono.variable} h-full antialiased`}
suppressHydrationWarning
> >
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col">
<ThemeProvider>{children}</ThemeProvider>
</body>
</html> </html>
); );
} }

View file

@ -0,0 +1,15 @@
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
}: {
children: React.ReactNode
}) {
return (
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</NextThemesProvider>
)
}

View file

@ -3,11 +3,11 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn" import { cn } from "cn"
const buttonVariants = cva( 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: { variants: {
variant: { 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: secondary:
"bg-secondary text-secondary-foreground border-border hover:bg-ink-100 dark:hover:bg-ink-800", "bg-secondary text-secondary-foreground border-border hover:bg-ink-100 dark:hover:bg-ink-800",
outline: outline:

View file

@ -0,0 +1,21 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

160
components/ui/dialog.tsx Normal file
View file

@ -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 <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View file

@ -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 <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View file

@ -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 (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control] has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[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<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
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<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View file

@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "cn"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

29
lib/generate-password.ts Normal file
View file

@ -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("")
}

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, FileTextIcon,
FoldersIcon, FoldersIcon,
InfoIcon,
KeyIcon, KeyIcon,
LayoutDashboardIcon, LayoutDashboardIcon,
LucideIcon, LucideIcon,
SettingsIcon, SettingsIcon,
UserRoundIcon, UserRoundIcon,
UsersRoundIcon UsersRoundIcon
} }
from "lucide-react"; from "lucide-react";
export type MenuItem = { export type MenuItem = {
@ -60,4 +61,36 @@ export const clientMenu: MenuItem[] = [
url: "/client/storage", url: "/client/storage",
icon: FoldersIcon 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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Session } from "../../types/user"; import { Session } from "../../types/user";
import { adminMenu, clientMenu } from "../../data/sidebar-menu-content"; import { adminMenu, clientMenu } from "../../data/sidebar-menu-content";
import { ClientsMenuItem } from "./clients-menu-item";
import { LogoutButton } from "./logout-button"; import { LogoutButton } from "./logout-button";
import { SidebarMenuLink } from "./sidebar-menu-link"; import { SidebarMenuLink } from "./sidebar-menu-link";
import { ThemeToggle } from "./theme-toggle";
import UserBadge from "./user-badge"; import UserBadge from "./user-badge";
export function AppSidebar({ session }: { session: Session }) { export function AppSidebar({ session }: { session: Session }) {
@ -37,21 +39,28 @@ export function AppSidebar({ session }: { session: Session }) {
<SidebarGroup> <SidebarGroup>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu className="gap-1.5"> <SidebarMenu className="gap-1.5">
{menu.map((item) => ( {menu.map((item) =>
<SidebarMenuLink item.url === "/admin/clients" ? (
key={item.url} <ClientsMenuItem key={item.url} />
title={item.title} ) : (
url={item.url} <SidebarMenuLink
icon={<item.icon className="size-4.5" />} key={item.url}
/> title={item.title}
))} url={item.url}
icon={<item.icon className="size-4.5" />}
/>
)
)}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter className="gap-3 p-4"> <SidebarFooter className="gap-3 p-4">
<p className="truncate text-xs text-muted-foreground">{session.email}</p> <p className="truncate text-xs text-muted-foreground">{session.email}</p>
<LogoutButton /> <div className="flex items-center gap-2">
<LogoutButton />
<ThemeToggle />
</div>
</SidebarFooter> </SidebarFooter>
<SidebarRail /> <SidebarRail />
</Sidebar> </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>
)
}

11
package-lock.json generated
View file

@ -15,6 +15,7 @@
"cn": "^0.4.0", "cn": "^0.4.0",
"lucide-react": "^1.48.0", "lucide-react": "^1.48.0",
"next": "16.3.6", "next": "16.3.6",
"next-themes": "^0.4.6",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"shadcn": "^4.21.0", "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": { "node_modules/next/node_modules/postcss": {
"version": "8.5.23", "version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",

View file

@ -16,6 +16,7 @@
"cn": "^0.4.0", "cn": "^0.4.0",
"lucide-react": "^1.48.0", "lucide-react": "^1.48.0",
"next": "16.3.6", "next": "16.3.6",
"next-themes": "^0.4.6",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"shadcn": "^4.21.0", "shadcn": "^4.21.0",