'use client' import { useMemo, useState } from 'react' import { useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { useLocale, useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { AttnLine } from '@/components/ui/attn-line' import { useToast } from '@/components/ui/use-toast' import { Loader2, CheckCircle, XCircle, Lock } from 'lucide-react' import { useCanWrite } from '@/lib/hooks/use-can-write' import { getErrorMessage } from '@/lib/errors/get-error-message' import { EMAIL_PATTERN, MAX_INVOICE_EMAIL_COPY_RECIPIENTS, parseInvoiceRecipientText, } from '@/lib/invoices/email-recipients' import { PERSONAL_NUMBER_INPUT_RE, UNDECRYPTABLE_PERSONAL_NUMBER_MASK, isMaskedPersonalNumber, } from '@/lib/customers/mask-personal-number' import { looksLikeSwedishPersonalNumber } from '@/lib/customers/personal-number-shape' import { COUNTRY_CONSISTENCY_MESSAGES, checkCountryConsistency, getCountryOptions, normalizeCountryCode, } from '@/lib/vat/country-codes' import type { CreateCustomerInput } from '@/types' interface CustomerFormProps { onSubmit: (data: CreateCustomerInput) => Promise isLoading: boolean initialData?: Partial } export default function CustomerForm({ onSubmit, isLoading, initialData, }: CustomerFormProps) { const { canWrite } = useCanWrite() const { toast } = useToast() const t = useTranslations('form_customer') const locale = useLocale() === 'en' ? 'en' : 'sv' const countryOptions = useMemo(() => getCountryOptions(locale), [locale]) const [isValidatingVat, setIsValidatingVat] = useState(false) const [vatValidationResult, setVatValidationResult] = useState<{ valid: boolean name?: string } | null>(null) const schema = useMemo(() => z.object({ name: z.string().min(1, t('name_required')), customer_type: z.enum(['individual', 'swedish_business', 'eu_business', 'non_eu_business']), customer_number: z.string().trim().max(32, t('customer_number_too_long')).optional(), contact_person: z.string().max(200, t('contact_person_too_long')).optional(), email: z.string().email(t('email_invalid')).optional().or(z.literal('')), phone: z.string().optional(), invoice_email_cc_addresses: z.string().optional(), invoice_email_bcc_addresses: z.string().optional(), address_line1: z.string().optional(), address_line2: z.string().optional(), postal_code: z.string().optional(), city: z.string().optional(), // ISO 3166-1 alpha-2. A row from before 2026-09 can still carry a name // the backfill could not map; it is shown as-is in the picker and has to // be replaced before the form saves. country: z.string().refine((v) => normalizeCountryCode(v) !== null, t('country_invalid')), org_number: z.string().optional(), vat_number: z.string().optional(), // Accepts a plaintext personnummer or either mask the API returns. The // '********-????' placeholder has to pass: it is what a row whose stored // value cannot be decrypted renders as, and rejecting it here blocked the // whole edit dialog, so the customer's name and address became unsavable // over a field the user could not fix. personal_number: z .string() .regex(PERSONAL_NUMBER_INPUT_RE, t('personal_number_invalid')) .optional() .or(z.literal('')), language: z.enum(['sv', 'en']).optional(), // 0 is a real value (betalning direkt). The old min(1) made the form // unsavable on "0" with no message at all (issue #2070); now the rule is // whole days 0-365 and the field says so when it does not hold. default_payment_terms: z .number({ message: t('payment_terms_invalid') }) .int(t('payment_terms_invalid')) .min(0, t('payment_terms_invalid')) .max(365, t('payment_terms_invalid')), notes: z.string().optional(), }).superRefine((customer, ctx) => { // Country vs customer type vs VAT prefix (#2025): an EU customer with // land Sverige got reverse charge and nothing objected until the // periodisk sammanställning, after the invoice was sent. The API refuses // the same combinations with a 400; saying it here keeps the fix one // click away instead of one failed save away. const countryIssue = checkCountryConsistency({ partyType: customer.customer_type, country: customer.country, vatNumber: customer.vat_number, }) if (countryIssue) { ctx.addIssue({ code: 'custom', path: ['country'], message: COUNTRY_CONSISTENCY_MESSAGES[countryIssue][locale], }) } // A personnummer entered as a business org number would be shown // unmasked in every list (only individual customers are masked). if ( customer.org_number && customer.customer_type !== 'individual' && looksLikeSwedishPersonalNumber(customer.org_number) ) { ctx.addIssue({ code: 'custom', path: ['org_number'], message: t('org_number_looks_personal'), }) } const cc = parseInvoiceRecipientText(customer.invoice_email_cc_addresses ?? '') const bcc = parseInvoiceRecipientText(customer.invoice_email_bcc_addresses ?? '') for (const [field, addresses] of [ ['invoice_email_cc_addresses', cc], ['invoice_email_bcc_addresses', bcc], ] as const) { const invalid = addresses.find((address) => !EMAIL_PATTERN.test(address)) if (invalid) { ctx.addIssue({ code: 'custom', path: [field], message: t('invoice_email_invalid', { address: invalid }), }) } } if (cc.length + bcc.length > MAX_INVOICE_EMAIL_COPY_RECIPIENTS) { ctx.addIssue({ code: 'custom', path: ['invoice_email_cc_addresses'], message: t('invoice_email_too_many', { count: MAX_INVOICE_EMAIL_COPY_RECIPIENTS }), }) } }), [t, locale]) type FormData = z.infer const { register, handleSubmit, watch, control, formState: { errors }, } = useForm({ resolver: zodResolver(schema), defaultValues: { name: initialData?.name || '', customer_type: initialData?.customer_type || 'swedish_business', customer_number: initialData?.customer_number || '', contact_person: initialData?.contact_person ?? '', email: initialData?.email || '', phone: initialData?.phone || '', invoice_email_cc_addresses: initialData?.invoice_email_cc_addresses?.join('\n') ?? '', invoice_email_bcc_addresses: initialData?.invoice_email_bcc_addresses?.join('\n') ?? '', address_line1: initialData?.address_line1 || '', postal_code: initialData?.postal_code || '', city: initialData?.city || '', country: normalizeCountryCode(initialData?.country) ?? initialData?.country ?? 'SE', org_number: initialData?.org_number || '', vat_number: initialData?.vat_number || '', personal_number: initialData?.personal_number || '', language: initialData?.language || 'sv', // ?? not ||: a stored 0 (betalning direkt) must not reopen as 30. default_payment_terms: initialData?.default_payment_terms ?? 30, notes: initialData?.notes || '', }, }) const customerType = watch('customer_type') const vatNumber = watch('vat_number') const countryValue = watch('country') // A stored value the picker does not list (an unmapped legacy name, or a // code outside the curated list) still has to be visible, or the field // would look empty while holding something. const countryValueUnlisted = countryValue && !countryOptions.some((option) => option.code === countryValue) // The stored value could not be decrypted. The field is editable (typing a // fresh personnummer replaces it); say so, because the placeholder on its own // reads like a rendering fault. const personalNumberUnreadable = watch('personal_number') === UNDECRYPTABLE_PERSONAL_NUMBER_MASK const handleValidateVat = async () => { if (!vatNumber) return setIsValidatingVat(true) setVatValidationResult(null) try { const response = await fetch('/api/vat/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ vat_number: vatNumber }), }) const result = await response.json() if (!response.ok) { // Map the parsed body plus the status: on this path `result.error` is // the canonical envelope OBJECT (the route is withRouteContext), and // rendering it as a toast description would crash the React render. toast({ title: t('vat_failed_title'), description: getErrorMessage(result, { statusCode: response.status }), variant: 'destructive', }) return } setVatValidationResult({ valid: result.valid, name: result.name, }) if (result.valid && result.name) { toast({ title: t('vat_verified_title'), description: t('vat_verified_description', { name: result.name }), }) } else if (!result.valid) { toast({ title: t('vat_failed_title'), description: result.error || t('vat_failed_default'), variant: 'destructive', }) } } catch { toast({ title: t('vat_error_title'), variant: 'destructive', }) } finally { setIsValidatingVat(false) } } const onFormSubmit = (data: FormData) => { const { invoice_email_cc_addresses: ccText, invoice_email_bcc_addresses: bccText, ...customerData } = data const isEditing = initialData !== undefined const payload: CreateCustomerInput = { ...customerData, // NULL means never configured and lets a migration enrich the row. // Empty values on an existing row are explicit clears and survive sync. contact_person: data.contact_person?.trim() || (isEditing ? '' : null), email: data.email || undefined, personal_number: data.personal_number || null, invoice_email_cc_addresses: ccText ? parseInvoiceRecipientText(ccText) : isEditing ? [] : null, invoice_email_bcc_addresses: bccText ? parseInvoiceRecipientText(bccText) : isEditing ? [] : null, } // A mask means "unchanged", whichever form it is. Sending it would be // harmless (the route ignores masks too) but omitting it keeps the intent // legible in the request body. if (isMaskedPersonalNumber(data.personal_number)) { delete payload.personal_number } onSubmit(payload) } return (
{/* Customer Type */}
( )} />

{t('type_hint')}

{/* Name */}
{errors.name && (

{errors.name.message}

)}
{/* Customer number */}
{errors.customer_number ? (

{errors.customer_number.message}

) : (

{t('customer_number_hint')}

)}
{/* Contact */}
{errors.contact_person && (

{errors.contact_person.message}

)}
{errors.email && (

{errors.email.message}

)}
{/* Customer-specific invoice recipients */}

{t('invoice_email_section')}