'use client' import { useEffect, useMemo, useState } from 'react' import { useAccounts, useCompanySettings } from '@/lib/reference-data/hooks' import { invalidateReferenceData } from '@/lib/reference-data/invalidate' 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 { SettingsGroup, SettingsInput, SettingsRow, SettingsSeg, SettingsSelect, SettingsTextarea, } from '@/components/settings/SettingsRows' import { ChevronDown, Loader2, Lock } from 'lucide-react' import { cn, formatCurrency } from '@/lib/utils' import { useCanWrite } from '@/lib/hooks/use-can-write' import { createClient } from '@/lib/supabase/client' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog' import type { CreateArticleInput } from '@/types' import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account' import { ROT_WORK_TYPES, RUT_WORK_TYPES, normalizeHouseworkType, } from '@/lib/invoices/rot-rut-rules' // A row from the currencies reference table (lib migration // 20260630110000_currencies_reference_table.sql). interface CurrencyOption { code: string name: string } // Unit list mirrors the invoice line editor (app/(dashboard)/invoices/new/page.tsx). const UNITS = ['st', 'tim', 'dag', 'månad', 'km', 'kg'] as const // Legal Swedish VAT rates as integer percent. Matches vatRatePercent in // lib/api/schemas.ts (25 | 12 | 6 | 0). const VAT_RATES = [25, 12, 6, 0] as const /** Money is rounded to öre with arithmetic, never toFixed (CLAUDE.md rule 6). */ function round2(value: number): number { return Math.round(value * 100) / 100 } // SettingsInput is a bare input, so it does not carry the wheel guard that // components/ui/input.tsx applies to type="number". Without it, scrolling the // page with the cursor over a focused price field silently edits the amount. function blurOnWheel(e: React.WheelEvent) { e.currentTarget.blur() } interface ArticleFormProps { onSubmit: (data: CreateArticleInput) => Promise isLoading: boolean initialData?: Partial /** Closes the host dialog. When omitted the cancel button is not rendered. */ onCancel?: () => void } export default function ArticleForm({ onSubmit, isLoading, initialData, onCancel, }: ArticleFormProps) { const { canWrite } = useCanWrite() const supabase = createClient() const t = useTranslations('form_article') const tCommon = useTranslations('common') // Active class 1-3 posting accounts for the combobox. The combobox accepts // unknown 4-digit numbers optimistically: the API answers with // ACCOUNTS_NOT_IN_CHART for activatable BAS accounts, and the host page's // ActivateAccountsDialog flow takes over (same UX as the journal entry form). // Balance-sheet and revenue accounts for the posting override, from the // session cache (lib/reference-data): populated on the first paint. const { accounts: activeAccounts } = useAccounts() const postingAccounts = useMemo( () => activeAccounts.filter((account) => account.account_class >= 1 && account.account_class <= 3), [activeAccounts], ) // Inline account creation: what the user typed in the combobox when they hit // "Skapa konto": non-null opens AddAccountDialog prefilled with it. const [createAccountPrefill, setCreateAccountPrefill] = useState(null) // Momsregistrerad? A non-VAT-registered company never charges moms, so the // VAT field is hidden and the rate forced to 0: mirrors the invoice editor. // Icke momsregistrerad verksamhet: VAT controls hidden and lines at 0 %. // Derived from the session-cached settings row (lib/reference-data); a // missing column keeps the registered-company behaviour, as before. const { settings: companySettings } = useCompanySettings() const vatRegistered = typeof companySettings?.vat_registered === 'boolean' ? companySettings.vat_registered : true // Supported currencies, fetched from the currencies reference table rather // than hard-coded. Falls back to the article's own currency (or SEK) if the // fetch fails so the Select is never empty. const [currencies, setCurrencies] = useState([]) // Currency options come from the currencies reference table: one source of // truth, no hard-coded list. useEffect(() => { let cancelled = false supabase .from('currencies') .select('code, name') .eq('active', true) .order('sort_order', { ascending: true }) .then(({ data }) => { if (!cancelled && data) setCurrencies(data as CurrencyOption[]) }) return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Open "Fler fält" by default when it already holds data, so an edit never // hides a value the user previously set. Currency and posting account are no // longer in here: both are permanent rows. const [moreOpen, setMoreOpen] = useState( Boolean( initialData?.article_number || initialData?.name_en || initialData?.cost_price != null || initialData?.ean || initialData?.housework_type || initialData?.notes, ), ) // UI-local schema mirroring CreateArticleSchema (lib/api/schemas.ts). const schema = useMemo( () => z.object({ article_number: z.string().trim().max(64, t('number_too_long')).optional(), name: z.string().min(1, t('name_required')), name_en: z.string().optional(), type: z.enum(['vara', 'tjanst']), unit: z.string().min(1), price_excl_vat: z.number({ message: t('price_required') }).nonnegative(t('price_required')), vat_rate: z.union([z.literal(25), z.literal(12), z.literal(6), z.literal(0)]), // ISO 4217 alpha-3; the authoritative allow-list is the currencies // table (the DB FK rejects unknown codes). currency: z.string().regex(/^[A-Z]{3}$/), revenue_account: z .string() .regex(INVOICE_POSTING_ACCOUNT_REGEX, t('posting_account_invalid')) .or(z.literal('')) .optional(), cost_price: z.number().nonnegative().optional(), ean: z.string().optional(), housework_type: z.string().optional(), notes: z.string().optional(), }), [t], ) type FormData = z.infer const { register, handleSubmit, watch, control, setValue, formState: { errors }, } = useForm({ resolver: zodResolver(schema), defaultValues: { article_number: initialData?.article_number || '', name: initialData?.name || '', name_en: initialData?.name_en || '', type: initialData?.type || 'tjanst', unit: initialData?.unit || 'st', price_excl_vat: initialData?.price_excl_vat ?? 0, vat_rate: (initialData?.vat_rate as FormData['vat_rate']) ?? 25, currency: initialData?.currency ?? 'SEK', revenue_account: initialData?.revenue_account || '', cost_price: initialData?.cost_price ?? undefined, ean: initialData?.ean || '', // Canonical form (work-type code, bare ROT/RUT, or ''): a stray value // from an import must not sit invisibly in the select as "Ingen". housework_type: normalizeHouseworkType(initialData?.housework_type) ?? '', notes: initialData?.notes || '', }, }) // Legacy articles carry only the kind ('ROT'/'RUT'), stored before this // form offered Skatteverket work types. Keep that choice selectable so an // edit never silently drops the flag; the user upgrades it to a real // arbetstyp when they want the invoice row's work type pre-filled too. const legacyHouseworkKind = (() => { const v = normalizeHouseworkType(initialData?.housework_type) return v === 'ROT' || v === 'RUT' ? v : null })() const type = watch('type') const watchedName = watch('name') const watchedUnit = watch('unit') const watchedPrice = watch('price_excl_vat') const watchedVat = watch('vat_rate') const watchedCurrency = watch('currency') const watchedAccount = watch('revenue_account') const watchedNumber = watch('article_number') // The summary strip: what this article becomes as a line on an invoice. // Display only, so it never posts anything, but it is the reason currency is // impossible to miss here. const price = Number.isFinite(watchedPrice) ? Number(watchedPrice) : 0 const effectiveVat = vatRegistered ? Number(watchedVat) || 0 : 0 const vatAmount = round2((price * effectiveVat) / 100) const totalInclVat = round2(price + vatAmount) const currency = watchedCurrency || 'SEK' // Keep the article's own currency selectable even before the fetch resolves // or if it has since been deactivated. const currencyCodes = useMemo(() => { const codes = currencies.map((c) => c.code) return codes.includes(currency) ? codes : [currency, ...codes] }, [currencies, currency]) const onFormSubmit = (data: FormData) => { onSubmit({ article_number: data.article_number?.trim() || null, name: data.name, name_en: data.name_en || null, type: data.type, unit: data.unit, price_excl_vat: data.price_excl_vat, vat_rate: vatRegistered ? data.vat_rate : 0, currency: data.currency, revenue_account: data.revenue_account || null, cost_price: data.cost_price ?? null, ean: data.ean || null, housework_type: type === 'tjanst' ? data.housework_type || null : null, notes: data.notes || null, }) } const fieldError = (message?: string) => message ?

