see comments

This commit is contained in:
Rajpreet0 2026-09-28 16:15:01 +02:00
parent a9fdcf9fe5
commit 6dd4587818
9 changed files with 253 additions and 57 deletions

View file

@ -5,36 +5,79 @@ import { revalidatePath } from "next/cache";
import { createAdminClient } from "@/lib/supabase/admin";
import { verifySession } from "@/lib/supabase/dal";
const StammdatenSchema = z.object({
profileId: z.string().uuid(),
const IBAN_REGEX = /^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/;
companyName: z.string().min(1, { error: "Firmenname ist erforderlich." }),
legalForm: z.string().min(1, { error: "Rechtsform ist erforderlich." }),
industry: z.string().optional(),
vatId: z.string().optional(),
taxNumber: z.string().optional(),
commercialRegisterNumber: z.string().optional(),
const StammdatenSchema = z
.object({
profileId: z.string().uuid(),
street: z.string().min(1, { error: "Straße ist erforderlich." }),
postalCode: z.string().min(1, { error: "PLZ ist erforderlich." }),
city: z.string().min(1, { error: "Stadt ist erforderlich." }),
country: z.string().length(2).default("DE"),
companyName: z.string().min(1, { error: "Firmenname ist erforderlich." }),
legalForm: z.string().min(1, { error: "Rechtsform ist erforderlich." }),
industry: z.string().optional(),
vatId: z.string().optional(),
taxNumber: z.string().optional(),
commercialRegisterNumber: z.string().optional(),
billingStreet: z.string().optional(),
billingPostalCode: z.string().optional(),
billingCity: z.string().optional(),
billingCountry: z.string().optional(),
street: z.string().min(1, { error: "Straße ist erforderlich." }),
postalCode: z.string().min(1, { error: "PLZ ist erforderlich." }),
city: z.string().min(1, { error: "Stadt ist erforderlich." }),
country: z.string().length(2).default("DE"),
contactName: z.string().min(1, { error: "Ansprechpartner ist erforderlich." }),
contactPosition: z.string().optional(),
contactEmail: z.email({ error: "Bitte eine gültige Email eingeben." }),
contactPhone: z.string().optional(),
billingStreet: z.string().optional(),
billingPostalCode: z.string().optional(),
billingCity: z.string().optional(),
billingCountry: z.string().optional(),
paymentMethod: z.enum(["invoice", "sepa_debit", "card"]).default("invoice"),
accountHolder: z.string().optional(),
iban: z.string().optional(),
bic: z.string().optional(),
});
contactName: z.string().min(1, { error: "Ansprechpartner ist erforderlich." }),
contactPosition: z.string().optional(),
contactEmail: z.email({ error: "Bitte eine gültige Email eingeben." }),
contactPhone: z.string().optional(),
paymentMethod: z.enum(["invoice", "sepa_debit", "card"]).default("invoice"),
accountHolder: z.string().optional(),
iban: z.string().optional(),
bic: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.paymentMethod === "sepa_debit") {
if (!data.accountHolder) {
ctx.addIssue({
code: "custom",
path: ["accountHolder"],
message: "Kontoinhaber ist bei SEPA-Lastschrift erforderlich.",
});
}
if (!data.iban) {
ctx.addIssue({
code: "custom",
path: ["iban"],
message: "IBAN ist bei SEPA-Lastschrift erforderlich.",
});
} else if (!IBAN_REGEX.test(data.iban)) {
ctx.addIssue({
code: "custom",
path: ["iban"],
message: "Bitte eine gültige IBAN eingeben.",
});
}
}
const billingFields = [
data.billingStreet,
data.billingPostalCode,
data.billingCity,
data.billingCountry,
];
const filledCount = billingFields.filter(Boolean).length;
if (filledCount > 0 && filledCount < billingFields.length) {
ctx.addIssue({
code: "custom",
path: ["billingStreet"],
message: "Bitte alle Felder der Rechnungsadresse ausfüllen oder alle leer lassen.",
});
}
});
export type StammdatenState = { error: string } | { success: true } | undefined;
@ -141,22 +184,31 @@ export async function saveStammdaten(
const { error } = await supabase.from("clients").update(clientPayload).eq("id", clientId);
if (error) return { error: "Stammdaten konnten nicht gespeichert werden." };
} else {
const { data: newClient, error } = await supabase
.from("clients")
.insert(clientPayload)
.select("id")
.single();
const { data: newClientId, error } = await supabase.rpc("create_client_and_link_profile", {
p_profile_id: data.profileId,
p_company_name: clientPayload.company_name,
p_legal_form: clientPayload.legal_form,
p_industry: clientPayload.industry,
p_vat_id: clientPayload.vat_id,
p_tax_number: clientPayload.tax_number,
p_commercial_register_number: clientPayload.commercial_register_number,
p_street: clientPayload.street,
p_postal_code: clientPayload.postal_code,
p_city: clientPayload.city,
p_country: clientPayload.country,
p_billing_street: clientPayload.billing_street,
p_billing_postal_code: clientPayload.billing_postal_code,
p_billing_city: clientPayload.billing_city,
p_billing_country: clientPayload.billing_country,
p_contact_name: clientPayload.contact_name,
p_contact_position: clientPayload.contact_position,
p_contact_email: clientPayload.contact_email,
p_contact_phone: clientPayload.contact_phone,
});
if (error || !newClient) return { error: "Stammdaten konnten nicht gespeichert werden." };
if (error || !newClientId) return { error: "Stammdaten konnten nicht gespeichert werden." };
clientId = newClient.id;
const { error: linkError } = await supabase
.from("profiles")
.update({ client_id: clientId })
.eq("id", data.profileId);
if (linkError) return { error: "Verknüpfung mit Profil fehlgeschlagen." };
clientId = newClientId;
}
const { error: paymentError } = await supabase.from("client_payment_details").upsert({

View file

@ -16,10 +16,14 @@ export async function getStammdaten(profileId: string) {
return { client: null, paymentDetails: null }
}
const [{ data: client }, { data: paymentDetails }] = await Promise.all([
supabase.from("clients").select("*").eq("id", profile.client_id).single(),
supabase.from("client_payment_details").select("*").eq("client_id", profile.client_id).single(),
const [clientRes, paymentRes] = await Promise.all([
supabase.from("clients").select("*").eq("id", profile.client_id).maybeSingle(),
supabase.from("client_payment_details").select("*").eq("client_id", profile.client_id).maybeSingle(),
])
return { client: client ?? null, paymentDetails: paymentDetails ?? null }
if (clientRes.error || paymentRes.error) {
throw new Error("Stammdaten konnten nicht geladen werden.")
}
return { client: clientRes.data, paymentDetails: paymentRes.data }
}

View file

@ -18,6 +18,12 @@ export function StammdatenPanel({
paymentDetails: PaymentDetails;
}) {
const [isEditing, setIsEditing] = useState(false);
const [resetCount, setResetCount] = useState(0);
function handleDone() {
setIsEditing(false);
setResetCount((count) => count + 1);
}
return (
<div className="flex flex-col gap-8 w-full">
@ -30,12 +36,12 @@ export function StammdatenPanel({
)}
</div>
<StammdatenForm
key={JSON.stringify({ client, paymentDetails })}
key={`${resetCount}-${JSON.stringify({ client, paymentDetails })}`}
profileId={profileId}
client={client}
paymentDetails={paymentDetails}
isEditing={isEditing}
onDone={() => setIsEditing(false)}
onDone={handleDone}
/>
</div>
);

View file

@ -10,18 +10,28 @@ import { Button } from "@/components/ui/button";
export function TermsDialog() {
const [open, setOpen] = useState(true);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
async function handleAccept() {
setPending(true);
const result = await acceptTerms();
setError(null);
if (result && "success" in result) {
setOpen(false);
router.refresh();
try {
const result = await acceptTerms();
if (result && "success" in result) {
setOpen(false);
router.refresh();
} else {
setError(result?.error ?? "Zustimmung konnte nicht gespeichert werden.");
}
} catch {
setError("Zustimmung konnte nicht gespeichert werden. Bitte erneut versuchen.");
} finally {
setPending(false);
}
setPending(false);
}
}
return (
<Dialog open={open} onOpenChange={() => {}}>
@ -29,9 +39,14 @@ export function TermsDialog() {
<DialogHeader>
<DialogTitle>Nutzungsbedingungen</DialogTitle>
<DialogDescription>
Bitte akzeptiere unsere Nutzungsbedingungen, um fortzufahren.
Bitte lies unsere{" "}
<a href="/terms" target="_blank" rel="noopener noreferrer">
Nutzungsbedingungen
</a>{" "}
und akzeptiere sie, um fortzufahren.
</DialogDescription>
</DialogHeader>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button onClick={handleAccept} disabled={pending}>
{pending ? "..." : "Akzeptieren"}