Add customer management

This commit is contained in:
2025-07-06 08:31:48 +00:00
parent 2bd76aa6bb
commit 916dbfcf95
57 changed files with 2442 additions and 161 deletions

View File

@@ -0,0 +1,4 @@
export const customerRoutes = {
create: "/customers",
validate: "/customers/validate",
};

View File

@@ -0,0 +1,14 @@
import {NextRequest, NextResponse} from "next/server";
import {serverCall} from "@/lib/api/serverCall";
export async function GET() {
const data = await serverCall("/customers", "GET");
const customers = await data.json();
return NextResponse.json(customers);
}
export async function POST(req: NextRequest) {
const body = await req.json()
const result = await serverCall("/customers", "POST", body);
return NextResponse.json(result.json());
}

View File

@@ -0,0 +1,196 @@
"use client";
import {useState, useEffect, useMemo} from "react";
import {useRouter} from "next/navigation";
import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
import {Card, CardContent} from "@/components/ui/card";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious
} from "@/components/ui/pagination";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import {motion} from "framer-motion";
import {ArrowRight} from "lucide-react";
import {NewCustomerModal} from "@/components/customers/modal/NewCustomerModal";
import axios from "axios";
export interface CustomerPhoneNumber {
number: string;
note: string;
creator: string;
lastModifier: string;
createdAt: string;
updatedAt: string;
}
export interface CustomerNote {
text: string;
creator: string;
lastModifier: string;
createdAt: string;
updatedAt: string;
}
export interface Customer {
id: string;
email: string;
name: string;
companyName: string;
phoneNumbers: CustomerPhoneNumber[];
street: string;
zip: string;
city: string;
notes: CustomerNote[];
createdAt: string;
}
export default function CustomersPage() {
const router = useRouter();
const [customers, setCustomers] = useState<Customer[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const pageSize = 15;
useEffect(() => {
axios.get("/api/customers").then((res) => {
setCustomers(res.data);
setLoading(false);
});
}, []);
const filtered = useMemo(() => {
return customers.filter(
(c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.email.toLowerCase().includes(search.toLowerCase())
);
}, [customers, search]);
const paginated = useMemo(() => {
const start = (page - 1) * pageSize;
return filtered.slice(start, start + pageSize);
}, [filtered, page]);
const totalPages = Math.ceil(filtered.length / pageSize);
return (
<div className="p-6 space-y-6 text-sm overflow-x-auto">
<motion.div
initial={{opacity: 0}}
animate={{opacity: 1}}
transition={{duration: 0.3}}
className="grid grid-cols-1 md:grid-cols-3 gap-4"
>
<Card>
<CardContent>
<div className="text-sm text-muted-foreground">Kunden</div>
<div className="text-3xl font-bold">{customers.length}</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="text-sm text-muted-foreground">Demo-Statistik</div>
<div className="text-3xl font-bold"></div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="text-sm text-muted-foreground">Letzte Aktivität</div>
<div className="text-3xl font-bold"></div>
</CardContent>
</Card>
</motion.div>
<motion.div initial={{opacity: 0}} animate={{opacity: 1}} transition={{duration: 0.3}}>
<Card>
<CardContent className="space-y-6">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<Input placeholder="Suche" value={search} onChange={(e) => setSearch(e.target.value)}/>
<NewCustomerModal/>
</div>
{customers.length === 0 && loading ? (
<div className="text-center text-muted-foreground">Lade Kunden...</div>
) : (
<div className="overflow-x-auto">
<Table className="text-xs min-w-[700px]">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Firma</TableHead>
<TableHead>Telefon</TableHead>
<TableHead>Straße</TableHead>
<TableHead>PLZ</TableHead>
<TableHead>Ort</TableHead>
<TableHead>Erstellt am</TableHead>
<TableHead>Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginated.map((customer) => (
<TableRow key={customer.id}>
<TableCell
className="max-w-[180px] truncate">{customer.name}</TableCell>
<TableCell
className="max-w-[180px] truncate">{customer.email}</TableCell>
<TableCell
className="max-w-[180px] truncate">{customer.companyName}</TableCell>
<TableCell className="max-w-[140px] truncate">
{customer.phoneNumbers?.[0]?.number}
</TableCell>
<TableCell
className="max-w-[180px] truncate">{customer.street}</TableCell>
<TableCell>{customer.zip}</TableCell>
<TableCell>{customer.city}</TableCell>
<TableCell>{new Date(customer.createdAt).toLocaleString()}</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/customers/${customer.id}`)}
>
<ArrowRight className="w-4 h-4"/>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<Pagination className="justify-center pt-4">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setPage((p) => Math.max(1, p - 1))}
className={page === 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
<PaginationItem>
<PaginationNext
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
className={page === totalPages ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</CardContent>
</Card>
</motion.div>
</div>
);
}

View File

@@ -41,22 +41,21 @@ export default async function RootLayout({
}
>
<AppSidebar/>
<main>
<SidebarInset>
<header
className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1"/>
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<DynamicBreadcrumb/>
</div>
</header>
</SidebarInset>
<SidebarInset>
<header
className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1"/>
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<DynamicBreadcrumb/>
</div>
</header>
{children}
</main>
</SidebarInset>
</SidebarProvider>
) : (
<LoginScreen/>

View File

@@ -5,7 +5,7 @@ import {
Home,
Scale,
User2,
Settings
Settings, LayoutDashboard
} from "lucide-react";
import {
@@ -50,6 +50,14 @@ const rheinItems = [
},
];
const customerItems = [
{
title: "Kundenübersicht",
url: "/customers",
icon: LayoutDashboard,
},
];
export function AppSidebar() {
return (
<Sidebar>
@@ -77,6 +85,27 @@ export function AppSidebar() {
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Kunden</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="flex flex-col gap-y-1">
{customerItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
className="hover:bg-accent hover:text-accent-foreground"
>
<a href={item.url}>
<item.icon/>
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* Demos section */}
<SidebarGroup>
<SidebarGroupLabel>Demos</SidebarGroupLabel>

View File

@@ -0,0 +1,280 @@
"use client";
import {useState} from "react";
import {motion} from "framer-motion";
import {Trash2} from "lucide-react";
import {Button} from "@/components/ui/button";
import {Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,} from "@/components/ui/dialog";
import {Label} from "@/components/ui/label";
import {Input} from "@/components/ui/input";
import {Textarea} from "@/components/ui/textarea";
import {Progress} from "@/components/ui/progress";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {CreateCustomerDto, NoteDto, PhoneNumberDto} from "@/services/customers/dtos/createCustomer.dto";
import {addCustomer} from "@/services/customers/usecases/addCustomer";
import {validateCustomer} from "@/services/customers/usecases/validateCustomer";
export function NewCustomerModal() {
const [step, setStep] = useState(1);
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [companyName, setCompanyName] = useState("");
const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumberDto[]>([{note: "", number: ""}]);
const [notes, setNotes] = useState<NoteDto[]>([{text: ""}]);
const [street, setStreet] = useState("");
const [zip, setZip] = useState("");
const [city, setCity] = useState("");
const [matches, setMatches] = useState<CustomerMatch[]>([]);
const [showDetailModal, setShowDetailModal] = useState(false);
const [selectedCustomer] = useState<CustomerMatch | null>(null);
type CustomerMatch = {
id: string;
name: string;
email: string;
companyName: string;
street: string;
zip: string;
city: string;
};
const emailExists = matches.some(m => m.email.toLowerCase() === email.toLowerCase());
const companyExists = matches.some(m => m.companyName.toLowerCase() === companyName.toLowerCase());
const addressExists = matches.some(m =>
m.street.toLowerCase() === street.toLowerCase() &&
m.zip.toLowerCase() === zip.toLowerCase() &&
m.city.toLowerCase() === city.toLowerCase()
);
const validateField = async () => {
try {
const result = await validateCustomer({email, companyName, street, zip, city});
setMatches(result);
} catch (err) {
console.error("Validation failed", err);
}
};
const handleSubmit = async () => {
if (!email || !name || !companyName || !street || !zip || !city) return;
const payload: CreateCustomerDto = {email, name, companyName, street, zip, city, phoneNumbers, notes};
await addCustomer(payload);
location.reload();
};
const renderFormInput = (
label: string,
value: string,
onChange: (value: string) => void,
error?: boolean,
className?: string
) => (
<div className="space-y-2">
<Label>{label} *</Label>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={validateField}
className={error ? "border border-red-500" : className}
/>
</div>
);
const renderCustomerInfo = (customer: CustomerMatch) => (
<div className="space-y-1 text-sm">
<div><strong>Name:</strong> {customer.name}</div>
<div><strong>Firma:</strong> {customer.companyName}</div>
<div><strong>E-Mail:</strong> {customer.email}</div>
<div><strong>Adresse:</strong> {customer.street}, {customer.zip} {customer.city}</div>
</div>
);
const updatePhoneNumber = (i: number, key: keyof PhoneNumberDto, value: string) => {
const updated = [...phoneNumbers];
updated[i][key] = value;
setPhoneNumbers(updated);
};
const removePhoneNumber = (i: number) => {
setPhoneNumbers(phoneNumbers.filter((_, idx) => idx !== i));
};
const updateNote = (i: number, value: string) => {
const updated = [...notes];
updated[i].text = value;
setNotes(updated);
};
const renderStepOne = () => (
<div className="space-y-4">
<div className="space-y-2">
{renderFormInput("Name", name, setName)}
{renderFormInput("Firma", companyName, setCompanyName, companyExists)}
{renderFormInput("E-Mail", email, setEmail, emailExists)}
{emailExists && (
<div className="text-red-500 text-sm">Ein Kunde mit dieser E-Mail existiert bereits.</div>
)}
</div>
<div className="space-y-2">
<Label>Telefonnummern *</Label>
{phoneNumbers.map((p, i) => (
<div key={i} className="grid grid-cols-[2fr_3fr_auto] gap-2 items-center w-full">
<Input
placeholder="Nummer"
value={p.number}
onChange={(e) => updatePhoneNumber(i, "number", e.target.value)}
/>
<Input
placeholder="Notiz"
value={p.note}
onChange={(e) => updatePhoneNumber(i, "note", e.target.value)}
/>
<Button variant="ghost" size="icon" onClick={() => removePhoneNumber(i)}>
<Trash2 className="w-4 h-4"/>
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => setPhoneNumbers([...phoneNumbers, {note: "", number: ""}])}
>
+ Telefonnummer hinzufügen
</Button>
</div>
</div>
);
const renderStepTwo = () => (
<div className="space-y-4">
<div className="grid grid-cols-12 gap-4">
<div className="col-span-6">
{renderFormInput("Straße", street, setStreet, addressExists)}
</div>
<div className="col-span-3">
{renderFormInput("PLZ", zip, setZip, addressExists)}
</div>
<div className="col-span-3">
{renderFormInput("Ort", city, setCity, addressExists)}
</div>
</div>
<div className="space-y-2">
<Label>Notizen</Label>
{notes.map((note, i) => (
<div key={i} className="flex items-start gap-2">
<Textarea
placeholder={`Notiz ${i + 1}`}
value={note.text}
rows={3}
className="w-full resize-y"
onChange={(e) => updateNote(i, e.target.value)}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setNotes(notes.filter((_, idx) => idx !== i))}
>
<Trash2 className="w-4 h-4"/>
</Button>
</div>
))}
<Button
type="button"
onClick={() => setNotes([...notes, {text: ""}])}
variant="outline"
size="sm"
>
+ Notiz hinzufügen
</Button>
</div>
</div>
);
const renderDuplicationCard = () => (
<motion.div initial={{opacity: 0}} animate={{opacity: 1}} transition={{duration: 0.3}} className="mt-6">
<Card
className={emailExists ? "border-red-500 bg-red-100 dark:bg-red-900/20" : "border-yellow-500 bg-yellow-100 dark:bg-yellow-900/20"}>
<CardHeader className="text-sm font-semibold">
{matches.length} mögliche Duplikate gefunden
</CardHeader>
<CardContent>
<Button variant="outline" size="sm" className="w-full"
onClick={() => setShowDetailModal(true)}>Details</Button>
</CardContent>
</Card>
</motion.div>
);
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button onClick={() => {
setOpen(true);
setStep(1);
}}>Neue Kunde</Button>
</DialogTrigger>
<DialogContent className="w-full max-w-5xl max-h-[95vh] overflow-y-auto">
<DialogHeader><DialogTitle>Neuen Kunden anlegen</DialogTitle></DialogHeader>
<div className="mb-4">
<div className="text-xs font-semibold mb-2">Schritt {step} von 2</div>
<Progress value={step === 1 ? 50 : 100}/>
</div>
{step === 1 ? renderStepOne() : renderStepTwo()}
{matches.length > 0 && renderDuplicationCard()}
<div className="flex justify-between mt-6">
<Button variant="secondary" disabled={step === 1} onClick={() => setStep(step - 1)}>
Zurück
</Button>
{step === 1 ? (
<Button
onClick={() => setStep(2)}
disabled={!email || !name || !companyName || phoneNumbers.length === 0 || !phoneNumbers.some(p => p.number.trim()) || emailExists}
>
Weiter
</Button>
) : (
<Button onClick={handleSubmit} disabled={!street || !zip || !city}>
Anlegen
</Button>
)}
</div>
</DialogContent>
</Dialog>
<Dialog open={showDetailModal} onOpenChange={setShowDetailModal}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto space-y-4">
<DialogHeader>
<DialogTitle>Kundendetails</DialogTitle>
</DialogHeader>
{matches.length === 1 ? (
renderCustomerInfo(matches[0])
) : (
<div className="space-y-4">
{matches.map((customer) => (
<Card key={customer.id}>
<CardContent className="pt-4">
{renderCustomerInfo(customer)}
</CardContent>
</Card>
))}
{selectedCustomer && (
<motion.div
initial={{opacity: 0, y: 10}}
animate={{opacity: 1, y: 0}}
className="p-4 border rounded-md bg-muted"
>
{renderCustomerInfo(selectedCustomer)}
</motion.div>
)}
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

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

View File

@@ -0,0 +1,28 @@
// lib/api/callApi.ts
import {serverCall} from "@/lib/api/serverCall";
export async function callApi<TResponse, TRequest = unknown>(
path: string,
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
body?: TRequest
): Promise<TResponse> {
const res = await serverCall(path, method, body);
const contentType = res.headers.get("content-type") ?? "";
const isJson = contentType.includes("application/json");
const rawBody = isJson ? await res.json() : await res.text();
console.log(`[api ${path}] Response:`, res.status, rawBody);
if (!res.ok) {
const errorMessage = isJson
? (rawBody?.message ?? rawBody?.errors?.join(", ")) ?? "Unbekannter Fehler"
: String(rawBody);
console.error(`[api ${path}] Error:`, errorMessage);
throw new Error(errorMessage);
}
return rawBody as TResponse;
}

View File

@@ -0,0 +1,28 @@
// lib/callBackendApi.ts
import {getServerSession} from "next-auth";
import {authOptions} from "@/lib/auth/authOptions";
export async function serverCall(
path: string,
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
body?: unknown
): Promise<Response> {
const url = `${process.env.INTERNAL_BACKEND_URL ?? "http://localhost:8080/api"}${path}`;
const session = await getServerSession(authOptions);
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (session != null) {
headers["Authorization"] = `Bearer ${session.accessToken}`;
}
console.log("[api] Calling backend API: ", method, url, body);
return fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
}

View File

@@ -15,10 +15,10 @@ const {
NEXTAUTH_SECRET,
} = process.env;
if (!KEYCLOAK_CLIENT_ID) throw new Error("Missing KEYCLOAK_CLIENT_ID");
if (!KEYCLOAK_CLIENT_SECRET) throw new Error("Missing KEYCLOAK_CLIENT_SECRET");
if (!KEYCLOAK_ISSUER) throw new Error("Missing KEYCLOAK_ISSUER");
if (!NEXTAUTH_SECRET) throw new Error("Missing NEXTAUTH_SECRET");
// if (!KEYCLOAK_CLIENT_ID) throw new Error("Missing KEYCLOAK_CLIENT_ID");
// if (!KEYCLOAK_CLIENT_SECRET) throw new Error("Missing KEYCLOAK_CLIENT_SECRET");
// if (!KEYCLOAK_ISSUER) throw new Error("Missing KEYCLOAK_ISSUER");
// if (!NEXTAUTH_SECRET) throw new Error("Missing NEXTAUTH_SECRET");
console.log("[auth] Using Keycloak provider:");
console.log(" - Client ID:", KEYCLOAK_CLIENT_ID);
@@ -42,8 +42,8 @@ async function isTokenValid(token: string): Promise<boolean> {
export const authOptions: NextAuthOptions = {
providers: [
KeycloakProvider({
clientId: KEYCLOAK_CLIENT_ID,
clientSecret: KEYCLOAK_CLIENT_SECRET,
clientId: KEYCLOAK_CLIENT_ID as string,
clientSecret: KEYCLOAK_CLIENT_SECRET as string,
issuer: KEYCLOAK_ISSUER,
}),
],

View File

@@ -4,5 +4,6 @@ export const breadcrumbMap: Record<string, string> = {
'settings': 'Settings',
'demo': 'Demo',
'users': 'User Management',
'customers': 'Kundenübersicht',
// Add more mappings as needed
};

View File

@@ -0,0 +1,16 @@
// lib/ui/showError.ts
import {toast} from "sonner";
export function showError(error: unknown, fallback = "Ein unbekannter Fehler ist aufgetreten") {
let message: string;
if (error instanceof Error) {
message = error.message;
} else if (typeof error === "string") {
message = error;
} else {
message = fallback;
}
toast.error(message);
}

View File

@@ -11,9 +11,12 @@
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.22.0",
@@ -24,7 +27,9 @@
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.3.1"
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"zustand": "^5.0.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -1308,6 +1313,29 @@
}
}
},
"node_modules/@radix-ui/react-label": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu": {
"version": "2.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz",
@@ -1451,6 +1479,30 @@
}
}
},
"node_modules/@radix-ui/react-progress": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
"integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz",
@@ -2892,6 +2944,12 @@
"node": ">= 0.4"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -2918,6 +2976,17 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -2993,7 +3062,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3149,6 +3217,18 @@
"simple-swizzle": "^0.2.2"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -3309,6 +3389,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@@ -3342,7 +3431,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -3447,7 +3535,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3457,7 +3544,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3495,7 +3581,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -3508,7 +3593,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -4115,6 +4199,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -4131,6 +4235,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/form-data": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/framer-motion": {
"version": "12.22.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.22.0.tgz",
@@ -4162,7 +4282,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -4203,7 +4322,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -4237,7 +4355,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -4325,7 +4442,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4404,7 +4520,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4417,7 +4532,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -4433,7 +4547,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -5393,7 +5506,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5423,6 +5535,27 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -6050,6 +6183,12 @@
"react-is": "^16.13.1"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -6572,6 +6711,16 @@
"is-arrayish": "^0.3.1"
}
},
"node_modules/sonner": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.6.tgz",
"integrity": "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -7305,6 +7454,35 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zustand": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.6.tgz",
"integrity": "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@@ -12,9 +12,12 @@
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.22.0",
@@ -25,7 +28,9 @@
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.3.1"
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"zustand": "^5.0.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3",

View File

@@ -0,0 +1,19 @@
export interface PhoneNumberDto {
number: string;
note: string;
}
export interface NoteDto {
text: string;
}
export interface CreateCustomerDto {
email: string;
name: string;
companyName: string;
phoneNumbers: PhoneNumberDto[];
street: string;
zip: string;
city: string;
notes: NoteDto[];
}

View File

@@ -0,0 +1,32 @@
export interface PhoneNumber {
number: string;
note: string;
createdBy: string;
createdAt: string;
updatedBy: string;
updatedAt: string;
}
export interface Note {
text: string;
createdBy: string;
createdAt: string;
updatedBy: string;
updatedAt: string;
}
export interface Customer {
id: string;
email: string;
name: string;
companyName: string;
phoneNumbers: PhoneNumber[];
street: string;
zip: string;
city: string;
notes: Note[];
createdBy: string;
createdAt: string;
updatedBy: string;
updatedAt: string;
}

View File

@@ -0,0 +1,13 @@
import {Customer} from "@/services/customers/entities/customer";
import {CreateCustomerDto} from "@/services/customers/dtos/createCustomer.dto";
import {callApi} from "@/lib/api/callApi";
export class CustomerRepository {
static async getAll(): Promise<Customer[]> {
return await callApi<Customer[]>("/customers", "GET");
}
static async create(payload: CreateCustomerDto): Promise<void> {
await callApi<void, CreateCustomerDto>("/customers", "POST", payload);
}
}

View File

@@ -0,0 +1,21 @@
"use server";
import {CreateCustomerDto} from "@/services/customers/dtos/createCustomer.dto";
import {CustomerRepository} from "@/services/customers/repositories/customerRepository";
export async function addCustomer(params: CreateCustomerDto): Promise<void> {
const {email, name, companyName, street, zip, city, phoneNumbers, notes} = params;
const payload: CreateCustomerDto = {
email,
name,
companyName,
street,
zip,
city,
phoneNumbers,
notes: notes.map(({text}) => ({text})),
};
await CustomerRepository.create(payload);
}

View File

@@ -0,0 +1,15 @@
"use server";
import {callApi} from "@/lib/api/callApi";
import {Customer} from "@/app/customers/page";
import {customerRoutes} from "@/app/api/customers/customerRoutes";
export async function validateCustomer(input: {
email: string;
companyName: string;
street: string;
zip: string;
city: string;
}): Promise<Customer[]> {
return await callApi<Customer[]>(customerRoutes.validate, "POST", input);
}