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

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