'use client' import { useState, useMemo, useEffect } from 'react' import { useTranslations } from 'next-intl' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Search, ChevronDown, ChevronUp, AlertTriangle, Info, Building2, PenLine } 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 { convertLibraryToBookingTemplate, LIBRARY_TEMPLATE_PREFIX, isLibraryTemplateId } from '@/lib/bookkeeping/template-library' import { getAccountName } from '@/lib/bookkeeping/client-account-names' import type { BookingTemplateLibrary, 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_LABEL_KEYS: Record = { premises: 'group_premises', vehicle: 'group_vehicle', it_software: 'group_it_software', office_supplies: 'group_office_supplies', marketing: 'group_marketing', travel: 'group_travel', representation: 'group_representation', insurance: 'group_insurance', professional_services: 'group_professional_services', bank_finance: 'group_bank_finance', telecom: 'group_telecom', education: 'group_education', personnel: 'group_personnel', revenue: 'group_revenue', financial: 'group_financial', private_transfers: 'group_private_transfers', equipment: 'group_equipment', } function getVatLabelKey(template: BookingTemplate): string | null { if (!template.vat_treatment) return null switch (template.vat_treatment) { case 'standard_25': return 'vat_standard_25' case 'reduced_12': return 'vat_reduced_12' case 'reduced_6': return 'vat_reduced_6' case 'reverse_charge': return 'vat_reverse_charge' case 'export': return 'vat_export' case 'exempt': return 'vat_exempt' default: return null } } // Reverse charge (omvänd moms) carries the ochre emphasis via the sanctioned // `warning` variant; every other VAT treatment uses the neutral `secondary`. function getVatBadgeVariant(vatTreatment: string | null | undefined): 'secondary' | 'warning' { return vatTreatment === 'reverse_charge' ? 'warning' : 'secondary' } function groupTemplates(templates: BookingTemplate[]): Map { const grouped = new Map() 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 } interface LibraryTemplateCardProps { raw: BookingTemplateLibrary converted: BookingTemplate | null selected: boolean onClick: () => void } function LibraryTemplateCard({ raw, converted, selected, onClick }: LibraryTemplateCardProps) { const t = useTranslations('tx_template_picker') // Convertible templates render the familiar two-account summary; complex // ones list the business legs (the cost/revenue accounts) so the user can // recognise the template at a glance, and carry an "opens editor" badge. const businessLines = raw.lines.filter((l) => l.type === 'business') const vatLabelKey = converted ? getVatLabelKey(converted) : null return ( ) } function TemplateCard({ template, selected, onClick, compact }: TemplateCardProps) { const t = useTranslations('tx_template_picker') const vatLabelKey = getVatLabelKey(template) return ( ) } interface TemplatePickerProps { direction: 'expense' | 'income' entityType?: EntityType suggestedTemplates?: SuggestedTemplate[] recentTemplateIds?: string[] onSelect: (template: BookingTemplate) => void onSelectCounterparty?: (templateId: string) => void onPickLibraryTemplate?: (raw: BookingTemplateLibrary) => void selectedTemplateId?: string } export default function TemplatePicker({ direction, entityType, suggestedTemplates, onSelect, onSelectCounterparty, onPickLibraryTemplate, selectedTemplateId, }: TemplatePickerProps) { const t = useTranslations('tx_template_picker') const [searchQuery, setSearchQuery] = useState('') const [showAdvanced, setShowAdvanced] = useState(false) const [libraryRaw, setLibraryRaw] = useState([]) // Map direction to template direction filter (transfers show in both). // Direction filtering applies only to the static "Vanliga mallar" list: // user-created library templates ignore it (inferred direction is unreliable // and users know what they made). const templateDirection = direction === 'income' ? 'income' : 'expense' // Fetch the user's library templates (company + team scope). We keep them // in their raw shape so we can render every template, even ones that don't // fit convertLibraryToBookingTemplate's simple 2-account contract: those // get routed through the manual booking dialog instead of the QuickReview // single-account path. useEffect(() => { const controller = new AbortController() ;(async () => { try { const res = await fetch('/api/settings/booking-templates', { signal: controller.signal }) if (!res.ok) return const { data } = await res.json() as { data?: BookingTemplateLibrary[] } if (!data) return setLibraryRaw(data.filter((tt) => !tt.is_system && tt.is_active)) } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return } })() return () => { controller.abort() } }, []) // Lazy convertibility map. A template is "convertible" if it fits the // simple debit/credit pair shape the QuickReview booking path expects. // Non-convertible templates are still shown: they just route to the // full journal-entry editor on click. const convertedById = useMemo(() => { const m = new Map() for (const raw of libraryRaw) m.set(raw.id, convertLibraryToBookingTemplate(raw)) return m }, [libraryRaw]) 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] ) // Library templates filtered by entity_type only. Direction is NOT applied // here: see the comment on convertedById above. const relevantLibraryRaw = useMemo(() => { return libraryRaw.filter((tt) => { if (entityType && tt.entity_type && tt.entity_type !== 'all' && tt.entity_type !== entityType) { return false } return true }) }, [libraryRaw, entityType]) // Convertible templates surface first; within each group, sort by name. const sortedLibraryRaw = useMemo(() => { return [...relevantLibraryRaw].sort((a, b) => { const ac = convertedById.get(a.id) ? 0 : 1 const bc = convertedById.get(b.id) ? 0 : 1 if (ac !== bc) return ac - bc return a.name.localeCompare(b.name, 'sv') }) }, [relevantLibraryRaw, convertedById]) // Search results (static + library). Library search ignores direction; the // static catalog still respects it because it's curated content. const searchResults = useMemo< | { library: BookingTemplateLibrary[]; staticTemplates: BookingTemplate[] } | null >(() => { if (!searchQuery.trim()) return null const q = searchQuery.toLowerCase() const libraryMatches = sortedLibraryRaw.filter((tt) => tt.name.toLowerCase().includes(q) || (tt.description ?? '').toLowerCase().includes(q) ) const staticMatches = searchTemplates(searchQuery, entityType).filter((tt) => { return tt.direction === templateDirection || tt.direction === 'transfer' }) return { library: libraryMatches, staticTemplates: staticMatches } }, [searchQuery, entityType, templateDirection, sortedLibraryRaw]) // Group templates by group for display const commonGrouped = useMemo(() => groupTemplates(allCommon), [allCommon]) const advancedGrouped = useMemo(() => groupTemplates(allAdvanced), [allAdvanced]) const bumpLibraryMru = (libraryId: string) => { fetch(`/api/settings/booking-templates/${libraryId}/touch`, { method: 'POST' }).catch(() => {}) } const handleSelect = (template: BookingTemplate) => { if (isLibraryTemplateId(template.id)) { bumpLibraryMru(template.id.slice(LIBRARY_TEMPLATE_PREFIX.length)) } onSelect(template) } // Click a raw library card. Always book a user's mall from its LITERAL lines // via the journal-entry editor (onPickLibraryTemplate → applyTemplate → /book), // for both convertible and non-convertible shapes. // // The old "convertible → onSelect(converted)" branch routed through the // QuickReview fast path, which books a single category + one account_override // and silently discards the template's chosen debit/credit. A kundinbetalning // mall (D 1930 / K 1510) came out as a generic cost (D 6991 / K 1930), or with // a VAT line as D 1930 / K 1930 / K 2611, and the result flipped with the // direction the converter happened to infer from the business/settlement tags. // Routing every library template through the editor books exactly the accounts // the user defined, independent of those tags. See template-library.test.ts. // // MRU is only bumped once we know the click will do something: otherwise a // consumer that omits onPickLibraryTemplate would reorder MRU for a template // the user never actually applied. const handleSelectLibraryRaw = (raw: BookingTemplateLibrary) => { if (onPickLibraryTemplate) { bumpLibraryMru(raw.id) onPickLibraryTemplate(raw) return } // Fallback only for consumers that didn't wire the editor path: fall back to // the lossy converted shape rather than leaving the click dead. The single // render site (the transactions page) always passes onPickLibraryTemplate, // so this branch is not reached in the app today. const converted = convertedById.get(raw.id) ?? null if (converted) { bumpLibraryMru(raw.id) onSelect(converted) } } // 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 (
{/* Search bar */}
setSearchQuery(e.target.value)} placeholder={t('search_placeholder')} className="pl-9 h-9" />
{/* Scrollable content */}
{/* Search results */} {searchResults !== null ? ( (() => { const totalResults = searchResults.library.length + searchResults.staticTemplates.length return (

{totalResults === 0 ? t('no_results') : t('n_results', { count: totalResults })}

{searchResults.library.map((raw) => ( handleSelectLibraryRaw(raw)} /> ))} {searchResults.staticTemplates.map((tt) => ( handleSelect(tt)} /> ))}
) })() ) : ( <> {/* User-created library templates (company + team scope). Direction is intentionally NOT applied here: all the user's own templates are shown regardless of expense/income context. */} {sortedLibraryRaw.length > 0 && (

{t('my_templates')}

{sortedLibraryRaw.map((raw) => ( handleSelectLibraryRaw(raw)} /> ))}
)} {/* Counterparty templates: learned from history */} {hasCounterparty && (

{t('previous_counterparties')}

{counterpartySuggestions.slice(0, 3).map((s) => ( ))}
)} {/* Suggested templates */} {hasSuggestions && (

{t('suggested')}

{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 ( handleSelect(fullTemplate)} compact /> ) })}
)} {/* Common templates grouped */}

{t('common_templates')}

{GROUP_ORDER.filter((g) => commonGrouped.has(g)).map((group) => (

{t(GROUP_LABEL_KEYS[group])}

{commonGrouped.get(group)!.map((t) => ( handleSelect(t)} compact /> ))}
))}
{/* Advanced templates (collapsible) */} {allAdvanced.length > 0 && (
{showAdvanced && (
{GROUP_ORDER.filter((g) => advancedGrouped.has(g)).map((group) => (

{t(GROUP_LABEL_KEYS[group])}

{advancedGrouped.get(group)!.map((t) => ( handleSelect(t)} compact /> ))}
))}
)}
)} )}
) }