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>
This commit is contained in:
co-authored by
Claude Opus 4.6
greptile-apps[bot]
parent
cd376e1cad
commit
bf36ebfd88
@@ -0,0 +1,250 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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 { BookingTemplateLibrary, BookingTemplateCategory, EntityType } from '@/types'
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
|
||||
interface Props {
|
||||
onApply: (lines: FormLine[], description: string) => void
|
||||
entityType?: EntityType
|
||||
}
|
||||
|
||||
const SCOPE_ICONS = {
|
||||
system: Globe,
|
||||
team: Users,
|
||||
company: Building2,
|
||||
} as const
|
||||
|
||||
export default function BookingTemplatePicker({ onApply, entityType }: Props) {
|
||||
const { toast } = useToast()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState<BookingTemplateCategory | 'all'>('all')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
|
||||
const fetchTemplates = useCallback(async (signal?: AbortSignal) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const r = await fetch('/api/settings/booking-templates', { signal })
|
||||
const { data } = await r.json()
|
||||
setTemplates(data || [])
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
toast({ title: 'Fel', description: 'Kunde inte hämta mallar', variant: 'destructive' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const controller = new AbortController()
|
||||
fetchTemplates(controller.signal)
|
||||
return () => { controller.abort() }
|
||||
}, [open, fetchTemplates])
|
||||
|
||||
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)
|
||||
onApply(lines, selected.name)
|
||||
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 sm:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Sök mall..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</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="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{t.name}</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 shrink-0">
|
||||
<ScopeIcon className="h-3 w-3 mr-0.5" />
|
||||
{SCOPE_LABELS[scope]}
|
||||
</Badge>
|
||||
{t.entity_type !== 'all' && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 shrink-0">
|
||||
{t.entity_type === 'enskild_firma' ? 'EF' : 'AB'}
|
||||
</Badge>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -14,10 +14,12 @@ import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
||||
import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent'
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker'
|
||||
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency } from '@/types'
|
||||
|
||||
@@ -69,6 +71,7 @@ export default function JournalEntryForm({
|
||||
}: Props) {
|
||||
const { canWrite } = useCanWrite()
|
||||
const { toast } = useToast()
|
||||
const { company } = useCompany()
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('')
|
||||
const [entryDate, setEntryDate] = useState(initialDate ?? new Date().toISOString().split('T')[0])
|
||||
@@ -235,6 +238,11 @@ export default function JournalEntryForm({
|
||||
? Math.round(computedForeignAmount * rate * 100) / 100
|
||||
: 0
|
||||
|
||||
const handleTemplateApply = (templateLines: FormLine[], templateDescription: string) => {
|
||||
setLines(templateLines)
|
||||
if (!description) setDescription(templateDescription)
|
||||
}
|
||||
|
||||
const handleReview = () => {
|
||||
if (!selectedPeriod || !description || !isBalanced || periodMismatch) return
|
||||
const hasDocuments = uploadedFiles.some((f) => f.status === 'uploaded')
|
||||
@@ -575,15 +583,21 @@ export default function JournalEntryForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addLine}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Lägg till rad
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addLine}
|
||||
className="flex-1"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Lägg till rad
|
||||
</Button>
|
||||
<BookingTemplatePicker
|
||||
onApply={handleTemplateApply}
|
||||
entityType={company?.entity_type}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entry lines — desktop table */}
|
||||
@@ -678,15 +692,20 @@ export default function JournalEntryForm({
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addLine}
|
||||
className="mt-2"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Lägg till rad
|
||||
</Button>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addLine}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Lägg till rad
|
||||
</Button>
|
||||
<BookingTemplatePicker
|
||||
onApply={handleTemplateApply}
|
||||
entityType={company?.entity_type}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document attachments */}
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user