305 lines
13 KiB
TypeScript
305 lines
13 KiB
TypeScript
"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";
|
|
import {useErrorHandler} from "@/components/error-boundary";
|
|
import {showInfoToast, showSuccessToast} from "@/lib/ui/showToast";
|
|
|
|
interface NewCustomerModalProps {
|
|
onCustomerCreated?: () => void;
|
|
}
|
|
|
|
export function NewCustomerModal({onCustomerCreated}: Readonly<NewCustomerModalProps>) {
|
|
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);
|
|
const handleError = useErrorHandler();
|
|
|
|
type CustomerMatch = {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
companyName: string;
|
|
street: string;
|
|
zip: string;
|
|
city: string;
|
|
};
|
|
|
|
const emailExists = Array.isArray(matches) && matches.some(m => m.email.toLowerCase() === email.toLowerCase());
|
|
const companyExists = Array.isArray(matches) && matches.some(m => m.companyName.toLowerCase() === companyName.toLowerCase());
|
|
const addressExists = Array.isArray(matches) && 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 || []);
|
|
showInfoToast("Datenvalidierung abgeschlossen");
|
|
} catch (err) {
|
|
setMatches([]); // Ensure matches is always an array
|
|
handleError(err);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!email || !name || !companyName || !street || !zip || !city) return;
|
|
try {
|
|
const payload: CreateCustomerDto = {email, name, companyName, street, zip, city, phoneNumbers, notes};
|
|
await addCustomer(payload);
|
|
showSuccessToast("Kunde erfolgreich erstellt");
|
|
setOpen(false);
|
|
if (onCustomerCreated) {
|
|
onCustomerCreated();
|
|
}
|
|
} catch (err) {
|
|
handleError(err);
|
|
}
|
|
};
|
|
|
|
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={() => {
|
|
// Prevent closing on backdrop click - modal can only be closed explicitly
|
|
}}>
|
|
<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" showCloseButton={false}>
|
|
<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">
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button variant="secondary" disabled={step === 1} onClick={() => setStep(step - 1)}>
|
|
Zurück
|
|
</Button>
|
|
</div>
|
|
{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>
|
|
</>
|
|
);
|
|
}
|