mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
new clients functionality
This commit is contained in:
parent
9dbacf4d45
commit
5edd697056
25 changed files with 1268 additions and 56 deletions
50
modules/admin/actions/client.ts
Normal file
50
modules/admin/actions/client.ts
Normal 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 }
|
||||
}
|
||||
15
modules/admin/queries/clients.ts
Normal file
15
modules/admin/queries/clients.ts
Normal 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
|
||||
}
|
||||
87
modules/admin/ui/components/add-client-dialog.tsx
Normal file
87
modules/admin/ui/components/add-client-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
modules/admin/ui/components/clients-table.tsx
Normal file
123
modules/admin/ui/components/clients-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
modules/admin/ui/components/copy-field.tsx
Normal file
36
modules/admin/ui/components/copy-field.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
modules/admin/ui/views/client-list-view.tsx
Normal file
15
modules/admin/ui/views/client-list-view.tsx
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
92
modules/shared/ui/components/clients-menu-item.tsx
Normal file
92
modules/shared/ui/components/clients-menu-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
modules/shared/ui/components/theme-toggle.tsx
Normal file
38
modules/shared/ui/components/theme-toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue