Files
accounted/components/settings/BookingTemplatesPanel.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

463 lines
18 KiB
TypeScript

'use client'
import { useLocale, useTranslations } from 'next-intl'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { HelpPopover } from '@/components/ui/help-popover'
import { useToast } from '@/components/ui/use-toast'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { SettingsGroup } from '@/components/settings/SettingsRows'
import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Pencil, Copy } from 'lucide-react'
import { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate } from '@/lib/bookkeeping/template-library'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { TemplateForm } from '@/components/settings/TemplateForm'
import { downloadFile } from '@/lib/browser/download-file'
import type { ErrorLocale } from '@/lib/errors/get-error-message'
import { cn } from '@/lib/utils'
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
export function BookingTemplatesPanel() {
const t = useTranslations('settings_booking_templates')
const locale = useLocale() as ErrorLocale
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)
const [isExporting, setIsExporting] = 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))
// The pickers read the session cache (lib/reference-data): drop the
// deleted template there too, not just from this panel's own list.
void invalidateReferenceData('ref:booking-templates')
toast({ title: t('toast_deleted') })
} finally {
setDeletingId(null)
}
}
async function handleExport() {
// The button is disabled while a run is in flight; this also covers the
// keyboard/double-click race before React has re-rendered it.
if (isExporting) return
setIsExporting(true)
try {
const result = await downloadFile({
url: '/api/settings/booking-templates/export',
filename: 'bokforingsmallar.json',
locale,
})
// Success is silent on purpose: the saved file is the feedback. On
// failure nothing was written to disk, so exactly one toast tells the
// user why. Never two: TOAST_LIMIT is 1, so a second toast in the same
// tick evicts the first and only the last one is ever rendered.
if (!result.ok) {
toast({
title: t('toast_export_failed'),
description:
result.reason === 'timeout' ? t('toast_export_timeout') : result.message,
variant: 'destructive',
})
}
} finally {
setIsExporting(false)
}
}
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()
void invalidateReferenceData('ref:booking-templates')
} 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 (
<>
<SettingsGroup>
{/* Group eyebrow with the panel's actions on the right: export/import as
quiet buttons, "Ny mall" as the one pill. The old card description
lives behind the "?". */}
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-1">
<p className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<span>{t('title')}</span>
<HelpPopover className="shrink-0">{t('description')}</HelpPopover>
</p>
{canWrite && (
<div className="flex shrink-0 items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={isExporting}
className="text-muted-foreground hover:text-foreground"
>
{isExporting ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1.5 h-3.5 w-3.5" />
)}
{t('export')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => importRef.current?.click()}
className="text-muted-foreground hover:text-foreground"
>
<Upload className="mr-1.5 h-3.5 w-3.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="mr-1.5 h-3.5 w-3.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>
{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="py-12 text-center text-sm text-muted-foreground">
{t('empty_state')}
</p>
) : null}
</SettingsGroup>
{!isLoading && templates.length > 0 && (
<>
{/* System templates */}
{systemTemplates.length > 0 && (
<TemplateSection
title={t('section_system')}
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')}
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')}
templates={companyTemplates}
expandedId={expandedId}
onToggle={setExpandedId}
deletingId={deletingId}
onDelete={handleDelete}
canDelete={canWrite}
canEdit={canWrite}
onEdit={setActiveTemplate}
entityLabels={ENTITY_LABELS}
/>
)}
</>
)}
{/* 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,
templates,
expandedId,
onToggle,
deletingId,
onDelete,
canDelete,
canEdit = false,
canCustomize = false,
onEdit,
onCustomize,
entityLabels,
}: {
title: 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')
const tCommon = useTranslations('common')
return (
<SettingsGroup>
{/* Origin eyebrow with count; mirrors SettingsGroup's label line. */}
<p className="flex items-center gap-2 px-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<span>{title}</span>
<span className="tabular-nums">{templates.length}</span>
</p>
<div>
{templates.map((tt) => {
const isExpanded = expandedId === tt.id
const isConvertible = convertLibraryToBookingTemplate(tt) !== null
return (
<div key={tt.id} className="border-b border-border">
<div className="flex items-center gap-3 px-1 py-3 transition-colors duration-150 hover:bg-secondary/60">
<button
type="button"
onClick={() => onToggle(isExpanded ? null : tt.id)}
aria-expanded={isExpanded}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
>
<ChevronDown
className={cn(
'h-4 w-4 shrink-0 text-muted-foreground transition-transform',
!isExpanded && '-rotate-90',
)}
/>
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="truncate text-sm">{tt.name}</span>
<span className="text-xs text-muted-foreground">
{TEMPLATE_CATEGORY_LABELS[tt.category]}
{tt.entity_type !== 'all' && ` · ${entityLabels[tt.entity_type]}`}
</span>
{!isConvertible && (
<Badge variant="warning" className="px-1.5 py-0 text-[10px]">
{t('unconvertible_badge')}
</Badge>
)}
</span>
</button>
{canCustomize && onCustomize && (
<Button
variant="ghost"
size="icon"
onClick={() => onCustomize(tt)}
aria-label={t('customize')}
title={t('customize')}
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
>
<Copy className="h-3.5 w-3.5" />
</Button>
)}
{canEdit && onEdit && (
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(tt)}
aria-label={t('edit')}
title={t('edit')}
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
{canDelete && (
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(tt.id)}
disabled={deletingId === tt.id}
aria-label={tCommon('delete')}
title={tCommon('delete')}
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
>
{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-1 pb-3">
{tt.description && (
<p className="mb-2 text-xs text-muted-foreground">{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 border-border">
<th className="w-14 py-1 text-left">{t('th_account')}</th>
<th className="py-1 text-left">{t('th_description')}</th>
<th className="w-16 py-1 text-center">{t('th_type')}</th>
<th className="w-12 py-1 text-right">{t('th_debit')}</th>
<th className="w-12 py-1 text-right">{t('th_credit')}</th>
</tr>
</thead>
<tbody>
{tt.lines.map((line: BookingTemplateLibraryLine, i: number) => (
<tr key={i} className="border-b border-border 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>
</SettingsGroup>
)
}