{message}

: null return (
( )} /> {fieldError(errors.name?.message)} ( field.onChange(e.target.value)} > {currencyCodes.map((code) => ( ))} )} /> {fieldError(errors.price_excl_vat?.message)} {/* Enhet closes the group when the company charges no moms, so the group never ends on a dangling hairline. */} ( field.onChange(e.target.value)} > {UNITS.map((u) => ( ))} )} /> {vatRegistered && ( ( field.onChange(Number(e.target.value))} > {VAT_RATES.map((rate) => ( ))} )} /> )} ( setCreateAccountPrefill(prefill)} /> )} /> {fieldError(errors.revenue_account?.message)} {moreOpen && ( {fieldError(errors.article_number?.message)} (v === '' || v == null ? undefined : Number(v)), })} /> {type === 'tjanst' && ( ( field.onChange(e.target.value)} > {legacyHouseworkKind && ( )} {ROT_WORK_TYPES.map((w) => ( ))} {RUT_WORK_TYPES.map((w) => ( ))} )} /> )} )}
{/* The line this article becomes. Full-bleed against the dialog's p-6. */}
{watchedName || t('summary_unnamed')} 1 {watchedUnit} {formatCurrency(price, currency)} {effectiveVat > 0 && ( <> {t('summary_plus_vat')} {formatCurrency(vatAmount, currency)} )} {watchedAccount ? ( <> {t('summary_booked_on')} {watchedAccount} ) : null} {formatCurrency(totalInclVat, currency)}
{!watchedNumber && (

{t('number_hint')}

)}
{onCancel && ( )}
{/* Inline custom-account creation (renders in a portal, outside the form). After create: refresh the chart and select the new number as the article's posting account: mirrors the journal entry form. */} { if (!next) setCreateAccountPrefill(null) }} initialAccountNumber={ createAccountPrefill && /^\d{1,4}$/.test(createAccountPrefill) ? createAccountPrefill : undefined } initialAccountName={ createAccountPrefill && !/^\d{1,4}$/.test(createAccountPrefill) ? createAccountPrefill : undefined } onCreated={async (account) => { // The new account must be in the shared chart before the field points at it. await invalidateReferenceData('ref:accounts') setValue('revenue_account', account.account_number, { shouldDirty: true }) setCreateAccountPrefill(null) }} /> ) }