Files
accounted/components/settings/BookingTemplatesPanel.tsx
T
Jakob Wennberg 21cfcbe180 feat(bookkeeping): edit & customize booking templates in GUI (#704)
* feat(bookkeeping): edit & customize booking templates in GUI

Make bokföringsmallar editable from /settings/templates and surface the
ratio (Andel) field, addressing two user requests.

- Refactor CreateTemplateForm into a shared TemplateForm (create / edit /
  duplicate) reusing the existing POST and PUT routes.
- Add an Edit (pencil) action on company/team templates, and an Anpassa
  (customize) action on read-only "Standard" templates that forks a
  company-scoped copy — letting a company override the standard 1930
  settlement account with e.g. 1920 without mutating the shared template.
- Show the ratio field progressively (only when a template splits across
  more than one cost line), with an InfoTooltip, a non-blocking
  "shares must sum to 1.0" warning, and a live "1 000 kr" split preview.
- Add settings_booking_templates i18n keys in both sv and en.

Frontend-only: no API, schema, type, or migration changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bookkeeping): show ratio input on cost lines only

Addresses PR review: the Andel input also appeared on settlement lines
when a template had multiple cost lines, but settlement ratios don't feed
the "shares sum to 1.0" check or balance validation. Restrict the editable
ratio to cost/revenue lines; the settlement leg (full counter-amount) is
shown in the live preview instead. No behavior change to applyTemplate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:20:26 +02:00

793 lines
31 KiB
TypeScript

