Files
accounted/components/customers/CustomerForm.tsx
T
Mattsson 00ae3540db feat(customers): carry contact person and invoice copy recipients through migration (#1392)
* feat(customers): carry contact person and invoice copy recipients through migration

Extends the arcim-migration entity mapper, Fortnox provider mapper, canonical
DTOs, customer APIs (web + v1) and invoice send flows so contact person and
customer-level invoice CC/BCC addresses survive provider migrations. NULL
means unconfigured and empty means an explicit clear, so re-syncs enrich
legacy gaps without resurrecting deliberately removed values. Fortnox fixed
assets are split into a dedicated follow-up issue.

Fixes #1345

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(db): bump customer metadata migration past pack-slug version

Main already contains 20260803230000; keep new versions strictly newest so
Supabase branching applies them in order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(customers): complete Customer type consumers and make enrichment payload resolvable

The preview-pdf mock customer and the makeCustomer fixture now carry the
three new metadata fields, fixing the type-check failure in Build (zero
extensions) and Vercel.

The enrichment update in the migration orchestrator now spells its payload
as an object literal typed CustomerMetadataEnrichment (absent keys drop at
serialization), so the phantom-column guard resolves the columns instead of
counting another unresolvable dynamic payload past its ceiling. The cc/bcc
guards also verify element types instead of casting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:00:03 +02:00

527 lines
19 KiB
TypeScript

'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 { 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 type { CreateCustomerInput } from '@/types'
interface CustomerFormProps {
onSubmit: (data: CreateCustomerInput) => Promise<void>
isLoading: boolean
initialData?: Partial<CreateCustomerInput>
}
export default function CustomerForm({
onSubmit,
isLoading,
initialData,
}: CustomerFormProps) {
const { canWrite } = useCanWrite()
const { toast } = useToast()
const t = useTranslations('form_customer')
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(),
country: z.string().optional(),
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(),
default_payment_terms: z.number().min(1).optional(),
notes: z.string().optional(),
}).superRefine((customer, ctx) => {
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])
type FormData = z.infer<typeof schema>
const {
register,
handleSubmit,
watch,
control,
formState: { errors },
} = useForm<FormData>({
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: initialData?.country || 'Sweden',
org_number: initialData?.org_number || '',
vat_number: initialData?.vat_number || '',
personal_number: initialData?.personal_number || '',
language: initialData?.language || 'sv',
default_payment_terms: initialData?.default_payment_terms || 30,
notes: initialData?.notes || '',
},
})
const customerType = watch('customer_type')
const vatNumber = watch('vat_number')
// 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 (
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
{/* Customer Type */}
<div className="space-y-2">
<Label>{t('type_label')}</Label>
<Controller
name="customer_type"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={(v) => { if (v) field.onChange(v) }}>
<SelectTrigger>
<SelectValue placeholder={t('type_placeholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="individual">{t('type_individual')}</SelectItem>
<SelectItem value="swedish_business">{t('type_swedish_business')}</SelectItem>
<SelectItem value="eu_business">{t('type_eu_business')}</SelectItem>
<SelectItem value="non_eu_business">{t('type_non_eu_business')}</SelectItem>
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
{t('type_hint')}
</p>
</div>
{/* Name */}
<div className="space-y-2">
<Label htmlFor="name">{t('name_label')}</Label>
<Input
id="name"
placeholder={t('name_placeholder')}
{...register('name')}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
{/* Customer number */}
<div className="space-y-2">
<Label htmlFor="customer_number">{t('customer_number_label')}</Label>
<Input
id="customer_number"
placeholder={t('customer_number_placeholder')}
{...register('customer_number')}
/>
{errors.customer_number ? (
<p className="text-sm text-destructive">{errors.customer_number.message}</p>
) : (
<p className="text-xs text-muted-foreground">{t('customer_number_hint')}</p>
)}
</div>
{/* Contact */}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="contact_person">{t('contact_person_label')}</Label>
<Input
id="contact_person"
placeholder={t('contact_person_placeholder')}
{...register('contact_person')}
/>
{errors.contact_person && (
<p className="text-sm text-destructive">{errors.contact_person.message}</p>
)}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="email">{t('email_label')}</Label>
<Input
id="email"
type="email"
placeholder={t('email_placeholder')}
{...register('email')}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">{t('phone_label')}</Label>
<Input
id="phone"
placeholder={t('phone_placeholder')}
{...register('phone')}
/>
</div>
</div>
</div>
{/* Customer-specific invoice recipients */}
<div className="space-y-4">
<h3 className="text-sm font-medium">{t('invoice_email_section')}</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="invoice_email_cc_addresses">{t('invoice_email_cc_label')}</Label>
<Textarea
id="invoice_email_cc_addresses"
rows={3}
placeholder={t('invoice_email_placeholder')}
{...register('invoice_email_cc_addresses')}
/>
{errors.invoice_email_cc_addresses && (
<p className="text-sm text-destructive">{errors.invoice_email_cc_addresses.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="invoice_email_bcc_addresses">{t('invoice_email_bcc_label')}</Label>
<Textarea
id="invoice_email_bcc_addresses"
rows={3}
placeholder={t('invoice_email_placeholder')}
{...register('invoice_email_bcc_addresses')}
/>
{errors.invoice_email_bcc_addresses && (
<p className="text-sm text-destructive">{errors.invoice_email_bcc_addresses.message}</p>
)}
</div>
</div>
<p className="text-xs text-muted-foreground">{t('invoice_email_hint')}</p>
</div>
{/* Address */}
<div className="space-y-4">
<h3 className="font-medium">{t('address_section')}</h3>
<div className="space-y-2">
<Label htmlFor="address_line1">{t('street_label')}</Label>
<Input
id="address_line1"
placeholder={t('street_placeholder')}
{...register('address_line1')}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">{t('postal_label')}</Label>
<Input
id="postal_code"
placeholder={t('postal_placeholder')}
{...register('postal_code')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="city">{t('city_label')}</Label>
<Input
id="city"
placeholder={t('city_placeholder')}
{...register('city')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="country">{t('country_label')}</Label>
<Input
id="country"
placeholder={t('country_placeholder')}
{...register('country')}
/>
</div>
</div>
</div>
{/* Identification: depends on customer type */}
{customerType === 'individual' ? (
<div className="space-y-4 pt-4 border-t">
<h3 className="font-medium">{t('individual_section')}</h3>
<div className="space-y-2">
<Label htmlFor="personal_number">{t('personal_number_label')}</Label>
<Input
id="personal_number"
placeholder={t('personal_number_placeholder')}
{...register('personal_number')}
/>
{errors.personal_number ? (
<p className="text-sm text-destructive">{errors.personal_number.message}</p>
) : personalNumberUnreadable ? (
<AttnLine>{t('personal_number_unreadable')}</AttnLine>
) : null}
</div>
</div>
) : (
<div className="space-y-4 pt-4 border-t">
<h3 className="font-medium">{t('business_section')}</h3>
<div className="space-y-2">
<Label htmlFor="org_number">{t('org_number_label')}</Label>
<Input
id="org_number"
placeholder={t('org_number_placeholder')}
{...register('org_number')}
/>
</div>
{(customerType === 'eu_business' || customerType === 'non_eu_business') && (
<div className="space-y-2">
<Label htmlFor="vat_number">{t('vat_label')}</Label>
<div className="flex gap-2">
<Input
id="vat_number"
placeholder={customerType === 'eu_business' ? t('vat_placeholder_eu') : t('vat_placeholder_se')}
{...register('vat_number')}
className="flex-1"
/>
{customerType === 'eu_business' && (
<Button
type="button"
variant="outline"
onClick={handleValidateVat}
disabled={!vatNumber || isValidatingVat}
>
{isValidatingVat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : vatValidationResult?.valid ? (
<CheckCircle className="h-4 w-4 text-success" />
) : vatValidationResult?.valid === false ? (
<XCircle className="h-4 w-4 text-destructive" />
) : (
t('vat_verify')
)}
</Button>
)}
</div>
{customerType === 'eu_business' && (
<p className="text-xs text-muted-foreground">
{t('vat_hint_eu')}
</p>
)}
</div>
)}
</div>
)}
{/* Payment terms */}
<div className="space-y-2">
<Label htmlFor="payment_terms">{t('payment_terms_label')}</Label>
<Input
id="payment_terms"
type="number"
{...register('default_payment_terms', { valueAsNumber: true })}
/>
</div>
{/* Invoice language */}
<div className="space-y-2">
<Label>{t('language_label')}</Label>
<Controller
name="language"
control={control}
render={({ field }) => (
<Select value={field.value ?? 'sv'} onValueChange={(v) => { if (v) field.onChange(v) }}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sv">{t('language_sv')}</SelectItem>
<SelectItem value="en">{t('language_en')}</SelectItem>
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">{t('language_hint')}</p>
</div>
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes">{t('notes_label')}</Label>
<Textarea
id="notes"
placeholder={t('notes_placeholder')}
{...register('notes')}
/>
</div>
{/* Submit */}
<div className="flex justify-end gap-2">
<Button
type="submit"
disabled={isLoading || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('submit_saving')}
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-4 w-4" />
{t('submit_save')}
</>
) : (
t('submit_save')
)}
</Button>
</div>
</form>
)
}