'use client' import { useEffect, 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 { ChevronDown, Loader2, Lock } from 'lucide-react' import { cn } from '@/lib/utils' import { useCanWrite } from '@/lib/hooks/use-can-write' import { useCompany } from '@/contexts/CompanyContext' import { createClient } from '@/lib/supabase/client' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog' import type { BASAccount, CreateArticleInput } from '@/types' import { INVOICE_POSTING_ACCOUNT_REGEX } from '@/lib/invoices/posting-account' // 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 interface ArticleFormProps { onSubmit: (data: CreateArticleInput) => Promise isLoading: boolean initialData?: Partial } export default function ArticleForm({ onSubmit, isLoading, initialData, }: ArticleFormProps) { const { canWrite } = useCanWrite() const { company } = useCompany() const supabase = createClient() const t = useTranslations('form_article') // 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). const [postingAccounts, setPostingAccounts] = useState([]) // 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. const [vatRegistered, setVatRegistered] = useState(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([]) async function fetchRevenueAccounts() { try { const res = await fetch('/api/bookkeeping/accounts') const body = await res.json() const accounts = ((body?.data as BASAccount[]) || []) .filter((account) => account.account_class >= 1 && account.account_class <= 3) setPostingAccounts(accounts) } catch { // Non-fatal: the combobox degrades to free 4-digit entry. } } useEffect(() => { fetchRevenueAccounts() }, []) // 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 }, []) useEffect(() => { if (!company?.id) return let cancelled = false supabase .from('company_settings') .select('vat_registered') .eq('company_id', company.id) .single() .then(({ data }) => { if (!cancelled && typeof data?.vat_registered === 'boolean') { setVatRegistered(data.vat_registered) } }) return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [company?.id]) // Open the advanced section by default when it already holds data, so an // edit never hides a value the user previously set. const [advancedOpen, setAdvancedOpen] = useState( Boolean( (initialData?.currency && initialData.currency !== 'SEK') || initialData?.revenue_account || 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 || '', housework_type: initialData?.housework_type || '', notes: initialData?.notes || '', }, }) const type = watch('type') 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, }) } return (
{/* Type + article number */}
( )} />
{errors.article_number ? (

{errors.article_number.message}

) : (

{t('number_hint')}

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

{errors.name.message}

)}
{/* English name */}

{t('name_en_hint')}

{/* Unit + price + VAT (moms hidden for non-momsregistrerade) */}
( )} />
{errors.price_excl_vat && (

{errors.price_excl_vat.message}

)}
{vatRegistered && (
( )} />
)}
{/* Advanced (collapsible) */}
{advancedOpen && (
{/* Revenue account */}
( setCreateAccountPrefill(prefill)} /> )} /> {errors.revenue_account && (

{errors.revenue_account.message}

)}

{t('revenue_account_hint')}

{/* Cost price */}
(v === '' || v == null ? undefined : Number(v)), })} />

{t('cost_price_hint')}

{/* Currency */}
{ // Always keep the current value selectable, even before the // fetch resolves or if it's since been deactivated. const codes = currencies.map((c) => c.code) const options = codes.includes(field.value) ? codes : [field.value, ...codes] return ( ) }} />

{t('currency_hint')}

{/* EAN */}
{/* Housework type (tjänst only) */} {type === 'tjanst' && (
( )} />

{t('housework_hint')}

)} {/* Notes */}