'use client'
import { useTranslations } from 'next-intl'
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
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 { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Building2, Users, Globe, Pencil, Copy } from 'lucide-react'
import { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate, applyTemplate } from '@/lib/bookkeeping/template-library'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { formatCurrency } from '@/lib/utils'
import type { BookingTemplateLibrary, BookingTemplateCategory, BookingTemplateLibraryLine } from '@/types'
export function BookingTemplatesPanel() {
const t = useTranslations('settings_booking_templates')
const { toast } = useToast()
const { canWrite } = useCanWrite()
const ENTITY_LABELS: Record<string, string> = {
all: t('entity_all'),
enskild_firma: t('entity_enskild_firma'),
aktiebolag: t('entity_aktiebolag'),
}
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([])
const [isLoading, setIsLoading] = useState(true)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false)
// Shared dialog for editing a company/team template or customizing (duplicating)
// a read-only system template. Mode is derived from is_system.
const [activeTemplate, setActiveTemplate] = useState<BookingTemplateLibrary | null>(null)
const importRef = useRef<HTMLInputElement>(null)
const fetchTemplates = useCallback(async () => {
try {
const res = await fetch('/api/settings/booking-templates')
const json = await res.json()
if (json.data) setTemplates(json.data)
} catch {
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [toast, t])
useEffect(() => { fetchTemplates() }, [fetchTemplates])
async function handleDelete(id: string) {
setDeletingId(id)
try {
const res = await fetch('/api/settings/booking-templates', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
})
if (!res.ok) {
toast({ title: t('toast_delete_failed'), variant: 'destructive' })
return
}
setTemplates((prev) => prev.filter((tt) => tt.id !== id))
toast({ title: t('toast_deleted') })
} finally {
setDeletingId(null)
}
}
async function handleExport() {
try {
const res = await fetch('/api/settings/booking-templates/export')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'bokforingsmallar.json'
a.click()
URL.revokeObjectURL(url)
} catch {
toast({ title: t('toast_export_failed'), variant: 'destructive' })
}
}
async function handleImport(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
try {
const text = await file.text()
const payload = JSON.parse(text)
const res = await fetch('/api/settings/booking-templates/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const json = await res.json()
if (!res.ok) {
toast({ title: t('toast_import_error'), description: json.error || t('toast_import_generic'), variant: 'destructive' })
return
}
toast({ title: t('toast_import_done'), description: t('toast_import_count', { count: json.imported }) })
fetchTemplates()
} catch {
toast({ title: t('toast_import_error'), description: t('toast_invalid_file'), variant: 'destructive' })
} finally {
// Reset input so same file can be imported again
if (importRef.current) importRef.current.value = ''
}
}
// Group templates by scope
const systemTemplates = templates.filter((tt) => tt.is_system)
const teamTemplates = templates.filter((tt) => tt.team_id && !tt.is_system)
const companyTemplates = templates.filter((tt) => tt.company_id && !tt.is_system)
// Names of existing company templates — used for a soft "name already exists"
// hint when creating or customizing (never blocks save).
const companyTemplateNames = companyTemplates.map((tt) => tt.name)
return (
<>
<Card>
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>
{t('description')}
</CardDescription>
</div>
{canWrite && (
<div className="flex gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('export')}
</Button>
<Button variant="outline" size="sm" onClick={() => importRef.current?.click()}>
<Upload className="h-3.5 w-3.5 mr-1.5" />
{t('import')}
</Button>
<input
ref={importRef}
type="file"
accept=".json"
className="hidden"
onChange={handleImport}
/>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-3.5 w-3.5 mr-1.5" />
{t('new_template')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('create_dialog_title')}</DialogTitle>
</DialogHeader>
<TemplateForm
mode="create"
entityLabels={ENTITY_LABELS}
duplicateNamePool={companyTemplateNames}
onSaved={() => {
setShowCreate(false)
fetchTemplates()
}}
/>
</DialogContent>
</Dialog>
</div>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : templates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-12">
{t('empty_state')}
</p>
) : (
<div className="space-y-6">
{/* System templates */}
{systemTemplates.length > 0 && (
<TemplateSection
title={t('section_system')}
icon={Globe}
templates={systemTemplates}
expandedId={expandedId}
onToggle={setExpandedId}
deletingId={deletingId}
onDelete={handleDelete}
canDelete={false}
canEdit={false}
canCustomize={canWrite}
onCustomize={setActiveTemplate}
entityLabels={ENTITY_LABELS}
/>
)}
{/* Team templates */}
{teamTemplates.length > 0 && (
<TemplateSection
title={t('section_team')}
icon={Users}
templates={teamTemplates}
expandedId={expandedId}
onToggle={setExpandedId}
deletingId={deletingId}
onDelete={handleDelete}
canDelete={canWrite}
canEdit={canWrite}
onEdit={setActiveTemplate}
entityLabels={ENTITY_LABELS}
/>
)}
{/* Company templates */}
{companyTemplates.length > 0 && (
<TemplateSection
title={t('section_company')}
icon={Building2}
templates={companyTemplates}
expandedId={expandedId}
onToggle={setExpandedId}
deletingId={deletingId}
onDelete={handleDelete}
canDelete={canWrite}
canEdit={canWrite}
onEdit={setActiveTemplate}
entityLabels={ENTITY_LABELS}
/>
)}
</div>
)}
</CardContent>
</Card>
{/* Shared edit / customize dialog. Editing a company or team template uses
PUT; customizing a read-only system template creates a company-scoped
copy via POST. The form is keyed by template id so it re-seeds state when
switching between rows. */}
<Dialog open={!!activeTemplate} onOpenChange={(open) => { if (!open) setActiveTemplate(null) }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{activeTemplate?.is_system ? t('customize_dialog_title') : t('edit_dialog_title')}
</DialogTitle>
</DialogHeader>
{activeTemplate && (
<TemplateForm
key={activeTemplate.id}
mode={activeTemplate.is_system ? 'duplicate' : 'edit'}
initialTemplate={activeTemplate}
entityLabels={ENTITY_LABELS}
duplicateNamePool={companyTemplateNames}
onSaved={() => {
setActiveTemplate(null)
fetchTemplates()
}}
/>
)}
</DialogContent>
</Dialog>
</>
)
}
function TemplateSection({
title,
icon: Icon,
templates,
expandedId,
onToggle,
deletingId,
onDelete,
canDelete,
canEdit = false,
canCustomize = false,
onEdit,
onCustomize,
entityLabels,
}: {
title: string
icon: React.ComponentType<{ className?: string }>
templates: BookingTemplateLibrary[]
expandedId: string | null
onToggle: (id: string | null) => void
deletingId: string | null
onDelete: (id: string) => void
canDelete: boolean
canEdit?: boolean
canCustomize?: boolean
onEdit?: (template: BookingTemplateLibrary) => void
onCustomize?: (template: BookingTemplateLibrary) => void
entityLabels: Record<string, string>
}) {
const t = useTranslations('settings_booking_templates')
return (
<div>
<div className="flex items-center gap-2 mb-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">{title}</h3>
<Badge variant="secondary" className="text-xs">{templates.length}</Badge>
</div>
<div className="space-y-1">
{templates.map((tt) => {
const isExpanded = expandedId === tt.id
const isConvertible = convertLibraryToBookingTemplate(tt) !== null
return (
<div
key={tt.id}
className="rounded-lg border"
>
<div className="flex items-center gap-3 p-3 hover:bg-muted/50 transition-colors">
<button
type="button"
onClick={() => onToggle(isExpanded ? null : tt.id)}
className="flex items-center gap-3 flex-1 min-w-0 text-left"
>
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-0' : '-rotate-90'}`} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{tt.name}</span>
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{TEMPLATE_CATEGORY_LABELS[tt.category]}
</Badge>
{tt.entity_type !== 'all' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{entityLabels[tt.entity_type]}
</Badge>
)}
{!isConvertible && (
<Badge variant="warning" className="text-[10px] px-1.5 py-0">
{t('unconvertible_badge')}
</Badge>
)}
</div>
</div>
</button>
{canCustomize && onCustomize && (
<Button
variant="ghost"
size="sm"
onClick={() => onCustomize(tt)}
aria-label={t('customize')}
title={t('customize')}
className="h-8 w-8 p-0 shrink-0"
>
<Copy className="h-3.5 w-3.5" />
</Button>
)}
{canEdit && onEdit && (
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(tt)}
aria-label={t('edit')}
title={t('edit')}
className="h-8 w-8 p-0 shrink-0"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
{canDelete && (
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(tt.id)}
disabled={deletingId === tt.id}
className="h-8 w-8 p-0 shrink-0"
>
{deletingId === tt.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
{isExpanded && (
<div className="px-3 pb-3 pt-0">
{tt.description && (
<p className="text-xs text-muted-foreground mb-2">{tt.description}</p>
)}
<table className="w-full text-xs">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="text-left py-1 w-14">{t('th_account')}</th>
<th className="text-left py-1">{t('th_description')}</th>
<th className="text-center py-1 w-16">{t('th_type')}</th>
<th className="text-right py-1 w-12">{t('th_debit')}</th>
<th className="text-right py-1 w-12">{t('th_credit')}</th>
</tr>
</thead>
<tbody>
{tt.lines.map((line: BookingTemplateLibraryLine, i: number) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1 font-mono">{line.account}</td>
<td className="py-1">{line.label}</td>
<td className="py-1 text-center">
{line.type === 'vat' && line.vat_rate
? t('vat_with_rate', { rate: (line.vat_rate * 100).toFixed(0) })
: line.type === 'settlement' ? t('type_settlement') : t('type_cost_revenue')}
</td>
<td className="py-1 text-right">{line.side === 'debit' ? t('debit_short') : ''}</td>
<td className="py-1 text-right">{line.side === 'credit' ? t('credit_short') : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
})}
</div>
</div>
)
}
type TemplateFormMode = 'create' | 'edit' | 'duplicate'
function TemplateForm({
mode,
initialTemplate,
entityLabels,
duplicateNamePool = [],
onSaved,
}: {
mode: TemplateFormMode
initialTemplate?: BookingTemplateLibrary
entityLabels: Record<string, string>
duplicateNamePool?: string[]
onSaved: () => void
}) {
const t = useTranslations('settings_booking_templates')
const { toast } = useToast()
const [isSubmitting, setIsSubmitting] = useState(false)
// When customizing a system template (mode 'duplicate') we suggest a distinct
// "(anpassad)" name so the company copy doesn't read as the standard one.
const [name, setName] = useState(() =>
initialTemplate
? mode === 'duplicate'
? t('copy_name_suffix', { name: initialTemplate.name })
: initialTemplate.name
: '',
)
const [description, setDescription] = useState(initialTemplate?.description ?? '')
const [category, setCategory] = useState<BookingTemplateCategory>(initialTemplate?.category ?? 'other')
const [entityType, setEntityType] = useState<'all' | 'enskild_firma' | 'aktiebolag'>(
initialTemplate?.entity_type ?? 'all',
)
const [lines, setLines] = useState<BookingTemplateLibraryLine[]>(() =>
initialTemplate
? initialTemplate.lines.map((l) => ({ ...l }))
: [
{ account: '', label: '', side: 'debit', type: 'business', ratio: 1 },
{ account: '', label: '', side: 'credit', type: 'settlement', ratio: 1 },
],
)
function updateLine(index: number, field: keyof BookingTemplateLibraryLine, value: string | number) {
setLines((prev) => {
const updated = [...prev]
updated[index] = { ...updated[index], [field]: value }
return updated
})
}
function updateLineType(index: number, newType: BookingTemplateLibraryLine['type']) {
setLines((prev) => {
const updated = [...prev]
const current = updated[index]
const next: BookingTemplateLibraryLine = { ...current, type: newType }
// Auto-pick a sensible default for the type-specific field so the
// converter (and applyTemplate) sees a complete line shape.
if (newType === 'vat' && next.vat_rate === undefined) {
next.vat_rate = 0.25
}
updated[index] = next
return updated
})
}
// Default new lines to a VAT line — the 2-line template starts with one
// business + one settlement, and the natural extension is a VAT leg.
// Defaulting to 'business' instead would silently break the converter
// (which requires exactly one business line) and the template would
// disappear from the transaction picker.
function addLine() {
setLines((prev) => [...prev, { account: '', label: '', side: 'debit', type: 'vat', vat_rate: 0.25 }])
}
function removeLine(index: number) {
if (lines.length <= 2) return
setLines((prev) => prev.filter((_, i) => i !== index))
}
// Ratio is only load-bearing when a template splits the amount across more
// than one cost/revenue line. Hide it for the simple case to keep the form
// approachable for non-accountants; it stays 1.0 under the hood.
const businessLineCount = lines.filter((l) => l.type === 'business').length
const showRatio = businessLineCount > 1
// The ratio only validates against cost/revenue lines (businessRatioSum), so
// only those get an editable input. The settlement leg is the full counter-
// amount (ratio 1.0) and is shown in the live preview, not as a control —
// an editable settlement ratio that doesn't feed the sum check would mislead.
const firstRatioIndex = showRatio ? lines.findIndex((l) => l.type === 'business') : -1
const businessRatioSum = lines
.filter((l) => l.type === 'business')
.reduce((sum, l) => sum + (l.ratio ?? 1), 0)
const ratioSumOff = showRatio && Math.abs(businessRatioSum - 1) > 0.001
// Live split preview for a 1 000 kr amount. Computed only once every line has
// an account so the table doesn't flicker while the form is half-filled.
const preview = useMemo(() => {
if (lines.some((l) => !l.account)) return null
try {
return applyTemplate(lines, 1000)
} catch {
return null
}
}, [lines])
// Soft, non-blocking hint when the chosen name collides with an existing
// company template (no DB unique constraint — duplicates are allowed).
const nameCollision =
mode !== 'edit' &&
name.trim().length > 0 &&
duplicateNamePool.some((n) => n.trim().toLowerCase() === name.trim().toLowerCase())
// Real-time check: can this draft be picked from the transaction sheet?
// If not, we show a hint — save remains allowed (templates may still be
// useful from the journal-entry form).
const isConvertible = (() => {
const draft: BookingTemplateLibrary = {
id: initialTemplate?.id ?? '',
company_id: null,
team_id: null,
created_by: null,
name,
description,
category,
entity_type: entityType,
lines,
is_system: false,
is_active: true,
created_at: '',
updated_at: '',
}
return convertLibraryToBookingTemplate(draft) !== null
})()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!name || lines.some((l) => !l.account || !l.label)) {
toast({ title: t('toast_fill_all_fields'), variant: 'destructive' })
return
}
setIsSubmitting(true)
try {
// Edit updates the existing template in place (PUT); create and duplicate
// both write a new company-scoped template (POST).
const isEdit = mode === 'edit'
const url = isEdit
? `/api/settings/booking-templates/${initialTemplate!.id}`
: '/api/settings/booking-templates'
const res = await fetch(url, {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, category, entity_type: entityType, lines }),
})
if (!res.ok) {
const json = await res.json().catch(() => ({}))
toast({ title: json.error || t('toast_create_failed'), variant: 'destructive' })
return
}
toast({ title: isEdit ? t('toast_updated') : t('toast_created') })
onSaved()
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label>{t('name_label')}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('name_placeholder')}
autoFocus={mode === 'duplicate'}
onFocus={mode === 'duplicate' ? (e) => e.target.select() : undefined}
/>
</div>
<div>
<Label>{t('description_label')} <span className="text-muted-foreground font-normal">{t('optional_suffix')}</span></Label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder={t('description_placeholder')} rows={2} className="resize-none" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{t('category_label')}</Label>
<Select value={category} onValueChange={(v) => setCategory(v as BookingTemplateCategory)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(TEMPLATE_CATEGORY_LABELS).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('entity_type_label')}</Label>
<Select value={entityType} onValueChange={(v) => setEntityType(v as typeof entityType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(entityLabels).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>{t('lines_label')}</Label>
<div className="space-y-2 mt-1">
{lines.map((line, i) => {
const showRatioInput = showRatio && line.type === 'business'
return (
<div key={i} className="rounded-md border border-border p-2 space-y-1.5">
<div className="flex items-center gap-2">
<Input
value={line.account}
onChange={(e) => updateLine(i, 'account', e.target.value.replace(/\D/g, '').slice(0, 4))}
placeholder={t('account_placeholder')}
className="w-20 font-mono"
maxLength={4}
/>
<Input
value={line.label}
onChange={(e) => updateLine(i, 'label', e.target.value)}
placeholder={t('description_short_placeholder')}
className="flex-1 min-w-0"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeLine(i)}
disabled={lines.length <= 2}
className="h-8 w-8 p-0 shrink-0"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center gap-2">
<Select value={line.side} onValueChange={(v) => updateLine(i, 'side', v)}>
<SelectTrigger className="w-24"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="debit">{t('debit_label')}</SelectItem>
<SelectItem value="credit">{t('credit_label')}</SelectItem>
</SelectContent>
</Select>
<Select value={line.type} onValueChange={(v) => updateLineType(i, v as BookingTemplateLibraryLine['type'])}>
<SelectTrigger className="flex-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="business">{t('type_cost')}</SelectItem>
<SelectItem value="vat">{t('type_vat')}</SelectItem>
<SelectItem value="settlement">{t('type_settlement')}</SelectItem>
</SelectContent>
</Select>
{line.type === 'vat' && (
<Select
value={String(line.vat_rate ?? 0.25)}
onValueChange={(v) => updateLine(i, 'vat_rate', Number(v))}
>
<SelectTrigger className="w-24" aria-label={t('vat_rate_label')}><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0.25">{t('vat_rate_25')}</SelectItem>
<SelectItem value="0.12">{t('vat_rate_12')}</SelectItem>
<SelectItem value="0.06">{t('vat_rate_6')}</SelectItem>
<SelectItem value="0">{t('vat_rate_0')}</SelectItem>
</SelectContent>
</Select>
)}
{showRatioInput && (
<div className="flex items-center gap-1 shrink-0">
<Input
type="number"
inputMode="decimal"
step="0.1"
min={0}
max={10}
value={String(line.ratio ?? 1)}
onChange={(e) => {
const n = Number(e.target.value)
if (!Number.isNaN(n)) updateLine(i, 'ratio', n)
}}
aria-label={t('ratio_label')}
className="w-16 font-mono tabular-nums text-right"
/>
{i === firstRatioIndex && <InfoTooltip content={t('ratio_help')} />}
</div>
)}
</div>
</div>
)})}
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="h-3 w-3 mr-1" />
{t('add_line')}
</Button>
</div>
</div>
{ratioSumOff && (
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
<p className="text-xs text-warning-foreground leading-snug">
{t('ratio_sum_warning')}
</p>
</div>
)}
{preview && (
<div>
<Label>{t('preview_label')}</Label>
<table className="w-full text-xs mt-1">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="text-left py-1 w-14">{t('th_account')}</th>
<th className="text-left py-1">{t('th_description')}</th>
<th className="text-right py-1 w-20">{t('th_debit')}</th>
<th className="text-right py-1 w-20">{t('th_credit')}</th>
</tr>
</thead>
<tbody>
{preview.map((pl, i) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1 font-mono">{pl.account_number}</td>
<td className="py-1">{pl.line_description}</td>
<td className="py-1 text-right tabular-nums">
{pl.debit_amount ? formatCurrency(Number(pl.debit_amount)) : ''}
</td>
<td className="py-1 text-right tabular-nums">
{pl.credit_amount ? formatCurrency(Number(pl.credit_amount)) : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{nameCollision && (
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
<p className="text-xs text-warning-foreground leading-snug">
{t('duplicate_name_warning')}
</p>
</div>
)}
{!isConvertible && (
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
<p className="text-xs text-warning-foreground leading-snug">
{t('unconvertible_hint')}
</p>
</div>
)}
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{mode === 'create' ? t('create_button') : t('save_button')}
</Button>
</form>
)
}