'use client' import { useMemo } from 'react' import { useAccounts } from '@/lib/reference-data/hooks' 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 { Loader2, Lock, X } from 'lucide-react' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import { useCanWrite } from '@/lib/hooks/use-can-write' import { getCountryOptions, normalizeCountryCode } from '@/lib/vat/country-codes' import { formatOrgNumberDisplay } from '@/lib/invariants/org-number' import { registryFormFill, type RegistryFormField } from '@/lib/parties/registry-form-fill' import { useRegistryAutofill } from '@/components/parties/use-registry-autofill' import { RegistryAutofillNote } from '@/components/parties/RegistryAutofillNote' import type { CreateSupplierInput } from '@/types' interface SupplierFormProps { onSubmit: (data: CreateSupplierInput) => Promise isLoading: boolean initialData?: Partial } export default function SupplierForm({ onSubmit, isLoading, initialData, }: SupplierFormProps) { const { canWrite } = useCanWrite() const t = useTranslations('form_supplier') const locale = useLocale() === 'en' ? 'en' : 'sv' const countryOptions = useMemo(() => getCountryOptions(locale), [locale]) // Chart of accounts from the session cache (lib/reference-data): the // konto combobox is populated on the first paint; without the chart it // still accepts a typed 4-digit number. const { accounts } = useAccounts() // The default account seeds expense lines on supplier invoices, so the // browsable list is cost classes 4-7. Any other 4-digit number can still be // typed in; the API only enforces the format. const expenseAccounts = useMemo( () => accounts.filter((a) => a.account_class >= 4 && a.account_class <= 7), [accounts] ) const accountNameByNumber = useMemo( () => new Map(accounts.map((a) => [a.account_number, a.account_name])), [accounts] ) const schema = useMemo(() => z.object({ name: z.string().min(1, t('name_required')), supplier_type: z.enum(['swedish_business', 'eu_business', 'non_eu_business']), email: z.string().email(t('email_invalid')).optional().or(z.literal('')), phone: 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; an unmapped legacy name is shown as-is 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(), bankgiro: z.string().optional(), plusgiro: z.string().optional(), iban: z.string().optional(), bic: z.string().optional(), clearing_number: z.string().optional(), account_number: z.string().optional(), default_expense_account: z.string().optional(), // Whole days 0-365; 0 = betalning direkt (issue #2070, same as CustomerForm). default_payment_terms: z .number({ message: t('default_payment_terms_invalid') }) .int(t('default_payment_terms_invalid')) .min(0, t('default_payment_terms_invalid')) .max(365, t('default_payment_terms_invalid')), default_currency: z.string().optional(), notes: z.string().optional(), }), [t]) type FormData = z.infer const { register, handleSubmit, control, watch, getValues, setValue, formState: { errors }, } = useForm({ resolver: zodResolver(schema), defaultValues: { name: initialData?.name || '', supplier_type: initialData?.supplier_type || 'swedish_business', email: initialData?.email || '', phone: initialData?.phone || '', address_line1: initialData?.address_line1 || '', address_line2: initialData?.address_line2 || '', postal_code: initialData?.postal_code || '', city: initialData?.city || '', country: normalizeCountryCode(initialData?.country) ?? initialData?.country ?? 'SE', // Stored as 10 digits, shown as XXXXXX-XXXX (the placeholder's shape); // the API canonicalises what the user submits. org_number: formatOrgNumberDisplay(initialData?.org_number), vat_number: initialData?.vat_number || '', bankgiro: initialData?.bankgiro || '', plusgiro: initialData?.plusgiro || '', iban: initialData?.iban || '', bic: initialData?.bic || '', clearing_number: initialData?.clearing_number || '', account_number: initialData?.account_number || '', default_expense_account: initialData?.default_expense_account || '', // ?? not ||: a stored 0 (betalning direkt) must not reopen as 30. default_payment_terms: initialData?.default_payment_terms ?? 30, default_currency: initialData?.default_currency || 'SEK', notes: initialData?.notes || '', }, }) const supplierType = watch('supplier_type') const orgNumber = watch('org_number') // A complete org number of a Swedish company is looked up in SCB's // register once, and the fields it knows are filled where nothing has // been typed (issue #2218). Quiet when the environment has no SCB // credentials. The VAT number is among the fields here: the form shows it. const autofill = useRegistryAutofill({ orgNumber, enabled: canWrite && supplierType === 'swedish_business', initialOrgNumber: initialData?.org_number, apply: (now, before) => { const v = getValues() const patch = registryFormFill( { name: v.name ?? '', email: v.email ?? '', phone: v.phone ?? '', address_line1: v.address_line1 ?? '', address_line2: v.address_line2 ?? '', postal_code: v.postal_code ?? '', city: v.city ?? '', vat_number: v.vat_number ?? '', }, now, before, ) for (const [field, value] of Object.entries(patch)) { setValue(field as RegistryFormField, value ?? '', { shouldDirty: true, shouldValidate: true }) } return Object.keys(patch) }, }) 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) // Empty strings go through as-is: the API schemas normalize them (dropped on // create, null on update so a cleared field actually clears the column). const onFormSubmit = (data: FormData) => { onSubmit(data) } return (
{/* Supplier Type */}
( )} />
{/* Identification first: on a Swedish company's org number the register fills the rest */}
{/* Name */}
{errors.name && (

{errors.name.message}

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

{errors.email.message}

)}
{/* Address */}

{t('address_section')}

( )} /> {errors.country && (

{errors.country.message}

)}
{/* Payment details */}

{t('payment_section')}

{/* Defaults */}
(
{field.value ? ( ) : null}
)} />

{t('default_payment_terms_help')}

{errors.default_payment_terms && (

{errors.default_payment_terms.message}

)}
( )} />
{/* Notes */}