* feat: separate AR/AP/accounting into distinct nav groups (#92) Split the flat "Finans" sidebar group into three visually distinct sections — Försäljning (AR), Inköp (AP), and Redovisning — so users coming from Fortnox immediately find customer invoicing and supplier invoices as top-level concepts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: journal entry detail view, correction chain, and account name display - Add journal entry detail page at /bookkeeping/[id] with full entry view - Add correction chain API and component showing storno relationships - Add JournalEntryStatusBadge component for entry status display - Show debit/credit account names in template picker and review dialogs - Expand client-side BAS account name mapping with additional accounts - Show account codes on transaction inbox suggestion buttons Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback — N+1 query, duplicate name, nav dedup - Batch reverse-lookup into single query per BFS iteration (was N+1) - Differentiate account 2393 from 2893 in display names - Extract shared loop for desktop/mobile nav group rendering Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: counterparty templates, Skatteverket extension, VAT form completeness, and UI cleanup - Add counterparty-based categorization templates (learned from user approvals and auto-ingestion) with fuzzy matching in the mapping engine - Add Skatteverket extension for direct VAT declaration submission via API - Complete VAT declaration form with all 30 SKV 4700 boxes (ruta 08, 35-42, 50, 60-62) - Fix ruta 49 formula to include import VAT (ruta 60+61+62) - Simplify dashboard UI: remove redundant icons from stat cards, customer cards, invoice list, supplier invoices; use Badge variants consistently - Add SkatteverketPanel component to reports page - Add categorization_templates and skatteverket_tokens migrations - Update tests and helpers for new types Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review feedback — VAT detection, migration timestamps, dedup - Fix detectVatTreatment to derive actual rate (12%/6%) from VAT line description instead of hardcoding standard_25 - Rename skatteverket_tokens migration to 20260324120001 to avoid duplicate timestamp with categorization_templates (fixes Supabase deployment failure) - Make refreshAccessToken accept previousRefreshCount param to enforce refresh limit contract at the type level - Fix rate limiter TOCTOU by claiming slot before await - Extract formatRedovisare/formatRedovisningsperiod to shared lib/skatteverket/format.ts — eliminates duplication between mappers.ts and SkatteverketPanel.tsx Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: multi-line counterparty templates, batch matching, settings restructure Counterparty template engine: - Multi-line booking patterns (line_pattern JSONB) for complex entries with split VAT, tax accounts, and ratio-based allocation - Batch matching (findCounterpartyTemplatesBatch) — 1 DB query for all transactions instead of up to 3 per transaction - SIE voucher population (populateTemplatesFromSieVouchers) — extracts patterns from historical vouchers on import with dominance filtering - Source priority system (user_approved > sie_import > auto_learned) - Centralized counterparty: prefix helpers to prevent string fragility - Fix: re-approval path now updates line_pattern Transaction categorization: - /describe route returns counterparty_match in parallel with templates/AI - /categorize route accepts counterparty_template_id for direct booking - /suggest-categories uses batch matching, injects counterparty suggestions - transaction-entries supports all_lines_complete for multi-line patterns UI: - TemplatePicker shows "Tidigare motparter" section (no AI extension needed) - DescribeTransactionDialog shows counterparty match card with detail - QuickReviewDialog supports counterparty line patterns - JournalEntryPreview renders multi-line patterns with VAT/ratio math - Inline LinePatternEntry types replaced with shared import from @/types Settings restructure: - 8 tabs → 5: merged Säkerhet + Utseende + Kalender into Konto - Renamed "Motparter" → "Mallar" - CounterpartyTemplatesPanel: click-to-expand detail view with account lines, VAT, confidence, aliases, and delete Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — account_override guard, DELETE body parsing, stale test - Block account_override when counterparty_template_id is set (prevents corrupting stored template via override → upsert correction path) - Wrap DELETE request.json() in try-catch for malformed body (400 not 500) - Clean up stale 3-query mock enqueues in test for batch-based find Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useMemo } from 'react'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Search, ChevronDown, ChevronUp, AlertTriangle, Info } from 'lucide-react'
|
|
import {
|
|
getCommonTemplates,
|
|
getAdvancedTemplates,
|
|
searchTemplates,
|
|
type BookingTemplate,
|
|
type TemplateGroup,
|
|
} from '@/lib/bookkeeping/booking-templates'
|
|
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
|
import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
|
|
import { getAccountName } from '@/lib/bookkeeping/client-account-names'
|
|
import type { EntityType } from '@/types'
|
|
import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
|
|
|
const GROUP_ORDER: TemplateGroup[] = [
|
|
'premises', 'vehicle', 'it_software', 'office_supplies', 'marketing',
|
|
'travel', 'representation', 'insurance', 'professional_services',
|
|
'bank_finance', 'telecom', 'education', 'personnel', 'revenue',
|
|
'financial', 'private_transfers', 'equipment',
|
|
]
|
|
|
|
const GROUP_LABELS: Record<TemplateGroup, string> = {
|
|
premises: 'Lokalkostnader',
|
|
vehicle: 'Fordon',
|
|
it_software: 'IT & Programvara',
|
|
office_supplies: 'Kontorsmaterial',
|
|
marketing: 'Marknadsföring',
|
|
travel: 'Resor & Transport',
|
|
representation: 'Representation',
|
|
insurance: 'Försäkringar',
|
|
professional_services: 'Professionella tjänster',
|
|
bank_finance: 'Bank & Finans',
|
|
telecom: 'Telekom & Internet',
|
|
education: 'Utbildning',
|
|
personnel: 'Personal',
|
|
revenue: 'Intäkter',
|
|
financial: 'Finansiella poster',
|
|
private_transfers: 'Privata transaktioner',
|
|
equipment: 'Inventarier & Utrustning',
|
|
}
|
|
|
|
function getVatLabel(template: BookingTemplate): string | null {
|
|
if (!template.vat_treatment) return null
|
|
switch (template.vat_treatment) {
|
|
case 'standard_25': return '25% moms'
|
|
case 'reduced_12': return '12% moms'
|
|
case 'reduced_6': return '6% moms'
|
|
case 'reverse_charge': return 'Omvänd moms'
|
|
case 'export': return 'Momsfri (export)'
|
|
case 'exempt': return 'Momsfri'
|
|
default: return null
|
|
}
|
|
}
|
|
|
|
function groupTemplates(templates: BookingTemplate[]): Map<TemplateGroup, BookingTemplate[]> {
|
|
const grouped = new Map<TemplateGroup, BookingTemplate[]>()
|
|
for (const t of templates) {
|
|
const list = grouped.get(t.group) || []
|
|
list.push(t)
|
|
grouped.set(t.group, list)
|
|
}
|
|
return grouped
|
|
}
|
|
|
|
interface TemplateCardProps {
|
|
template: BookingTemplate
|
|
selected: boolean
|
|
onClick: () => void
|
|
compact?: boolean
|
|
}
|
|
|
|
function TemplateCard({ template, selected, onClick, compact }: TemplateCardProps) {
|
|
const vatLabel = getVatLabel(template)
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={`w-full text-left rounded-lg border px-3 py-2.5 transition-colors hover:bg-muted/50 ${
|
|
selected
|
|
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
|
: 'border-border'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<p className={`font-medium ${compact ? 'text-sm' : 'text-sm'} leading-tight`}>
|
|
{template.name_sv}
|
|
</p>
|
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
|
<span className="text-xs font-mono text-muted-foreground">
|
|
D: {formatAccountWithName(template.debit_account)} · K: {formatAccountWithName(template.credit_account)}
|
|
</span>
|
|
{vatLabel && (
|
|
<Badge
|
|
variant="secondary"
|
|
className={`text-[10px] px-1.5 py-0 ${
|
|
template.vat_treatment === 'reverse_charge'
|
|
? 'bg-warning/10 text-warning-foreground'
|
|
: ''
|
|
}`}
|
|
>
|
|
{vatLabel}
|
|
</Badge>
|
|
)}
|
|
{template.requires_vat_registration_data && (
|
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-warning/30 text-warning-foreground gap-0.5">
|
|
<AlertTriangle className="h-2.5 w-2.5" />
|
|
Kräver momsreg.nr
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{template.requires_review && (
|
|
<Info className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0 mt-0.5" />
|
|
)}
|
|
</div>
|
|
{template.special_rules_sv && !compact && (
|
|
<p className="text-[11px] text-muted-foreground mt-1.5 leading-snug">
|
|
{template.special_rules_sv}
|
|
</p>
|
|
)}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
interface TemplatePickerProps {
|
|
direction: 'expense' | 'income'
|
|
entityType?: EntityType
|
|
suggestedTemplates?: SuggestedTemplate[]
|
|
recentTemplateIds?: string[]
|
|
onSelect: (template: BookingTemplate) => void
|
|
onSelectCounterparty?: (templateId: string) => void
|
|
selectedTemplateId?: string
|
|
}
|
|
|
|
export default function TemplatePicker({
|
|
direction,
|
|
entityType,
|
|
suggestedTemplates,
|
|
onSelect,
|
|
onSelectCounterparty,
|
|
selectedTemplateId,
|
|
}: TemplatePickerProps) {
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [showAdvanced, setShowAdvanced] = useState(false)
|
|
|
|
// Map direction to template direction filter (transfers show in both)
|
|
const templateDirection = direction === 'income' ? 'income' : 'expense'
|
|
|
|
const commonTemplates = useMemo(
|
|
() => getCommonTemplates(entityType, templateDirection),
|
|
[entityType, templateDirection]
|
|
)
|
|
|
|
const advancedTemplates = useMemo(
|
|
() => getAdvancedTemplates(entityType, templateDirection),
|
|
[entityType, templateDirection]
|
|
)
|
|
|
|
// Also include transfer templates in both directions
|
|
const commonTransfers = useMemo(
|
|
() => getCommonTemplates(entityType, 'transfer'),
|
|
[entityType]
|
|
)
|
|
const advancedTransfers = useMemo(
|
|
() => getAdvancedTemplates(entityType, 'transfer'),
|
|
[entityType]
|
|
)
|
|
|
|
const allCommon = useMemo(
|
|
() => [...commonTemplates, ...commonTransfers],
|
|
[commonTemplates, commonTransfers]
|
|
)
|
|
const allAdvanced = useMemo(
|
|
() => [...advancedTemplates, ...advancedTransfers],
|
|
[advancedTemplates, advancedTransfers]
|
|
)
|
|
|
|
// Search results
|
|
const searchResults = useMemo(() => {
|
|
if (!searchQuery.trim()) return null
|
|
return searchTemplates(searchQuery, entityType).filter((t) => {
|
|
if (t.direction === templateDirection || t.direction === 'transfer') return true
|
|
return false
|
|
})
|
|
}, [searchQuery, entityType, templateDirection])
|
|
|
|
// Group templates by group for display
|
|
const commonGrouped = useMemo(() => groupTemplates(allCommon), [allCommon])
|
|
const advancedGrouped = useMemo(() => groupTemplates(allAdvanced), [allAdvanced])
|
|
|
|
const handleSelect = (template: BookingTemplate) => {
|
|
onSelect(template)
|
|
}
|
|
|
|
// Split suggestions: counterparty templates vs regular booking templates
|
|
const counterpartySuggestions = useMemo(() => {
|
|
if (!suggestedTemplates) return []
|
|
return suggestedTemplates.filter(s => isCounterpartyTemplateId(s.template_id))
|
|
}, [suggestedTemplates])
|
|
const resolvedSuggestions = useMemo(() => {
|
|
if (!suggestedTemplates) return []
|
|
return suggestedTemplates.filter(s => !isCounterpartyTemplateId(s.template_id))
|
|
}, [suggestedTemplates])
|
|
const hasCounterparty = counterpartySuggestions.length > 0 && !!onSelectCounterparty
|
|
const hasSuggestions = resolvedSuggestions.length > 0
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Search bar */}
|
|
<div className="relative px-4 pt-3 pb-2">
|
|
<Search className="absolute left-7 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground mt-0.5" />
|
|
<Input
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Sök mall..."
|
|
className="pl-9 h-9"
|
|
/>
|
|
</div>
|
|
|
|
{/* Scrollable content */}
|
|
<div className="flex-1 overflow-auto px-4 pb-4 space-y-4">
|
|
{/* Search results */}
|
|
{searchResults !== null ? (
|
|
<div>
|
|
<p className="text-xs font-medium text-muted-foreground mb-2">
|
|
{searchResults.length === 0 ? 'Inga resultat' : `${searchResults.length} resultat`}
|
|
</p>
|
|
<div className="space-y-1.5">
|
|
{searchResults.map((t) => (
|
|
<TemplateCard
|
|
key={t.id}
|
|
template={t}
|
|
selected={selectedTemplateId === t.id}
|
|
onClick={() => handleSelect(t)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Counterparty templates — learned from history */}
|
|
{hasCounterparty && (
|
|
<div>
|
|
<p className="text-xs font-medium text-muted-foreground mb-2">Tidigare motparter</p>
|
|
<div className="space-y-1.5">
|
|
{counterpartySuggestions.slice(0, 3).map((s) => (
|
|
<button
|
|
key={s.template_id}
|
|
type="button"
|
|
onClick={() => onSelectCounterparty!(s.template_id)}
|
|
className="w-full text-left rounded-lg border border-border px-3 py-2.5 transition-colors hover:bg-muted/50"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium text-sm leading-tight">{s.name_sv}</p>
|
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
|
{s.line_pattern && s.line_pattern.length > 0 ? (
|
|
s.line_pattern.filter(lp => lp.type === 'business').map((lp, i) => (
|
|
<span key={i} className="text-xs font-mono text-muted-foreground">
|
|
{formatAccountWithName(lp.account)}
|
|
</span>
|
|
))
|
|
) : (
|
|
<span className="text-xs font-mono text-muted-foreground">
|
|
D: {getAccountName(s.debit_account)} · K: {getAccountName(s.credit_account)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<span className="text-[10px] text-muted-foreground flex-shrink-0 mt-0.5">
|
|
{s.description_sv}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Suggested templates */}
|
|
{hasSuggestions && (
|
|
<div>
|
|
<p className="text-xs font-medium text-muted-foreground mb-2">Föreslagna</p>
|
|
<div className="space-y-1.5">
|
|
{resolvedSuggestions.slice(0, 5).map((s) => {
|
|
// Find the full template object
|
|
const fullTemplate = allCommon.find((t) => t.id === s.template_id) ||
|
|
allAdvanced.find((t) => t.id === s.template_id)
|
|
if (!fullTemplate) return null
|
|
return (
|
|
<TemplateCard
|
|
key={s.template_id}
|
|
template={fullTemplate}
|
|
selected={selectedTemplateId === s.template_id}
|
|
onClick={() => handleSelect(fullTemplate)}
|
|
compact
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Common templates grouped */}
|
|
<div>
|
|
<p className="text-xs font-medium text-muted-foreground mb-2">Vanliga mallar</p>
|
|
<div className="space-y-3">
|
|
{GROUP_ORDER.filter((g) => commonGrouped.has(g)).map((group) => (
|
|
<div key={group}>
|
|
<p className="text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wider mb-1">
|
|
{GROUP_LABELS[group]}
|
|
</p>
|
|
<div className="space-y-1.5">
|
|
{commonGrouped.get(group)!.map((t) => (
|
|
<TemplateCard
|
|
key={t.id}
|
|
template={t}
|
|
selected={selectedTemplateId === t.id}
|
|
onClick={() => handleSelect(t)}
|
|
compact
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Advanced templates (collapsible) */}
|
|
{allAdvanced.length > 0 && (
|
|
<div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="w-full justify-between text-xs text-muted-foreground h-8"
|
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
>
|
|
Fler mallar ({allAdvanced.length})
|
|
{showAdvanced ? (
|
|
<ChevronUp className="h-3.5 w-3.5" />
|
|
) : (
|
|
<ChevronDown className="h-3.5 w-3.5" />
|
|
)}
|
|
</Button>
|
|
{showAdvanced && (
|
|
<div className="space-y-3 mt-2">
|
|
{GROUP_ORDER.filter((g) => advancedGrouped.has(g)).map((group) => (
|
|
<div key={group}>
|
|
<p className="text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wider mb-1">
|
|
{GROUP_LABELS[group]}
|
|
</p>
|
|
<div className="space-y-1.5">
|
|
{advancedGrouped.get(group)!.map((t) => (
|
|
<TemplateCard
|
|
key={t.id}
|
|
template={t}
|
|
selected={selectedTemplateId === t.id}
|
|
onClick={() => handleSelect(t)}
|
|
compact
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|