New classification logic etc
This commit is contained in:
@@ -0,0 +1,241 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { createClient } from '@/lib/supabase/client'
|
||||||
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
|
import { DeadlineList } from '@/components/deadlines/DeadlineList'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { AlertTriangle, ArrowRight } from 'lucide-react'
|
||||||
|
import type { Deadline } from '@/types'
|
||||||
|
|
||||||
|
export default function DeadlinesPage() {
|
||||||
|
const [deadlines, setDeadlines] = useState<Deadline[]>([])
|
||||||
|
const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
|
||||||
|
const [overdueInvoices, setOverdueInvoices] = useState<{ count: number; total: number }>({ count: 0, total: 0 })
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const { toast } = useToast()
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch deadlines with customer names
|
||||||
|
const { data: deadlinesData, error: deadlinesError } = await supabase
|
||||||
|
.from('deadlines')
|
||||||
|
.select('*, customer:customers(name)')
|
||||||
|
.order('due_date', { ascending: true })
|
||||||
|
|
||||||
|
if (deadlinesError) throw deadlinesError
|
||||||
|
|
||||||
|
// Fetch customers for the form
|
||||||
|
const { data: customersData, error: customersError } = await supabase
|
||||||
|
.from('customers')
|
||||||
|
.select('id, name')
|
||||||
|
.order('name', { ascending: true })
|
||||||
|
|
||||||
|
if (customersError) throw customersError
|
||||||
|
|
||||||
|
// Fetch overdue invoices summary
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
const { data: overdueData, error: overdueError } = await supabase
|
||||||
|
.from('invoices')
|
||||||
|
.select('total_sek, total')
|
||||||
|
.in('status', ['sent', 'unpaid'])
|
||||||
|
.lt('due_date', today)
|
||||||
|
|
||||||
|
if (overdueError) throw overdueError
|
||||||
|
|
||||||
|
const overdueCount = overdueData?.length || 0
|
||||||
|
const overdueTotal = (overdueData || []).reduce(
|
||||||
|
(sum, inv) => sum + (inv.total_sek || inv.total || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
setDeadlines(deadlinesData || [])
|
||||||
|
setCustomers(customersData || [])
|
||||||
|
setOverdueInvoices({ count: overdueCount, total: overdueTotal })
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Fel',
|
||||||
|
description: 'Kunde inte hamta data',
|
||||||
|
variant: 'destructive',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [supabase, toast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
const handleDeadlineCreate = async (
|
||||||
|
data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'>
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/deadlines', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = await response.json()
|
||||||
|
throw new Error(result.error || 'Failed to create deadline')
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Deadline skapad',
|
||||||
|
description: 'Din deadline har sparats',
|
||||||
|
})
|
||||||
|
|
||||||
|
fetchData()
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Fel',
|
||||||
|
description: error instanceof Error ? error.message : 'Kunde inte skapa deadline',
|
||||||
|
variant: 'destructive',
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeadlineToggle = async (deadline: Deadline) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/deadlines/${deadline.id}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ is_completed: !deadline.is_completed }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = await response.json()
|
||||||
|
throw new Error(result.error || 'Failed to toggle deadline')
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: deadline.is_completed ? 'Markerad som ej klar' : 'Markerad som klar',
|
||||||
|
})
|
||||||
|
|
||||||
|
fetchData()
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Fel',
|
||||||
|
description: error instanceof Error ? error.message : 'Kunde inte uppdatera deadline',
|
||||||
|
variant: 'destructive',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeadlineEdit = async (deadline: Deadline) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/deadlines/${deadline.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(deadline),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = await response.json()
|
||||||
|
throw new Error(result.error || 'Failed to edit deadline')
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Deadline uppdaterad',
|
||||||
|
description: 'Dina andringar har sparats',
|
||||||
|
})
|
||||||
|
|
||||||
|
fetchData()
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Fel',
|
||||||
|
description: error instanceof Error ? error.message : 'Kunde inte uppdatera deadline',
|
||||||
|
variant: 'destructive',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeadlineDelete = async (deadline: Deadline) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/deadlines/${deadline.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = await response.json()
|
||||||
|
throw new Error(result.error || 'Failed to delete deadline')
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Deadline borttagen',
|
||||||
|
})
|
||||||
|
|
||||||
|
fetchData()
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Fel',
|
||||||
|
description: error instanceof Error ? error.message : 'Kunde inte ta bort deadline',
|
||||||
|
variant: 'destructive',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Deadlines</h1>
|
||||||
|
</div>
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-10 bg-muted rounded w-48 mb-4" />
|
||||||
|
<div className="h-96 bg-muted rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Deadlines</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{overdueInvoices.count > 0 && (
|
||||||
|
<Link href="/invoices?status=unpaid" className="block group">
|
||||||
|
<Card className="border-destructive/50 bg-destructive/5 hover:bg-destructive/10 transition-colors">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Forfallna fakturor</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{overdueInvoices.count} st totalt{' '}
|
||||||
|
{overdueInvoices.total.toLocaleString('sv-SE')} kr
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="destructive">{overdueInvoices.count}</Badge>
|
||||||
|
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:translate-x-0.5 transition-transform" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DeadlineList
|
||||||
|
deadlines={deadlines}
|
||||||
|
customers={customers}
|
||||||
|
onDeadlineCreate={handleDeadlineCreate}
|
||||||
|
onDeadlineToggle={handleDeadlineToggle}
|
||||||
|
onDeadlineEdit={handleDeadlineEdit}
|
||||||
|
onDeadlineDelete={handleDeadlineDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -252,6 +252,13 @@ export default async function DashboardPage() {
|
|||||||
streak_count: streakCount,
|
streak_count: streakCount,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch enabled extension toggles
|
||||||
|
const { data: enabledToggles } = await supabase
|
||||||
|
.from('extension_toggles')
|
||||||
|
.select('sector_slug, extension_slug')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('enabled', true)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardContent
|
<DashboardContent
|
||||||
firstName={firstName}
|
firstName={firstName}
|
||||||
@@ -272,6 +279,7 @@ export default async function DashboardPage() {
|
|||||||
missingUnderlagCount,
|
missingUnderlagCount,
|
||||||
}}
|
}}
|
||||||
onboardingProgress={onboardingProgress}
|
onboardingProgress={onboardingProgress}
|
||||||
|
enabledExtensions={enabledToggles || []}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { seedAllTemplateEmbeddings, getSchemaVersion } from '@/lib/bookkeeping/template-embeddings'
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const authHeader = request.headers.get('authorization')
|
||||||
|
const cronSecret = process.env.CRON_SECRET
|
||||||
|
|
||||||
|
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { seeded, errors } = await seedAllTemplateEmbeddings()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: errors.length === 0,
|
||||||
|
seeded,
|
||||||
|
errors,
|
||||||
|
schema_version: getSchemaVersion(),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Seeding failed: ${error instanceof Error ? error.message : 'Unknown error'}` },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,7 +104,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suggestions[tx.id] = result
|
suggestions[tx.id] = result
|
||||||
template_suggestions[tx.id] = getSuggestedTemplates(tx as Transaction, entityType)
|
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType)
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ suggestions, template_suggestions })
|
return NextResponse.json({ suggestions, template_suggestions })
|
||||||
|
|||||||
@@ -198,6 +198,13 @@ export default async function RootPage() {
|
|||||||
streak_count: streakCount,
|
streak_count: streakCount,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch enabled extension toggles
|
||||||
|
const { data: enabledToggles } = await supabase
|
||||||
|
.from('extension_toggles')
|
||||||
|
.select('sector_slug, extension_slug')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('enabled', true)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<DashboardNav companyName={settings.company_name || 'Min verksamhet'} entityType={settings.entity_type || 'enskild_firma'} />
|
<DashboardNav companyName={settings.company_name || 'Min verksamhet'} entityType={settings.entity_type || 'enskild_firma'} />
|
||||||
@@ -220,6 +227,7 @@ export default async function RootPage() {
|
|||||||
receiptQueue,
|
receiptQueue,
|
||||||
missingUnderlagCount,
|
missingUnderlagCount,
|
||||||
}}
|
}}
|
||||||
|
enabledExtensions={enabledToggles || []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ChatWidget />
|
<ChatWidget />
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
export { CalendarHeader } from './CalendarHeader'
|
|
||||||
export { CalendarGrid } from './CalendarGrid'
|
|
||||||
export { CalendarDayCell } from './CalendarDayCell'
|
|
||||||
export { PaymentCalendar } from './PaymentCalendar'
|
|
||||||
export { PaymentSummaryCard } from './PaymentSummaryCard'
|
|
||||||
export { DayDetailModal } from './DayDetailModal'
|
|
||||||
export { DeadlineCard } from './DeadlineCard'
|
|
||||||
export { DeadlineFilters } from './DeadlineFilters'
|
|
||||||
export { DeadlineForm } from './DeadlineForm'
|
|
||||||
export { DeadlineList } from './DeadlineList'
|
|
||||||
export { UpcomingDeadlinesWidget } from './UpcomingDeadlinesWidget'
|
|
||||||
export { TaxTodoWidget } from './TaxTodoWidget'
|
|
||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
getEnhancedTaxWarningStatus
|
getEnhancedTaxWarningStatus
|
||||||
} from '@/lib/tax/calculator'
|
} from '@/lib/tax/calculator'
|
||||||
import FSkattWarningCard from '@/components/dashboard/FSkattWarningCard'
|
import FSkattWarningCard from '@/components/dashboard/FSkattWarningCard'
|
||||||
import { UpcomingDeadlinesWidget } from '@/components/calendar/UpcomingDeadlinesWidget'
|
import { UpcomingDeadlinesWidget } from '@/components/deadlines/UpcomingDeadlinesWidget'
|
||||||
|
import { TaxTodoWidget } from '@/components/deadlines/TaxTodoWidget'
|
||||||
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
|
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
|
||||||
import {
|
import {
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -27,9 +28,11 @@ import {
|
|||||||
Landmark,
|
Landmark,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
MessageCircle,
|
|
||||||
FileWarning,
|
FileWarning,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { getExtensionDefinition } from '@/lib/extensions/sectors'
|
||||||
|
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||||
|
import type { QuickActionDefinition } from '@/lib/extensions/types'
|
||||||
import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
|
import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
|
||||||
|
|
||||||
interface DashboardContentProps {
|
interface DashboardContentProps {
|
||||||
@@ -51,11 +54,31 @@ interface DashboardContentProps {
|
|||||||
missingUnderlagCount: number
|
missingUnderlagCount: number
|
||||||
}
|
}
|
||||||
onboardingProgress?: OnboardingProgress
|
onboardingProgress?: OnboardingProgress
|
||||||
|
enabledExtensions?: { sector_slug: string; extension_slug: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardContent({ firstName, settings, summary, onboardingProgress }: DashboardContentProps) {
|
export default function DashboardContent({ firstName, settings, summary, onboardingProgress, enabledExtensions }: DashboardContentProps) {
|
||||||
const [showAllAlerts, setShowAllAlerts] = useState(false)
|
const [showAllAlerts, setShowAllAlerts] = useState(false)
|
||||||
const [showMore, setShowMore] = useState(false)
|
const [showMore, setShowMore] = useState(false)
|
||||||
|
const [liveExtensions, setLiveExtensions] = useState(enabledExtensions ?? [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLiveExtensions(enabledExtensions ?? [])
|
||||||
|
}, [enabledExtensions])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = ((e: CustomEvent<{ sector_slug: string; extension_slug: string; enabled: boolean }>) => {
|
||||||
|
setLiveExtensions(prev => {
|
||||||
|
if (e.detail.enabled) {
|
||||||
|
if (prev.some(x => x.sector_slug === e.detail.sector_slug && x.extension_slug === e.detail.extension_slug)) return prev
|
||||||
|
return [...prev, { sector_slug: e.detail.sector_slug, extension_slug: e.detail.extension_slug }]
|
||||||
|
}
|
||||||
|
return prev.filter(x => !(x.sector_slug === e.detail.sector_slug && x.extension_slug === e.detail.extension_slug))
|
||||||
|
})
|
||||||
|
}) as EventListener
|
||||||
|
window.addEventListener('extension-toggle-changed', handler)
|
||||||
|
return () => window.removeEventListener('extension-toggle-changed', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||||
const preliminaryTaxMonthly = settings?.preliminary_tax_monthly || 0
|
const preliminaryTaxMonthly = settings?.preliminary_tax_monthly || 0
|
||||||
@@ -205,7 +228,15 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
|||||||
const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
|
const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
|
||||||
const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS
|
const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS
|
||||||
|
|
||||||
const openAiChat = () => window.dispatchEvent(new Event('open-ai-chat'))
|
// Build extension quick actions from enabled extensions
|
||||||
|
const extensionQuickActions: (QuickActionDefinition & { key: string })[] = liveExtensions
|
||||||
|
.map(toggle => {
|
||||||
|
const def = getExtensionDefinition(toggle.sector_slug, toggle.extension_slug)
|
||||||
|
if (!def?.quickAction) return null
|
||||||
|
return { ...def.quickAction, key: `${toggle.sector_slug}/${toggle.extension_slug}` }
|
||||||
|
})
|
||||||
|
.filter((a): a is QuickActionDefinition & { key: string } => a !== null)
|
||||||
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
|
|
||||||
// Quick action items
|
// Quick action items
|
||||||
const quickActions = [
|
const quickActions = [
|
||||||
@@ -242,7 +273,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
|||||||
const todoItems: { label: string; href: string; count: number; variant: 'destructive' | 'warning' | 'default' }[] = []
|
const todoItems: { label: string; href: string; count: number; variant: 'destructive' | 'warning' | 'default' }[] = []
|
||||||
|
|
||||||
if (passedDeadlines.length > 0) {
|
if (passedDeadlines.length > 0) {
|
||||||
todoItems.push({ label: 'passerade deadlines', href: '/calendar', count: passedDeadlines.length, variant: 'destructive' })
|
todoItems.push({ label: 'passerade deadlines', href: '/deadlines', count: passedDeadlines.length, variant: 'destructive' })
|
||||||
}
|
}
|
||||||
if (summary.overdueInvoicesCount > 0) {
|
if (summary.overdueInvoicesCount > 0) {
|
||||||
todoItems.push({ label: 'förfallna fakturor', href: '/invoices?status=unpaid', count: summary.overdueInvoicesCount, variant: 'destructive' })
|
todoItems.push({ label: 'förfallna fakturor', href: '/invoices?status=unpaid', count: summary.overdueInvoicesCount, variant: 'destructive' })
|
||||||
@@ -431,18 +462,42 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{/* AI assistant quick action */}
|
{/* Extension quick actions */}
|
||||||
<button onClick={openAiChat} className="group text-left">
|
{extensionQuickActions.map((action) => {
|
||||||
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
|
const Icon = resolveIcon(action.icon)
|
||||||
<div className="p-2 rounded-lg bg-muted/50">
|
if (action.href) {
|
||||||
<MessageCircle className="h-4 w-4 text-muted-foreground" />
|
return (
|
||||||
</div>
|
<Link key={action.key} href={action.href} className="group">
|
||||||
<div className="min-w-0">
|
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
|
||||||
<p className="text-sm font-medium truncate">AI-assistent</p>
|
<div className="p-2 rounded-lg bg-muted/50">
|
||||||
<p className="text-xs text-muted-foreground truncate hidden md:block">Fråga om bokföring</p>
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="min-w-0">
|
||||||
</button>
|
<p className="text-sm font-medium truncate">{action.label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={action.key}
|
||||||
|
onClick={() => window.dispatchEvent(new Event(action.event!))}
|
||||||
|
className="group text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
|
||||||
|
<div className="p-2 rounded-lg bg-muted/50">
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{action.label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -453,6 +508,13 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tax todo widget — visible when there are incomplete tax deadlines */}
|
||||||
|
{summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && (
|
||||||
|
<section className="mb-10">
|
||||||
|
<TaxTodoWidget deadlines={summary.deadlines} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Alerts section — always visible */}
|
{/* Alerts section — always visible */}
|
||||||
{alertItems.length > 0 && (
|
{alertItems.length > 0 && (
|
||||||
<section id="alerts-section" className="mb-10">
|
<section id="alerts-section" className="mb-10">
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ interface NavItem {
|
|||||||
// All nav items for sidebar and mobile drawer
|
// All nav items for sidebar and mobile drawer
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ href: '/', label: 'Översikt', icon: LayoutDashboard, group: 'main' },
|
{ href: '/', label: 'Översikt', icon: LayoutDashboard, group: 'main' },
|
||||||
{ href: '/calendar', label: 'Kalender', icon: Calendar, group: 'main' },
|
{ href: '/deadlines', label: 'Deadlines', icon: Calendar, group: 'main' },
|
||||||
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' },
|
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' },
|
||||||
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans' },
|
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans' },
|
||||||
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'finans' },
|
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'finans' },
|
||||||
|
|||||||
@@ -231,9 +231,9 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link href="/calendar" className="block">
|
<Link href="/deadlines" className="block">
|
||||||
<Button variant="ghost" className="w-full justify-between">
|
<Button variant="ghost" className="w-full justify-between">
|
||||||
Visa alla skattedeadlines
|
Visa alla deadlines
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
+2
-2
@@ -201,9 +201,9 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<Link href="/calendar" className="block">
|
<Link href="/deadlines" className="block">
|
||||||
<Button variant="ghost" className="w-full justify-between mt-2">
|
<Button variant="ghost" className="w-full justify-between mt-2">
|
||||||
Visa kalender
|
Visa alla deadlines
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { DeadlineCard } from './DeadlineCard'
|
||||||
|
export { DeadlineFilters } from './DeadlineFilters'
|
||||||
|
export { DeadlineForm } from './DeadlineForm'
|
||||||
|
export { DeadlineList } from './DeadlineList'
|
||||||
|
export { UpcomingDeadlinesWidget } from './UpcomingDeadlinesWidget'
|
||||||
|
export { TaxTodoWidget } from './TaxTodoWidget'
|
||||||
+16
-29
@@ -3,10 +3,11 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
import { createClient } from '@/lib/supabase/client'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { PaymentCalendar } from '@/components/calendar/PaymentCalendar'
|
import { PaymentCalendar } from '@/extensions/general/calendar/components/PaymentCalendar'
|
||||||
|
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||||
import type { Invoice, Deadline } from '@/types'
|
import type { Invoice, Deadline } from '@/types'
|
||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) {
|
||||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||||
const [deadlines, setDeadlines] = useState<Deadline[]>([])
|
const [deadlines, setDeadlines] = useState<Deadline[]>([])
|
||||||
const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
|
const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
|
||||||
@@ -18,7 +19,6 @@ export default function CalendarPage() {
|
|||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch invoices with customer names
|
|
||||||
const { data: invoicesData, error: invoicesError } = await supabase
|
const { data: invoicesData, error: invoicesError } = await supabase
|
||||||
.from('invoices')
|
.from('invoices')
|
||||||
.select('*, customer:customers(name)')
|
.select('*, customer:customers(name)')
|
||||||
@@ -26,7 +26,6 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
if (invoicesError) throw invoicesError
|
if (invoicesError) throw invoicesError
|
||||||
|
|
||||||
// Fetch deadlines with customer names
|
|
||||||
const { data: deadlinesData, error: deadlinesError } = await supabase
|
const { data: deadlinesData, error: deadlinesError } = await supabase
|
||||||
.from('deadlines')
|
.from('deadlines')
|
||||||
.select('*, customer:customers(name)')
|
.select('*, customer:customers(name)')
|
||||||
@@ -34,7 +33,6 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
if (deadlinesError) throw deadlinesError
|
if (deadlinesError) throw deadlinesError
|
||||||
|
|
||||||
// Fetch customers for the form
|
|
||||||
const { data: customersData, error: customersError } = await supabase
|
const { data: customersData, error: customersError } = await supabase
|
||||||
.from('customers')
|
.from('customers')
|
||||||
.select('id, name')
|
.select('id, name')
|
||||||
@@ -45,10 +43,10 @@ export default function CalendarPage() {
|
|||||||
setInvoices(invoicesData || [])
|
setInvoices(invoicesData || [])
|
||||||
setDeadlines(deadlinesData || [])
|
setDeadlines(deadlinesData || [])
|
||||||
setCustomers(customersData || [])
|
setCustomers(customersData || [])
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Fel',
|
title: 'Fel',
|
||||||
description: 'Kunde inte hämta data',
|
description: 'Kunde inte hamta data',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
@@ -101,7 +99,7 @@ export default function CalendarPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
fetchData()
|
fetchData()
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Fel',
|
title: 'Fel',
|
||||||
description: 'Kunde inte uppdatera deadline',
|
description: 'Kunde inte uppdatera deadline',
|
||||||
@@ -112,31 +110,20 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="animate-pulse">
|
||||||
<div>
|
<div className="h-10 bg-muted rounded w-48 mb-4" />
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Kalender</h1>
|
<div className="h-96 bg-muted rounded" />
|
||||||
</div>
|
|
||||||
<div className="animate-pulse">
|
|
||||||
<div className="h-10 bg-muted rounded w-48 mb-4" />
|
|
||||||
<div className="h-96 bg-muted rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<PaymentCalendar
|
||||||
<div>
|
invoices={invoices}
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Kalender</h1>
|
deadlines={deadlines}
|
||||||
</div>
|
customers={customers}
|
||||||
|
onDeadlineCreate={handleDeadlineCreate}
|
||||||
<PaymentCalendar
|
onDeadlineToggle={handleDeadlineToggle}
|
||||||
invoices={invoices}
|
/>
|
||||||
deadlines={deadlines}
|
|
||||||
customers={customers}
|
|
||||||
onDeadlineCreate={handleDeadlineCreate}
|
|
||||||
onDeadlineToggle={handleDeadlineToggle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,9 @@
|
|||||||
* in server components or API routes.
|
* in server components or API routes.
|
||||||
*
|
*
|
||||||
* Provider-abstracted AI categorization for Swedish BAS account mapping.
|
* Provider-abstracted AI categorization for Swedish BAS account mapping.
|
||||||
* Default implementation uses Claude Haiku for cost efficiency.
|
* Uses Claude Haiku with structured tool outputs for reliable JSON.
|
||||||
|
* Accepts pre-filtered candidate templates from embedding search (Tier 2)
|
||||||
|
* instead of dumping all ~100 templates into the prompt.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import 'server-only'
|
import 'server-only'
|
||||||
@@ -27,11 +29,29 @@ export interface TransactionForCategorization {
|
|||||||
currency: string
|
currency: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AccountUsageEntry {
|
||||||
|
account_number: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantHistoryEntry {
|
||||||
|
merchant_name: string
|
||||||
|
category: string
|
||||||
|
template_id: string | null
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface CategorizationContext {
|
export interface CategorizationContext {
|
||||||
entityType: EntityType
|
entityType: EntityType
|
||||||
recentHistory: { description: string; category: string }[]
|
recentHistory: { description: string; category: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EnrichedCategorizationContext extends CategorizationContext {
|
||||||
|
candidateTemplates: BookingTemplate[]
|
||||||
|
userAccountUsage: AccountUsageEntry[]
|
||||||
|
merchantHistory: MerchantHistoryEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface CategorizationSuggestion {
|
export interface CategorizationSuggestion {
|
||||||
transactionId: string
|
transactionId: string
|
||||||
category: TransactionCategory
|
category: TransactionCategory
|
||||||
@@ -46,7 +66,7 @@ export interface CategorizationSuggestion {
|
|||||||
export interface CategorizationProvider {
|
export interface CategorizationProvider {
|
||||||
categorize(
|
categorize(
|
||||||
transactions: TransactionForCategorization[],
|
transactions: TransactionForCategorization[],
|
||||||
context: CategorizationContext
|
context: CategorizationContext | EnrichedCategorizationContext
|
||||||
): Promise<CategorizationSuggestion[]>
|
): Promise<CategorizationSuggestion[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,11 +96,18 @@ function getCategoryAccountMap(entityType: EntityType): Record<string, { account
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an abbreviated template reference for the AI prompt.
|
* Build template reference from candidate templates (pre-filtered by embeddings)
|
||||||
* Filters by direction (expense/income) to keep prompt concise.
|
* or fall back to full template list if no candidates provided.
|
||||||
*/
|
*/
|
||||||
function getTemplateReference(direction: 'expense' | 'income'): string {
|
function getTemplateReference(
|
||||||
return BOOKING_TEMPLATES
|
direction: 'expense' | 'income',
|
||||||
|
candidateTemplates?: BookingTemplate[]
|
||||||
|
): string {
|
||||||
|
const templates = candidateTemplates && candidateTemplates.length > 0
|
||||||
|
? candidateTemplates
|
||||||
|
: BOOKING_TEMPLATES
|
||||||
|
|
||||||
|
return templates
|
||||||
.filter((t) => t.direction === direction || t.direction === 'transfer')
|
.filter((t) => t.direction === direction || t.direction === 'transfer')
|
||||||
.map((t) => `${t.id}: ${t.name_sv} → ${t.debit_account}/${t.credit_account}`)
|
.map((t) => `${t.id}: ${t.name_sv} → ${t.debit_account}/${t.credit_account}`)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
@@ -97,6 +124,38 @@ ICKE-AVDRAGSGILLA KOSTNADER (svensk skatterätt):
|
|||||||
- Telefon/dator vid blandad användning: Bara yrkesmässig del avdragsgill
|
- Telefon/dator vid blandad användning: Bara yrkesmässig del avdragsgill
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Classify Transaction Tool Schema
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const CLASSIFY_TOOL: Anthropic.Tool = {
|
||||||
|
name: 'classify_transactions',
|
||||||
|
description: 'Classify a batch of bank transactions into Swedish BAS accounts and booking templates.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: {
|
||||||
|
suggestions: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
transactionId: { type: 'string', description: 'Transaction ID' },
|
||||||
|
templateId: { type: 'string', description: 'Booking template ID (from the provided templates list)' },
|
||||||
|
category: { type: 'string', description: 'Transaction category (e.g. expense_software, income_services, private)' },
|
||||||
|
basAccount: { type: 'string', description: 'BAS account number (4 digits)' },
|
||||||
|
taxCode: { type: ['string', 'null'], description: 'Tax code: MPI for deductible expenses with VAT, MP1 for income with VAT, null for VAT-exempt/private' },
|
||||||
|
confidence: { type: 'number', description: 'Confidence score 0.0-1.0' },
|
||||||
|
reasoning: { type: 'string', description: 'Short reasoning in Swedish' },
|
||||||
|
isPrivate: { type: 'boolean', description: 'Whether this is a private expense' },
|
||||||
|
},
|
||||||
|
required: ['transactionId', 'category', 'basAccount', 'confidence', 'reasoning', 'isPrivate'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['suggestions'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Anthropic Provider
|
// Anthropic Provider
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -116,23 +175,40 @@ export class AnthropicCategorizationProvider implements CategorizationProvider {
|
|||||||
|
|
||||||
async categorize(
|
async categorize(
|
||||||
transactions: TransactionForCategorization[],
|
transactions: TransactionForCategorization[],
|
||||||
context: CategorizationContext
|
context: CategorizationContext | EnrichedCategorizationContext
|
||||||
): Promise<CategorizationSuggestion[]> {
|
): Promise<CategorizationSuggestion[]> {
|
||||||
// Cap batch size
|
// Cap batch size
|
||||||
const batch = transactions.slice(0, MAX_BATCH_SIZE)
|
const batch = transactions.slice(0, MAX_BATCH_SIZE)
|
||||||
if (batch.length === 0) return []
|
if (batch.length === 0) return []
|
||||||
|
|
||||||
|
const enriched = isEnrichedContext(context) ? context : null
|
||||||
const privateAccount = context.entityType === 'aktiebolag' ? '2893' : '2013'
|
const privateAccount = context.entityType === 'aktiebolag' ? '2893' : '2013'
|
||||||
const categoryAccountMap = getCategoryAccountMap(context.entityType)
|
const categoryAccountMap = getCategoryAccountMap(context.entityType)
|
||||||
|
|
||||||
// Build template references based on batch direction (most transactions will be same direction)
|
// Build template references — use candidate templates if available
|
||||||
const hasExpenses = batch.some((t) => t.amount < 0)
|
const hasExpenses = batch.some((t) => t.amount < 0)
|
||||||
const hasIncome = batch.some((t) => t.amount > 0)
|
const hasIncome = batch.some((t) => t.amount > 0)
|
||||||
|
const candidates = enriched?.candidateTemplates
|
||||||
const templateRef = [
|
const templateRef = [
|
||||||
hasExpenses ? `UTGIFTSMALLAR:\n${getTemplateReference('expense')}` : '',
|
hasExpenses ? `UTGIFTSMALLAR:\n${getTemplateReference('expense', candidates)}` : '',
|
||||||
hasIncome ? `INTÄKTSMALLAR:\n${getTemplateReference('income')}` : '',
|
hasIncome ? `INTÄKTSMALLAR:\n${getTemplateReference('income', candidates)}` : '',
|
||||||
].filter(Boolean).join('\n\n')
|
].filter(Boolean).join('\n\n')
|
||||||
|
|
||||||
|
// Build account usage context
|
||||||
|
const accountUsageContext = enriched?.userAccountUsage && enriched.userAccountUsage.length > 0
|
||||||
|
? `\nAnvändarens mest använda konton:\n${enriched.userAccountUsage
|
||||||
|
.slice(0, 15)
|
||||||
|
.map((a) => `- ${a.account_number} (${a.count} bokningar)`)
|
||||||
|
.join('\n')}`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
// Build merchant history context
|
||||||
|
const merchantHistoryContext = enriched?.merchantHistory && enriched.merchantHistory.length > 0
|
||||||
|
? `\nTidigare kategorisering av dessa handlare:\n${enriched.merchantHistory
|
||||||
|
.map((m) => `- "${m.merchant_name}" → ${m.category}${m.template_id ? ` (mall: ${m.template_id})` : ''} (${m.count}x)`)
|
||||||
|
.join('\n')}`
|
||||||
|
: ''
|
||||||
|
|
||||||
const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen.
|
const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen.
|
||||||
Din uppgift är att kategorisera varje transaktion till rätt mall-ID (templateId) och BAS-konto.
|
Din uppgift är att kategorisera varje transaktion till rätt mall-ID (templateId) och BAS-konto.
|
||||||
|
|
||||||
@@ -181,29 +257,11 @@ REGLER:
|
|||||||
)
|
)
|
||||||
.join('\n\n')
|
.join('\n\n')
|
||||||
|
|
||||||
const userPrompt = `Kategorisera följande transaktioner:
|
const userPrompt = `Kategorisera följande transaktioner med classify_transactions-verktyget:
|
||||||
${historyContext}
|
${historyContext}${accountUsageContext}${merchantHistoryContext}
|
||||||
|
|
||||||
TRANSAKTIONER:
|
TRANSAKTIONER:
|
||||||
${transactionList}
|
${transactionList}`
|
||||||
|
|
||||||
Returnera ett JSON-objekt med följande struktur:
|
|
||||||
{
|
|
||||||
"suggestions": [
|
|
||||||
{
|
|
||||||
"transactionId": "id",
|
|
||||||
"category": "expense_software",
|
|
||||||
"templateId": "it_saas_subscription",
|
|
||||||
"basAccount": "5420",
|
|
||||||
"taxCode": "MPI",
|
|
||||||
"confidence": 0.9,
|
|
||||||
"reasoning": "Spotify-prenumeration, typisk programvarukostnad",
|
|
||||||
"isPrivate": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
Returnera ENDAST JSON-objektet, ingen annan text.`
|
|
||||||
|
|
||||||
let lastError: Error | null = null
|
let lastError: Error | null = null
|
||||||
|
|
||||||
@@ -212,7 +270,15 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
|
|||||||
const message = await this.client.messages.create({
|
const message = await this.client.messages.create({
|
||||||
model: this.model,
|
model: this.model,
|
||||||
max_tokens: 4096,
|
max_tokens: 4096,
|
||||||
system: systemPrompt,
|
system: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: systemPrompt,
|
||||||
|
cache_control: { type: 'ephemeral' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tools: [CLASSIFY_TOOL],
|
||||||
|
tool_choice: { type: 'tool', name: 'classify_transactions' },
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -221,33 +287,20 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const content = message.content[0]
|
// Extract tool_use block from response
|
||||||
if (content.type !== 'text') {
|
const toolUseBlock = message.content.find(
|
||||||
throw new Error('Unexpected response type from AI')
|
(block) => block.type === 'tool_use' && block.name === 'classify_transactions'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!toolUseBlock || toolUseBlock.type !== 'tool_use') {
|
||||||
|
throw new Error('No tool_use block in AI response')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip markdown code blocks if present
|
const input = toolUseBlock.input as { suggestions?: unknown[] }
|
||||||
let jsonText = content.text.trim()
|
return this.validateSuggestions(input.suggestions || [], batch, context.entityType)
|
||||||
if (jsonText.startsWith('```json')) {
|
|
||||||
jsonText = jsonText.slice(7)
|
|
||||||
} else if (jsonText.startsWith('```')) {
|
|
||||||
jsonText = jsonText.slice(3)
|
|
||||||
}
|
|
||||||
if (jsonText.endsWith('```')) {
|
|
||||||
jsonText = jsonText.slice(0, -3)
|
|
||||||
}
|
|
||||||
jsonText = jsonText.trim()
|
|
||||||
|
|
||||||
const parsed = JSON.parse(jsonText)
|
|
||||||
return this.validateSuggestions(parsed.suggestions || [], batch, context.entityType)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||||||
|
|
||||||
// Don't retry on parse errors
|
|
||||||
if (error instanceof SyntaxError) {
|
|
||||||
throw new Error(`Failed to parse AI response: ${lastError.message}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt < MAX_RETRIES - 1) {
|
if (attempt < MAX_RETRIES - 1) {
|
||||||
await sleep(RETRY_DELAY_MS * (attempt + 1))
|
await sleep(RETRY_DELAY_MS * (attempt + 1))
|
||||||
}
|
}
|
||||||
@@ -297,6 +350,12 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEnrichedContext(
|
||||||
|
ctx: CategorizationContext | EnrichedCategorizationContext
|
||||||
|
): ctx is EnrichedCategorizationContext {
|
||||||
|
return 'candidateTemplates' in ctx
|
||||||
|
}
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import {
|
|||||||
AnthropicCategorizationProvider,
|
AnthropicCategorizationProvider,
|
||||||
type CategorizationProvider,
|
type CategorizationProvider,
|
||||||
type TransactionForCategorization,
|
type TransactionForCategorization,
|
||||||
type CategorizationContext,
|
type EnrichedCategorizationContext,
|
||||||
type CategorizationSuggestion,
|
type CategorizationSuggestion,
|
||||||
|
type AccountUsageEntry,
|
||||||
|
type MerchantHistoryEntry,
|
||||||
} from './categorizer'
|
} from './categorizer'
|
||||||
|
import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings'
|
||||||
|
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Settings
|
// Settings
|
||||||
@@ -117,7 +121,7 @@ export async function categorizeTransactions(
|
|||||||
currency: t.currency,
|
currency: t.currency,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const context = await buildContext(userId, supabase)
|
const context = await buildEnrichedContext(userId, supabase, batch)
|
||||||
|
|
||||||
const aiProvider = getProvider(settings.providerModel)
|
const aiProvider = getProvider(settings.providerModel)
|
||||||
const suggestions = await aiProvider.categorize(batch, context)
|
const suggestions = await aiProvider.categorize(batch, context)
|
||||||
@@ -168,7 +172,7 @@ async function handleTransactionSynced(
|
|||||||
currency: t.currency,
|
currency: t.currency,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const context = await buildContext(userId, supabase)
|
const context = await buildEnrichedContext(userId, supabase, batch)
|
||||||
const aiProvider = getProvider(settings.providerModel)
|
const aiProvider = getProvider(settings.providerModel)
|
||||||
const suggestions = await aiProvider.categorize(batch, context)
|
const suggestions = await aiProvider.categorize(batch, context)
|
||||||
|
|
||||||
@@ -194,7 +198,11 @@ async function handleTransactionSynced(
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
async function buildContext(userId: string, supabase: any): Promise<CategorizationContext> {
|
async function buildEnrichedContext(
|
||||||
|
userId: string,
|
||||||
|
supabase: any,
|
||||||
|
transactions: TransactionForCategorization[]
|
||||||
|
): Promise<EnrichedCategorizationContext> {
|
||||||
// Fetch entity type
|
// Fetch entity type
|
||||||
const { data: companySettings } = await supabase
|
const { data: companySettings } = await supabase
|
||||||
.from('company_settings')
|
.from('company_settings')
|
||||||
@@ -221,7 +229,92 @@ async function buildContext(userId: string, supabase: any): Promise<Categorizati
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
return { entityType, recentHistory }
|
// Fetch user's most-used accounts (top 30)
|
||||||
|
const { data: accountUsageRows } = await supabase
|
||||||
|
.from('journal_entry_lines')
|
||||||
|
.select('account_number')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
|
||||||
|
const accountCounts = new Map<string, number>()
|
||||||
|
if (accountUsageRows) {
|
||||||
|
for (const row of accountUsageRows as { account_number: string }[]) {
|
||||||
|
accountCounts.set(row.account_number, (accountCounts.get(row.account_number) || 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const userAccountUsage: AccountUsageEntry[] = Array.from(accountCounts.entries())
|
||||||
|
.map(([account_number, count]) => ({ account_number, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 30)
|
||||||
|
|
||||||
|
// Fetch merchant history for this batch's merchants
|
||||||
|
const merchantNames = [...new Set(
|
||||||
|
transactions
|
||||||
|
.map((t) => t.merchant_name)
|
||||||
|
.filter((n): n is string => n !== null && n.length > 0)
|
||||||
|
)]
|
||||||
|
|
||||||
|
let merchantHistory: MerchantHistoryEntry[] = []
|
||||||
|
if (merchantNames.length > 0) {
|
||||||
|
const { data: merchantRows } = await supabase
|
||||||
|
.from('transactions')
|
||||||
|
.select('merchant_name, category, template_id')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.not('is_business', 'is', null)
|
||||||
|
.neq('category', 'uncategorized')
|
||||||
|
.in('merchant_name', merchantNames)
|
||||||
|
.limit(200)
|
||||||
|
|
||||||
|
if (merchantRows) {
|
||||||
|
const merchantMap = new Map<string, MerchantHistoryEntry>()
|
||||||
|
for (const row of merchantRows as { merchant_name: string; category: string; template_id: string | null }[]) {
|
||||||
|
const key = `${row.merchant_name}:${row.category}`
|
||||||
|
const existing = merchantMap.get(key)
|
||||||
|
if (existing) {
|
||||||
|
existing.count++
|
||||||
|
} else {
|
||||||
|
merchantMap.set(key, {
|
||||||
|
merchant_name: row.merchant_name,
|
||||||
|
category: row.category,
|
||||||
|
template_id: row.template_id,
|
||||||
|
count: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merchantHistory = Array.from(merchantMap.values())
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find candidate templates via embedding search
|
||||||
|
// Use a representative subset of transactions to find candidates
|
||||||
|
const representativeTransactions = transactions.slice(0, 5)
|
||||||
|
const candidateMap = new Map<string, BookingTemplate>()
|
||||||
|
|
||||||
|
for (const tx of representativeTransactions) {
|
||||||
|
try {
|
||||||
|
const matches = await findSimilarTemplates(
|
||||||
|
tx as unknown as Transaction,
|
||||||
|
entityType
|
||||||
|
)
|
||||||
|
for (const m of matches) {
|
||||||
|
if (!candidateMap.has(m.template.id)) {
|
||||||
|
candidateMap.set(m.template.id, m.template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Embedding search failed — continue without candidates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateTemplates = Array.from(candidateMap.values())
|
||||||
|
|
||||||
|
return {
|
||||||
|
entityType,
|
||||||
|
recentHistory,
|
||||||
|
candidateTemplates,
|
||||||
|
userAccountUsage,
|
||||||
|
merchantHistory,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function storeSuggestions(
|
async function storeSuggestions(
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ import { CalendarGrid } from './CalendarGrid'
|
|||||||
import { CalendarWeekView } from './CalendarWeekView'
|
import { CalendarWeekView } from './CalendarWeekView'
|
||||||
import { CalendarDayView } from './CalendarDayView'
|
import { CalendarDayView } from './CalendarDayView'
|
||||||
import { DayDetailModal } from './DayDetailModal'
|
import { DayDetailModal } from './DayDetailModal'
|
||||||
import { DeadlineForm } from './DeadlineForm'
|
import { DeadlineForm } from '@/components/deadlines/DeadlineForm'
|
||||||
|
|
||||||
interface PaymentCalendarProps {
|
interface PaymentCalendarProps {
|
||||||
invoices: Invoice[]
|
invoices: Invoice[]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Extension } from '@/lib/extensions/types'
|
||||||
|
|
||||||
|
export const calendarExtension: Extension = {
|
||||||
|
id: 'calendar',
|
||||||
|
name: 'Kalender',
|
||||||
|
version: '1.0.0',
|
||||||
|
}
|
||||||
@@ -152,7 +152,7 @@ export function createTaxDeadlinePayload(
|
|||||||
badge: '/icons/badge-72.png',
|
badge: '/icons/badge-72.png',
|
||||||
tag: `tax-deadline-${deadlineId}`,
|
tag: `tax-deadline-${deadlineId}`,
|
||||||
data: {
|
data: {
|
||||||
url: '/calendar',
|
url: '/deadlines',
|
||||||
type: 'tax_deadline',
|
type: 'tax_deadline',
|
||||||
id: deadlineId,
|
id: deadlineId,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ describe('findMatchingTemplates', () => {
|
|||||||
mcc_code: 5817,
|
mcc_code: 5817,
|
||||||
})
|
})
|
||||||
const matches = findMatchingTemplates(tx)
|
const matches = findMatchingTemplates(tx)
|
||||||
expect(matches.length).toBeLessThanOrEqual(5)
|
expect(matches.length).toBeLessThanOrEqual(20)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('results are sorted by confidence descending', () => {
|
it('results are sorted by confidence descending', () => {
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { makeTransaction, createMockSupabase } from '@/tests/helpers'
|
||||||
|
import { BOOKING_TEMPLATES } from '../booking-templates'
|
||||||
|
|
||||||
|
// Mock server-only (no-op in tests)
|
||||||
|
vi.mock('server-only', () => ({}))
|
||||||
|
|
||||||
|
// Mock OpenAI Embeddings
|
||||||
|
vi.mock('@langchain/openai', () => {
|
||||||
|
class MockOpenAIEmbeddings {
|
||||||
|
embedQuery = vi.fn().mockResolvedValue(new Array(1536).fill(0.1))
|
||||||
|
embedDocuments = vi.fn().mockImplementation((texts: string[]) =>
|
||||||
|
Promise.resolve(texts.map(() => new Array(1536).fill(0.1)))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return { OpenAIEmbeddings: MockOpenAIEmbeddings }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock Supabase
|
||||||
|
const { supabase: mockSupabase, mockResult } = createMockSupabase()
|
||||||
|
vi.mock('@/lib/supabase/server', () => ({
|
||||||
|
createServiceClient: vi.fn().mockResolvedValue(mockSupabase),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('template-embeddings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildEmbeddingText', () => {
|
||||||
|
it('includes all relevant fields for a template', async () => {
|
||||||
|
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||||
|
|
||||||
|
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_rent')!
|
||||||
|
const text = buildEmbeddingText(template)
|
||||||
|
|
||||||
|
// Should include Swedish and English name
|
||||||
|
expect(text).toContain('Lokalhyra')
|
||||||
|
expect(text).toContain('Office rent')
|
||||||
|
|
||||||
|
// Should include description
|
||||||
|
expect(text).toContain(template.description_sv)
|
||||||
|
|
||||||
|
// Should include keywords
|
||||||
|
expect(text).toContain('hyra')
|
||||||
|
expect(text).toContain('lokal')
|
||||||
|
|
||||||
|
// Should include group
|
||||||
|
expect(text).toContain('premises')
|
||||||
|
|
||||||
|
// Should include direction
|
||||||
|
expect(text).toContain('utgift')
|
||||||
|
|
||||||
|
// Should include accounts
|
||||||
|
expect(text).toContain('5010')
|
||||||
|
expect(text).toContain('1930')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes VAT treatment when present', async () => {
|
||||||
|
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||||
|
|
||||||
|
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_electricity')!
|
||||||
|
const text = buildEmbeddingText(template)
|
||||||
|
|
||||||
|
expect(text).toContain('standard_25')
|
||||||
|
expect(text).toContain('25%')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes special rules when present', async () => {
|
||||||
|
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||||
|
|
||||||
|
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_rent')!
|
||||||
|
const text = buildEmbeddingText(template)
|
||||||
|
|
||||||
|
expect(text).toContain(template.special_rules_sv!)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes MCC codes when present', async () => {
|
||||||
|
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||||
|
|
||||||
|
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_electricity')!
|
||||||
|
const text = buildEmbeddingText(template)
|
||||||
|
|
||||||
|
expect(text).toContain('4900')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes deductibility note for non-full deductibility', async () => {
|
||||||
|
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||||
|
|
||||||
|
const template = BOOKING_TEMPLATES.find((t) => t.deductibility === 'non_deductible')!
|
||||||
|
const text = buildEmbeddingText(template)
|
||||||
|
|
||||||
|
expect(text).toContain('non_deductible')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates text for all 100 templates without error', async () => {
|
||||||
|
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||||
|
|
||||||
|
for (const template of BOOKING_TEMPLATES) {
|
||||||
|
const text = buildEmbeddingText(template)
|
||||||
|
expect(text.length).toBeGreaterThan(10)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildTransactionQueryText', () => {
|
||||||
|
it('combines description, merchant, and direction', async () => {
|
||||||
|
const { buildTransactionQueryText } = await import('../template-embeddings')
|
||||||
|
|
||||||
|
const tx = makeTransaction({
|
||||||
|
description: 'SPOTIFY PREMIUM',
|
||||||
|
merchant_name: 'Spotify',
|
||||||
|
amount: -109,
|
||||||
|
mcc_code: 5815,
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = buildTransactionQueryText(tx)
|
||||||
|
|
||||||
|
expect(text).toContain('SPOTIFY PREMIUM')
|
||||||
|
expect(text).toContain('Spotify')
|
||||||
|
expect(text).toContain('MCC 5815')
|
||||||
|
expect(text).toContain('utgift')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks positive amounts as income', async () => {
|
||||||
|
const { buildTransactionQueryText } = await import('../template-embeddings')
|
||||||
|
|
||||||
|
const tx = makeTransaction({
|
||||||
|
description: 'Inbetalning',
|
||||||
|
amount: 5000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = buildTransactionQueryText(tx)
|
||||||
|
expect(text).toContain('intäkt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles null merchant_name and mcc_code', async () => {
|
||||||
|
const { buildTransactionQueryText } = await import('../template-embeddings')
|
||||||
|
|
||||||
|
const tx = makeTransaction({
|
||||||
|
description: 'Some payment',
|
||||||
|
merchant_name: null,
|
||||||
|
mcc_code: null,
|
||||||
|
amount: -100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = buildTransactionQueryText(tx)
|
||||||
|
expect(text).toContain('Some payment')
|
||||||
|
expect(text).toContain('utgift')
|
||||||
|
expect(text).not.toContain('MCC')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getSchemaVersion', () => {
|
||||||
|
it('returns a consistent hash string', async () => {
|
||||||
|
const { getSchemaVersion } = await import('../template-embeddings')
|
||||||
|
|
||||||
|
const v1 = getSchemaVersion()
|
||||||
|
const v2 = getSchemaVersion()
|
||||||
|
|
||||||
|
expect(v1).toBe(v2)
|
||||||
|
expect(v1).toHaveLength(12)
|
||||||
|
expect(v1).toMatch(/^[a-f0-9]+$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('findSimilarTemplates', () => {
|
||||||
|
it('returns empty array on RPC error (graceful fallback)', async () => {
|
||||||
|
const { findSimilarTemplates } = await import('../template-embeddings')
|
||||||
|
|
||||||
|
// Mock staleness check
|
||||||
|
mockResult({ data: { schema_version: 'test' }, error: null })
|
||||||
|
|
||||||
|
const tx = makeTransaction({
|
||||||
|
description: 'SPOTIFY',
|
||||||
|
amount: -109,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The mock will return error for the RPC call
|
||||||
|
mockResult({ data: null, error: { message: 'RPC failed' } })
|
||||||
|
const results = await findSimilarTemplates(tx)
|
||||||
|
expect(results).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array when no embeddings exist', async () => {
|
||||||
|
const { findSimilarTemplates } = await import('../template-embeddings')
|
||||||
|
|
||||||
|
mockResult({ data: [], error: null })
|
||||||
|
|
||||||
|
const tx = makeTransaction({
|
||||||
|
description: 'Random purchase',
|
||||||
|
amount: -50,
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await findSimilarTemplates(tx)
|
||||||
|
expect(results).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -617,7 +617,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
|
|||||||
deductibility: 'full',
|
deductibility: 'full',
|
||||||
special_rules_sv: 'Ofta utländsk leverantör (USA) med omvänd skattskyldighet',
|
special_rules_sv: 'Ofta utländsk leverantör (USA) med omvänd skattskyldighet',
|
||||||
mcc_codes: [],
|
mcc_codes: [],
|
||||||
keywords: ['openai', 'chatgpt', 'anthropic', 'claude', 'ai', 'midjourney', 'copilot'],
|
keywords: ['openai', 'chatgpt', 'anthropic', 'claude', 'ai', 'midjourney', 'copilot', 'mistral', 'claude', 'gemini'],
|
||||||
risk_level: 'NONE',
|
risk_level: 'NONE',
|
||||||
requires_review: false,
|
requires_review: false,
|
||||||
impact_score: 7,
|
impact_score: 7,
|
||||||
@@ -663,7 +663,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
|
|||||||
vat_rate: 0.25,
|
vat_rate: 0.25,
|
||||||
deductibility: 'full',
|
deductibility: 'full',
|
||||||
mcc_codes: [5111, 5112, 5943, 5944],
|
mcc_codes: [5111, 5112, 5943, 5944],
|
||||||
keywords: ['kontorsmaterial', 'pennor', 'papper', 'office supplies', 'staples', 'kontorsvaror'],
|
keywords: ['kontorsmaterial', 'pennor', 'papper', 'office supplies', 'staples', 'kontorsvaror', 'kontor'],
|
||||||
risk_level: 'NONE',
|
risk_level: 'NONE',
|
||||||
requires_review: false,
|
requires_review: false,
|
||||||
impact_score: 7,
|
impact_score: 7,
|
||||||
@@ -2534,7 +2534,7 @@ export function findMatchingTemplates(
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
.sort((a, b) => b.confidence - a.confidence)
|
.sort((a, b) => b.confidence - a.confidence)
|
||||||
.slice(0, 5)
|
.slice(0, 20)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* Template Embeddings Module
|
||||||
|
*
|
||||||
|
* SERVER-ONLY: Uses OpenAI embeddings and Supabase service client.
|
||||||
|
*
|
||||||
|
* Provides semantic search over booking templates using pgvector.
|
||||||
|
* Templates are pre-embedded and stored in the database. Transaction
|
||||||
|
* text is embedded at query time and compared via cosine similarity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'server-only'
|
||||||
|
import { OpenAIEmbeddings } from '@langchain/openai'
|
||||||
|
import {
|
||||||
|
BOOKING_TEMPLATES,
|
||||||
|
getTemplateById,
|
||||||
|
type BookingTemplate,
|
||||||
|
type TemplateMatch,
|
||||||
|
} from './booking-templates'
|
||||||
|
import type { Transaction, EntityType } from '@/types'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export const EMBEDDING_MODEL = 'text-embedding-3-small'
|
||||||
|
const EMBEDDING_LOGIC_VERSION = '1'
|
||||||
|
const MATCH_COUNT = 20
|
||||||
|
const MATCH_THRESHOLD = 0.5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema version is a hash of the model + embedding logic version.
|
||||||
|
* Bump EMBEDDING_LOGIC_VERSION when buildEmbeddingText changes.
|
||||||
|
*/
|
||||||
|
export function getSchemaVersion(): string {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(`${EMBEDDING_MODEL}:${EMBEDDING_LOGIC_VERSION}`)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Embedding Text Builders
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a rich text representation of a template for embedding.
|
||||||
|
* Includes all semantically relevant fields.
|
||||||
|
*/
|
||||||
|
export function buildEmbeddingText(template: BookingTemplate): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
parts.push(`${template.name_sv} (${template.name_en})`)
|
||||||
|
parts.push(template.description_sv)
|
||||||
|
|
||||||
|
if (template.keywords.length > 0) {
|
||||||
|
parts.push(`Nyckelord: ${template.keywords.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(`Grupp: ${template.group}`)
|
||||||
|
parts.push(`Typ: ${template.direction === 'expense' ? 'utgift' : template.direction === 'income' ? 'intäkt' : 'överföring'}`)
|
||||||
|
parts.push(`Konton: ${template.debit_account} (debet) / ${template.credit_account} (kredit)`)
|
||||||
|
|
||||||
|
if (template.vat_treatment) {
|
||||||
|
parts.push(`Moms: ${template.vat_treatment} (${template.vat_rate * 100}%)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.special_rules_sv) {
|
||||||
|
parts.push(`Regler: ${template.special_rules_sv}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.mcc_codes.length > 0) {
|
||||||
|
parts.push(`MCC-koder: ${template.mcc_codes.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.deductibility !== 'full') {
|
||||||
|
parts.push(`Avdragsrätt: ${template.deductibility}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('. ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build query text from a transaction for embedding search.
|
||||||
|
*/
|
||||||
|
export function buildTransactionQueryText(transaction: Transaction): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
if (transaction.description) {
|
||||||
|
parts.push(transaction.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transaction.merchant_name) {
|
||||||
|
parts.push(transaction.merchant_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transaction.mcc_code) {
|
||||||
|
parts.push(`MCC ${transaction.mcc_code}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(transaction.amount < 0 ? 'utgift' : 'intäkt')
|
||||||
|
|
||||||
|
return parts.join(' — ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Embeddings Client
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
let embeddingsInstance: OpenAIEmbeddings | null = null
|
||||||
|
|
||||||
|
function getEmbeddingsClient(): OpenAIEmbeddings {
|
||||||
|
if (!embeddingsInstance) {
|
||||||
|
embeddingsInstance = new OpenAIEmbeddings({
|
||||||
|
modelName: EMBEDDING_MODEL,
|
||||||
|
openAIApiKey: process.env.OPENAI_API_KEY,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return embeddingsInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Seed All Template Embeddings
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function seedAllTemplateEmbeddings(): Promise<{
|
||||||
|
seeded: number
|
||||||
|
errors: string[]
|
||||||
|
}> {
|
||||||
|
const { createServiceClient } = await import('@/lib/supabase/server')
|
||||||
|
const supabase = await createServiceClient()
|
||||||
|
const embeddings = getEmbeddingsClient()
|
||||||
|
const schemaVersion = getSchemaVersion()
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
// Build texts for all templates
|
||||||
|
const texts = BOOKING_TEMPLATES.map((t) => buildEmbeddingText(t))
|
||||||
|
|
||||||
|
// Batch embed all texts
|
||||||
|
let vectors: number[][]
|
||||||
|
try {
|
||||||
|
vectors = await embeddings.embedDocuments(texts)
|
||||||
|
} catch (error) {
|
||||||
|
return { seeded: 0, errors: [`Embedding generation failed: ${error}`] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert each template embedding
|
||||||
|
let seeded = 0
|
||||||
|
for (let i = 0; i < BOOKING_TEMPLATES.length; i++) {
|
||||||
|
const template = BOOKING_TEMPLATES[i]
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('booking_template_embeddings')
|
||||||
|
.upsert(
|
||||||
|
{
|
||||||
|
template_id: template.id,
|
||||||
|
embedding: JSON.stringify(vectors[i]),
|
||||||
|
embedding_text: texts[i],
|
||||||
|
model: EMBEDDING_MODEL,
|
||||||
|
schema_version: schemaVersion,
|
||||||
|
},
|
||||||
|
{ onConflict: 'template_id' }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
errors.push(`Failed to upsert ${template.id}: ${error.message}`)
|
||||||
|
} else {
|
||||||
|
seeded++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { seeded, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Find Similar Templates (Semantic Search)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
let stalenessWarned = false
|
||||||
|
|
||||||
|
export async function findSimilarTemplates(
|
||||||
|
transaction: Transaction,
|
||||||
|
entityType?: EntityType,
|
||||||
|
matchCount: number = MATCH_COUNT
|
||||||
|
): Promise<TemplateMatch[]> {
|
||||||
|
try {
|
||||||
|
const { createServiceClient } = await import('@/lib/supabase/server')
|
||||||
|
const supabase = await createServiceClient()
|
||||||
|
const embeddings = getEmbeddingsClient()
|
||||||
|
|
||||||
|
// Check schema version staleness on first call
|
||||||
|
if (!stalenessWarned) {
|
||||||
|
const { data: sample } = await supabase
|
||||||
|
.from('booking_template_embeddings')
|
||||||
|
.select('schema_version')
|
||||||
|
.limit(1)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (sample && sample.schema_version !== getSchemaVersion()) {
|
||||||
|
console.warn(
|
||||||
|
`[template-embeddings] Schema version mismatch: DB has "${sample.schema_version}", current is "${getSchemaVersion()}". Re-seed embeddings.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
stalenessWarned = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed the transaction query text
|
||||||
|
const queryText = buildTransactionQueryText(transaction)
|
||||||
|
const queryVector = await embeddings.embedQuery(queryText)
|
||||||
|
|
||||||
|
// Request extra results to account for post-filtering
|
||||||
|
const requestCount = matchCount + 10
|
||||||
|
|
||||||
|
const { data, error } = await supabase.rpc('match_booking_templates', {
|
||||||
|
query_embedding: JSON.stringify(queryVector),
|
||||||
|
match_count: requestCount,
|
||||||
|
match_threshold: MATCH_THRESHOLD,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
console.error('[template-embeddings] RPC error:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map RPC results to TemplateMatch[], filtering by entity type and direction
|
||||||
|
const isExpense = transaction.amount < 0
|
||||||
|
const isIncome = transaction.amount > 0
|
||||||
|
const results: TemplateMatch[] = []
|
||||||
|
|
||||||
|
for (const row of data as { template_id: string; similarity: number }[]) {
|
||||||
|
const template = getTemplateById(row.template_id)
|
||||||
|
if (!template) continue
|
||||||
|
|
||||||
|
// Filter by entity applicability
|
||||||
|
if (entityType && template.entity_applicability !== 'all' && template.entity_applicability !== entityType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by direction
|
||||||
|
if (template.direction === 'expense' && !isExpense) continue
|
||||||
|
if (template.direction === 'income' && !isIncome) continue
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
template,
|
||||||
|
confidence: Math.round(row.similarity * 100) / 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (results.length >= matchCount) break
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[template-embeddings] findSimilarTemplates failed:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ describe('sectors registry', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should have 18 total extensions', () => {
|
it('should have 18 total extensions', () => {
|
||||||
expect(getAllExtensions().length).toBe(18)
|
expect(getAllExtensions().length).toBe(19)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should have unique slugs within each sector', () => {
|
it('should have unique slugs within each sector', () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Bell,
|
Bell,
|
||||||
|
Inbox,
|
||||||
Landmark,
|
Landmark,
|
||||||
UtensilsCrossed,
|
UtensilsCrossed,
|
||||||
ChefHat,
|
ChefHat,
|
||||||
@@ -31,6 +32,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Bell,
|
Bell,
|
||||||
|
Inbox,
|
||||||
Landmark,
|
Landmark,
|
||||||
UtensilsCrossed,
|
UtensilsCrossed,
|
||||||
ChefHat,
|
ChefHat,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { sruExportExtension } from '@/extensions/sru-export'
|
|||||||
import { neBilagaExtension } from '@/extensions/ne-bilaga'
|
import { neBilagaExtension } from '@/extensions/ne-bilaga'
|
||||||
import { aiChatExtension } from '@/extensions/general/ai-chat'
|
import { aiChatExtension } from '@/extensions/general/ai-chat'
|
||||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||||
|
import { calendarExtension } from '@/extensions/general/calendar'
|
||||||
import type { Extension } from './types'
|
import type { Extension } from './types'
|
||||||
|
|
||||||
// ── Enable Banking (PSD2) — opt-in extension ───────────────────────────
|
// ── Enable Banking (PSD2) — opt-in extension ───────────────────────────
|
||||||
@@ -28,6 +29,7 @@ const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
|||||||
neBilagaExtension,
|
neBilagaExtension,
|
||||||
aiChatExtension,
|
aiChatExtension,
|
||||||
invoiceInboxExtension,
|
invoiceInboxExtension,
|
||||||
|
calendarExtension,
|
||||||
// enableBankingExtension, // Uncomment to activate PSD2 bank sync
|
// enableBankingExtension, // Uncomment to activate PSD2 bank sync
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ export const SECTORS: Sector[] = [
|
|||||||
description: 'AI-assistent för skatte- och bokföringsfrågor',
|
description: 'AI-assistent för skatte- och bokföringsfrågor',
|
||||||
longDescription:
|
longDescription:
|
||||||
'Ställ frågor om skatt, bokföring och företagande till en AI-assistent som förstår svensk redovisning. Svar baserade på aktuella regler och praxis.',
|
'Ställ frågor om skatt, bokföring och företagande till en AI-assistent som förstår svensk redovisning. Svar baserade på aktuella regler och praxis.',
|
||||||
|
quickAction: {
|
||||||
|
label: 'AI-assistent',
|
||||||
|
description: 'Fråga om bokföring',
|
||||||
|
icon: 'MessageSquare',
|
||||||
|
event: 'open-ai-chat',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'push-notifications',
|
slug: 'push-notifications',
|
||||||
@@ -76,6 +82,18 @@ export const SECTORS: Sector[] = [
|
|||||||
longDescription:
|
longDescription:
|
||||||
'Skicka leverantörsfakturor till en dedikerad e-postadress eller ladda upp manuellt. AI extraherar automatiskt leverantörsdata, belopp och moms. Granska och bekräfta med ett klick för att skapa leverantörsfakturor.',
|
'Skicka leverantörsfakturor till en dedikerad e-postadress eller ladda upp manuellt. AI extraherar automatiskt leverantörsdata, belopp och moms. Granska och bekräfta med ett klick för att skapa leverantörsfakturor.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
slug: 'calendar',
|
||||||
|
name: 'Kalender',
|
||||||
|
sector: 'general',
|
||||||
|
category: 'operations',
|
||||||
|
icon: 'Calendar',
|
||||||
|
dataPattern: 'core',
|
||||||
|
readsCoreTables: ['invoices', 'deadlines', 'customers'],
|
||||||
|
description: 'Fullstandig kalendervy med manads-, vecko- och dagsvisning',
|
||||||
|
longDescription:
|
||||||
|
'Se alla fakturadatum och deadlines i en interaktiv kalender med manads-, vecko- och dagsvy.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
slug: 'enable-banking',
|
slug: 'enable-banking',
|
||||||
name: 'Bankintegration (PSD2)',
|
name: 'Bankintegration (PSD2)',
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ export type SectorSlug = 'general' | 'restaurant' | 'construction' | 'hotel' | '
|
|||||||
/** How an extension gets its data */
|
/** How an extension gets its data */
|
||||||
export type ExtensionDataPattern = 'core' | 'manual' | 'both'
|
export type ExtensionDataPattern = 'core' | 'manual' | 'both'
|
||||||
|
|
||||||
|
/** Dashboard quick action declared by an extension */
|
||||||
|
export interface QuickActionDefinition {
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
href?: string
|
||||||
|
event?: string
|
||||||
|
order?: number
|
||||||
|
}
|
||||||
|
|
||||||
/** Extension metadata for the marketplace and workspace routing */
|
/** Extension metadata for the marketplace and workspace routing */
|
||||||
export interface ExtensionDefinition {
|
export interface ExtensionDefinition {
|
||||||
slug: string
|
slug: string
|
||||||
@@ -28,6 +38,7 @@ export interface ExtensionDefinition {
|
|||||||
dataPattern: ExtensionDataPattern
|
dataPattern: ExtensionDataPattern
|
||||||
readsCoreTables?: string[]
|
readsCoreTables?: string[]
|
||||||
hasOwnData?: boolean
|
hasOwnData?: boolean
|
||||||
|
quickAction?: QuickActionDefinition
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sector definition with its extensions */
|
/** Sector definition with its extensions */
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const WORKSPACES: Record<WorkspaceKey, ComponentType<WorkspaceComponentProps>> =
|
|||||||
'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')),
|
'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')),
|
||||||
'general/push-notifications': dynamic(() => import('@/components/extensions/general/PushNotificationsWorkspace')),
|
'general/push-notifications': dynamic(() => import('@/components/extensions/general/PushNotificationsWorkspace')),
|
||||||
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/InvoiceInboxWorkspace')),
|
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/InvoiceInboxWorkspace')),
|
||||||
|
'general/calendar': dynamic(() => import('@/components/extensions/general/CalendarWorkspace')),
|
||||||
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
|
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
|
||||||
// Restaurant
|
// Restaurant
|
||||||
'restaurant/food-cost': dynamic(() => import('@/components/extensions/restaurant/FoodCostWorkspace')),
|
'restaurant/food-cost': dynamic(() => import('@/components/extensions/restaurant/FoodCostWorkspace')),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { suggestCategory } from '@/lib/tax/expense-warnings'
|
import { suggestCategory } from '@/lib/tax/expense-warnings'
|
||||||
import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping'
|
import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||||
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
|
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
|
||||||
|
import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings'
|
||||||
import type { Transaction, TransactionCategory, EntityType, MappingRule } from '@/types'
|
import type { Transaction, TransactionCategory, EntityType, MappingRule } from '@/types'
|
||||||
|
|
||||||
export interface SuggestedCategory {
|
export interface SuggestedCategory {
|
||||||
@@ -198,13 +199,25 @@ export interface SuggestedTemplate {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get suggested booking templates for a transaction.
|
* Get suggested booking templates for a transaction.
|
||||||
* Uses multi-signal matching (MCC, keywords, description patterns).
|
* Tries embedding-based semantic search first, falls back to keyword matching.
|
||||||
*/
|
*/
|
||||||
export function getSuggestedTemplates(
|
export async function getSuggestedTemplates(
|
||||||
transaction: Transaction,
|
transaction: Transaction,
|
||||||
entityType?: EntityType
|
entityType?: EntityType
|
||||||
): SuggestedTemplate[] {
|
): Promise<SuggestedTemplate[]> {
|
||||||
const matches = findMatchingTemplates(transaction, entityType)
|
let matches: TemplateMatch[]
|
||||||
|
|
||||||
|
try {
|
||||||
|
matches = await findSimilarTemplates(transaction, entityType)
|
||||||
|
} catch {
|
||||||
|
matches = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to keyword matching if embedding search returns nothing
|
||||||
|
if (matches.length === 0) {
|
||||||
|
matches = findMatchingTemplates(transaction, entityType)
|
||||||
|
}
|
||||||
|
|
||||||
return matches.map((m: TemplateMatch) => ({
|
return matches.map((m: TemplateMatch) => ({
|
||||||
template_id: m.template.id,
|
template_id: m.template.id,
|
||||||
name_sv: m.template.name_sv,
|
name_sv: m.template.name_sv,
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
-- Migration 040: Booking Template Embeddings
|
||||||
|
-- Stores pre-computed embeddings for booking templates to enable
|
||||||
|
-- semantic similarity search for transaction classification.
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- booking_template_embeddings
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
create table public.booking_template_embeddings (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
template_id text unique not null,
|
||||||
|
embedding extensions.vector(1536) not null,
|
||||||
|
embedding_text text not null,
|
||||||
|
model text not null,
|
||||||
|
schema_version text not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.booking_template_embeddings enable row level security;
|
||||||
|
|
||||||
|
-- Shared system data — any authenticated user can read
|
||||||
|
create policy "booking_template_embeddings_select" on public.booking_template_embeddings
|
||||||
|
for select using (true);
|
||||||
|
|
||||||
|
-- Only service role can insert/update (no user-scoped writes)
|
||||||
|
-- RLS blocks regular users from writing; service role bypasses RLS
|
||||||
|
|
||||||
|
create trigger booking_template_embeddings_updated_at
|
||||||
|
before update on public.booking_template_embeddings
|
||||||
|
for each row execute function public.update_updated_at_column();
|
||||||
|
|
||||||
|
-- HNSW index for fast cosine similarity search
|
||||||
|
create index idx_booking_template_embeddings_embedding
|
||||||
|
on public.booking_template_embeddings
|
||||||
|
using hnsw (embedding extensions.vector_cosine_ops);
|
||||||
|
|
||||||
|
create index idx_booking_template_embeddings_template_id
|
||||||
|
on public.booking_template_embeddings (template_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- match_booking_templates RPC (vector similarity search)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
create or replace function public.match_booking_templates(
|
||||||
|
query_embedding extensions.vector,
|
||||||
|
match_count int default 20,
|
||||||
|
match_threshold float default 0.5
|
||||||
|
)
|
||||||
|
returns table (
|
||||||
|
template_id text,
|
||||||
|
embedding_text text,
|
||||||
|
similarity float
|
||||||
|
)
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public, extensions
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
return query
|
||||||
|
select
|
||||||
|
bte.template_id,
|
||||||
|
bte.embedding_text,
|
||||||
|
1 - (bte.embedding <=> query_embedding)::float as similarity
|
||||||
|
from public.booking_template_embeddings bte
|
||||||
|
where 1 - (bte.embedding <=> query_embedding) >= match_threshold
|
||||||
|
order by bte.embedding <=> query_embedding
|
||||||
|
limit match_count;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function public.match_booking_templates(extensions.vector, int, float) to authenticated;
|
||||||
Reference in New Issue
Block a user