'use client' import { useMemo, useState } from 'react' import { useRouter } from 'next/navigation' import { useLocale, useTranslations } from 'next-intl' import { Loader2, Plus, Trash2 } from 'lucide-react' 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 { DetailSection } from '@/components/ui/detail-section' import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table' import { useToast } from '@/components/ui/use-toast' import ArticleCombobox from '@/components/invoices/ArticleCombobox' import { resolveLineVatRates, FALLBACK_VAT_RATE } from '@/components/invoices/line-vat-rates' import { useArticles, useCustomers } from '@/lib/reference-data/hooks' import { sortArticles } from '@/lib/articles/sort' import { computeLineNet } from '@/lib/invoices/line-amounts' import { roundOre } from '@/lib/money' import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { cn, formatCurrency } from '@/lib/utils' import { formatQty, todayIso } from '@/components/sales-orders/labels' import type { Article, SalesOrder, SalesOrderItem, SalesOrderItemInput } from '@/types' // The invoice editor's currency set (CurrencySchema in lib/api/schemas.ts). const CURRENCIES = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] as const // Dense-row cell controls: same vocabulary as the invoice editor's line grid // (rounded-sm leaves inside the table surface, hairline-free until focus). const CELL_INPUT_CLASS = 'h-8 rounded-sm border border-transparent bg-transparent px-2 py-1 text-[13px] transition-colors duration-150 hover:bg-secondary/40 focus-visible:bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground/60' const CELL_SELECT_TRIGGER_CLASS = 'h-8 w-auto gap-1 rounded-sm border-transparent bg-transparent px-2 py-1 text-[13px] shadow-none hover:bg-secondary/40 tabular-nums' interface LineState { key: string id?: string line_type: 'product' | 'text' description: string // Numeric fields are held as strings so the inputs stay controlled while // the user types ("1." or "" are valid intermediate states). quantity: string unit: string unit_price: string discount_percent: string vat_rate: number article_id: string | null revenue_account: string | null // Edit mode: what has already been invoiced on this line (read-only hint). invoiced_qty?: number } interface SalesOrderFormProps { mode: 'create' | 'edit' initial?: SalesOrder } let keyCounter = 0 function nextKey(): string { keyCounter += 1 return `line-${keyCounter}` } function parseNumber(value: string): number { const n = parseFloat(value.replace(',', '.')) return Number.isFinite(n) ? n : 0 } function lineFromItem(item: SalesOrderItem): LineState { return { key: nextKey(), id: item.id, line_type: item.line_type ?? 'product', description: item.description, quantity: String(item.quantity), unit: item.unit, unit_price: String(item.unit_price), discount_percent: item.discount_percent > 0 ? String(item.discount_percent) : '', vat_rate: item.vat_rate, article_id: item.article_id, revenue_account: item.revenue_account, invoiced_qty: item.invoiced_qty, } } function blankProductLine(vatRate: number): LineState { return { key: nextKey(), line_type: 'product', description: '', quantity: '1', unit: 'st', unit_price: '', discount_percent: '', vat_rate: vatRate, article_id: null, revenue_account: null, } } function blankTextLine(): LineState { return { key: nextKey(), line_type: 'text', description: '', quantity: '0', unit: '', unit_price: '0', discount_percent: '', vat_rate: 0, article_id: null, revenue_account: null, } } /** * Create/edit form for a kundorder. A lighter sibling of the invoice editor: * same customer picker, same article prefill and per-line VAT plan, same * line math (computeLineNet), minus everything invoice-only (ROT/RUT, * periodisering, payment links). POST on create, PATCH on edit; existing * line ids ride along on edit so delivered/invoiced history survives. */ export default function SalesOrderForm({ mode, initial }: SalesOrderFormProps) { const t = useTranslations('sales_order_form') const tCommon = useTranslations('common') const errorLocale = useLocale() as ErrorLocale const router = useRouter() const { toast } = useToast() const { customers, isLoading: customersLoading } = useCustomers() const { articles: articleRows } = useArticles() const articles = useMemo( () => sortArticles(articleRows.filter((a) => a.active !== false)), [articleRows], ) const [customerId, setCustomerId] = useState(initial?.customer_id ?? '') const [orderDate, setOrderDate] = useState(initial?.order_date ?? todayIso()) const [requestedDeliveryDate, setRequestedDeliveryDate] = useState( initial?.requested_delivery_date ?? '', ) const [currency, setCurrency] = useState(initial?.currency ?? 'SEK') const [yourReference, setYourReference] = useState(initial?.your_reference ?? '') const [ourReference, setOurReference] = useState(initial?.our_reference ?? '') const [notes, setNotes] = useState(initial?.notes ?? '') const [lines, setLines] = useState(() => { const items = [...(initial?.items ?? [])].sort((a, b) => a.sort_order - b.sort_order) return items.length > 0 ? items.map(lineFromItem) : [blankProductLine(FALLBACK_VAT_RATE)] }) const [errors, setErrors] = useState<{ customer?: string; lines?: string }>({}) const [isSubmitting, setIsSubmitting] = useState(false) const selectedCustomer = useMemo( () => customers.find((c) => c.id === customerId) ?? null, [customers, customerId], ) // The lawful VAT set for the picked customer (0% first for a foreign // business); before a customer is picked, the domestic set. const vatPlan = useMemo(() => resolveLineVatRates(selectedCustomer), [selectedCustomer]) const vatOptions = vatPlan.options.length > 0 ? vatPlan.options : resolveLineVatRates({ customer_type: 'swedish_business', vat_number_validated: false }).options function updateLine(key: string, patch: Partial) { setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) } function removeLine(key: string) { setLines((prev) => (prev.length > 1 ? prev.filter((l) => l.key !== key) : prev)) } function applyArticle(key: string, value: string) { if (value === 'none') { updateLine(key, { article_id: null, revenue_account: null }) return } const article = articles.find((a) => a.id === value) as Article | undefined if (!article) return const rateAllowed = vatOptions.some((o) => o.rate === article.vat_rate) updateLine(key, { article_id: article.id, description: article.name, unit: article.unit, unit_price: String(article.price_excl_vat), vat_rate: rateAllowed ? article.vat_rate : vatPlan.defaultRate, revenue_account: article.revenue_account, }) } function handleCustomerChange(nextId: string) { const previousDefault = vatPlan.defaultRate setCustomerId(nextId) const nextCustomer = customers.find((c) => c.id === nextId) ?? null const nextDefault = resolveLineVatRates(nextCustomer).defaultRate // Only lines still on the previous default follow the customer switch; // an explicitly chosen rate is left alone (same rule as the invoice editor). if (nextDefault !== previousDefault) { setLines((prev) => prev.map((l) => l.line_type === 'product' && l.vat_rate === previousDefault ? { ...l, vat_rate: nextDefault } : l, ), ) } } // Client-side totals, same öre-exact formulas as the server. const totals = useMemo(() => { let subtotal = 0 let vat = 0 for (const l of lines) { if (l.line_type === 'text') continue const net = computeLineNet( parseNumber(l.quantity), parseNumber(l.unit_price), l.discount_percent ? parseNumber(l.discount_percent) : null, ) subtotal = roundOre(subtotal + net) vat = roundOre(vat + roundOre((net * l.vat_rate) / 100)) } return { subtotal, vat, total: roundOre(subtotal + vat) } }, [lines]) function lineNet(l: LineState): number { if (l.line_type === 'text') return 0 return computeLineNet( parseNumber(l.quantity), parseNumber(l.unit_price), l.discount_percent ? parseNumber(l.discount_percent) : null, ) } async function handleSubmit(e: React.FormEvent) { e.preventDefault() const nextErrors: { customer?: string; lines?: string } = {} if (!customerId) nextErrors.customer = t('validation_customer_required') const productLines = lines.filter((l) => l.line_type === 'product') if (productLines.length === 0 || productLines.some((l) => !l.description.trim())) { nextErrors.lines = t('validation_lines_required') } setErrors(nextErrors) if (nextErrors.customer || nextErrors.lines) return const items: SalesOrderItemInput[] = lines.map((l) => ({ ...(l.id ? { id: l.id } : {}), line_type: l.line_type, description: l.description, quantity: l.line_type === 'text' ? 0 : parseNumber(l.quantity), unit: l.line_type === 'text' ? '' : l.unit, unit_price: l.line_type === 'text' ? 0 : parseNumber(l.unit_price), discount_percent: l.line_type === 'text' || !l.discount_percent ? null : parseNumber(l.discount_percent), vat_rate: l.line_type === 'text' ? 0 : l.vat_rate, article_id: l.article_id, revenue_account: l.revenue_account, })) const body = { customer_id: customerId, order_date: orderDate, requested_delivery_date: requestedDeliveryDate || null, currency, your_reference: yourReference.trim() || null, our_reference: ourReference.trim() || null, notes: notes.trim() || null, items, } setIsSubmitting(true) try { const url = mode === 'edit' && initial ? `/api/sales-orders/${initial.id}` : '/api/sales-orders' const response = await fetch(url, { method: mode === 'edit' ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) const json = await response.json().catch(() => null) if (!response.ok) { toast({ title: mode === 'edit' ? t('update_failed_title') : t('create_failed_title'), description: getErrorMessage(json, { locale: errorLocale, statusCode: response.status }), variant: 'destructive', }) return } const order = json?.data as SalesOrder toast({ title: mode === 'edit' ? t('updated_title') : t('created_title', { number: order.order_number ?? '' }), }) router.push(`/sales-orders/${order.id}`) } catch (err) { toast({ title: mode === 'edit' ? t('update_failed_title') : t('create_failed_title'), description: getErrorMessage(err, { locale: errorLocale }), variant: 'destructive', }) } finally { setIsSubmitting(false) } } return (
{errors.customer &&

{errors.customer}

}
setOrderDate(e.target.value)} className="tabular-nums" required />
setRequestedDeliveryDate(e.target.value)} className="tabular-nums" />
setYourReference(e.target.value)} maxLength={200} />
setOurReference(e.target.value)} maxLength={200} />