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