From bf36ebfd882cf39d5b062b8ddb4beb38a3b5fe3e Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:42:14 +0200 Subject: [PATCH] feat: booking template library with system templates and cross-company sharing (#235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) * 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) Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- app/(dashboard)/settings/templates/page.tsx | 8 +- .../settings/booking-templates/[id]/route.ts | 60 +++ .../booking-templates/export/route.ts | 34 ++ .../booking-templates/import/route.ts | 83 +++ app/api/settings/booking-templates/route.ts | 123 +++++ .../bookkeeping/BookingTemplatePicker.tsx | 250 +++++++++ components/bookkeeping/JournalEntryForm.tsx | 55 +- components/settings/BookingTemplatesPanel.tsx | 490 ++++++++++++++++++ .../__tests__/template-library.test.ts | 98 ++++ lib/bookkeeping/template-library.ts | 78 +++ ...0260413160000_booking_template_library.sql | 386 ++++++++++++++ types/index.ts | 39 ++ 12 files changed, 1685 insertions(+), 19 deletions(-) create mode 100644 app/api/settings/booking-templates/[id]/route.ts create mode 100644 app/api/settings/booking-templates/export/route.ts create mode 100644 app/api/settings/booking-templates/import/route.ts create mode 100644 app/api/settings/booking-templates/route.ts create mode 100644 components/bookkeeping/BookingTemplatePicker.tsx create mode 100644 components/settings/BookingTemplatesPanel.tsx create mode 100644 lib/bookkeeping/__tests__/template-library.test.ts create mode 100644 lib/bookkeeping/template-library.ts create mode 100644 supabase/migrations/20260413160000_booking_template_library.sql diff --git a/app/(dashboard)/settings/templates/page.tsx b/app/(dashboard)/settings/templates/page.tsx index 52e32b09..3ea33122 100644 --- a/app/(dashboard)/settings/templates/page.tsx +++ b/app/(dashboard)/settings/templates/page.tsx @@ -1,7 +1,13 @@ 'use client' +import { BookingTemplatesPanel } from '@/components/settings/BookingTemplatesPanel' import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel' export default function TemplatesSettingsPage() { - return + return ( +
+ + +
+ ) } diff --git a/app/api/settings/booking-templates/[id]/route.ts b/app/api/settings/booking-templates/[id]/route.ts new file mode 100644 index 00000000..eab838ec --- /dev/null +++ b/app/api/settings/booking-templates/[id]/route.ts @@ -0,0 +1,60 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { requireWritePermission } from '@/lib/auth/require-write' +import { z } from 'zod' +import { validateBody } from '@/lib/api/validate' + +const BookingTemplateLineSchema = z.object({ + account: z.string().regex(/^\d{4}$/), + label: z.string().min(1), + side: z.enum(['debit', 'credit']), + type: z.enum(['business', 'vat', 'settlement']), + ratio: z.number().min(0).max(10).optional(), + vat_rate: z.number().min(0).max(1).optional(), +}) + +const UpdateBookingTemplateSchema = z.object({ + name: z.string().min(1).max(200).optional(), + description: z.string().max(2000).optional(), + category: z.enum([ + 'eu_trade', 'tax_account', 'private_transfer', + 'salary', 'representation', 'year_end', + 'vat', 'financial', 'other', + ]).optional(), + entity_type: z.enum(['all', 'enskild_firma', 'aktiebolag']).optional(), + lines: z.array(BookingTemplateLineSchema).min(2).optional(), +}) + +/** + * PUT /api/settings/booking-templates/[id] + * Update a non-system template. + */ +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const result = await validateBody(request, UpdateBookingTemplateSchema) + if (!result.success) return result.response + + // RLS prevents updating system templates + const { data, error } = await supabase + .from('booking_template_library') + .update(result.data) + .eq('id', id) + .eq('is_system', false) + .select() + .single() + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (!data) return NextResponse.json({ error: 'Template not found' }, { status: 404 }) + + return NextResponse.json({ data }) +} diff --git a/app/api/settings/booking-templates/export/route.ts b/app/api/settings/booking-templates/export/route.ts new file mode 100644 index 00000000..49ef6231 --- /dev/null +++ b/app/api/settings/booking-templates/export/route.ts @@ -0,0 +1,34 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' + +/** + * GET /api/settings/booking-templates/export + * Export company + team templates as JSON (excludes system templates). + * Useful for sharing templates between unrelated companies. + */ +export async function GET() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const companyId = await requireCompanyId(supabase, user.id) + + const { data, error } = await supabase + .from('booking_template_library') + .select('name, description, category, entity_type, lines') + .eq('company_id', companyId) + .eq('is_active', true) + .eq('is_system', false) + .order('category') + .order('name') + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + return new NextResponse(JSON.stringify({ version: 1, templates: data }, null, 2), { + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': 'attachment; filename="bokforingsmallar.json"', + }, + }) +} diff --git a/app/api/settings/booking-templates/import/route.ts b/app/api/settings/booking-templates/import/route.ts new file mode 100644 index 00000000..a89c9052 --- /dev/null +++ b/app/api/settings/booking-templates/import/route.ts @@ -0,0 +1,83 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { z } from 'zod' + +const ImportLineSchema = z.object({ + account: z.string().regex(/^\d{4}$/), + label: z.string().min(1), + side: z.enum(['debit', 'credit']), + type: z.enum(['business', 'vat', 'settlement']), + ratio: z.number().min(0).max(10).optional(), + vat_rate: z.number().min(0).max(1).optional(), +}) + +const ImportTemplateSchema = z.object({ + name: z.string().min(1).max(200), + description: z.string().max(2000).default(''), + category: z.enum([ + 'eu_trade', 'tax_account', 'private_transfer', + 'salary', 'representation', 'year_end', + 'vat', 'financial', 'other', + ]).default('other'), + entity_type: z.enum(['all', 'enskild_firma', 'aktiebolag']).default('all'), + lines: z.array(ImportLineSchema).min(2), +}) + +const ImportPayloadSchema = z.object({ + version: z.number(), + templates: z.array(ImportTemplateSchema).min(1).max(100), +}) + +/** + * POST /api/settings/booking-templates/import + * Import templates from JSON (exported from another company). + * Creates company-scoped templates for the active company. + */ +export async function POST(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } + + const parsed = ImportPayloadSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid import format', details: parsed.error.issues }, + { status: 400 }, + ) + } + + const rows = parsed.data.templates.map((t) => ({ + company_id: companyId, + team_id: null, + created_by: user.id, + name: t.name, + description: t.description, + category: t.category, + entity_type: t.entity_type, + lines: t.lines, + is_system: false, + })) + + const { data, error } = await supabase + .from('booking_template_library') + .insert(rows) + .select() + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + return NextResponse.json({ data, imported: data?.length ?? 0 }, { status: 201 }) +} diff --git a/app/api/settings/booking-templates/route.ts b/app/api/settings/booking-templates/route.ts new file mode 100644 index 00000000..fa4e0420 --- /dev/null +++ b/app/api/settings/booking-templates/route.ts @@ -0,0 +1,123 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { z } from 'zod' +import { validateBody } from '@/lib/api/validate' + +const BookingTemplateLineSchema = z.object({ + account: z.string().regex(/^\d{4}$/), + label: z.string().min(1), + side: z.enum(['debit', 'credit']), + type: z.enum(['business', 'vat', 'settlement']), + ratio: z.number().min(0).max(10).optional(), + vat_rate: z.number().min(0).max(1).optional(), +}) + +const CreateBookingTemplateSchema = z.object({ + name: z.string().min(1).max(200), + description: z.string().max(2000).default(''), + category: z.enum([ + 'eu_trade', 'tax_account', 'private_transfer', + 'salary', 'representation', 'year_end', + 'vat', 'financial', 'other', + ]).default('other'), + entity_type: z.enum(['all', 'enskild_firma', 'aktiebolag']).default('all'), + lines: z.array(BookingTemplateLineSchema).min(2), + team_id: z.string().uuid().optional(), +}) + +/** + * GET /api/settings/booking-templates + * Returns all templates visible to the current user: + * system + company + team templates. + */ +export async function GET() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + // RLS handles scoping (system OR company OR team) + const { data, error } = await supabase + .from('booking_template_library') + .select('*') + .eq('is_active', true) + .order('is_system', { ascending: false }) + .order('category') + .order('name') + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + return NextResponse.json({ data }) +} + +/** + * POST /api/settings/booking-templates + * Create a company-scoped or team-scoped template. + */ +export async function POST(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const result = await validateBody(request, CreateBookingTemplateSchema) + if (!result.success) return result.response + + const body = result.data + const companyId = body.team_id ? null : await requireCompanyId(supabase, user.id) + + const { data, error } = await supabase + .from('booking_template_library') + .insert({ + company_id: companyId, + team_id: body.team_id ?? null, + created_by: user.id, + name: body.name, + description: body.description, + category: body.category, + entity_type: body.entity_type, + lines: body.lines, + is_system: false, + }) + .select() + .single() + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + return NextResponse.json({ data }, { status: 201 }) +} + +/** + * DELETE /api/settings/booking-templates + * Soft-delete a template by id (company or team scope only, never system). + */ +export async function DELETE(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + let id: string | undefined + try { + const body = await request.json() + id = body?.id + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }) + + // RLS prevents deleting system templates (btl_delete policy checks NOT is_system) + const { error } = await supabase + .from('booking_template_library') + .update({ is_active: false }) + .eq('id', id) + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + return NextResponse.json({ data: { success: true } }) +} diff --git a/components/bookkeeping/BookingTemplatePicker.tsx b/components/bookkeeping/BookingTemplatePicker.tsx new file mode 100644 index 00000000..3c8f6925 --- /dev/null +++ b/components/bookkeeping/BookingTemplatePicker.tsx @@ -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([]) + const [isLoading, setIsLoading] = useState(false) + const [search, setSearch] = useState('') + const [selectedCategory, setSelectedCategory] = useState('all') + const [amount, setAmount] = useState('') + const [selectedId, setSelectedId] = useState(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 ( + + + + + + + Bokföringsmallar + + + {/* Search + category filter */} +
+
+ + setSearch(e.target.value)} + placeholder="Sök mall..." + className="pl-9" + /> +
+
+ + {availableCategories.map((cat) => ( + + ))} +
+
+ + {/* Template list */} +
+ {isLoading ? ( +

Laddar mallar...

+ ) : filtered.length === 0 ? ( +

Inga mallar hittades.

+ ) : ( + filtered.map((t) => { + const scope = getTemplateScope(t) + const ScopeIcon = SCOPE_ICONS[scope] + const isSelected = selectedId === t.id + return ( + + ) + }) + )} +
+ + {/* Apply section */} + {selected && ( +
+
+ + setAmount(e.target.value)} + placeholder="0,00" + min="0" + step="0.01" + inputMode="decimal" + autoFocus + /> +
+ +
+ )} +
+
+ ) +} diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index a7cbd35c..a3402ca6 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -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([]) 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({ - +
+ + +
{/* Entry lines — desktop table */} @@ -678,15 +692,20 @@ export default function JournalEntryForm({ - +
+ + +
{/* Document attachments */} diff --git a/components/settings/BookingTemplatesPanel.tsx b/components/settings/BookingTemplatesPanel.tsx new file mode 100644 index 00000000..deffbaf1 --- /dev/null +++ b/components/settings/BookingTemplatesPanel.tsx @@ -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 = { + all: 'Alla', + enskild_firma: 'Enskild firma', + aktiebolag: 'Aktiebolag', +} + +export function BookingTemplatesPanel() { + const { toast } = useToast() + const { canWrite } = useCanWrite() + + const [templates, setTemplates] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [deletingId, setDeletingId] = useState(null) + const [expandedId, setExpandedId] = useState(null) + const [showCreate, setShowCreate] = useState(false) + const importRef = useRef(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) { + 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 ( + + +
+
+ Bokföringsmallar + + Återanvändbara mallar för vanliga bokföringstransaktioner. Standardmallar visas för alla, egna mallar kan skapas och delas. + +
+ {canWrite && ( +
+ + + + + + + + + + Skapa bokföringsmall + + { + setShowCreate(false) + fetchTemplates() + }} + /> + + +
+ )} +
+
+ + {isLoading ? ( +
+ +
+ ) : templates.length === 0 ? ( +

+ Inga mallar hittades. +

+ ) : ( +
+ {/* System templates */} + {systemTemplates.length > 0 && ( + + )} + + {/* Team templates */} + {teamTemplates.length > 0 && ( + + )} + + {/* Company templates */} + {companyTemplates.length > 0 && ( + + )} +
+ )} +
+
+ ) +} + +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 ( +
+
+ +

{title}

+ {templates.length} +
+
+ {templates.map((t) => { + const isExpanded = expandedId === t.id + return ( +
+ + )} + + {isExpanded && ( +
+ {t.description && ( +

{t.description}

+ )} + + + + + + + + + + + + {t.lines.map((line: BookingTemplateLibraryLine, i: number) => ( + + + + + + + + ))} + +
KontoBeskrivningTypDebetKredit
{line.account}{line.label} + {line.type === 'vat' && line.vat_rate + ? `Moms ${(line.vat_rate * 100).toFixed(0)}%` + : line.type === 'settlement' ? 'Betalning' : 'Kostnad/Intäkt'} + {line.side === 'debit' ? 'D' : ''}{line.side === 'credit' ? 'K' : ''}
+
+ )} +
+ ) + })} +
+
+ ) +} + +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('other') + const [entityType, setEntityType] = useState<'all' | 'enskild_firma' | 'aktiebolag'>('all') + const [lines, setLines] = useState([ + { 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 ( +
+
+ + setName(e.target.value)} placeholder="T.ex. Inköp EU-varor" /> +
+
+ +