264 lines
9.1 KiB
TypeScript
264 lines
9.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useTranslations } from "next-intl";
|
|
import { Card } from "@/components/ui/card";
|
|
import type { OrgBilling } from "@/types";
|
|
|
|
interface Props {
|
|
initial: OrgBilling | null;
|
|
/**
|
|
* Personal-account (individual customer) flag from the session.
|
|
* Individuals get a "Full name" field instead of "Company name",
|
|
* and the VAT input is hidden entirely — they don't have one and
|
|
* showing the field would only confuse. The underlying column is
|
|
* still `company_name` in the DB and the invoice PDF; for an
|
|
* individual that field carries their full name, which is
|
|
* exactly what should print on the invoice.
|
|
*/
|
|
isPersonal: boolean;
|
|
}
|
|
|
|
/**
|
|
* Customer billing settings form. Drives PUT /api/settings/billing
|
|
* which upserts org_billing for the caller's org.
|
|
*
|
|
* Validation is the same regex as the server-side zod schema for
|
|
* the country field (ISO 3166-1 alpha-2). Other fields are checked
|
|
* for required + max-length client-side; the server is the
|
|
* authority and re-validates everything.
|
|
*
|
|
* On success we router.refresh() the page so the server component
|
|
* re-fetches and any "create now" -> "edit" wording flips.
|
|
*/
|
|
export function BillingSettingsForm({ initial, isPersonal }: Props) {
|
|
const t = useTranslations("settingsBilling");
|
|
const router = useRouter();
|
|
const [form, setForm] = useState({
|
|
companyName: initial?.companyName ?? "",
|
|
contactName: initial?.contactName ?? "",
|
|
streetAddress: initial?.streetAddress ?? "",
|
|
postalCode: initial?.postalCode ?? "",
|
|
city: initial?.city ?? "",
|
|
country: initial?.country ?? "CH",
|
|
vatNumber: initial?.vatNumber ?? "",
|
|
billingEmail: initial?.billingEmail ?? "",
|
|
notes: initial?.notes ?? "",
|
|
});
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [savedFlash, setSavedFlash] = useState(false);
|
|
|
|
const set =
|
|
(field: keyof typeof form) =>
|
|
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
|
setForm((f) => ({ ...f, [field]: e.target.value }));
|
|
|
|
const submit = async () => {
|
|
setError(null);
|
|
setSavedFlash(false);
|
|
// Client-side gate on required fields — the server re-validates.
|
|
if (
|
|
!form.companyName.trim() ||
|
|
!form.streetAddress.trim() ||
|
|
!form.postalCode.trim() ||
|
|
!form.city.trim() ||
|
|
!form.country.trim() ||
|
|
!form.billingEmail.trim()
|
|
) {
|
|
setError(t("missingRequired"));
|
|
return;
|
|
}
|
|
if (!/^[A-Za-z]{2}$/.test(form.country.trim())) {
|
|
setError(t("invalidCountry"));
|
|
return;
|
|
}
|
|
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.billingEmail.trim())) {
|
|
setError(t("invalidEmail"));
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
try {
|
|
const res = await fetch("/api/settings/billing", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
companyName: form.companyName.trim(),
|
|
// Personal accounts don't have a contact-name field
|
|
// (companyName IS their name); force null so stale state
|
|
// from a previously-org-flagged account can't carry over.
|
|
contactName: isPersonal ? null : form.contactName.trim() || null,
|
|
streetAddress: form.streetAddress.trim(),
|
|
postalCode: form.postalCode.trim(),
|
|
city: form.city.trim(),
|
|
country: form.country.trim().toUpperCase(),
|
|
// Personal accounts never have a VAT number — force null
|
|
// regardless of stale state, in case a value was stored
|
|
// before the account got flagged as personal.
|
|
vatNumber: isPersonal ? null : form.vatNumber.trim() || null,
|
|
billingEmail: form.billingEmail.trim(),
|
|
notes: form.notes.trim() || null,
|
|
}),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
}
|
|
setSavedFlash(true);
|
|
router.refresh();
|
|
} catch (e: any) {
|
|
setError(e?.message ?? String(e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<div className="space-y-4">
|
|
<Field
|
|
label={isPersonal ? t("fullNameLabel") : t("companyNameLabel")}
|
|
required
|
|
>
|
|
<input
|
|
type="text"
|
|
value={form.companyName}
|
|
onChange={set("companyName")}
|
|
maxLength={200}
|
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
|
/>
|
|
</Field>
|
|
{!isPersonal && (
|
|
<Field label={t("contactNameLabel")} hint={t("contactNameHint")}>
|
|
<input
|
|
type="text"
|
|
value={form.contactName}
|
|
onChange={set("contactName")}
|
|
maxLength={200}
|
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
|
/>
|
|
</Field>
|
|
)}
|
|
<Field label={t("streetAddressLabel")} required>
|
|
<input
|
|
type="text"
|
|
value={form.streetAddress}
|
|
onChange={set("streetAddress")}
|
|
maxLength={200}
|
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
|
/>
|
|
</Field>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<Field label={t("postalCodeLabel")} required>
|
|
<input
|
|
type="text"
|
|
value={form.postalCode}
|
|
onChange={set("postalCode")}
|
|
maxLength={20}
|
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
|
/>
|
|
</Field>
|
|
<Field label={t("cityLabel")} required>
|
|
<input
|
|
type="text"
|
|
value={form.city}
|
|
onChange={set("city")}
|
|
maxLength={100}
|
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label={t("countryLabel")}
|
|
required
|
|
hint={t("countryHint")}
|
|
>
|
|
<input
|
|
type="text"
|
|
value={form.country}
|
|
onChange={(e) =>
|
|
setForm((f) => ({
|
|
...f,
|
|
country: e.target.value.toUpperCase().slice(0, 2),
|
|
}))
|
|
}
|
|
maxLength={2}
|
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm uppercase font-mono"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
{!isPersonal && (
|
|
<Field label={t("vatNumberLabel")} hint={t("vatNumberHint")}>
|
|
<input
|
|
type="text"
|
|
value={form.vatNumber}
|
|
onChange={set("vatNumber")}
|
|
maxLength={40}
|
|
placeholder="CHE-123.456.789 MWST"
|
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm font-mono"
|
|
/>
|
|
</Field>
|
|
)}
|
|
<Field label={t("billingEmailLabel")} required hint={t("billingEmailHint")}>
|
|
<input
|
|
type="email"
|
|
value={form.billingEmail}
|
|
onChange={set("billingEmail")}
|
|
maxLength={200}
|
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
|
/>
|
|
</Field>
|
|
<Field label={t("notesLabel")} hint={t("notesHint")}>
|
|
<textarea
|
|
value={form.notes}
|
|
onChange={set("notes")}
|
|
maxLength={2000}
|
|
rows={3}
|
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
|
/>
|
|
</Field>
|
|
{error && (
|
|
<p className="text-sm text-error">{error}</p>
|
|
)}
|
|
{savedFlash && (
|
|
<p className="text-sm text-success">{t("saved")}</p>
|
|
)}
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={submit}
|
|
disabled={busy}
|
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
|
>
|
|
{busy ? t("saving") : initial ? t("saveChanges") : t("createBilling")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function Field({
|
|
label,
|
|
required,
|
|
hint,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
required?: boolean;
|
|
hint?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
|
{label}
|
|
{required && <span className="text-error ml-1">*</span>}
|
|
</label>
|
|
{children}
|
|
{hint && (
|
|
<p className="text-xs text-text-muted mt-1 italic">{hint}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|