Files
accounted/components/settings/BookingTemplatesPanel.tsx
T
Mattsson bf36ebfd88 feat: booking template library with system templates and cross-company sharing (#235)
* feat: implement viewer role permissions for bank transaction imports and connections

* feat: add booking_template_library table with 30 system templates

Three-level scoping (system/team/company), RLS policies for
read/write/delete, and pre-seeded templates for EU reverse charge,
tax account, private transfers, salary, representation, year-end,
VAT netting, and bank/finance scenarios.

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

* feat: add template library types, helpers, and tests

- BookingTemplateLibrary/Line/Category types in types/index.ts
- applyTemplate() converts template lines + amount into form lines
- Category labels, scope helpers for UI display
- 8 unit tests for amount calculation, VAT, rounding, and scope detection

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

* feat: add booking template CRUD, export, and import API routes

- GET/POST/DELETE /api/settings/booking-templates (list, create, soft-delete)
- PUT /api/settings/booking-templates/[id] (update non-system templates)
- GET /api/settings/booking-templates/export (JSON download)
- POST /api/settings/booking-templates/import (bulk import from JSON)

All routes enforce auth, write permissions, and Zod validation.

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

* feat: add template picker UI and settings management panel

- BookingTemplatePicker: dialog with search, category/entity-type filter,
  line preview, and amount input — integrated into JournalEntryForm
- BookingTemplatesPanel: settings page with grouped templates
  (system/team/company), create dialog, export/import, soft-delete
- Settings templates page now shows both booking and counterparty templates

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

* Update supabase/migrations/20260413160000_booking_template_library.sql

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-14 09:42:14 +02:00

491 lines
18 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback, useRef } 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 } from 'lucide-react'
import { TEMPLATE_CATEGORY_LABELS } from '@/lib/bookkeeping/template-library'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { BookingTemplateLibrary, BookingTemplateCategory, BookingTemplateLibraryLine } from '@/types'
const ENTITY_LABELS: Record<string, string> = {
all: 'Alla',
enskild_firma: 'Enskild firma',
aktiebolag: 'Aktiebolag',
}
export function BookingTemplatesPanel() {
const { toast } = useToast()
const { canWrite } = useCanWrite()
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 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: 'Fel', description: 'Kunde inte hämta mallar', variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [toast])
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: 'Fel', description: 'Kunde inte ta bort mall', variant: 'destructive' })
return
}
setTemplates((prev) => prev.filter((t) => t.id !== id))
toast({ title: 'Mall borttagen' })
} 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: 'Fel', description: 'Kunde inte exportera mallar', 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: 'Importfel', description: json.error || 'Kunde inte importera', variant: 'destructive' })
return
}
toast({ title: 'Import klar', description: `${json.imported} mall(ar) importerade.` })
fetchTemplates()
} catch {
toast({ title: 'Importfel', description: 'Ogiltig fil', 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((t) => t.is_system)
const teamTemplates = templates.filter((t) => t.team_id && !t.is_system)
const companyTemplates = templates.filter((t) => t.company_id && !t.is_system)
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Bokföringsmallar</CardTitle>
<CardDescription>
Återanvändbara mallar för vanliga bokföringstransaktioner. Standardmallar visas för alla, egna mallar kan skapas och delas.
</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" />
Exportera
</Button>
<Button variant="outline" size="sm" onClick={() => importRef.current?.click()}>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Importera
</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" />
Ny mall
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Skapa bokföringsmall</DialogTitle>
</DialogHeader>
<CreateTemplateForm
onCreated={() => {
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">
Inga mallar hittades.
</p>
) : (
<div className="space-y-6">
{/* System templates */}
{systemTemplates.length > 0 && (
<TemplateSection
title="Standardmallar"
icon={Globe}
templates={systemTemplates}
expandedId={expandedId}
onToggle={setExpandedId}
deletingId={deletingId}
onDelete={handleDelete}
canDelete={false}
/>
)}
{/* Team templates */}
{teamTemplates.length > 0 && (
<TemplateSection
title="Teammallar"
icon={Users}
templates={teamTemplates}
expandedId={expandedId}
onToggle={setExpandedId}
deletingId={deletingId}
onDelete={handleDelete}
canDelete={canWrite}
/>
)}
{/* Company templates */}
{companyTemplates.length > 0 && (
<TemplateSection
title="Företagsmallar"
icon={Building2}
templates={companyTemplates}
expandedId={expandedId}
onToggle={setExpandedId}
deletingId={deletingId}
onDelete={handleDelete}
canDelete={canWrite}
/>
)}
</div>
)}
</CardContent>
</Card>
)
}
function TemplateSection({
title,
icon: Icon,
templates,
expandedId,
onToggle,
deletingId,
onDelete,
canDelete,
}: {
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
}) {
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((t) => {
const isExpanded = expandedId === t.id
return (
<div
key={t.id}
className="rounded-lg border"
>
<button
type="button"
onClick={() => onToggle(isExpanded ? null : t.id)}
className="w-full flex items-center gap-3 p-3 text-left hover:bg-muted/50 transition-colors"
>
<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">{t.name}</span>
<div className="flex items-center gap-1.5 mt-0.5">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{TEMPLATE_CATEGORY_LABELS[t.category]}
</Badge>
{t.entity_type !== 'all' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{ENTITY_LABELS[t.entity_type]}
</Badge>
)}
</div>
</div>
{canDelete && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
onDelete(t.id)
}}
disabled={deletingId === t.id}
className="h-8 w-8 p-0 shrink-0"
>
{deletingId === t.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
)}
</button>
{isExpanded && (
<div className="px-3 pb-3 pt-0">
{t.description && (
<p className="text-xs text-muted-foreground mb-2">{t.description}</p>
)}
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b">
<th className="text-left py-1 w-14">Konto</th>
<th className="text-left py-1">Beskrivning</th>
<th className="text-center py-1 w-16">Typ</th>
<th className="text-right py-1 w-12">Debet</th>
<th className="text-right py-1 w-12">Kredit</th>
</tr>
</thead>
<tbody>
{t.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
? `Moms ${(line.vat_rate * 100).toFixed(0)}%`
: line.type === 'settlement' ? 'Betalning' : 'Kostnad/Intäkt'}
</td>
<td className="py-1 text-right">{line.side === 'debit' ? 'D' : ''}</td>
<td className="py-1 text-right">{line.side === 'credit' ? 'K' : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
})}
</div>
</div>
)
}
function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
const { toast } = useToast()
const [isSubmitting, setIsSubmitting] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState<BookingTemplateCategory>('other')
const [entityType, setEntityType] = useState<'all' | 'enskild_firma' | 'aktiebolag'>('all')
const [lines, setLines] = useState<BookingTemplateLibraryLine[]>([
{ 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 addLine() {
setLines((prev) => [...prev, { account: '', label: '', side: 'debit', type: 'business', ratio: 1 }])
}
function removeLine(index: number) {
if (lines.length <= 2) return
setLines((prev) => prev.filter((_, i) => i !== index))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!name || lines.some((l) => !l.account || !l.label)) {
toast({ title: 'Fyll i alla fält', variant: 'destructive' })
return
}
setIsSubmitting(true)
try {
const res = await fetch('/api/settings/booking-templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, category, entity_type: entityType, lines }),
})
if (!res.ok) {
const json = await res.json()
toast({ title: 'Fel', description: json.error || 'Kunde inte skapa mall', variant: 'destructive' })
return
}
toast({ title: 'Mall skapad' })
onCreated()
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label>Namn</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="T.ex. Inköp EU-varor" />
</div>
<div>
<Label>Beskrivning <span className="text-muted-foreground font-normal">(valfritt)</span></Label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="När ska denna mall användas?" rows={2} className="resize-none" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Kategori</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>Företagstyp</Label>
<Select value={entityType} onValueChange={(v) => setEntityType(v as typeof entityType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(ENTITY_LABELS).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>Rader</Label>
<div className="space-y-2 mt-1">
{lines.map((line, i) => (
<div key={i} 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="Konto"
className="w-20 font-mono"
maxLength={4}
/>
<Input
value={line.label}
onChange={(e) => updateLine(i, 'label', e.target.value)}
placeholder="Beskrivning"
className="flex-1"
/>
<Select value={line.side} onValueChange={(v) => updateLine(i, 'side', v)}>
<SelectTrigger className="w-20"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="debit">Debet</SelectItem>
<SelectItem value="credit">Kredit</SelectItem>
</SelectContent>
</Select>
<Select value={line.type} onValueChange={(v) => updateLine(i, 'type', v)}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="business">Kostnad</SelectItem>
<SelectItem value="vat">Moms</SelectItem>
<SelectItem value="settlement">Betalning</SelectItem>
</SelectContent>
</Select>
<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>
))}
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="h-3 w-3 mr-1" />
Lägg till rad
</Button>
</div>
</div>
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Skapa mall
</Button>
</form>
)
}