Add customer management
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user