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>
463 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|