Files
accounted/components/bookkeeping/BookingTemplatePicker.tsx
T
567fae654c perf(bookkeeping): booking dialogs render populated on open from the session cache (#1935)
The bookkeeping dialogs were the customer's "fields load late" in its
purest form: Bokför (TransactionBookingDialog + the embedded
JournalEntryForm) issued five requests on every open (fiscal periods,
accounts, settings, cash accounts, then the voucher preview once the first
two had landed), Nytt verifikat the same minus one, BookDirectlyDialog
four, and the template dialogs two. Each Radix dialog unmounts on close, so
every reopen paid the full price again, and several fields visibly
flipped: the bank line seeded '1930' then rewrote itself, the series
defaulted to 'A' until settings arrived, the period select was empty.

All of them now read lib/reference-data (seeded by the dashboard layout):

- JournalEntryForm: periods, accounts and settings from the hooks;
  dimensionsEnabled derived, not fetched; the voucher-number preview is
  keyed on the entry date (the route resolves the period from it) so it
  fires as soon as the series is known instead of after the period fetch;
  after activating accounts it invalidates the shared accounts cache; the
  create-period dialog callback invalidates the periods cache.
- TransactionBookingDialog: settlement account and its name derived with
  useMemo from the cached cash accounts; the form mounts on the first paint.
- BookDirectlyDialog: cash accounts, periods and accounts from the hooks;
  the '1930'-then-rewrite disappears because the resolved account is known
  on the first render.
- TemplateBookDialog, BookingTemplatePicker, TemplatePicker: templates
  (and periods) from the hooks.
- BookingTemplatesPanel (delete, import) and CreatePeriodDialog (create)
  invalidate the corresponding cache entries so every picker sees the
  change at once.
- fetchers.ts: booking templates are booking_templates rows
  (BookingTemplateLibrary), not the static BookingTemplate shape.

Per open: Bokför 5 requests -> 0 blocking (voucher preview is a
non-blocking hint), Nytt verifikat 5 -> 1 non-blocking, BookDirectly
4 -> 0, Mall 2 -> 0, template pickers 1 -> 0.
raw-reference-fetch ratchet: 51 -> 46 files.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:37:28 +02:00

248 lines
9.5 KiB
TypeScript

'use client'
import { useState, useEffect, useMemo } from 'react'
import { useBookingTemplates } from '@/lib/reference-data/hooks'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { BookOpen, Search, Building2, Users, Globe } from 'lucide-react'
import { TEMPLATE_CATEGORY_LABELS, SCOPE_LABELS, getTemplateScope, applyTemplate } from '@/lib/bookkeeping/template-library'
import type { BookingTemplateCategory, EntityType } from '@/types'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
interface Props {
onApply: (lines: FormLine[], description: string, category?: BookingTemplateCategory) => void
entityType?: EntityType
/** Prefill the "total amount" field when the caller already knows it (e.g.
* booking from an underlag with a known total). The user can still edit it. */
defaultAmount?: number
}
const SCOPE_ICONS = {
system: Globe,
team: Users,
company: Building2,
} as const
export default function BookingTemplatePicker({ onApply, entityType, defaultAmount }: Props) {
const { toast } = useToast()
const [open, setOpen] = useState(false)
// Session-cached (lib/reference-data): the list is there on the first
// open and reopening costs no request; writes in the settings panel
// invalidate it.
const { templates, isLoading, error: templatesError } = useBookingTemplates()
const [search, setSearch] = useState('')
const [selectedCategory, setSelectedCategory] = useState<BookingTemplateCategory | 'all'>('all')
const [amount, setAmount] = useState('')
const [selectedId, setSelectedId] = useState<string | null>(null)
// Prefill the amount from the caller's known total each time the picker
// opens. Only when provided: callers without a known amount (e.g. the
// journal-entry form) keep the blank-then-type behaviour.
useEffect(() => {
if (open && defaultAmount != null && defaultAmount > 0) {
setAmount(String(Math.round(defaultAmount * 100) / 100))
}
}, [open, defaultAmount])
useEffect(() => {
if (open && templatesError) toast({ title: 'Kunde inte hämta mallar', variant: 'destructive' })
}, [open, templatesError, toast])
const filtered = useMemo(() => {
let result = templates
// Filter by entity type
if (entityType) {
result = result.filter((t) => t.entity_type === 'all' || t.entity_type === entityType)
}
// Filter by category
if (selectedCategory !== 'all') {
result = result.filter((t) => t.category === selectedCategory)
}
// Filter by search
if (search) {
const lower = search.toLowerCase()
result = result.filter(
(t) =>
t.name.toLowerCase().includes(lower) ||
t.description.toLowerCase().includes(lower),
)
}
return result
}, [templates, entityType, selectedCategory, search])
// Unique categories present in templates
const availableCategories = useMemo(() => {
const cats = new Set(templates.map((t) => t.category))
return Array.from(cats).sort()
}, [templates])
const selected = selectedId ? templates.find((t) => t.id === selectedId) : null
function handleApply() {
if (!selected) return
const totalAmount = parseFloat(amount)
if (!totalAmount || totalAmount <= 0) {
toast({ title: 'Ange belopp', description: 'Ange ett belopp för att använda mallen.', variant: 'destructive' })
return
}
const lines = applyTemplate(selected.lines, totalAmount)
// Fire-and-forget MRU bump so this template surfaces at the top next time.
fetch(`/api/settings/booking-templates/${selected.id}/touch`, { method: 'POST' }).catch(() => {})
onApply(lines, selected.name, selected.category)
setOpen(false)
setSelectedId(null)
setAmount('')
setSearch('')
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" type="button">
<BookOpen className="h-3.5 w-3.5 mr-1.5" />
Använd mall
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Bokföringsmallar</DialogTitle>
</DialogHeader>
{/* Search + category filter */}
<div className="flex flex-col gap-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Sök mall..."
className="pl-9"
autoFocus
/>
</div>
<div className="flex gap-1 flex-wrap">
<Button
variant={selectedCategory === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedCategory('all')}
type="button"
>
Alla
</Button>
{availableCategories.map((cat) => (
<Button
key={cat}
variant={selectedCategory === cat ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedCategory(cat as BookingTemplateCategory)}
type="button"
>
{TEMPLATE_CATEGORY_LABELS[cat as BookingTemplateCategory]}
</Button>
))}
</div>
</div>
{/* Template list */}
<div className="flex-1 overflow-y-auto space-y-1 min-h-0">
{isLoading ? (
<p className="text-sm text-muted-foreground py-8 text-center">Laddar mallar...</p>
) : filtered.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">Inga mallar hittades.</p>
) : (
filtered.map((t) => {
const scope = getTemplateScope(t)
const ScopeIcon = SCOPE_ICONS[scope]
const isSelected = selectedId === t.id
return (
<button
key={t.id}
type="button"
onClick={() => setSelectedId(isSelected ? null : t.id)}
className={`w-full text-left rounded-lg border p-3 transition-colors ${
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40 hover:bg-muted/50'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{t.name}</div>
<div className="flex items-center gap-1 text-[11px] text-muted-foreground mt-0.5">
<ScopeIcon className="h-3 w-3 shrink-0" />
<span>
{SCOPE_LABELS[scope]}
{t.entity_type !== 'all' &&
` · ${t.entity_type === 'enskild_firma' ? 'EF' : 'AB'}`}
</span>
</div>
{t.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{t.description}
</p>
)}
</div>
</div>
{/* Show lines preview when selected */}
{isSelected && (
<div className="mt-2 pt-2 border-t space-y-1">
{t.lines.map((line, i) => (
<div key={i} className="flex items-center gap-2 text-xs font-mono text-muted-foreground">
<span className="w-10">{line.account}</span>
<span className="flex-1 truncate">{line.label}</span>
<span className={line.side === 'debit' ? 'text-foreground' : ''}>
{line.side === 'debit' ? (line.type === 'vat' && line.vat_rate ? `${(line.vat_rate * 100).toFixed(0)}% moms` : 'D') : ''}
</span>
<span className={line.side === 'credit' ? 'text-foreground' : ''}>
{line.side === 'credit' ? (line.type === 'vat' && line.vat_rate ? `${(line.vat_rate * 100).toFixed(0)}% moms` : 'K') : ''}
</span>
</div>
))}
</div>
)}
</button>
)
})
)}
</div>
{/* Apply section */}
{selected && (
<div className="flex items-end gap-3 pt-3 border-t">
<div className="flex-1">
<label className="text-xs font-medium text-muted-foreground mb-1 block">
Totalt belopp (inkl. moms)
</label>
<Input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0,00"
min="0"
step="0.01"
inputMode="decimal"
autoFocus
/>
</div>
<Button onClick={handleApply} type="button">
Använd mall
</Button>
</div>
)}
</DialogContent>
</Dialog>
)
}