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>
295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import { useTranslations } from 'next-intl'
|
|
import { cn, formatCurrency } from '@/lib/utils'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
|
import {
|
|
applyTemplate,
|
|
getTemplateScope,
|
|
SCOPE_LABELS,
|
|
TEMPLATE_CATEGORY_LABELS,
|
|
} from '@/lib/bookkeeping/template-library'
|
|
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
|
import { roundOre } from '@/lib/money'
|
|
import { ArrowLeft, Check, ChevronRight, Loader2, Search } from 'lucide-react'
|
|
import type { BookingTemplateLibrary } from '@/types'
|
|
import { useBookingTemplates, useFiscalPeriods } from '@/lib/reference-data/hooks'
|
|
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
|
|
|
interface Props {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
/** Fired after a verifikat is booked from a template. */
|
|
onCreated: () => void
|
|
}
|
|
|
|
/** Sum a side of the computed lines in öre-safe steps. */
|
|
function sumSide(lines: FormLine[], side: 'debit_amount' | 'credit_amount'): number {
|
|
return lines.reduce((acc, l) => roundOre(acc + (parseFloat(l[side]) || 0)), 0)
|
|
}
|
|
|
|
/**
|
|
* "Bokför från mall" (UI-migration plan PR 4, scene 9): a centered modal
|
|
* with the template list (existing booking_template_library data, MRU
|
|
* ordering from the API), then date + editable amount that recomputes the
|
|
* kontering live via applyTemplate, a "Balanserar" row, and direct booking
|
|
* (user action, so no Granskning detour).
|
|
*/
|
|
export default function TemplateBookDialog({ open, onOpenChange, onCreated }: Props) {
|
|
const t = useTranslations('bookkeeping')
|
|
const { toast } = useToast()
|
|
|
|
// Session-cached (lib/reference-data): opening the dialog costs no
|
|
// requests once the lists are in the cache. null = still loading.
|
|
const { templates: cachedTemplates, isLoading: templatesLoading } = useBookingTemplates()
|
|
const templates: BookingTemplateLibrary[] | null = templatesLoading ? null : cachedTemplates
|
|
const { periods } = useFiscalPeriods()
|
|
const [search, setSearch] = useState('')
|
|
const [selected, setSelected] = useState<BookingTemplateLibrary | null>(null)
|
|
const [entryDate, setEntryDate] = useState(() => new Date().toISOString().split('T')[0])
|
|
const [amountInput, setAmountInput] = useState('')
|
|
const [submitting, setSubmitting] = useState(false)
|
|
|
|
// Reset per open so yesterday's half-typed amount never leaks into today.
|
|
useEffect(() => {
|
|
if (open) return
|
|
setSelected(null)
|
|
setSearch('')
|
|
setAmountInput('')
|
|
setEntryDate(new Date().toISOString().split('T')[0])
|
|
}, [open])
|
|
|
|
const amount = useMemo(() => {
|
|
const parsed = parseFloat(amountInput.replace(/\s/g, '').replace(',', '.'))
|
|
return Number.isFinite(parsed) && parsed > 0 ? roundOre(parsed) : 0
|
|
}, [amountInput])
|
|
|
|
// The live kontering: recomputed from the template's line pattern on
|
|
// every amount change (momssplit etc. handled by applyTemplate).
|
|
const lines = useMemo<FormLine[]>(
|
|
() => (selected && amount > 0 ? applyTemplate(selected.lines, amount) : []),
|
|
[selected, amount],
|
|
)
|
|
const totalDebit = sumSide(lines, 'debit_amount')
|
|
const totalCredit = sumSide(lines, 'credit_amount')
|
|
const balanced = lines.length >= 2 && totalDebit === totalCredit && totalDebit > 0
|
|
|
|
const filteredTemplates = (templates ?? []).filter((tpl) =>
|
|
tpl.name.toLowerCase().includes(search.trim().toLowerCase()),
|
|
)
|
|
|
|
const periodForDate = periods.find(
|
|
(p) => p.period_start <= entryDate && entryDate <= p.period_end,
|
|
)
|
|
|
|
const handleBook = async () => {
|
|
if (!selected || !balanced) return
|
|
if (!periodForDate) {
|
|
toast({ title: t('tpl_no_period'), variant: 'destructive' })
|
|
return
|
|
}
|
|
setSubmitting(true)
|
|
try {
|
|
const res = await fetch('/api/bookkeeping/journal-entries', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
fiscal_period_id: periodForDate.id,
|
|
entry_date: entryDate,
|
|
description: selected.name,
|
|
lines: lines.map((l) => ({
|
|
account_number: l.account_number,
|
|
debit_amount: parseFloat(l.debit_amount) || 0,
|
|
credit_amount: parseFloat(l.credit_amount) || 0,
|
|
line_description: l.line_description || undefined,
|
|
})),
|
|
}),
|
|
})
|
|
const result = await res.json()
|
|
if (!res.ok) {
|
|
toast({
|
|
title: t('toast_post_failed'),
|
|
description: getErrorMessage(result, { context: 'journal_entry' }),
|
|
variant: 'destructive',
|
|
})
|
|
return
|
|
}
|
|
// MRU ordering for the next open; fire-and-forget.
|
|
void fetch(`/api/settings/booking-templates/${selected.id}/touch`, {
|
|
method: 'POST',
|
|
}).catch(() => {})
|
|
toast({
|
|
title: t('toast_posted_title'),
|
|
description: t('toast_posted_description', {
|
|
voucher: formatVoucher(result.data ?? {}),
|
|
}),
|
|
})
|
|
onOpenChange(false)
|
|
onCreated()
|
|
} catch {
|
|
toast({ title: t('toast_post_failed_generic'), variant: 'destructive' })
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(next) => !submitting && onOpenChange(next)}>
|
|
<DialogContent className="sm:min-w-[460px] sm:max-w-lg">
|
|
<DialogHeader>
|
|
{/* data-ph-mask: the template name is user data */}
|
|
<DialogTitle data-ph-mask="" className="font-display text-lg tracking-tight">
|
|
{selected ? selected.name : t('tpl_dialog_title')}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{!selected ? (
|
|
<>
|
|
<div className="flex items-center gap-2 border-b border-border/60 pb-2">
|
|
<Search className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
|
<input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t('tpl_search_placeholder')}
|
|
className="w-full bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground/60 focus:outline-none"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="-mx-2 max-h-80 overflow-y-auto px-1">
|
|
{templates === null ? (
|
|
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
</div>
|
|
) : filteredTemplates.length === 0 ? (
|
|
<p className="px-2.5 py-6 text-center text-[13px] text-muted-foreground">
|
|
{t('tpl_empty')}
|
|
</p>
|
|
) : (
|
|
filteredTemplates.map((tpl) => (
|
|
<button
|
|
key={tpl.id}
|
|
type="button"
|
|
onClick={() => setSelected(tpl)}
|
|
className="flex w-full items-center gap-2.5 rounded-sm px-2.5 py-2.5 text-left transition-colors hover:bg-secondary/60"
|
|
>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-[13px] text-foreground">
|
|
{tpl.name}
|
|
</span>
|
|
<span className="block truncate text-[11px] text-muted-foreground">
|
|
{TEMPLATE_CATEGORY_LABELS[tpl.category] ?? tpl.category}
|
|
{' · '}
|
|
{SCOPE_LABELS[getTemplateScope(tpl)]}
|
|
</span>
|
|
</span>
|
|
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/60" />
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelected(null)}
|
|
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
<ArrowLeft className="h-3.5 w-3.5" />
|
|
{t('tpl_back')}
|
|
</button>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tpl-date">{t('tpl_date_label')}</Label>
|
|
<Input
|
|
id="tpl-date"
|
|
type="date"
|
|
value={entryDate}
|
|
onChange={(e) => setEntryDate(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tpl-amount">{t('tpl_amount_label')}</Label>
|
|
<Input
|
|
id="tpl-amount"
|
|
inputMode="decimal"
|
|
placeholder="0,00"
|
|
value={amountInput}
|
|
onChange={(e) => setAmountInput(e.target.value)}
|
|
autoFocus
|
|
className="tabular-nums"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Live kontering preview */}
|
|
<div className="rounded-lg border border-border">
|
|
{lines.length === 0 ? (
|
|
<p className="px-3 py-4 text-center text-[13px] text-muted-foreground">
|
|
{t('tpl_enter_amount')}
|
|
</p>
|
|
) : (
|
|
<>
|
|
{lines.map((l, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex items-center gap-3 border-b border-border/60 px-3 py-2 text-[13px] last:border-b-0"
|
|
>
|
|
<span className="w-12 font-mono text-muted-foreground">
|
|
{l.account_number}
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate">{l.line_description}</span>
|
|
<span className="w-24 text-right tabular-nums">
|
|
{l.debit_amount ? formatCurrency(parseFloat(l.debit_amount)) : ''}
|
|
</span>
|
|
<span className="w-24 text-right tabular-nums text-muted-foreground">
|
|
{l.credit_amount ? formatCurrency(parseFloat(l.credit_amount)) : ''}
|
|
</span>
|
|
</div>
|
|
))}
|
|
<div
|
|
className={cn(
|
|
'flex items-center justify-between px-3 py-2 text-[12.5px]',
|
|
balanced ? 'text-success' : 'text-destructive',
|
|
)}
|
|
>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
{balanced && <Check className="h-3.5 w-3.5" />}
|
|
{balanced ? t('tpl_balances') : t('tpl_not_balancing')}
|
|
</span>
|
|
<span className="tabular-nums">
|
|
{formatCurrency(totalDebit)} / {formatCurrency(totalCredit)}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={submitting}>
|
|
{t('tpl_cancel')}
|
|
</Button>
|
|
<Button onClick={() => void handleBook()} disabled={!balanced || submitting}>
|
|
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
|
{t('tpl_book')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|