feat: remove AI extensions, restructure settings, and add atomic voucher commits (#157)
Remove AI-dependent extensions (ai-chat, ai-categorization, receipt-ocr, invoice-inbox) and their infrastructure (lib/ai/*, ai-consent, LangChain/ Anthropic/OpenAI deps) to simplify core and reduce bundle size. Restructure monolithic settings page into dedicated sub-pages (company, bookkeeping, invoicing, tax, banking, api, account, team, templates) with shared layout and sidebar navigation. Add atomic commit_journal_entry RPC so voucher number increment and status update happen in a single transaction — prevents burned numbers on constraint failures. Add continuity check report and voucher gap explanation tracking. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -359,7 +359,7 @@ function SIEImportWizard() {
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
if (data.error === 'duplicate') {
|
||||
if (data.error === 'duplicate' || data.error === 'duplicate_period') {
|
||||
setError(data.message)
|
||||
} else if (data.error === 'validation') {
|
||||
setError(`${data.message}: ${data.errors?.join(', ') || 'Unknown validation error'}`)
|
||||
|
||||
@@ -29,10 +29,12 @@ export default function KpiPage() {
|
||||
const { data: periodsData } = await periodsRes.json()
|
||||
const { data: prefsData } = await prefsRes.json()
|
||||
|
||||
setPeriods(periodsData || [])
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const activePeriods = (periodsData || []).filter((p: FiscalPeriod) => p.period_start <= today)
|
||||
setPeriods(activePeriods)
|
||||
if (prefsData) setPreferences(prefsData)
|
||||
if (periodsData && periodsData.length > 0) {
|
||||
setSelectedPeriod(periodsData[0].id)
|
||||
if (activePeriods.length > 0) {
|
||||
setSelectedPeriod(activePeriods[0].id)
|
||||
}
|
||||
} catch {
|
||||
setError('Kunde inte hämta data')
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Receipt, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
const ReceiptsPageOCR = dynamic(
|
||||
() => import('@/extensions/general/receipt-ocr/pages/ReceiptsPage'),
|
||||
{ loading: () => <div className="flex items-center justify-center h-64"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> }
|
||||
)
|
||||
|
||||
const ocrEnabled = ENABLED_EXTENSION_IDS.has('receipt-ocr')
|
||||
import { Receipt } from 'lucide-react'
|
||||
|
||||
export default function ReceiptsPage() {
|
||||
if (ocrEnabled) {
|
||||
return <ReceiptsPageOCR />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -32,19 +17,13 @@ export default function ReceiptsPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Receipt className="h-5 w-5" />
|
||||
Kvittoscanning
|
||||
Kvitton
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Aktivera tillägget "Kvittoscanning" för att skanna kvitton med AI,
|
||||
extrahera data automatiskt och matcha mot transaktioner.
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Kvittoscanning är inte tillgängligt just nu.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/extensions/general?highlight=receipt-ocr">
|
||||
Aktivera i Tillägg
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
const ScanReceiptPageOCR = dynamic(
|
||||
() => import('@/extensions/general/receipt-ocr/pages/scan/ScanReceiptPage'),
|
||||
{ loading: () => <div className="flex items-center justify-center h-64"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> }
|
||||
)
|
||||
|
||||
const ocrEnabled = ENABLED_EXTENSION_IDS.has('receipt-ocr')
|
||||
|
||||
export default function ScanReceiptPage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!ocrEnabled) {
|
||||
router.replace('/receipts')
|
||||
}
|
||||
router.replace('/receipts')
|
||||
}, [router])
|
||||
|
||||
if (!ocrEnabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <ScanReceiptPageOCR />
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -81,9 +81,11 @@ export default function ReportsPage() {
|
||||
async function fetchPeriods() {
|
||||
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
||||
const { data } = await res.json()
|
||||
setPeriods(data || [])
|
||||
if (data && data.length > 0) {
|
||||
setSelectedPeriod(data[0].id)
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const activePeriods = (data || []).filter((p: FiscalPeriod) => p.period_start <= today)
|
||||
setPeriods(activePeriods)
|
||||
if (activePeriods.length > 0) {
|
||||
setSelectedPeriod(activePeriods[0].id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Sun, Moon, Monitor, LogOut } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { SecuritySettings } from '@/components/settings/SecuritySettings'
|
||||
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
|
||||
import { AccountDangerZone } from '@/components/settings/AccountDangerZone'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
|
||||
export default function AccountSettingsPage() {
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const hasCalendarExtension = ENABLED_EXTENSION_IDS.has('calendar')
|
||||
const { settings } = useSettings()
|
||||
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
async function handleLogout() {
|
||||
await supabase.auth.signOut()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Appearance */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Utseende
|
||||
</h2>
|
||||
{mounted && (
|
||||
<div className="flex gap-3">
|
||||
{([
|
||||
{ value: 'light', label: 'Ljust', icon: Sun },
|
||||
{ value: 'dark', label: 'Mörkt', icon: Moon },
|
||||
{ value: 'system', label: 'System', icon: Monitor },
|
||||
] as const).map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTheme(value)}
|
||||
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
theme === value
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Security */}
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<SecuritySettings />
|
||||
</div>
|
||||
|
||||
{/* Calendar feed */}
|
||||
{hasCalendarExtension && (
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<CalendarFeedSettings />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logout */}
|
||||
<section className="border-t border-border/8 pt-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kontoinställningar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">Logga ut</p>
|
||||
<p className="text-sm text-muted-foreground">Logga ut från ditt konto</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Logga ut
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Delete account — only for non-sandbox */}
|
||||
{!settings?.is_sandbox && <AccountDangerZone />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
|
||||
|
||||
export default function ApiSettingsPage() {
|
||||
return <ApiKeysPanel />
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { AlertTriangle, CreditCard, ExternalLink } from 'lucide-react'
|
||||
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
|
||||
const BankingPanel = getSettingsPanel('enable-banking')
|
||||
|
||||
export default function BankingSettingsPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [bankConnectionError, setBankConnectionError] = useState<string | null>(null)
|
||||
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||
|
||||
useEffect(() => {
|
||||
const bankConnected = searchParams.get('bank_connected')
|
||||
const bankError = searchParams.get('bank_error')
|
||||
|
||||
if (bankConnected === 'true') {
|
||||
toast({
|
||||
title: 'Bank ansluten!',
|
||||
description: 'Din bank är nu kopplad. Transaktioner hämtas...',
|
||||
})
|
||||
|
||||
const connectionId = searchParams.get('connection_id')
|
||||
if (connectionId) {
|
||||
fetch('/api/extensions/ext/enable-banking/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: connectionId, days_back: 90 }),
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.imported > 0) {
|
||||
toast({
|
||||
title: 'Transaktioner hämtade',
|
||||
description: `${data.imported} transaktioner importerade`,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
router.replace('/settings/banking')
|
||||
}
|
||||
|
||||
if (bankError) {
|
||||
const errorMsg = decodeURIComponent(bankError)
|
||||
toast({
|
||||
title: 'Anslutning misslyckades',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setBankConnectionError(errorMsg)
|
||||
router.replace('/settings/banking')
|
||||
}
|
||||
}, [searchParams, router, toast])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{bankConnectionError && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-destructive">{bankConnectionError}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Du kan också <Link href="/import?mode=bank" className="underline hover:text-foreground">importera transaktioner via bankfil</Link> istället.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setBankConnectionError(null)}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Stäng"
|
||||
>
|
||||
<span className="text-lg leading-none">×</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasBankingExtension && BankingPanel ? (
|
||||
<BankingPanel />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<CreditCard className="h-10 w-10 text-muted-foreground/40 mb-4" />
|
||||
<p className="font-medium mb-1">Bankintegration (PSD2) är inte aktiverad</p>
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-md">
|
||||
Aktivera tillägget Enable Banking för att koppla ditt bankkonto och automatiskt hämta transaktioner.
|
||||
</p>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/extensions">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Gå till Tillägg
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
import { PeriodLockingSettings } from '@/components/settings/PeriodLockingSettings'
|
||||
import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
export default function BookkeepingSettingsPage() {
|
||||
const { settings, isLoading, updateSettings } = useSettings()
|
||||
|
||||
if (isLoading || !settings) return <SettingsLoadingSkeleton />
|
||||
|
||||
function handleSave(formData: FormData) {
|
||||
const autoLockValue = formData.get('auto_lock_period_days') as string
|
||||
const lockedThrough = (formData.get('bookkeeping_locked_through') as string) || null
|
||||
const accountingMethod = (formData.get('accounting_method') as string) || 'accrual'
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
bookkeeping_locked_through: lockedThrough,
|
||||
auto_lock_period_days: autoLockValue === 'none' ? null : parseInt(autoLockValue),
|
||||
accounting_method: accountingMethod,
|
||||
}
|
||||
updateSettings(updates as Partial<CompanySettings>)
|
||||
return updates
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
{/* Accounting method */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Bokföringsmetod
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accounting_method">Metod</Label>
|
||||
<select
|
||||
id="accounting_method"
|
||||
name="accounting_method"
|
||||
defaultValue={settings.accounting_method || 'accrual'}
|
||||
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="accrual">Faktureringsmetoden</option>
|
||||
<option value="cash">Kontantmetoden</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{settings.entity_type === 'aktiebolag'
|
||||
? 'Aktiebolag med omsättning över 3 MSEK måste använda faktureringsmetoden.'
|
||||
: 'Kontantmetoden är tillgänglig för enskild firma med omsättning under 3 MSEK.'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Period locking */}
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<PeriodLockingSettings settings={settings} />
|
||||
</div>
|
||||
</SettingsFormWrapper>
|
||||
|
||||
{/* Voucher series — read-only, no form submit needed */}
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<VoucherSeriesManager />
|
||||
</div>
|
||||
|
||||
{/* Cross-links */}
|
||||
<div className="border-t border-border/8 pt-8 space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Relaterat
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
href="/bookkeeping"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Räkenskapsår och ingående balanser
|
||||
</Link>
|
||||
<Link
|
||||
href="/bookkeeping"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Kontoplan (BAS)
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { CompanyInfoForm } from '@/components/settings/CompanyInfoForm'
|
||||
import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection'
|
||||
import { LogoUpload } from '@/components/settings/LogoUpload'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
export default function CompanySettingsPage() {
|
||||
const { settings, isLoading, updateSettings } = useSettings()
|
||||
|
||||
if (isLoading || !settings) return <SettingsLoadingSkeleton />
|
||||
|
||||
function handleSave(formData: FormData) {
|
||||
const updates: Record<string, unknown> = {
|
||||
...(formData.has('company_name') && { company_name: formData.get('company_name') as string }),
|
||||
...(formData.has('org_number') && { org_number: formData.get('org_number') as string }),
|
||||
address_line1: formData.get('address_line1') as string,
|
||||
postal_code: formData.get('postal_code') as string,
|
||||
city: formData.get('city') as string,
|
||||
phone: (formData.get('phone') as string) || '',
|
||||
email: (formData.get('email') as string) || '',
|
||||
website: (formData.get('website') as string) || '',
|
||||
}
|
||||
updateSettings(updates as Partial<CompanySettings>)
|
||||
return updates
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
<CompanyInfoForm settings={settings} />
|
||||
</SettingsFormWrapper>
|
||||
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<LogoUpload
|
||||
logoUrl={settings.logo_url}
|
||||
onUpdate={(url) => updateSettings({ logo_url: url })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<CompanyMembersSection />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { BankDetailsForm, validateBankFields } from '@/components/settings/BankDetailsForm'
|
||||
import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm'
|
||||
import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
export default function InvoicingSettingsPage() {
|
||||
const { settings, isLoading, updateSettings } = useSettings()
|
||||
const { toast } = useToast()
|
||||
|
||||
if (isLoading || !settings) return <SettingsLoadingSkeleton />
|
||||
|
||||
function handleSave(formData: FormData) {
|
||||
const bankErrors = validateBankFields(formData)
|
||||
if (bankErrors.length > 0) {
|
||||
toast({
|
||||
title: 'Kontrollera bankuppgifter',
|
||||
description: bankErrors.map(e => e.message).join(', '),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return {}
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
bank_name: formData.get('bank_name') as string,
|
||||
clearing_number: formData.get('clearing_number') as string,
|
||||
account_number: formData.get('account_number') as string,
|
||||
bankgiro: (formData.get('bankgiro') as string) || null,
|
||||
invoice_prefix: (formData.get('invoice_prefix') as string) || null,
|
||||
next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1,
|
||||
invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30,
|
||||
invoice_default_notes: (formData.get('invoice_default_notes') as string) || null,
|
||||
}
|
||||
updateSettings(updates as Partial<CompanySettings>)
|
||||
return updates
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
<BankDetailsForm settings={settings} />
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<InvoiceSettingsForm settings={settings} />
|
||||
</div>
|
||||
</SettingsFormWrapper>
|
||||
|
||||
{/* PDF settings — saves individually via toggle switches */}
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { SettingsNav } from '@/components/settings/SettingsSidebar'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
const TAB_TO_ROUTE: Record<string, string> = {
|
||||
company: '/settings/company',
|
||||
invoicing: '/settings/invoicing',
|
||||
bookkeeping: '/settings/bookkeeping',
|
||||
tax: '/settings/tax',
|
||||
team: '/settings/team',
|
||||
banking: '/settings/banking',
|
||||
templates: '/settings/templates',
|
||||
account: '/settings/account',
|
||||
api: '/settings/api',
|
||||
}
|
||||
|
||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { company } = useCompany()
|
||||
const [isSandbox, setIsSandbox] = useState(false)
|
||||
|
||||
// Fetch sandbox status
|
||||
useEffect(() => {
|
||||
if (!company?.id) return
|
||||
const supabase = createClient()
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('is_sandbox')
|
||||
.eq('company_id', company.id)
|
||||
.single()
|
||||
.then(({ data }) => {
|
||||
if (data?.is_sandbox) setIsSandbox(true)
|
||||
})
|
||||
}, [company?.id])
|
||||
|
||||
// Handle legacy ?tab= URLs
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get('tab')
|
||||
if (tab && TAB_TO_ROUTE[tab]) {
|
||||
router.replace(TAB_TO_ROUTE[tab])
|
||||
}
|
||||
}, [searchParams, router])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Inställningar</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Hantera ditt företag och konto
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsNav isSandbox={isSandbox} />
|
||||
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,862 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Loader2,
|
||||
CreditCard,
|
||||
LogOut,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
ExternalLink,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { CompanySettings } from '@/types'
|
||||
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
|
||||
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
|
||||
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
|
||||
import { SupportLink } from '@/components/ui/support-link'
|
||||
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
|
||||
import { SecuritySettings } from '@/components/settings/SecuritySettings'
|
||||
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
|
||||
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel'
|
||||
import { TeamPanel } from '@/components/settings/TeamPanel'
|
||||
import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
|
||||
const BankingPanel = getSettingsPanel('enable-banking')
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
const { company, isTeamMember } = useCompany()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [settings, setSettings] = useState<CompanySettings | null>(null)
|
||||
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||
const hasCalendarExtension = ENABLED_EXTENSION_IDS.has('calendar')
|
||||
const hasMcpExtension = ENABLED_EXTENSION_IDS.has('mcp-server')
|
||||
const [bankConnectionError, setBankConnectionError] = useState<string | null>(null)
|
||||
const [bankgiroError, setBankgiroError] = useState<string | null>(null)
|
||||
const [clearingError, setClearingError] = useState<string | null>(null)
|
||||
const [accountNumberError, setAccountNumberError] = useState<string | null>(null)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState('')
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
const hasCompany = !!company
|
||||
const defaultTab = hasCompany ? 'company' : (isTeamMember ? 'team' : 'account')
|
||||
const initialTab = searchParams.get('tab') || defaultTab
|
||||
const [activeTab, setActiveTab] = useState(initialTab)
|
||||
|
||||
const settingsTabs = [
|
||||
{ value: 'company', label: 'Företag', show: hasCompany },
|
||||
{ value: 'team', label: 'Lag', show: isTeamMember },
|
||||
{ value: 'banking', label: 'Bank (PSD2)', show: hasCompany && !settings?.is_sandbox && hasBankingExtension },
|
||||
{ value: 'templates', label: 'Mallar', show: hasCompany },
|
||||
{ value: 'account', label: 'Konto', show: true },
|
||||
{ value: 'api', label: 'API', show: hasCompany && hasMcpExtension },
|
||||
].filter(t => t.show)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
async function fetchData() {
|
||||
setIsLoading(true)
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
if (company?.id) {
|
||||
const settingsRes = await supabase.from('company_settings').select('*').eq('company_id', company.id).single()
|
||||
setSettings(settingsRes.data)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
|
||||
// Handle callback messages
|
||||
const bankConnected = searchParams.get('bank_connected')
|
||||
const bankError = searchParams.get('bank_error')
|
||||
|
||||
if (bankConnected === 'true') {
|
||||
toast({
|
||||
title: 'Bank ansluten!',
|
||||
description: 'Din bank är nu kopplad. Transaktioner hämtas...',
|
||||
})
|
||||
|
||||
// Auto-sync transactions after connection
|
||||
const connectionId = searchParams.get('connection_id')
|
||||
if (connectionId) {
|
||||
fetch('/api/extensions/ext/enable-banking/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: connectionId, days_back: 90 }),
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.imported > 0) {
|
||||
toast({
|
||||
title: 'Transaktioner hämtade',
|
||||
description: `${data.imported} transaktioner importerade`,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
router.replace('/settings?tab=banking')
|
||||
}
|
||||
|
||||
if (bankError) {
|
||||
const errorMsg = decodeURIComponent(bankError)
|
||||
toast({
|
||||
title: 'Anslutning misslyckades',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setBankConnectionError(errorMsg)
|
||||
setActiveTab('banking')
|
||||
router.replace('/settings?tab=banking')
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
async function handleSaveSettings(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
if (!settings) return
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
|
||||
// Validate bank fields before saving
|
||||
const clearingVal = (formData.get('clearing_number') as string || '').trim()
|
||||
const accountVal = (formData.get('account_number') as string || '').trim()
|
||||
const bankgiroVal = (formData.get('bankgiro') as string || '').trim()
|
||||
|
||||
let hasErrors = false
|
||||
|
||||
if (clearingVal && !/^\d{4,5}$/.test(clearingVal)) {
|
||||
setClearingError('Clearingnummer måste vara 4-5 siffror')
|
||||
hasErrors = true
|
||||
} else {
|
||||
setClearingError(null)
|
||||
}
|
||||
|
||||
if (accountVal && !/^\d{6,12}$/.test(accountVal)) {
|
||||
setAccountNumberError('Kontonummer måste vara 6-12 siffror')
|
||||
hasErrors = true
|
||||
} else {
|
||||
setAccountNumberError(null)
|
||||
}
|
||||
|
||||
if (bankgiroVal && !validateBankgiroNumber(bankgiroVal)) {
|
||||
setBankgiroError('Ogiltigt bankgironummer (7-8 siffror med kontrollsiffra)')
|
||||
hasErrors = true
|
||||
} else {
|
||||
setBankgiroError(null)
|
||||
}
|
||||
|
||||
if (hasErrors) return
|
||||
|
||||
setIsSaving(true)
|
||||
|
||||
// Disabled inputs are excluded from FormData by the browser,
|
||||
// so only include company_name/org_number when not locked
|
||||
const updates: Record<string, unknown> = {
|
||||
...(formData.has('company_name') && { company_name: formData.get('company_name') as string }),
|
||||
...(formData.has('org_number') && { org_number: formData.get('org_number') as string }),
|
||||
address_line1: formData.get('address_line1') as string,
|
||||
postal_code: formData.get('postal_code') as string,
|
||||
city: formData.get('city') as string,
|
||||
bank_name: formData.get('bank_name') as string,
|
||||
clearing_number: formData.get('clearing_number') as string,
|
||||
account_number: formData.get('account_number') as string,
|
||||
bankgiro: (formData.get('bankgiro') as string) || null,
|
||||
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
|
||||
invoice_prefix: formData.get('invoice_prefix') as string || null,
|
||||
next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1,
|
||||
invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30,
|
||||
accounting_method: formData.get('accounting_method') as string || 'accrual',
|
||||
invoice_default_notes: (formData.get('invoice_default_notes') as string) || null,
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Kunde inte spara inställningar')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Sparat',
|
||||
description: 'Dina inställningar har uppdaterats',
|
||||
})
|
||||
setSettings({ ...settings, ...updates } as typeof settings)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Kunde inte spara inställningar',
|
||||
description: error instanceof Error ? error.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await supabase.auth.signOut()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
async function handleDeleteAccount() {
|
||||
if (deleteConfirmText !== 'RADERA') return
|
||||
setIsDeleting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/account/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ confirm: 'RADERA' }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Kunde inte radera kontot')
|
||||
}
|
||||
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Kunde inte radera kontot',
|
||||
description: error instanceof Error ? error.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-72 mt-2 animate-pulse" />
|
||||
</div>
|
||||
<div className="h-10 bg-muted rounded w-96 animate-pulse" />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="h-5 bg-muted rounded w-32 animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-56 mt-1 animate-pulse" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-24 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-32 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-16 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-20 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-12 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Inställningar</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Hantera dina företags- och kontoinställningar
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
{/* Mobile: dropdown selector */}
|
||||
<div className="sm:hidden">
|
||||
<Select value={activeTab} onValueChange={setActiveTab}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{settingsTabs.map(t => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Desktop: tab pills */}
|
||||
<TabsList className="hidden sm:inline-flex flex-wrap h-auto gap-1">
|
||||
{settingsTabs.map(t => (
|
||||
<TabsTrigger key={t.value} value={t.value}>{t.label}</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{/* Company settings */}
|
||||
<TabsContent value="company">
|
||||
<form onSubmit={handleSaveSettings} className="space-y-6">
|
||||
{/* Företagsuppgifter */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Företagsuppgifter</CardTitle>
|
||||
<CardDescription>
|
||||
Namn, organisationsnummer och adress
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company_name">Företagsnamn</Label>
|
||||
<Input
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
defaultValue={settings?.company_name || ''}
|
||||
disabled={settings?.onboarding_complete === true}
|
||||
/>
|
||||
{settings?.onboarding_complete && (
|
||||
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org_number">Organisationsnummer</Label>
|
||||
<Input
|
||||
id="org_number"
|
||||
name="org_number"
|
||||
defaultValue={settings?.org_number || ''}
|
||||
disabled={settings?.onboarding_complete === true}
|
||||
/>
|
||||
{settings?.onboarding_complete && (
|
||||
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">Adress</Label>
|
||||
<Input
|
||||
id="address_line1"
|
||||
name="address_line1"
|
||||
defaultValue={settings?.address_line1 || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">Postnummer</Label>
|
||||
<Input
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
defaultValue={settings?.postal_code || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Input
|
||||
id="city"
|
||||
name="city"
|
||||
defaultValue={settings?.city || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bankuppgifter */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bankuppgifter</CardTitle>
|
||||
<CardDescription>
|
||||
Betalningsuppgifter som visas på dina fakturor
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Bank</Label>
|
||||
<BankNameCombobox
|
||||
defaultValue={settings?.bank_name || ''}
|
||||
enableBankingEnabled={hasBankingExtension}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clearing_number">Clearing</Label>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
name="clearing_number"
|
||||
inputMode="numeric"
|
||||
placeholder="XXXX"
|
||||
maxLength={5}
|
||||
defaultValue={settings?.clearing_number || ''}
|
||||
onChange={(e) => {
|
||||
e.target.value = e.target.value.replace(/\D/g, '')
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (!val) {
|
||||
setClearingError(null)
|
||||
return
|
||||
}
|
||||
if (!/^\d{4,5}$/.test(val)) {
|
||||
setClearingError('Clearingnummer måste vara 4-5 siffror')
|
||||
} else {
|
||||
setClearingError(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{clearingError && (
|
||||
<p className="text-xs text-destructive">{clearingError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
name="account_number"
|
||||
inputMode="numeric"
|
||||
placeholder="XXXXXXX"
|
||||
maxLength={12}
|
||||
defaultValue={settings?.account_number || ''}
|
||||
onChange={(e) => {
|
||||
e.target.value = e.target.value.replace(/\D/g, '')
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (!val) {
|
||||
setAccountNumberError(null)
|
||||
return
|
||||
}
|
||||
if (!/^\d{6,12}$/.test(val)) {
|
||||
setAccountNumberError('Kontonummer måste vara 6-12 siffror')
|
||||
} else {
|
||||
setAccountNumberError(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{accountNumberError && (
|
||||
<p className="text-xs text-destructive">{accountNumberError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-xs space-y-2">
|
||||
<Label htmlFor="bankgiro">Bankgiro</Label>
|
||||
<Input
|
||||
id="bankgiro"
|
||||
name="bankgiro"
|
||||
placeholder="XXX-XXXX"
|
||||
defaultValue={settings?.bankgiro || ''}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (!val) {
|
||||
setBankgiroError(null)
|
||||
return
|
||||
}
|
||||
if (validateBankgiroNumber(val)) {
|
||||
e.target.value = formatBankgiroNumber(val)
|
||||
setBankgiroError(null)
|
||||
} else {
|
||||
setBankgiroError('Ogiltigt bankgironummer (7-8 siffror med kontrollsiffra)')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{bankgiroError && (
|
||||
<p className="text-xs text-destructive">{bankgiroError}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Fakturainställningar */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fakturainställningar</CardTitle>
|
||||
<CardDescription>
|
||||
Numrering, betalningsvillkor och bokföringsmetod
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
|
||||
<Input
|
||||
id="invoice_prefix"
|
||||
name="invoice_prefix"
|
||||
placeholder="t.ex. F-"
|
||||
defaultValue={settings?.invoice_prefix || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="next_invoice_number">Nästa fakturanummer</Label>
|
||||
<Input
|
||||
id="next_invoice_number"
|
||||
name="next_invoice_number"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={settings?.next_invoice_number || 1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_default_days">Betalningsvillkor (dagar)</Label>
|
||||
<Input
|
||||
id="invoice_default_days"
|
||||
name="invoice_default_days"
|
||||
type="number"
|
||||
min="0"
|
||||
defaultValue={settings?.invoice_default_days || 30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accounting_method">Bokföringsmetod</Label>
|
||||
<select
|
||||
id="accounting_method"
|
||||
name="accounting_method"
|
||||
defaultValue={settings?.accounting_method || 'accrual'}
|
||||
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="accrual">Faktureringsmetoden</option>
|
||||
<option value="cash">Kontantmetoden</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{settings?.entity_type === 'aktiebolag'
|
||||
? 'Aktiebolag med omsättning över 3 MSEK måste använda faktureringsmetoden enligt BFL. Mindre aktiebolag kan välja kontantmetoden.'
|
||||
: 'Kontantmetoden är tillgänglig för enskild firma med omsättning under 3 MSEK.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_default_notes">Standardtext på fakturor</Label>
|
||||
<Textarea
|
||||
id="invoice_default_notes"
|
||||
name="invoice_default_notes"
|
||||
rows={3}
|
||||
placeholder="T.ex. betalningsvillkor, leveransinfo..."
|
||||
defaultValue={settings?.invoice_default_notes || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Texten föreslås automatiskt i anteckningsfältet vid ny faktura.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Skatteinställningar */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Skatteinställningar</CardTitle>
|
||||
<CardDescription>
|
||||
Preliminärskatt och F-skatt
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preliminary_tax_monthly">
|
||||
Månatlig preliminärskatt (F-skatt)
|
||||
</Label>
|
||||
<Input
|
||||
id="preliminary_tax_monthly"
|
||||
name="preliminary_tax_monthly"
|
||||
type="number"
|
||||
defaultValue={settings?.preliminary_tax_monthly || ''}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara ändringar'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Separator className="my-8" />
|
||||
|
||||
<CompanyMembersSection />
|
||||
</TabsContent>
|
||||
|
||||
{/* Team management */}
|
||||
<TabsContent value="team">
|
||||
<TeamPanel />
|
||||
</TabsContent>
|
||||
|
||||
{/* Banking settings — loaded dynamically from extension, hidden for sandbox */}
|
||||
{!settings?.is_sandbox && (
|
||||
<TabsContent value="banking" className="space-y-6">
|
||||
{bankConnectionError && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-destructive">{bankConnectionError}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Du kan också <Link href="/import?mode=bank" className="underline hover:text-foreground">importera transaktioner via bankfil</Link> istället.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setBankConnectionError(null)}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Stäng"
|
||||
>
|
||||
<span className="text-lg leading-none">×</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{hasBankingExtension && BankingPanel ? (
|
||||
<BankingPanel />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<CreditCard className="h-10 w-10 text-muted-foreground/40 mb-4" />
|
||||
<p className="font-medium mb-1">Bankintegration (PSD2) är inte aktiverad</p>
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-md">
|
||||
Aktivera tillägget Enable Banking för att koppla ditt bankkonto och automatiskt hämta transaktioner.
|
||||
</p>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/extensions">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Gå till Tillägg
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Counterparty templates */}
|
||||
<TabsContent value="templates">
|
||||
<CounterpartyTemplatesPanel />
|
||||
</TabsContent>
|
||||
|
||||
{/* API keys */}
|
||||
{hasMcpExtension && (
|
||||
<TabsContent value="api">
|
||||
<ApiKeysPanel />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Account — security, appearance, calendar, logout, delete */}
|
||||
<TabsContent value="account" className="space-y-6">
|
||||
{/* Appearance */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Utseende</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mounted && (
|
||||
<div className="flex gap-3">
|
||||
{([
|
||||
{ value: 'light', label: 'Ljust', icon: Sun },
|
||||
{ value: 'dark', label: 'Mörkt', icon: Moon },
|
||||
{ value: 'system', label: 'System', icon: Monitor },
|
||||
] as const).map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTheme(value)}
|
||||
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
theme === value
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security */}
|
||||
<SecuritySettings />
|
||||
|
||||
{/* Calendar feed */}
|
||||
{hasCalendarExtension && <CalendarFeedSettings />}
|
||||
|
||||
{/* Logout & delete */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kontoinställningar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">Logga ut</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Logga ut från ditt konto
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Logga ut
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!settings?.is_sandbox && <Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Radera konto</CardTitle>
|
||||
<CardDescription>
|
||||
Permanent borttagning av ditt konto och all data
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||
<div className="flex gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="font-medium text-destructive">Varning: Denna åtgärd kan inte ångras</p>
|
||||
<p className="text-muted-foreground">
|
||||
All din data raderas permanent — bokföring, fakturor, verifikationer, dokument och inställningar.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Enligt bokföringslagen (BFL 7 kap. 2§) ska räkenskapsinformation bevaras i 7 år. Du ansvarar själv för att exportera och arkivera din bokföringsdata innan du raderar kontot.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Har du frågor?{' '}
|
||||
<SupportLink variant="inline" subject="Fråga om kontoradering" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button variant="outline" className="w-full sm:w-auto min-h-11" asChild>
|
||||
<Link href="/reports?type=sie">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Exportera bokföringsdata (SIE)
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full sm:w-auto min-h-11"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
Radera mitt konto
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Delete account confirmation dialog */}
|
||||
<Dialog open={showDeleteDialog} onOpenChange={(open) => {
|
||||
setShowDeleteDialog(open)
|
||||
if (!open) setDeleteConfirmText('')
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Radera konto permanent</DialogTitle>
|
||||
<DialogDescription>
|
||||
All din data raderas permanent. Skriv <strong>RADERA</strong> nedan för att bekräfta.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delete-confirm">Bekräfta genom att skriva RADERA</Label>
|
||||
<Input
|
||||
id="delete-confirm"
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
placeholder="RADERA"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowDeleteDialog(false)
|
||||
setDeleteConfirmText('')
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={deleteConfirmText !== 'RADERA' || isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Raderar...
|
||||
</>
|
||||
) : (
|
||||
'Radera permanent'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
redirect('/settings/company')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { TaxSettingsForm } from '@/components/settings/TaxSettingsForm'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
export default function TaxSettingsPage() {
|
||||
const { settings, isLoading, updateSettings } = useSettings()
|
||||
|
||||
if (isLoading || !settings) return <SettingsLoadingSkeleton />
|
||||
|
||||
function handleSave(formData: FormData) {
|
||||
const updates: Record<string, unknown> = {
|
||||
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
|
||||
}
|
||||
updateSettings(updates as Partial<CompanySettings>)
|
||||
return updates
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
<TaxSettingsForm settings={settings} />
|
||||
</SettingsFormWrapper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { TeamPanel } from '@/components/settings/TeamPanel'
|
||||
|
||||
export default function TeamSettingsPage() {
|
||||
return <TeamPanel />
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel'
|
||||
|
||||
export default function TemplatesSettingsPage() {
|
||||
return <CounterpartyTemplatesPanel />
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType, LinePatternEntry } from '@/types'
|
||||
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
|
||||
interface QuickReviewState {
|
||||
transaction: TransactionWithInvoice
|
||||
@@ -814,7 +813,7 @@ export default function TransactionsPage() {
|
||||
onMarkPrivate={handleMarkPrivate}
|
||||
onOpenMatchDialog={openMatchDialog}
|
||||
onOpenCategoryDialog={openCategoryDialog}
|
||||
onOpenDescribe={ENABLED_EXTENSION_IDS.has('ai-categorization') ? openDescribeDialog : undefined}
|
||||
onOpenDescribe={openDescribeDialog}
|
||||
onOpenQuickReview={handleOpenQuickReview}
|
||||
onOpenTemplateReview={handleOpenTemplateReview}
|
||||
onToggleSelect={toggleBatchSelect}
|
||||
|
||||
@@ -108,23 +108,6 @@ export default function PrivacyPolicyPage() {
|
||||
<td className="py-2 pr-4">Globalt CDN (EU-regioner tillgängliga)</td>
|
||||
<td className="py-2">EU Data Residency</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="py-2 pr-4 font-medium">Anthropic</td>
|
||||
<td className="py-2 pr-4">
|
||||
Kvitto-OCR (receipt-ocr), transaktionskategorisering (ai-categorization),
|
||||
AI-chattassistent (ai-chat)
|
||||
</td>
|
||||
<td className="py-2 pr-4">USA</td>
|
||||
<td className="py-2">SCCs (standardavtalsklausuler)</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="py-2 pr-4 font-medium">OpenAI</td>
|
||||
<td className="py-2 pr-4">
|
||||
Embedding-generering för likhetssökning (transaktionsmallar, kunskapsbas)
|
||||
</td>
|
||||
<td className="py-2 pr-4">USA</td>
|
||||
<td className="py-2">SCCs (standardavtalsklausuler)</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="py-2 pr-4 font-medium">Enable Banking</td>
|
||||
<td className="py-2 pr-4">PSD2-bankkontouppkoppling</td>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = verifyCronSecret(request)
|
||||
if (authError) return authError
|
||||
|
||||
const aiExt = extensionRegistry.get('ai-categorization')
|
||||
if (!aiExt?.services?.seedAllTemplateEmbeddings || !aiExt?.services?.getSchemaVersion) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ai-categorization extension not loaded' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { seeded, errors } = await aiExt.services.seedAllTemplateEmbeddings()
|
||||
const schemaVersion = await aiExt.services.getSchemaVersion()
|
||||
|
||||
return NextResponse.json({
|
||||
success: errors.length === 0,
|
||||
seeded,
|
||||
errors,
|
||||
schema_version: schemaVersion,
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: `Seeding failed: ${error instanceof Error ? error.message : 'Unknown error'}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/extensions/ai-consent', () => ({
|
||||
AI_EXTENSIONS: ['receipt-ocr', 'ai-categorization', 'ai-chat'],
|
||||
hasAiConsent: vi.fn(),
|
||||
grantAiConsent: vi.fn(),
|
||||
revokeAiConsent: vi.fn(),
|
||||
isAiExtension: vi.fn((id: string) =>
|
||||
['receipt-ocr', 'ai-categorization', 'ai-chat'].includes(id)
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { hasAiConsent, grantAiConsent, revokeAiConsent } from '@/lib/extensions/ai-consent'
|
||||
import { GET, POST, DELETE } from '../route'
|
||||
|
||||
const mockCreateClient = vi.mocked(createClient)
|
||||
const mockHasAiConsent = vi.mocked(hasAiConsent)
|
||||
const mockGrantAiConsent = vi.mocked(grantAiConsent)
|
||||
const mockRevokeAiConsent = vi.mocked(revokeAiConsent)
|
||||
|
||||
function mockAuth(userId: string | null) {
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: {
|
||||
getUser: vi.fn().mockResolvedValue({
|
||||
data: { user: userId ? { id: userId } : null },
|
||||
}),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET /api/ai-consent', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuth(null)
|
||||
const { status } = await parseJsonResponse(await GET())
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns consent status for all AI extensions', async () => {
|
||||
mockAuth('user-1')
|
||||
mockHasAiConsent
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true)
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: Record<string, boolean> }>(
|
||||
await GET()
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual({
|
||||
'receipt-ocr': true,
|
||||
'ai-categorization': false,
|
||||
'ai-chat': true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/ai-consent', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuth(null)
|
||||
const req = createMockRequest('/api/ai-consent', {
|
||||
method: 'POST',
|
||||
body: { extension_id: 'receipt-ocr' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await POST(req))
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('grants consent for valid AI extension', async () => {
|
||||
mockAuth('user-1')
|
||||
mockGrantAiConsent.mockResolvedValue(undefined)
|
||||
|
||||
const req = createMockRequest('/api/ai-consent', {
|
||||
method: 'POST',
|
||||
body: { extension_id: 'receipt-ocr' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ data: { consented: boolean } }>(
|
||||
await POST(req)
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.consented).toBe(true)
|
||||
expect(mockGrantAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', 'receipt-ocr')
|
||||
})
|
||||
|
||||
it('returns 400 for non-AI extension', async () => {
|
||||
mockAuth('user-1')
|
||||
|
||||
const req = createMockRequest('/api/ai-consent', {
|
||||
method: 'POST',
|
||||
body: { extension_id: 'enable-banking' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await POST(req))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/ai-consent', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuth(null)
|
||||
const req = createMockRequest('/api/ai-consent', {
|
||||
method: 'DELETE',
|
||||
body: { extension_id: 'ai-chat' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await DELETE(req))
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('revokes consent for valid AI extension', async () => {
|
||||
mockAuth('user-1')
|
||||
mockRevokeAiConsent.mockResolvedValue(undefined)
|
||||
|
||||
const req = createMockRequest('/api/ai-consent', {
|
||||
method: 'DELETE',
|
||||
body: { extension_id: 'ai-chat' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ data: { consented: boolean } }>(
|
||||
await DELETE(req)
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.consented).toBe(false)
|
||||
expect(mockRevokeAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', 'ai-chat')
|
||||
})
|
||||
})
|
||||
@@ -1,76 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
AI_EXTENSIONS,
|
||||
hasAiConsent,
|
||||
grantAiConsent,
|
||||
revokeAiConsent,
|
||||
isAiExtension,
|
||||
} from '@/lib/extensions/ai-consent'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const statuses: Record<string, boolean> = {}
|
||||
for (const ext of AI_EXTENSIONS) {
|
||||
statuses[ext] = await hasAiConsent(supabase, companyId, ext)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: statuses })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const body = await request.json()
|
||||
const { extension_id } = body
|
||||
|
||||
if (!extension_id || !isAiExtension(extension_id)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or non-AI extension_id' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await grantAiConsent(supabase, user.id, companyId, extension_id)
|
||||
return NextResponse.json({ data: { consented: true } })
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const body = await request.json()
|
||||
const { extension_id } = body
|
||||
|
||||
if (!extension_id || !isAiExtension(extension_id)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or non-AI extension_id' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await revokeAiConsent(supabase, user.id, companyId, extension_id)
|
||||
return NextResponse.json({ data: { consented: false } })
|
||||
}
|
||||
@@ -48,7 +48,42 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: durationError }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check for overlapping periods
|
||||
// Enforce continuity: new period must chain from the latest existing period (BFL 3:1)
|
||||
const { data: latest } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_end, is_closed')
|
||||
.eq('company_id', companyId)
|
||||
.order('period_end', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (latest) {
|
||||
const prev = new Date(latest.period_end + 'T00:00:00')
|
||||
prev.setDate(prev.getDate() + 1)
|
||||
const expectedStart = prev.toISOString().split('T')[0]
|
||||
if (body.period_start !== expectedStart) {
|
||||
return NextResponse.json(
|
||||
{ error: `Period must start on ${expectedStart} (day after latest period ends)` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce: max one unclosed period (no skipping ahead)
|
||||
const { count: openCount } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_closed', false)
|
||||
|
||||
if (openCount && openCount > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot create a new period while an unclosed period exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Defense-in-depth: check for overlapping periods
|
||||
const { data: overlapping } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name')
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock supabase
|
||||
const mockFrom = vi.fn()
|
||||
const mockRpc = vi.fn()
|
||||
const mockAuth = vi.fn()
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn().mockResolvedValue({
|
||||
from: (...args: unknown[]) => mockFrom(...args),
|
||||
rpc: (...args: unknown[]) => mockRpc(...args),
|
||||
auth: { getUser: () => mockAuth() },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
function mockChain(result: { data?: unknown; error?: unknown; count?: number | null }) {
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'neq', 'in', 'insert', 'update', 'upsert', 'order', 'limit', 'single', 'maybeSingle']) {
|
||||
chain[m] = vi.fn().mockReturnValue(chain)
|
||||
}
|
||||
chain.single = vi.fn().mockResolvedValue(result)
|
||||
chain.maybeSingle = vi.fn().mockResolvedValue(result)
|
||||
// Make the chain thenable so await resolves to result
|
||||
chain.then = (resolve: (v: unknown) => void) => resolve(result)
|
||||
return chain
|
||||
}
|
||||
|
||||
function makeRequest(url: string, options?: RequestInit) {
|
||||
return new Request(url, options)
|
||||
}
|
||||
|
||||
async function parseJson(response: Response) {
|
||||
return response.json()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET /api/bookkeeping/voucher-gaps', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=fp-1')
|
||||
const response = await GET(request)
|
||||
const body = await parseJson(response)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(body.error).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('returns 400 when fiscal_period_id is missing', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps')
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns empty result when no voucher sequences exist', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
mockFrom.mockReturnValue(mockChain({ data: [], error: null }))
|
||||
|
||||
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=f47ac10b-58cc-4372-a567-0e02b2c3d479')
|
||||
const response = await GET(request)
|
||||
const body = await parseJson(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data.gaps).toEqual([])
|
||||
expect(body.data.totalGaps).toBe(0)
|
||||
expect(body.data.unexplainedGaps).toBe(0)
|
||||
})
|
||||
|
||||
it('returns gaps with explanations', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
let fromCallCount = 0
|
||||
mockFrom.mockImplementation(() => {
|
||||
fromCallCount++
|
||||
if (fromCallCount === 1) {
|
||||
// voucher_sequences query
|
||||
return mockChain({ data: [{ voucher_series: 'A' }], error: null })
|
||||
}
|
||||
if (fromCallCount === 2) {
|
||||
// gap_explanations query
|
||||
return mockChain({
|
||||
data: [{ id: 'exp-1', voucher_series: 'A', gap_start: 3, gap_end: 3, explanation: 'Transient error', user_id: 'user-1', created_at: '2026-01-01' }],
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
return mockChain({ data: null, error: null })
|
||||
})
|
||||
|
||||
mockRpc.mockResolvedValue({
|
||||
data: [{ gap_start: 3, gap_end: 3 }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=f47ac10b-58cc-4372-a567-0e02b2c3d479')
|
||||
const response = await GET(request)
|
||||
const body = await parseJson(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data.totalGaps).toBe(1)
|
||||
expect(body.data.unexplainedGaps).toBe(0)
|
||||
expect(body.data.gaps[0].explanation).toBeTruthy()
|
||||
expect(body.data.gaps[0].explanation.explanation).toBe('Transient error')
|
||||
})
|
||||
|
||||
it('returns unexplained gaps', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
let fromCallCount = 0
|
||||
mockFrom.mockImplementation(() => {
|
||||
fromCallCount++
|
||||
if (fromCallCount === 1) {
|
||||
return mockChain({ data: [{ voucher_series: 'A' }], error: null })
|
||||
}
|
||||
if (fromCallCount === 2) {
|
||||
return mockChain({ data: [], error: null })
|
||||
}
|
||||
return mockChain({ data: null, error: null })
|
||||
})
|
||||
|
||||
mockRpc.mockResolvedValue({
|
||||
data: [{ gap_start: 5, gap_end: 7 }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=f47ac10b-58cc-4372-a567-0e02b2c3d479')
|
||||
const response = await GET(request)
|
||||
const body = await parseJson(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data.totalGaps).toBe(1)
|
||||
expect(body.data.unexplainedGaps).toBe(1)
|
||||
expect(body.data.gaps[0].explanation).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/bookkeeping/voucher-gaps', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
gap_start: 5,
|
||||
gap_end: 7,
|
||||
explanation: 'Transient DB error during commit',
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 when explanation is empty', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
gap_start: 5,
|
||||
gap_end: 7,
|
||||
explanation: '',
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('saves gap explanation successfully', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
const savedRow = {
|
||||
id: 'exp-1',
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
voucher_series: 'A',
|
||||
gap_start: 5,
|
||||
gap_end: 7,
|
||||
explanation: 'Failed commit during bank sync',
|
||||
created_at: '2026-04-01',
|
||||
updated_at: '2026-04-01',
|
||||
}
|
||||
|
||||
mockFrom.mockReturnValue(mockChain({ data: savedRow, error: null }))
|
||||
|
||||
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
gap_start: 5,
|
||||
gap_end: 7,
|
||||
explanation: 'Failed commit during bank sync',
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const body = await parseJson(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data.explanation).toBe('Failed commit during bank sync')
|
||||
})
|
||||
|
||||
it('returns 403 when user lacks permission', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
mockFrom.mockReturnValue(mockChain({ data: null, error: { code: '42501', message: 'permission denied' } }))
|
||||
|
||||
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
gap_start: 5,
|
||||
gap_end: 7,
|
||||
explanation: 'Test explanation',
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const body = await parseJson(response)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(body.error).toContain('owners and admins')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { validateBody, validateQuery } from '@/lib/api/validate'
|
||||
import { VoucherGapQuerySchema, SaveGapExplanationSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const validation = validateQuery(request, VoucherGapQuerySchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { fiscal_period_id, voucher_series } = validation.data
|
||||
|
||||
// Get all series used in this period (or filter to specific series)
|
||||
let seriesQuery = supabase
|
||||
.from('voucher_sequences')
|
||||
.select('voucher_series')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscal_period_id)
|
||||
|
||||
if (voucher_series) {
|
||||
seriesQuery = seriesQuery.eq('voucher_series', voucher_series)
|
||||
}
|
||||
|
||||
const { data: seriesRows } = await seriesQuery
|
||||
|
||||
if (!seriesRows || seriesRows.length === 0) {
|
||||
return NextResponse.json({
|
||||
data: { gaps: [], totalGaps: 0, unexplainedGaps: 0 },
|
||||
})
|
||||
}
|
||||
|
||||
// Detect gaps per series
|
||||
const allGaps: Array<{
|
||||
series: string
|
||||
gap_start: number
|
||||
gap_end: number
|
||||
explanation: { id: string; explanation: string; user_id: string; created_at: string } | null
|
||||
}> = []
|
||||
|
||||
for (const row of seriesRows) {
|
||||
const { data: gaps, error: gapsError } = await supabase.rpc('detect_voucher_gaps', {
|
||||
p_company_id: companyId,
|
||||
p_fiscal_period_id: fiscal_period_id,
|
||||
p_series: row.voucher_series,
|
||||
})
|
||||
|
||||
if (!gapsError && gaps && gaps.length > 0) {
|
||||
for (const gap of gaps as Array<{ gap_start: number; gap_end: number }>) {
|
||||
allGaps.push({
|
||||
series: row.voucher_series,
|
||||
gap_start: gap.gap_start,
|
||||
gap_end: gap.gap_end,
|
||||
explanation: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch existing explanations and match them
|
||||
if (allGaps.length > 0) {
|
||||
const { data: explanations } = await supabase
|
||||
.from('voucher_gap_explanations')
|
||||
.select('id, voucher_series, gap_start, gap_end, explanation, user_id, created_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscal_period_id)
|
||||
|
||||
if (explanations) {
|
||||
const explanationMap = new Map(
|
||||
explanations.map((e) => [`${e.voucher_series}:${e.gap_start}:${e.gap_end}`, e])
|
||||
)
|
||||
|
||||
for (const gap of allGaps) {
|
||||
const key = `${gap.series}:${gap.gap_start}:${gap.gap_end}`
|
||||
const match = explanationMap.get(key)
|
||||
if (match) {
|
||||
gap.explanation = {
|
||||
id: match.id,
|
||||
explanation: match.explanation,
|
||||
user_id: match.user_id,
|
||||
created_at: match.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unexplained = allGaps.filter((g) => !g.explanation).length
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
gaps: allGaps,
|
||||
totalGaps: allGaps.length,
|
||||
unexplainedGaps: unexplained,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const validation = await validateBody(request, SaveGapExplanationSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { fiscal_period_id, voucher_series, gap_start, gap_end, explanation } = validation.data
|
||||
|
||||
// Upsert explanation (RLS enforces owner/admin role)
|
||||
const { data, error } = await supabase
|
||||
.from('voucher_gap_explanations')
|
||||
.upsert(
|
||||
{
|
||||
company_id: companyId,
|
||||
user_id: user.id,
|
||||
fiscal_period_id,
|
||||
voucher_series,
|
||||
gap_start,
|
||||
gap_end,
|
||||
explanation,
|
||||
},
|
||||
{ onConflict: 'company_id,fiscal_period_id,voucher_series,gap_start,gap_end' }
|
||||
)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === '42501') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only company owners and admins can document gap explanations' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -291,7 +291,7 @@ async function sendConsentExpiryNotification(
|
||||
const emailData = {
|
||||
bankName: connection.bank_name as string,
|
||||
daysUntilExpiry: daysLeft,
|
||||
renewalUrl: `${baseUrl}/settings?tab=banking`,
|
||||
renewalUrl: `${baseUrl}/settings/banking`,
|
||||
companyName: companySettings?.company_name || 'gnubok',
|
||||
isExpired,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
||||
import { hasAiConsent, isAiExtension } from '@/lib/extensions/ai-consent'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import type { ApiRouteDefinition } from '@/lib/extensions/types'
|
||||
|
||||
@@ -43,7 +42,7 @@ function matchPath(
|
||||
* Catch-all route for extension-declared API routes.
|
||||
*
|
||||
* URL scheme: /api/extensions/ext/{extensionId}/{...routePath}
|
||||
* Example: /api/extensions/ext/receipt-ocr/abc123/confirm → POST /:id/confirm
|
||||
* Example: /api/extensions/ext/mcp-server/mcp → POST /mcp
|
||||
*
|
||||
* - Looks up the extension in the registry
|
||||
* - Checks the extension toggle (disabled → 403)
|
||||
@@ -120,17 +119,6 @@ async function handleRequest(
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
// AI consent check
|
||||
if (isAiExtension(extensionId)) {
|
||||
const consented = await hasAiConsent(supabase, companyId, extensionId)
|
||||
if (!consented) {
|
||||
return NextResponse.json(
|
||||
{ error: 'AI consent required', code: 'AI_CONSENT_REQUIRED' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// If path params were extracted, create a new Request with them as search params
|
||||
let handlerRequest = request
|
||||
if (Object.keys(extractedParams).length > 0) {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
// Mock server-only
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
// Hoisted mocks to avoid reference-before-initialization
|
||||
const { mockVerify, mockServiceClientFn } = vi.hoisted(() => {
|
||||
return {
|
||||
mockVerify: vi.fn(),
|
||||
mockServiceClientFn: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock svix
|
||||
vi.mock('svix', () => ({
|
||||
Webhook: class MockWebhook {
|
||||
verify = mockVerify
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Supabase SSR
|
||||
vi.mock('@supabase/ssr', () => ({
|
||||
createServerClient: (...args: unknown[]) => mockServiceClientFn(...args),
|
||||
}))
|
||||
|
||||
// Mock email handler
|
||||
vi.mock('@/extensions/general/invoice-inbox/lib/email-handler', () => ({
|
||||
parseInboundPayload: vi.fn(),
|
||||
extractAttachments: vi.fn(),
|
||||
resolveUserFromEmail: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock unified document analyzer
|
||||
vi.mock('@/lib/ai/document-analyzer', () => ({
|
||||
analyzeDocument: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock supplier matcher
|
||||
vi.mock('@/extensions/general/invoice-inbox/lib/supplier-matcher', () => ({
|
||||
matchSupplier: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock receipt pipeline
|
||||
vi.mock('@/extensions/general/receipt-ocr/lib/receipt-pipeline', () => ({
|
||||
processReceiptFromDocument: vi.fn(),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler'
|
||||
|
||||
const mockParseInboundPayload = vi.mocked(parseInboundPayload)
|
||||
const mockExtractAttachments = vi.mocked(extractAttachments)
|
||||
const mockResolveUserFromEmail = vi.mocked(resolveUserFromEmail)
|
||||
|
||||
describe('Invoice Inbox Webhook Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.RESEND_WEBHOOK_SECRET = 'test-secret'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-key'
|
||||
})
|
||||
|
||||
function makeWebhookRequest(body: unknown = {}) {
|
||||
const bodyStr = JSON.stringify(body)
|
||||
return new Request('http://localhost:3000/api/extensions/invoice-inbox/webhook', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'svix-id': 'msg_test123',
|
||||
'svix-timestamp': String(Math.floor(Date.now() / 1000)),
|
||||
'svix-signature': 'v1,test-signature',
|
||||
},
|
||||
body: bodyStr,
|
||||
})
|
||||
}
|
||||
|
||||
it('returns 400 when webhook headers are missing', async () => {
|
||||
const request = new Request('http://localhost:3000/api/extensions/invoice-inbox/webhook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 401 when signature verification fails', async () => {
|
||||
mockVerify.mockImplementation(() => {
|
||||
throw new Error('Invalid signature')
|
||||
})
|
||||
|
||||
const response = await POST(makeWebhookRequest({ from: 'test@test.com', to: 'inbox@co.com' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 when payload is invalid', async () => {
|
||||
mockVerify.mockReturnValue(undefined)
|
||||
mockParseInboundPayload.mockReturnValue(null)
|
||||
|
||||
const response = await POST(makeWebhookRequest({}))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when user not found for email', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
mockServiceClientFn.mockReturnValue(supabase)
|
||||
|
||||
mockVerify.mockReturnValue(undefined)
|
||||
mockParseInboundPayload.mockReturnValue({
|
||||
from: 'supplier@test.com',
|
||||
to: 'unknown@inbox.com',
|
||||
subject: 'Invoice',
|
||||
html: null,
|
||||
text: null,
|
||||
attachments: [],
|
||||
created_at: '2024-06-15T10:00:00Z',
|
||||
})
|
||||
mockResolveUserFromEmail.mockResolvedValue(null)
|
||||
|
||||
const response = await POST(makeWebhookRequest({ from: 'supplier@test.com', to: 'unknown@inbox.com' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns success with 0 processed when no attachments', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
mockServiceClientFn.mockReturnValue(supabase)
|
||||
|
||||
mockVerify.mockReturnValue(undefined)
|
||||
mockParseInboundPayload.mockReturnValue({
|
||||
from: 'supplier@test.com',
|
||||
to: 'inbox@myco.com',
|
||||
subject: 'No attachments',
|
||||
html: null,
|
||||
text: null,
|
||||
attachments: [],
|
||||
created_at: '2024-06-15T10:00:00Z',
|
||||
})
|
||||
mockExtractAttachments.mockReturnValue([])
|
||||
mockResolveUserFromEmail.mockResolvedValue({ userId: 'user-1', companyId: 'company-1' })
|
||||
|
||||
// Insert inbox item with error status
|
||||
enqueueMany([
|
||||
{ data: { id: 'item-1' }, error: null },
|
||||
])
|
||||
|
||||
const response = await POST(makeWebhookRequest({ from: 'supplier@test.com', to: 'inbox@myco.com' }))
|
||||
const { status, body } = await parseJsonResponse<{ data: { processed: number } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.processed).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,290 +0,0 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Webhook } from 'svix'
|
||||
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler'
|
||||
import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher'
|
||||
import { analyzeDocument } from '@/lib/ai/document-analyzer'
|
||||
import { processReceiptFromDocument } from '@/extensions/general/receipt-ocr/lib/receipt-pipeline'
|
||||
import crypto from 'crypto'
|
||||
|
||||
function createServiceClient() {
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() { return [] },
|
||||
setAll() { },
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build raw email payload for BFL 7 kap. 2§ archiving.
|
||||
* Includes full email headers and body — excludes binary attachment content.
|
||||
*/
|
||||
function buildRawEmailPayload(body: Record<string, unknown>, payload: { from: string; to: string; subject: string; created_at: string }): Record<string, unknown> {
|
||||
return {
|
||||
from: payload.from,
|
||||
to: payload.to,
|
||||
subject: payload.subject,
|
||||
created_at: payload.created_at,
|
||||
text: body.text ?? null,
|
||||
html: body.html ?? null,
|
||||
headers: body.headers ?? null,
|
||||
message_id: body.message_id ?? null,
|
||||
in_reply_to: body.in_reply_to ?? null,
|
||||
references: body.references ?? null,
|
||||
archived_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Verify webhook signature
|
||||
const webhookSecret = process.env.RESEND_WEBHOOK_SECRET
|
||||
if (!webhookSecret) {
|
||||
console.error('[document-inbox] RESEND_WEBHOOK_SECRET not configured')
|
||||
return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const svixId = request.headers.get('svix-id')
|
||||
const svixTimestamp = request.headers.get('svix-timestamp')
|
||||
const svixSignature = request.headers.get('svix-signature')
|
||||
|
||||
if (!svixId || !svixTimestamp || !svixSignature) {
|
||||
return NextResponse.json({ error: 'Missing webhook headers' }, { status: 400 })
|
||||
}
|
||||
|
||||
const rawBody = await request.text()
|
||||
|
||||
try {
|
||||
const wh = new Webhook(webhookSecret)
|
||||
wh.verify(rawBody, {
|
||||
'svix-id': svixId,
|
||||
'svix-timestamp': svixTimestamp,
|
||||
'svix-signature': svixSignature,
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = JSON.parse(rawBody)
|
||||
const payload = parseInboundPayload(body)
|
||||
|
||||
if (!payload) {
|
||||
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
|
||||
}
|
||||
|
||||
const supabase = createServiceClient()
|
||||
|
||||
// Resolve user from recipient email
|
||||
const resolved = await resolveUserFromEmail(payload.to, supabase)
|
||||
|
||||
if (!resolved) {
|
||||
console.warn(`[document-inbox] No user found for email: ${payload.to}`)
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { userId, companyId } = resolved
|
||||
|
||||
// Build raw email payload for BFL 7:2 archiving (no binary attachment content)
|
||||
const rawEmailPayload = buildRawEmailPayload(body, payload)
|
||||
|
||||
// Extract file attachments
|
||||
const attachments = extractAttachments(payload)
|
||||
|
||||
if (attachments.length === 0) {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
status: 'error',
|
||||
source: 'email',
|
||||
email_from: payload.from,
|
||||
email_subject: payload.subject,
|
||||
email_received_at: payload.created_at,
|
||||
error_message: 'No supported attachments found',
|
||||
raw_email_payload: rawEmailPayload,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: { processed: 0, message: 'No attachments' } })
|
||||
}
|
||||
|
||||
const processed: string[] = []
|
||||
|
||||
for (const attachment of attachments) {
|
||||
try {
|
||||
const buffer = Buffer.from(attachment.content, 'base64')
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex')
|
||||
|
||||
// Upload to storage
|
||||
const storagePath = `documents/${userId}/inbox/${Date.now()}-${attachment.filename}`
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.upload(storagePath, buffer, { contentType: attachment.content_type })
|
||||
|
||||
if (uploadError) {
|
||||
console.error('[document-inbox] Upload failed:', uploadError)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create document attachment
|
||||
const { data: document, error: docError } = await supabase
|
||||
.from('document_attachments')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
storage_path: storagePath,
|
||||
file_name: attachment.filename,
|
||||
file_size_bytes: buffer.length,
|
||||
mime_type: attachment.content_type,
|
||||
sha256_hash: hash,
|
||||
upload_source: 'email',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (docError || !document) continue
|
||||
|
||||
// Unified classify + extract in a single Claude call
|
||||
let documentType: 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown' = 'supplier_invoice'
|
||||
let unifiedResult: Awaited<ReturnType<typeof analyzeDocument>> | null = null
|
||||
try {
|
||||
unifiedResult = await analyzeDocument(attachment.content, attachment.content_type)
|
||||
documentType = unifiedResult.classification.type
|
||||
console.log(`[document-inbox] Classified as ${documentType} (confidence: ${unifiedResult.classification.confidence})`)
|
||||
} catch (classifyErr) {
|
||||
console.error('[document-inbox] Classification failed, defaulting to supplier_invoice:', classifyErr)
|
||||
}
|
||||
|
||||
// Create inbox item with document type and raw email payload
|
||||
const { data: inboxItem, error: itemError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
status: 'processing',
|
||||
source: 'email',
|
||||
email_from: payload.from,
|
||||
email_subject: payload.subject,
|
||||
email_received_at: payload.created_at,
|
||||
document_id: document.id,
|
||||
document_type: documentType,
|
||||
raw_email_payload: rawEmailPayload,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (itemError || !inboxItem) continue
|
||||
|
||||
// Route based on document type
|
||||
try {
|
||||
switch (documentType) {
|
||||
case 'supplier_invoice': {
|
||||
// Use pre-extracted invoice data from unified call
|
||||
const extraction = unifiedResult?.invoice
|
||||
if (!extraction) {
|
||||
throw new Error('No invoice extraction available')
|
||||
}
|
||||
|
||||
const isReverseCharge = unifiedResult?.classification.isReverseCharge ?? false
|
||||
|
||||
// Store reverse charge flag in extracted data
|
||||
const extractedData = {
|
||||
...(extraction as unknown as Record<string, unknown>),
|
||||
isReverseCharge,
|
||||
}
|
||||
|
||||
// Supplier matching
|
||||
let matchedSupplierId: string | null = null
|
||||
const { data: suppliers } = await supabase
|
||||
.from('suppliers')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (suppliers && suppliers.length > 0) {
|
||||
const match = matchSupplier(extraction, suppliers)
|
||||
if (match && match.confidence >= 0.7) {
|
||||
matchedSupplierId = match.supplierId
|
||||
}
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
extracted_data: extractedData,
|
||||
confidence: extraction.confidence,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
})
|
||||
.eq('id', inboxItem.id)
|
||||
break
|
||||
}
|
||||
|
||||
case 'receipt': {
|
||||
// Use pre-extracted receipt data from unified call
|
||||
const { data: urlData } = supabase.storage.from('documents').getPublicUrl(storagePath)
|
||||
|
||||
const result = await processReceiptFromDocument(supabase, userId, companyId, attachment.content, attachment.content_type, {
|
||||
documentId: document.id,
|
||||
source: 'email',
|
||||
emailFrom: payload.from,
|
||||
storageUrl: urlData.publicUrl,
|
||||
preExtracted: unifiedResult?.receipt ?? undefined,
|
||||
})
|
||||
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
linked_receipt_id: result.receipt.id,
|
||||
confidence: result.receipt.extraction_confidence,
|
||||
})
|
||||
.eq('id', inboxItem.id)
|
||||
break
|
||||
}
|
||||
|
||||
case 'government_letter': {
|
||||
// Store with status ready for manual review
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
extracted_data: {
|
||||
sender: payload.from,
|
||||
subject: payload.subject,
|
||||
body: typeof body.text === 'string' ? body.text : null,
|
||||
},
|
||||
})
|
||||
.eq('id', inboxItem.id)
|
||||
break
|
||||
}
|
||||
|
||||
case 'unknown':
|
||||
default: {
|
||||
// Store with status ready for manual handling
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ status: 'ready' })
|
||||
.eq('id', inboxItem.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Processing failed'
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ status: 'error', error_message: message })
|
||||
.eq('id', inboxItem.id)
|
||||
}
|
||||
|
||||
processed.push(inboxItem.id)
|
||||
} catch (err) {
|
||||
console.error('[document-inbox] Processing attachment failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { processed: processed.length, ids: processed } })
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
calculateFileHash,
|
||||
} from '@/lib/import/sie-parser'
|
||||
import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
|
||||
import { generateImportPreview, checkDuplicateImport } from '@/lib/import/sie-import'
|
||||
import { generateImportPreview, checkDuplicateImport, checkDuplicatePeriodImport } from '@/lib/import/sie-import'
|
||||
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
|
||||
import type { SIEAccountMappingRecord } from '@/lib/import/types'
|
||||
|
||||
@@ -55,12 +55,12 @@ export async function POST(request: Request) {
|
||||
// Decode to string
|
||||
const content = decodeBuffer(arrayBuffer, encoding)
|
||||
|
||||
// Check for duplicate import
|
||||
const duplicate = await checkDuplicateImport(supabase, user.id, content)
|
||||
// Check for duplicate import (by file hash)
|
||||
const duplicate = await checkDuplicateImport(supabase, companyId, content)
|
||||
if (duplicate) {
|
||||
return NextResponse.json({
|
||||
error: 'duplicate',
|
||||
message: `This file has already been imported on ${duplicate.imported_at ? new Date(duplicate.imported_at).toLocaleDateString('sv-SE') : 'okänt datum'}`,
|
||||
message: `Denna fil har redan importerats ${duplicate.imported_at ? new Date(duplicate.imported_at).toLocaleDateString('sv-SE') : 'okänt datum'}`,
|
||||
importId: duplicate.id,
|
||||
}, { status: 409 })
|
||||
}
|
||||
@@ -68,6 +68,23 @@ export async function POST(request: Request) {
|
||||
// Parse the SIE file
|
||||
const parsed = parseSIEFile(content)
|
||||
|
||||
// Check for existing import covering the same fiscal period
|
||||
if (parsed.stats.fiscalYearStart && parsed.stats.fiscalYearEnd) {
|
||||
const periodDuplicate = await checkDuplicatePeriodImport(
|
||||
supabase,
|
||||
companyId,
|
||||
parsed.stats.fiscalYearStart,
|
||||
parsed.stats.fiscalYearEnd
|
||||
)
|
||||
if (periodDuplicate) {
|
||||
return NextResponse.json({
|
||||
error: 'duplicate_period',
|
||||
message: `En SIE-import för perioden ${parsed.stats.fiscalYearStart} – ${parsed.stats.fiscalYearEnd} finns redan (importerad ${periodDuplicate.imported_at ? new Date(periodDuplicate.imported_at).toLocaleDateString('sv-SE') : 'okänt datum'})`,
|
||||
importId: periodDuplicate.id,
|
||||
}, { status: 409 })
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the parsed data
|
||||
const validation = validateSIEFile(parsed)
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { validateBalanceContinuity } from '@/lib/reports/continuity-check'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
|
||||
/**
|
||||
* GET: Validate IB/UB continuity for a fiscal period.
|
||||
* Query param: period_id (required)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodId = searchParams.get('period_id')
|
||||
|
||||
if (!periodId) {
|
||||
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await validateBalanceContinuity(supabase, companyId, periodId)
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to validate continuity' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
|
||||
const MAX_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
if (!companyId) return NextResponse.json({ error: 'No company' }, { status: 403 })
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'Ingen fil angiven' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json({ error: 'Otillåten filtyp. Tillåtna: PNG, JPG, SVG, WebP.' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE) {
|
||||
return NextResponse.json({ error: 'Filen är för stor (max 2 MB).' }, { status: 400 })
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const ext = file.name.split('.').pop() || 'png'
|
||||
const storagePath = `logos/${companyId}/logo.${ext}`
|
||||
|
||||
// Upload (upsert to replace existing)
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.upload(storagePath, buffer, {
|
||||
contentType: file.type,
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
return NextResponse.json({ error: `Uppladdning misslyckades: ${uploadError.message}` }, { status: 500 })
|
||||
}
|
||||
|
||||
// Get public URL
|
||||
const { data: urlData } = supabase.storage
|
||||
.from('documents')
|
||||
.getPublicUrl(storagePath)
|
||||
|
||||
// Update company settings
|
||||
const { error: updateError } = await supabase
|
||||
.from('company_settings')
|
||||
.update({ logo_url: urlData.publicUrl })
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: 'Kunde inte uppdatera inställningar' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { logo_url: urlData.publicUrl } })
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
if (!companyId) return NextResponse.json({ error: 'No company' }, { status: 403 })
|
||||
|
||||
// Get current logo path
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('logo_url')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (settings?.logo_url) {
|
||||
// Extract storage path from URL
|
||||
const url = new URL(settings.logo_url)
|
||||
const pathMatch = url.pathname.match(/\/object\/public\/documents\/(.+)/)
|
||||
if (pathMatch) {
|
||||
await supabase.storage.from('documents').remove([pathMatch[1]])
|
||||
}
|
||||
}
|
||||
|
||||
// Clear logo_url
|
||||
await supabase
|
||||
.from('company_settings')
|
||||
.update({ logo_url: null })
|
||||
.eq('company_id', companyId)
|
||||
|
||||
return NextResponse.json({ data: { logo_url: null } })
|
||||
}
|
||||
@@ -14,21 +14,13 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
|
||||
findCounterpartyTemplate: vi.fn().mockResolvedValue(null),
|
||||
buildMappingResultFromCounterpartyTemplate: vi.fn(),
|
||||
formatCounterpartyName: vi.fn((name: string) => name),
|
||||
}))
|
||||
|
||||
// Mock extension registry
|
||||
const mockFindSimilarTemplates = vi.fn().mockResolvedValue([])
|
||||
vi.mock('@/lib/extensions/registry', () => ({
|
||||
extensionRegistry: {
|
||||
get: vi.fn().mockReturnValue({
|
||||
id: 'ai-categorization',
|
||||
name: 'AI',
|
||||
version: '1.0.0',
|
||||
services: {
|
||||
findSimilarTemplates: (...args: unknown[]) => mockFindSimilarTemplates(...args),
|
||||
},
|
||||
}),
|
||||
},
|
||||
// Mock booking templates
|
||||
const mockFindMatchingTemplates = vi.fn().mockReturnValue([])
|
||||
vi.mock('@/lib/bookkeeping/booking-templates', () => ({
|
||||
findMatchingTemplates: (...args: unknown[]) => mockFindMatchingTemplates(...args),
|
||||
}))
|
||||
|
||||
// Mock Supabase
|
||||
@@ -121,7 +113,7 @@ describe('POST /api/transactions/[id]/describe', () => {
|
||||
amount: -450,
|
||||
})
|
||||
|
||||
mockFindSimilarTemplates.mockResolvedValueOnce([
|
||||
mockFindMatchingTemplates.mockReturnValueOnce([
|
||||
{
|
||||
template: {
|
||||
id: 'restaurant_dining',
|
||||
@@ -131,6 +123,12 @@ describe('POST /api/transactions/[id]/describe', () => {
|
||||
debit_account: '6071',
|
||||
credit_account: '1930',
|
||||
description_sv: 'Representation - restaurang',
|
||||
vat_rate: 0.12,
|
||||
vat_treatment: 'reduced_12',
|
||||
deductibility: 'conditional',
|
||||
deductibility_note_sv: null,
|
||||
special_rules_sv: null,
|
||||
risk_level: 'MEDIUM',
|
||||
},
|
||||
confidence: 0.82,
|
||||
},
|
||||
@@ -162,23 +160,16 @@ describe('POST /api/transactions/[id]/describe', () => {
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.templates).toHaveLength(1)
|
||||
expect(body.data.needs_more_detail).toBe(false)
|
||||
expect(body.data.ai_suggestion).toBeNull()
|
||||
expect(body.data.user_description).toBe('business lunch with client')
|
||||
expect(body.data.batch_candidate_count).toBe(3)
|
||||
expect(body.data.merchant_name).toBe('Restaurant XYZ')
|
||||
|
||||
// Verify findSimilarTemplates was called with the user description
|
||||
expect(mockFindSimilarTemplates).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'tx-1' }),
|
||||
'enskild_firma',
|
||||
10,
|
||||
'business lunch with client'
|
||||
)
|
||||
})
|
||||
|
||||
it('sets needs_more_detail when confidence is low', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-2', merchant_name: null })
|
||||
|
||||
mockFindSimilarTemplates.mockResolvedValueOnce([
|
||||
mockFindMatchingTemplates.mockReturnValueOnce([
|
||||
{
|
||||
template: {
|
||||
id: 'misc',
|
||||
@@ -188,6 +179,12 @@ describe('POST /api/transactions/[id]/describe', () => {
|
||||
debit_account: '6991',
|
||||
credit_account: '1930',
|
||||
description_sv: 'Okategoriserad utgift',
|
||||
vat_rate: 0,
|
||||
vat_treatment: null,
|
||||
deductibility: 'full',
|
||||
deductibility_note_sv: null,
|
||||
special_rules_sv: null,
|
||||
risk_level: 'LOW',
|
||||
},
|
||||
confidence: 0.4,
|
||||
},
|
||||
|
||||
@@ -3,73 +3,13 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { DescribeTransactionSchema } from '@/lib/api/schemas'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
|
||||
import { findMatchingTemplates } from '@/lib/bookkeeping/booking-templates'
|
||||
import { findCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate, formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import type { Transaction, EntityType, VatTreatment } from '@/types'
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
|
||||
interface DescriptionAnalysisInput {
|
||||
description: string
|
||||
transactionAmount: number
|
||||
transactionDate: string
|
||||
transactionDescription: string
|
||||
merchantName: string | null
|
||||
currency: string
|
||||
entityType: EntityType
|
||||
}
|
||||
|
||||
interface DescriptionAnalysisResult {
|
||||
debitAccount: string
|
||||
creditAccount: string
|
||||
vatTreatment: VatTreatment | null
|
||||
category: string
|
||||
confidence: number
|
||||
reasoning: string
|
||||
warnings: string[]
|
||||
templateId: string | null
|
||||
}
|
||||
import type { Transaction, EntityType } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
async function getTemplateMatches(
|
||||
aiExt: Extension | undefined,
|
||||
transaction: Transaction,
|
||||
entityType: EntityType,
|
||||
description: string
|
||||
): Promise<TemplateMatch[]> {
|
||||
if (aiExt?.services?.findSimilarTemplates) {
|
||||
return aiExt.services.findSimilarTemplates(transaction, entityType, 10, description)
|
||||
}
|
||||
return findMatchingTemplates(transaction, entityType)
|
||||
}
|
||||
|
||||
async function getAiAnalysis(
|
||||
aiExt: Extension | undefined,
|
||||
transaction: Transaction,
|
||||
entityType: EntityType,
|
||||
description: string
|
||||
): Promise<DescriptionAnalysisResult | null> {
|
||||
if (!aiExt?.services?.analyzeDescription) return null
|
||||
|
||||
try {
|
||||
const input: DescriptionAnalysisInput = {
|
||||
description,
|
||||
transactionAmount: transaction.amount,
|
||||
transactionDate: transaction.date,
|
||||
transactionDescription: transaction.description,
|
||||
merchantName: transaction.merchant_name,
|
||||
currency: transaction.currency,
|
||||
entityType,
|
||||
}
|
||||
return await aiExt.services.analyzeDescription(input)
|
||||
} catch (error) {
|
||||
console.error('[describe] AI analysis failed, continuing with templates only:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
@@ -110,13 +50,10 @@ export async function POST(
|
||||
|
||||
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
const aiExt = extensionRegistry.get('ai-categorization')
|
||||
|
||||
// Run template matching, counterparty lookup, and AI analysis in parallel
|
||||
const [templates, counterpartyMatch, aiSuggestion] = await Promise.all([
|
||||
getTemplateMatches(aiExt, transaction as Transaction, entityType, description),
|
||||
// Run template matching and counterparty lookup in parallel
|
||||
const [templates, counterpartyMatch] = await Promise.all([
|
||||
findMatchingTemplates(transaction as Transaction, entityType),
|
||||
findCounterpartyTemplate(supabase, user.id, transaction as Transaction),
|
||||
getAiAnalysis(aiExt, transaction as Transaction, entityType, description),
|
||||
])
|
||||
|
||||
// Build counterparty suggestion if matched
|
||||
@@ -147,8 +84,7 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
// AI or counterparty match rescues weak templates
|
||||
const needsMoreDetail = (aiSuggestion || counterpartySuggestion)
|
||||
const needsMoreDetail = counterpartySuggestion
|
||||
? false
|
||||
: templates.length === 0 || templates[0].confidence < 0.55
|
||||
|
||||
@@ -185,16 +121,7 @@ export async function POST(
|
||||
risk_level: m.template.risk_level,
|
||||
})),
|
||||
counterparty_match: counterpartySuggestion,
|
||||
ai_suggestion: aiSuggestion ? {
|
||||
debit_account: aiSuggestion.debitAccount,
|
||||
credit_account: aiSuggestion.creditAccount,
|
||||
vat_treatment: aiSuggestion.vatTreatment,
|
||||
category: aiSuggestion.category,
|
||||
confidence: aiSuggestion.confidence,
|
||||
reasoning: aiSuggestion.reasoning,
|
||||
warnings: aiSuggestion.warnings,
|
||||
template_id: aiSuggestion.templateId,
|
||||
} : null,
|
||||
ai_suggestion: null,
|
||||
needs_more_detail: needsMoreDetail,
|
||||
user_description: description,
|
||||
batch_candidate_count: batchCandidateCount,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { getSuggestedCategories, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { findCounterpartyTemplatesBatch, formatCounterpartyName, toCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import type { Transaction, EntityType } from '@/types'
|
||||
@@ -65,30 +65,6 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch pre-computed AI suggestions for these transactions
|
||||
const aiKeys = ids.map((id: string) => `suggestion:${id}`)
|
||||
const { data: aiRecords } = await supabase
|
||||
.from('extension_data')
|
||||
.select('key, value')
|
||||
.eq('company_id', companyId)
|
||||
.eq('extension_id', 'ai-categorization')
|
||||
.in('key', aiKeys)
|
||||
|
||||
type AiSuggestion = { category: string; basAccount: string; confidence: number; reasoning: string }
|
||||
const aiSuggestionsMap: Record<string, AiSuggestion[]> = {}
|
||||
if (aiRecords) {
|
||||
for (const record of aiRecords) {
|
||||
const txId = record.key.replace('suggestion:', '')
|
||||
const value = record.value
|
||||
// Handle both single object (old) and array (new) storage formats
|
||||
if (Array.isArray(value)) {
|
||||
aiSuggestionsMap[txId] = value as AiSuggestion[]
|
||||
} else {
|
||||
aiSuggestionsMap[txId] = [value as AiSuggestion]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch entity type for template matching
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
@@ -105,19 +81,11 @@ export async function POST(request: Request) {
|
||||
const template_suggestions: Record<string, SuggestedTemplate[]> = {}
|
||||
|
||||
for (const tx of transactions) {
|
||||
let result = getSuggestedCategories(
|
||||
suggestions[tx.id] = getSuggestedCategories(
|
||||
tx as Transaction,
|
||||
mappingRules || [],
|
||||
categoryHistory
|
||||
)
|
||||
|
||||
// Merge pre-computed AI suggestions if available
|
||||
const aiSuggestions = aiSuggestionsMap[tx.id]
|
||||
if (aiSuggestions && aiSuggestions.length > 0) {
|
||||
result = mergeAiSuggestions(result, aiSuggestions, tx.amount)
|
||||
}
|
||||
|
||||
suggestions[tx.id] = result
|
||||
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType, mappingRules || undefined)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback, KeyboardEvent } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Send, Loader2 } from 'lucide-react'
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void
|
||||
disabled?: boolean
|
||||
isLoading?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
placeholder = 'Skriv ett meddelande...',
|
||||
}: ChatInputProps) {
|
||||
const [message, setMessage] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const trimmed = message.trim()
|
||||
if (trimmed && !disabled && !isLoading) {
|
||||
onSend(trimmed)
|
||||
setMessage('')
|
||||
// Reset textarea height
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
}
|
||||
}, [message, disabled, isLoading, onSend])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Send on Enter (without Shift)
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
},
|
||||
[handleSend]
|
||||
)
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setMessage(e.target.value)
|
||||
// Auto-resize textarea
|
||||
const textarea = e.target
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
||||
}
|
||||
|
||||
const isDisabled = disabled || isLoading || !message.trim()
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-end p-4 border-t bg-background">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled || isLoading}
|
||||
className="min-h-[44px] max-h-[200px] resize-none py-3"
|
||||
rows={1}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={isDisabled}
|
||||
size="icon"
|
||||
className="flex-shrink-0 h-[44px] w-[44px]"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronDown, ChevronUp, FileText, User, Bot, Database, Loader2 } from 'lucide-react'
|
||||
import type { ChatMessage as ChatMessageType, SourceReference } from '@/types/chat'
|
||||
import { ArtifactRenderer } from './artifacts/ArtifactRenderer'
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: ChatMessageType
|
||||
isStreaming?: boolean
|
||||
toolsExecuting?: string[]
|
||||
}
|
||||
|
||||
function SourcesList({ sources }: { sources: SourceReference[] }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
if (sources.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-border/40">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<FileText className="h-3 w-3" />
|
||||
<span>{sources.length} källa{sources.length !== 1 ? 'or' : ''}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{sources.map((source, index) => (
|
||||
<div
|
||||
key={source.id || index}
|
||||
className="text-xs bg-muted/50 rounded-md px-2.5 py-1.5"
|
||||
>
|
||||
<div className="font-medium text-foreground/80">
|
||||
{source.title}
|
||||
{source.section_title && (
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{' '}
|
||||
› {source.section_title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-0.5">
|
||||
{source.source_file} ({(source.similarity * 100).toFixed(0)}% relevans)
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
get_invoices: 'Hämtar fakturor',
|
||||
get_supplier_invoices: 'Hämtar leverantörsfakturor',
|
||||
get_account_balances: 'Hämtar kontosaldon',
|
||||
get_transactions: 'Hämtar transaktioner',
|
||||
get_journal_entries: 'Hämtar verifikationer',
|
||||
get_income_statement: 'Genererar resultaträkning',
|
||||
get_balance_sheet: 'Genererar balansräkning',
|
||||
get_vat_summary: 'Beräknar momssammanställning',
|
||||
get_company_overview: 'Hämtar företagsöversikt',
|
||||
get_aging_report: 'Genererar åldersanalys',
|
||||
}
|
||||
|
||||
function ToolExecutingIndicator({ tools }: { tools: string[] }) {
|
||||
if (tools.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 mb-2">
|
||||
{tools.map((toolName, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<Database className="h-3 w-3" />
|
||||
<span>{TOOL_LABELS[toolName] || toolName}...</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, isStreaming, toolsExecuting }: ChatMessageProps) {
|
||||
const isUser = message.role === 'user'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-3 px-4 py-3',
|
||||
isUser ? 'flex-row-reverse' : 'flex-row'
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center',
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<User className="h-4 w-4" />
|
||||
) : (
|
||||
<Bot className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 max-w-[80%] rounded-xl px-4 py-3',
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground ml-auto'
|
||||
: 'bg-muted/60 text-foreground'
|
||||
)}
|
||||
>
|
||||
{/* Tool execution indicator */}
|
||||
{!isUser && isStreaming && toolsExecuting && toolsExecuting.length > 0 && !message.content && (
|
||||
<ToolExecutingIndicator tools={toolsExecuting} />
|
||||
)}
|
||||
|
||||
<div className={cn(
|
||||
"text-sm break-words",
|
||||
!isUser && "prose prose-sm max-w-none prose-headings:text-foreground prose-headings:font-semibold prose-h1:text-base prose-h2:text-sm prose-h3:text-sm prose-p:text-foreground prose-p:my-1.5 prose-headings:my-2 prose-ul:my-1.5 prose-ol:my-1.5 prose-li:my-0.5 prose-li:text-foreground prose-strong:text-foreground prose-table:my-2 prose-table:text-xs prose-th:px-2 prose-th:py-1 prose-td:px-2 prose-td:py-1 prose-th:bg-muted/50 prose-th:border prose-td:border prose-th:border-border/50 prose-td:border-border/50 prose-th:text-foreground prose-td:text-foreground"
|
||||
)}>
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap m-0">{message.content}</p>
|
||||
) : (
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
)}
|
||||
{isStreaming && !isUser && (
|
||||
<span className="inline-block w-1.5 h-4 bg-current animate-pulse ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Artifact visualization */}
|
||||
{!isUser && message.artifact && (
|
||||
<ArtifactRenderer artifact={message.artifact} />
|
||||
)}
|
||||
|
||||
{!isUser && message.sources && message.sources.length > 0 && (
|
||||
<SourcesList sources={message.sources} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { ChatMessage } from './ChatMessage'
|
||||
import { ChatInput } from './ChatInput'
|
||||
import { useChatStream } from './useChatStream'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Bot, RotateCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface ChatPanelProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ChatPanel({ className }: ChatPanelProps) {
|
||||
const { toast } = useToast()
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const {
|
||||
messages,
|
||||
isLoading,
|
||||
isStreaming,
|
||||
error: _error,
|
||||
toolsExecuting,
|
||||
sendMessage,
|
||||
clearChat,
|
||||
} = useChatStream({
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
const handleClearChat = () => {
|
||||
clearChat()
|
||||
toast({
|
||||
title: 'Chatten rensad',
|
||||
description: 'En ny konversation har startats.',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className || ''}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Bot className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-sm">AI-assistent</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skatt, moms och bokföring
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{messages.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearChat}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
Ny chatt
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-6 py-8">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Bot className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h4 className="font-medium mb-2">Hur kan jag hjälpa dig?</h4>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
Jag kan svara på frågor om skatt, moms, bokföring och andra
|
||||
ekonomiska frågor för företagare.
|
||||
</p>
|
||||
<div className="mt-6 space-y-2 w-full max-w-xs">
|
||||
<SuggestionButton
|
||||
onClick={() => sendMessage('Hur går det för mitt företag?')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Hur går det för mitt företag?
|
||||
</SuggestionButton>
|
||||
<SuggestionButton
|
||||
onClick={() => sendMessage('Visa mina senaste fakturor')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Visa mina senaste fakturor
|
||||
</SuggestionButton>
|
||||
<SuggestionButton
|
||||
onClick={() => sendMessage('Hur ser min resultaträkning ut?')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Hur ser min resultaträkning ut?
|
||||
</SuggestionButton>
|
||||
<SuggestionButton
|
||||
onClick={() => sendMessage('Vad kan jag dra av som företagare?')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Vad kan jag dra av som företagare?
|
||||
</SuggestionButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4">
|
||||
{messages.map((message, index) => (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
isStreaming={
|
||||
isStreaming &&
|
||||
index === messages.length - 1 &&
|
||||
message.role === 'assistant'
|
||||
}
|
||||
toolsExecuting={
|
||||
isStreaming &&
|
||||
index === messages.length - 1 &&
|
||||
message.role === 'assistant'
|
||||
? toolsExecuting
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<ChatInput
|
||||
onSend={sendMessage}
|
||||
disabled={isStreaming}
|
||||
isLoading={isLoading}
|
||||
placeholder="Ställ en fråga om skatt, moms eller bokföring..."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SuggestionButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="w-full text-left text-sm px-3 py-2 rounded-lg border border-border/60 hover:bg-muted/50 hover:border-border transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ChatPanel } from './ChatPanel'
|
||||
import { MessageCircle, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
|
||||
const chatEnabled = ENABLED_EXTENSION_IDS.has('ai-chat')
|
||||
|
||||
export function ChatWidget() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
// Allow other components to open the chat via custom event
|
||||
useEffect(() => {
|
||||
const handler = () => setIsOpen(true)
|
||||
window.addEventListener('open-ai-chat', handler)
|
||||
return () => window.removeEventListener('open-ai-chat', handler)
|
||||
}, [])
|
||||
|
||||
if (!chatEnabled) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating chat panel */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-36 right-4 md:bottom-20 z-[60] transition-all duration-300 ease-in-out',
|
||||
isOpen
|
||||
? 'opacity-100 translate-y-0 pointer-events-auto'
|
||||
: 'opacity-0 translate-y-4 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div className="w-[380px] h-[550px] max-h-[calc(100vh-120px)] bg-background border rounded-xl shadow-xl overflow-hidden flex flex-col">
|
||||
{/* Close button in corner */}
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="absolute top-2 right-2 z-10 p-1.5 rounded-full bg-muted/80 hover:bg-muted transition-colors"
|
||||
aria-label="Stäng chatt"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<ChatPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating action button */}
|
||||
<Button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
size="icon"
|
||||
className={cn(
|
||||
'fixed bottom-20 right-4 md:bottom-4 z-[60] h-14 w-14 rounded-full shadow-lg transition-all duration-300',
|
||||
isOpen && 'rotate-90'
|
||||
)}
|
||||
aria-label={isOpen ? 'Stäng chatt' : 'Öppna chatt'}
|
||||
>
|
||||
{isOpen ? (
|
||||
<X className="h-6 w-6" />
|
||||
) : (
|
||||
<MessageCircle className="h-6 w-6" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ArtifactSpec } from '@/types/chat'
|
||||
import { ChatChart } from './ChatChart'
|
||||
import { ChatDataTable } from './ChatDataTable'
|
||||
import { ChatKpiCards } from './ChatKpiCards'
|
||||
import { ChatAgingBuckets } from './ChatAgingBuckets'
|
||||
|
||||
interface ArtifactRendererProps {
|
||||
artifact: ArtifactSpec
|
||||
}
|
||||
|
||||
export function ArtifactRenderer({ artifact }: ArtifactRendererProps) {
|
||||
switch (artifact.type) {
|
||||
case 'bar_chart':
|
||||
case 'line_chart':
|
||||
case 'pie_chart':
|
||||
case 'stacked_bar':
|
||||
return (
|
||||
<div className="mt-3 p-3 rounded-lg border border-border bg-card">
|
||||
<ChatChart artifact={artifact} />
|
||||
</div>
|
||||
)
|
||||
case 'table':
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<ChatDataTable artifact={artifact} />
|
||||
</div>
|
||||
)
|
||||
case 'kpi_cards':
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<ChatKpiCards artifact={artifact} />
|
||||
</div>
|
||||
)
|
||||
case 'aging_buckets':
|
||||
return (
|
||||
<div className="mt-3 p-3 rounded-lg border border-border bg-card">
|
||||
<ChatAgingBuckets artifact={artifact} />
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AgingBucketsArtifact } from '@/types/chat'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
const BUCKET_COLORS = [
|
||||
'bg-success',
|
||||
'bg-warning',
|
||||
'bg-warning/70',
|
||||
'bg-destructive/70',
|
||||
'bg-destructive',
|
||||
]
|
||||
|
||||
interface ChatAgingBucketsProps {
|
||||
artifact: AgingBucketsArtifact
|
||||
}
|
||||
|
||||
export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) {
|
||||
const { title, buckets, total } = artifact
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<h4 className="text-sm font-semibold">{title}</h4>
|
||||
<span className="text-sm font-bold tabular-nums">
|
||||
{formatCurrency(total)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stacked bar */}
|
||||
{total > 0 && (
|
||||
<div className="flex h-6 rounded-md overflow-hidden mb-3">
|
||||
{buckets.map((bucket, i) => {
|
||||
const widthPercent = (bucket.amount / total) * 100
|
||||
if (widthPercent < 0.5) return null
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${BUCKET_COLORS[i % BUCKET_COLORS.length]} transition-all`}
|
||||
style={{ width: `${widthPercent}%` }}
|
||||
title={`${bucket.label}: ${formatCurrency(bucket.amount)}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="space-y-1.5">
|
||||
{buckets.map((bucket, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-sm ${BUCKET_COLORS[i % BUCKET_COLORS.length]}`}
|
||||
/>
|
||||
<span className="text-muted-foreground">{bucket.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground">
|
||||
{bucket.count} st
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatCurrency(bucket.amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
LineChart,
|
||||
Line,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import type { ChartArtifact } from '@/types/chat'
|
||||
|
||||
const DEFAULT_COLORS = [
|
||||
'var(--color-chart-1, #3b82f6)',
|
||||
'var(--color-chart-2, #10b981)',
|
||||
'var(--color-chart-3, #f59e0b)',
|
||||
'var(--color-chart-4, #ef4444)',
|
||||
'var(--color-chart-5, #8b5cf6)',
|
||||
'var(--color-chart-6, #ec4899)',
|
||||
'var(--color-chart-7, #06b6d4)',
|
||||
'var(--color-chart-8, #84cc16)',
|
||||
]
|
||||
|
||||
function formatValue(value: number, unit?: string): string {
|
||||
const formatted = new Intl.NumberFormat('sv-SE').format(Math.round(value))
|
||||
return unit ? `${formatted} ${unit}` : formatted
|
||||
}
|
||||
|
||||
interface ChatChartProps {
|
||||
artifact: ChartArtifact
|
||||
}
|
||||
|
||||
export function ChatChart({ artifact }: ChatChartProps) {
|
||||
const { type, title, data, unit, subtitle } = artifact
|
||||
|
||||
const chartData = data.map((d, i) => ({
|
||||
...d,
|
||||
fill: d.color || DEFAULT_COLORS[i % DEFAULT_COLORS.length],
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold">{title}</h4>
|
||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{type === 'pie_chart' ? (
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="value"
|
||||
nameKey="label"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius="80%"
|
||||
label={(props) => {
|
||||
const name = props.name ?? ''
|
||||
const percent = typeof props.percent === 'number' ? props.percent : 0
|
||||
return `${name} (${(percent * 100).toFixed(0)}%)`
|
||||
}}
|
||||
labelLine={false}
|
||||
>
|
||||
{chartData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) => formatValue(Number(value ?? 0), unit)}
|
||||
/>
|
||||
</PieChart>
|
||||
) : type === 'line_chart' ? (
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11 }}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => formatValue(v, unit)}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => formatValue(Number(value ?? 0), unit)}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={DEFAULT_COLORS[0]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
/>
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11 }}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => formatValue(v, unit)}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => formatValue(Number(value ?? 0), unit)}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { TableArtifact } from '@/types/chat'
|
||||
|
||||
interface ChatDataTableProps {
|
||||
artifact: TableArtifact
|
||||
}
|
||||
|
||||
function formatCell(value: string | number, _align?: 'left' | 'right'): string {
|
||||
if (typeof value === 'number') {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
minimumFractionDigits: value % 1 !== 0 ? 2 : 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function ChatDataTable({ artifact }: ChatDataTableProps) {
|
||||
const { title, columns, rows, summary_row } = artifact
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h4 className="text-sm font-semibold mb-2">{title}</h4>
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/50">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-3 py-2 font-medium text-muted-foreground border-b border-border ${
|
||||
col.align === 'right' ? 'text-right' : 'text-left'
|
||||
}`}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-b border-border/50 hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`px-3 py-1.5 ${
|
||||
col.align === 'right' ? 'text-right tabular-nums' : 'text-left'
|
||||
}`}
|
||||
>
|
||||
{formatCell(row[col.key], col.align)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{summary_row && (
|
||||
<tr className="bg-muted/30 font-semibold">
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`px-3 py-2 border-t border-border ${
|
||||
col.align === 'right' ? 'text-right tabular-nums' : 'text-left'
|
||||
}`}
|
||||
>
|
||||
{summary_row[col.key] !== undefined
|
||||
? formatCell(summary_row[col.key], col.align)
|
||||
: ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||
import type { KpiCardsArtifact } from '@/types/chat'
|
||||
|
||||
interface ChatKpiCardsProps {
|
||||
artifact: KpiCardsArtifact
|
||||
}
|
||||
|
||||
export function ChatKpiCards({ artifact }: ChatKpiCardsProps) {
|
||||
const { title, cards } = artifact
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{title && <h4 className="text-sm font-semibold mb-2">{title}</h4>}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{cards.map((card, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border border-border bg-card p-3"
|
||||
>
|
||||
<p className="text-xs text-muted-foreground mb-1">{card.label}</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-lg font-bold tabular-nums">{card.value}</span>
|
||||
{card.trend && (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 text-xs ${
|
||||
card.trend === 'up'
|
||||
? 'text-success'
|
||||
: card.trend === 'down'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{card.trend === 'up' && <TrendingUp className="h-3 w-3" />}
|
||||
{card.trend === 'down' && <TrendingDown className="h-3 w-3" />}
|
||||
{card.trend === 'flat' && <Minus className="h-3 w-3" />}
|
||||
{card.change}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { ChatWidget } from './ChatWidget'
|
||||
export { ChatPanel } from './ChatPanel'
|
||||
export { ChatMessage } from './ChatMessage'
|
||||
export { ChatInput } from './ChatInput'
|
||||
export { useChatStream } from './useChatStream'
|
||||
@@ -1,233 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import type { ChatMessage, SourceReference, ArtifactSpec } from '@/types/chat'
|
||||
|
||||
interface UseChatStreamOptions {
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
interface UseChatStreamReturn {
|
||||
messages: ChatMessage[]
|
||||
isLoading: boolean
|
||||
isStreaming: boolean
|
||||
sessionId: string | null
|
||||
error: string | null
|
||||
toolsExecuting: string[]
|
||||
sendMessage: (message: string) => Promise<void>
|
||||
loadSession: (sessionId: string) => Promise<void>
|
||||
clearChat: () => void
|
||||
setSessionId: (id: string | null) => void
|
||||
}
|
||||
|
||||
export function useChatStream(options: UseChatStreamOptions = {}): UseChatStreamReturn {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [toolsExecuting, setToolsExecuting] = useState<string[]>([])
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const sendMessage = useCallback(async (message: string) => {
|
||||
if (!message.trim() || isLoading || isStreaming) return
|
||||
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
|
||||
// Add user message immediately
|
||||
const userMessage: ChatMessage = {
|
||||
id: `temp-${Date.now()}`,
|
||||
session_id: sessionId || '',
|
||||
user_id: '',
|
||||
role: 'user',
|
||||
content: message.trim(),
|
||||
sources: [],
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
setMessages((prev) => [...prev, userMessage])
|
||||
|
||||
// Add placeholder for assistant response
|
||||
const assistantPlaceholder: ChatMessage = {
|
||||
id: `temp-assistant-${Date.now()}`,
|
||||
session_id: sessionId || '',
|
||||
user_id: '',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
sources: [],
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
setMessages((prev) => [...prev, assistantPlaceholder])
|
||||
|
||||
try {
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
const response = await fetch('/api/extensions/ext/ai-chat/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: message.trim(),
|
||||
session_id: sessionId,
|
||||
}),
|
||||
signal: abortControllerRef.current.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to send message')
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
setIsStreaming(true)
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) throw new Error('No response body')
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let accumulatedContent = ''
|
||||
let sources: SourceReference[] = []
|
||||
let artifact: ArtifactSpec | null = null
|
||||
let newSessionId = sessionId
|
||||
let messageId: string | null = null
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value)
|
||||
const lines = chunk.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
|
||||
if (data.type === 'session') {
|
||||
newSessionId = data.session_id
|
||||
setSessionId(data.session_id)
|
||||
} else if (data.type === 'content') {
|
||||
accumulatedContent += data.content
|
||||
setToolsExecuting([]) // Clear tool indicators when content starts
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev]
|
||||
const lastMsg = newMessages[newMessages.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.content = accumulatedContent
|
||||
}
|
||||
return newMessages
|
||||
})
|
||||
} else if (data.type === 'sources') {
|
||||
sources = data.sources
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev]
|
||||
const lastMsg = newMessages[newMessages.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.sources = sources
|
||||
}
|
||||
return newMessages
|
||||
})
|
||||
} else if (data.type === 'tool_start') {
|
||||
setToolsExecuting((prev) => [...prev, data.toolName])
|
||||
} else if (data.type === 'artifact') {
|
||||
artifact = data.artifact
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev]
|
||||
const lastMsg = newMessages[newMessages.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.artifact = artifact
|
||||
}
|
||||
return newMessages
|
||||
})
|
||||
} else if (data.type === 'done') {
|
||||
messageId = data.message_id
|
||||
setToolsExecuting([])
|
||||
// Update the message IDs with real values
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev]
|
||||
const lastMsg = newMessages[newMessages.length - 1]
|
||||
if (lastMsg.role === 'assistant' && messageId) {
|
||||
lastMsg.id = messageId
|
||||
lastMsg.session_id = newSessionId || ''
|
||||
}
|
||||
return newMessages
|
||||
})
|
||||
} else if (data.type === 'error') {
|
||||
throw new Error(data.error)
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
// User cancelled, remove the placeholder
|
||||
setMessages((prev) => prev.slice(0, -1))
|
||||
} else {
|
||||
const errorMessage = err instanceof Error ? err.message : 'An error occurred'
|
||||
setError(errorMessage)
|
||||
options.onError?.(errorMessage)
|
||||
// Update placeholder with error
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev]
|
||||
const lastMsg = newMessages[newMessages.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.content = 'Ett fel uppstod. Försök igen.'
|
||||
}
|
||||
return newMessages
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setIsStreaming(false)
|
||||
abortControllerRef.current = null
|
||||
}
|
||||
}, [sessionId, isLoading, isStreaming, options])
|
||||
|
||||
const loadSession = useCallback(async (id: string) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/extensions/ext/ai-chat/sessions/${id}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load session')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setSessionId(id)
|
||||
setMessages(data.messages || [])
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load session'
|
||||
setError(errorMessage)
|
||||
options.onError?.(errorMessage)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [options])
|
||||
|
||||
const clearChat = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
setMessages([])
|
||||
setSessionId(null)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
messages,
|
||||
isLoading,
|
||||
isStreaming,
|
||||
sessionId,
|
||||
error,
|
||||
toolsExecuting,
|
||||
sendMessage,
|
||||
loadSession,
|
||||
clearChat,
|
||||
setSessionId,
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
if (summary.expiringBankConnections && summary.expiringBankConnections.length > 0) {
|
||||
const conn = summary.expiringBankConnections[0]
|
||||
alertItems.push(
|
||||
<Link key="bank-expiry" href="/settings?tab=banking" className="group">
|
||||
<Link key="bank-expiry" href="/settings/banking" className="group">
|
||||
<Card className="h-full border-warning/30 hover:bg-warning/[0.03] transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AI_DATA_DISCLOSURES, type AiExtensionId } from '@/lib/extensions/ai-consent'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface AiConsentDialogProps {
|
||||
extensionId: AiExtensionId
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConsented: () => void
|
||||
}
|
||||
|
||||
export function AiConsentDialog({
|
||||
extensionId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onConsented,
|
||||
}: AiConsentDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const disclosure = AI_DATA_DISCLOSURES[extensionId]
|
||||
|
||||
async function handleAccept() {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai-consent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ extension_id: extensionId }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
onOpenChange(false)
|
||||
onConsented()
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI-samtycke krävs</DialogTitle>
|
||||
<DialogDescription>
|
||||
Denna funktion använder AI-tjänster från externa leverantörer.
|
||||
Granska informationen nedan innan du fortsätter.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Leverantör</p>
|
||||
<p className="text-sm text-muted-foreground">{disclosure.provider}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Data som skickas</p>
|
||||
<ul className="text-sm text-muted-foreground list-disc pl-5 space-y-1">
|
||||
{disclosure.dataTypes.map((dt) => (
|
||||
<li key={dt}>{dt}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Syfte</p>
|
||||
<p className="text-sm text-muted-foreground">{disclosure.purpose}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Läs mer i vår{' '}
|
||||
<Link href="/privacy" className="underline underline-offset-4" target="_blank">
|
||||
integritetspolicy
|
||||
</Link>
|
||||
. Du kan när som helst återkalla ditt samtycke i inställningarna.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleAccept} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Sparar...' : 'Jag samtycker'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import { ChatPanel } from '@/components/chat/ChatPanel'
|
||||
|
||||
export default function AiChatWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
return (
|
||||
<div className="h-[calc(100vh-10rem)] max-w-4xl mx-auto">
|
||||
<ChatPanel className="h-full rounded-lg border border-border bg-background shadow-sm" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import type { InvoiceInboxItem, Supplier, DocumentClassificationType } from '@/types'
|
||||
import type { InvoiceInboxSettings } from '@/extensions/general/invoice-inbox/types'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Settings, Inbox, CheckCircle2, AlertTriangle, Receipt, FileText, RefreshCw } from 'lucide-react'
|
||||
import DocumentInboxCard from '@/components/extensions/general/document-inbox/DocumentInboxCard'
|
||||
import ReceiptInboxDetail from '@/components/extensions/general/document-inbox/ReceiptInboxDetail'
|
||||
import InboxUploadZone from '@/components/extensions/general/invoice-inbox/InboxUploadZone'
|
||||
import InboxDetailDialog from '@/components/extensions/general/invoice-inbox/InboxDetailDialog'
|
||||
import InboxSettingsDialog from '@/components/extensions/general/invoice-inbox/InboxSettingsDialog'
|
||||
|
||||
type TabValue = 'all' | DocumentClassificationType
|
||||
|
||||
const TABS: { value: TabValue; label: string }[] = [
|
||||
{ value: 'all', label: 'Alla' },
|
||||
{ value: 'supplier_invoice', label: 'Fakturor' },
|
||||
{ value: 'receipt', label: 'Kvitton' },
|
||||
{ value: 'government_letter', label: 'Myndighetspost' },
|
||||
{ value: 'unknown', label: 'Övrigt' },
|
||||
]
|
||||
|
||||
const DEFAULT_SETTINGS: InvoiceInboxSettings = {
|
||||
autoProcessEnabled: true,
|
||||
autoMatchSupplierEnabled: true,
|
||||
supplierMatchThreshold: 0.7,
|
||||
inboxEmail: null,
|
||||
}
|
||||
|
||||
export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
const [items, setItems] = useState<InvoiceInboxItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('all')
|
||||
const [selectedItem, setSelectedItem] = useState<InvoiceInboxItem | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [settings, setSettings] = useState<InvoiceInboxSettings>(DEFAULT_SETTINGS)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([])
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/inbox')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setItems(data ?? [])
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/settings')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) setSettings(data)
|
||||
}
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSuppliers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/suppliers')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setSuppliers(data ?? [])
|
||||
}
|
||||
} catch {
|
||||
// ok
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems()
|
||||
fetchSettings()
|
||||
fetchSuppliers()
|
||||
}, [fetchItems, fetchSettings, fetchSuppliers])
|
||||
|
||||
function handleUploadComplete(result: InvoiceInboxItem | InvoiceInboxItem[]) {
|
||||
const newItems = Array.isArray(result) ? result : [result]
|
||||
setItems((prev) => [...newItems, ...prev])
|
||||
for (const item of newItems) {
|
||||
pollItem(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
const [isMatching, setIsMatching] = useState(false)
|
||||
|
||||
async function handleMatchSweep() {
|
||||
setIsMatching(true)
|
||||
try {
|
||||
const res = await fetch('/api/documents/match-sweep', { method: 'POST' })
|
||||
if (res.ok) {
|
||||
await fetchItems()
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
} finally {
|
||||
setIsMatching(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function pollItem(itemId: string) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`)
|
||||
if (!res.ok) continue
|
||||
const { data } = await res.json()
|
||||
if (data && data.status !== 'processing') {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? data : it))
|
||||
)
|
||||
setSelectedItem((current) =>
|
||||
current?.id === itemId ? data : current
|
||||
)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemClick(item: InvoiceInboxItem) {
|
||||
setSelectedItem(item)
|
||||
}
|
||||
|
||||
async function handleConfirm(itemId: string, supplierId?: string) {
|
||||
const body: Record<string, string> = {}
|
||||
if (supplierId) body.supplier_id = supplierId
|
||||
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'confirmed' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
fetchSuppliers()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(itemId: string) {
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'rejected' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReprocess(itemId: string) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'processing' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/process`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) {
|
||||
setItems((prev) => prev.map((it) => (it.id === itemId ? data : it)))
|
||||
}
|
||||
} else {
|
||||
fetchItems()
|
||||
}
|
||||
}
|
||||
|
||||
function handleReceiptConfirm() {
|
||||
fetchItems()
|
||||
setSelectedItem(null)
|
||||
}
|
||||
|
||||
async function handleSaveSettings(updated: InvoiceInboxSettings) {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updated),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) setSettings(data)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems =
|
||||
activeTab === 'all'
|
||||
? items
|
||||
: items.filter((it) => (it.document_type ?? 'supplier_invoice') === activeTab)
|
||||
|
||||
const totalPending = items.filter((it) => it.status === 'ready' || it.status === 'pending').length
|
||||
const receiptCount = items.filter((it) => it.document_type === 'receipt' && it.status === 'ready').length
|
||||
const invoiceCount = items.filter((it) => (it.document_type ?? 'supplier_invoice') === 'supplier_invoice' && it.status === 'ready').length
|
||||
|
||||
// Determine which detail dialog to show
|
||||
const isReceiptSelected = selectedItem?.document_type === 'receipt'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Dokumentinkorg"
|
||||
description="Alla inkommande dokument — fakturor, kvitton och myndighetspost"
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleMatchSweep}
|
||||
disabled={isMatching}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isMatching ? 'animate-spin' : ''}`} />
|
||||
Matcha alla
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Inbox className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{totalPending}</p>
|
||||
<p className="text-xs text-muted-foreground">Att granska</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning/15">
|
||||
<FileText className="h-5 w-5 text-warning-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{invoiceCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Fakturor att granska</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-secondary/50">
|
||||
<Receipt className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{receiptCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Kvitton att granska</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Upload zone */}
|
||||
<InboxUploadZone
|
||||
onUploadComplete={handleUploadComplete}
|
||||
isUploading={isUploading}
|
||||
setIsUploading={setIsUploading}
|
||||
/>
|
||||
|
||||
{/* Tabs by document type */}
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>
|
||||
<TabsList>
|
||||
{TABS.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{TABS.map((tab) => (
|
||||
<TabsContent key={tab.value} value={tab.value}>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Inbox className="h-10 w-10 text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeTab === 'all'
|
||||
? 'Inga dokument ännu. Ladda upp ett dokument ovan eller skicka via e-post.'
|
||||
: 'Inga dokument av denna typ.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredItems.map((item) => (
|
||||
<DocumentInboxCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={() => handleItemClick(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{/* Receipt detail dialog */}
|
||||
{isReceiptSelected && (
|
||||
<ReceiptInboxDetail
|
||||
item={selectedItem}
|
||||
open={selectedItem != null && isReceiptSelected}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedItem(null)
|
||||
}}
|
||||
onConfirm={handleReceiptConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Invoice/other detail dialog (existing) */}
|
||||
{!isReceiptSelected && (
|
||||
<InboxDetailDialog
|
||||
item={selectedItem}
|
||||
open={selectedItem != null && !isReceiptSelected}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedItem(null)
|
||||
}}
|
||||
onConfirm={handleConfirm}
|
||||
onReject={handleReject}
|
||||
onReprocess={handleReprocess}
|
||||
suppliers={suppliers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Settings dialog */}
|
||||
<InboxSettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
settings={settings}
|
||||
onSave={handleSaveSettings}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export default function EnableBankingWorkspace({ userId }: WorkspaceComponentPro
|
||||
Koppla ditt bankkonto under Inställningar för att synka transaktioner automatiskt.
|
||||
</p>
|
||||
<Button asChild variant="outline" className="mt-4">
|
||||
<Link href="/settings?tab=banking">
|
||||
<Link href="/settings/banking">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Gå till bankinställningar
|
||||
</Link>
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import type { InvoiceInboxItem, Supplier, InboxItemStatus } from '@/types'
|
||||
import type { InvoiceInboxSettings } from '@/extensions/general/invoice-inbox/types'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Settings, Inbox, CheckCircle2, AlertTriangle } from 'lucide-react'
|
||||
import InboxItemCard from '@/components/extensions/general/invoice-inbox/InboxItemCard'
|
||||
import InboxUploadZone from '@/components/extensions/general/invoice-inbox/InboxUploadZone'
|
||||
import InboxDetailDialog from '@/components/extensions/general/invoice-inbox/InboxDetailDialog'
|
||||
import InboxSettingsDialog from '@/components/extensions/general/invoice-inbox/InboxSettingsDialog'
|
||||
|
||||
type TabValue = 'all' | InboxItemStatus
|
||||
|
||||
const TABS: { value: TabValue; label: string }[] = [
|
||||
{ value: 'all', label: 'Alla' },
|
||||
{ value: 'pending', label: 'Ny' },
|
||||
{ value: 'ready', label: 'Klar' },
|
||||
{ value: 'confirmed', label: 'Bekräftad' },
|
||||
{ value: 'rejected', label: 'Avvisad' },
|
||||
{ value: 'error', label: 'Fel' },
|
||||
]
|
||||
|
||||
const DEFAULT_SETTINGS: InvoiceInboxSettings = {
|
||||
autoProcessEnabled: true,
|
||||
autoMatchSupplierEnabled: true,
|
||||
supplierMatchThreshold: 0.7,
|
||||
inboxEmail: null,
|
||||
}
|
||||
|
||||
export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
const [items, setItems] = useState<InvoiceInboxItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('all')
|
||||
const [selectedItem, setSelectedItem] = useState<InvoiceInboxItem | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [settings, setSettings] = useState<InvoiceInboxSettings>(DEFAULT_SETTINGS)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([])
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/inbox')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setItems(data ?? [])
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — user sees empty state
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/settings')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) setSettings(data)
|
||||
}
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSuppliers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/suppliers')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setSuppliers(data ?? [])
|
||||
}
|
||||
} catch {
|
||||
// ok
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems()
|
||||
fetchSettings()
|
||||
fetchSuppliers()
|
||||
}, [fetchItems, fetchSettings, fetchSuppliers])
|
||||
|
||||
function handleUploadComplete(result: InvoiceInboxItem | InvoiceInboxItem[]) {
|
||||
const newItems = Array.isArray(result) ? result : [result]
|
||||
setItems((prev) => [...newItems, ...prev])
|
||||
for (const item of newItems) {
|
||||
pollItem(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function pollItem(itemId: string) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`)
|
||||
if (!res.ok) continue
|
||||
const { data } = await res.json()
|
||||
if (data && data.status !== 'processing') {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? data : it))
|
||||
)
|
||||
// Also update the detail dialog if it's open for this item
|
||||
setSelectedItem((current) =>
|
||||
current?.id === itemId ? data : current
|
||||
)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// continue polling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirm(
|
||||
itemId: string,
|
||||
supplierId?: string,
|
||||
newSupplierData?: import('@/components/extensions/general/invoice-inbox/InboxDetailDialog').NewSupplierData
|
||||
) {
|
||||
const body: Record<string, unknown> = {}
|
||||
if (supplierId) {
|
||||
body.supplier_id = supplierId
|
||||
} else if (newSupplierData) {
|
||||
body.new_supplier = newSupplierData
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'confirmed' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
fetchSuppliers() // New supplier may have been created
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(itemId: string) {
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'rejected' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReprocess(itemId: string) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'processing' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/process`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) {
|
||||
setItems((prev) => prev.map((it) => (it.id === itemId ? data : it)))
|
||||
}
|
||||
} else {
|
||||
// Refetch in case of error update
|
||||
fetchItems()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSettings(updated: InvoiceInboxSettings) {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updated),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) setSettings(data)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems =
|
||||
activeTab === 'all'
|
||||
? items
|
||||
: items.filter((it) => it.status === activeTab)
|
||||
|
||||
const totalCount = items.length
|
||||
const readyCount = items.filter((it) => it.status === 'ready').length
|
||||
const errorCount = items.filter((it) => it.status === 'error').length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Fakturainkorgen"
|
||||
description="Ladda upp leverantörsfakturor och låt AI extrahera data automatiskt"
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Inbox className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{totalCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Totalt</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning/15">
|
||||
<CheckCircle2 className="h-5 w-5 text-warning-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{readyCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Att granska</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-destructive/10">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{errorCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Fel</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Upload zone */}
|
||||
<InboxUploadZone
|
||||
onUploadComplete={handleUploadComplete}
|
||||
isUploading={isUploading}
|
||||
setIsUploading={setIsUploading}
|
||||
/>
|
||||
|
||||
{/* Tabs + item list */}
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>
|
||||
<TabsList>
|
||||
{TABS.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{TABS.map((tab) => (
|
||||
<TabsContent key={tab.value} value={tab.value}>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Inbox className="h-10 w-10 text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeTab === 'all'
|
||||
? 'Inga fakturor ännu. Ladda upp en faktura ovan.'
|
||||
: 'Inga fakturor med denna status.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredItems.map((item) => (
|
||||
<InboxItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{/* Detail dialog */}
|
||||
<InboxDetailDialog
|
||||
item={selectedItem}
|
||||
open={selectedItem != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedItem(null)
|
||||
}}
|
||||
onConfirm={handleConfirm}
|
||||
onReject={handleReject}
|
||||
onReprocess={handleReprocess}
|
||||
suppliers={suppliers}
|
||||
/>
|
||||
|
||||
{/* Settings dialog */}
|
||||
<InboxSettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
settings={settings}
|
||||
onSave={handleSaveSettings}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { InvoiceInboxItem, DocumentClassificationType } from '@/types'
|
||||
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
getStatusLabel,
|
||||
getStatusVariant,
|
||||
getConfidenceLabel,
|
||||
formatExtractionSummary,
|
||||
getDocumentTypeLabel,
|
||||
getDocumentTypeVariant,
|
||||
} from '@/lib/extensions/invoice-inbox-utils'
|
||||
import { Mail, Upload, FileText, Receipt, Landmark } from 'lucide-react'
|
||||
|
||||
interface DocumentInboxCardProps {
|
||||
item: InvoiceInboxItem
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const now = new Date()
|
||||
const date = new Date(dateStr)
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
if (diffMin < 1) return 'Just nu'
|
||||
if (diffMin < 60) return `${diffMin} min sedan`
|
||||
const diffH = Math.floor(diffMin / 60)
|
||||
if (diffH < 24) return `${diffH} tim sedan`
|
||||
const diffD = Math.floor(diffH / 24)
|
||||
if (diffD === 1) return 'Igår'
|
||||
return `${diffD} dagar sedan`
|
||||
}
|
||||
|
||||
function formatAmount(amount: number, currency: string = 'SEK'): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
function getDocumentIcon(type: DocumentClassificationType) {
|
||||
switch (type) {
|
||||
case 'receipt':
|
||||
return <Receipt className="h-5 w-5 text-muted-foreground" />
|
||||
case 'government_letter':
|
||||
return <Landmark className="h-5 w-5 text-muted-foreground" />
|
||||
default:
|
||||
return <FileText className="h-5 w-5 text-muted-foreground" />
|
||||
}
|
||||
}
|
||||
|
||||
function getSummaryText(item: InvoiceInboxItem): { label: string; total: number; currency: string } {
|
||||
const type = item.document_type ?? 'supplier_invoice'
|
||||
|
||||
switch (type) {
|
||||
case 'supplier_invoice': {
|
||||
const extraction = item.extracted_data as unknown as InvoiceExtractionResult | null
|
||||
const summary = formatExtractionSummary(extraction)
|
||||
return {
|
||||
label: (item.supplier as { name?: string } | undefined)?.name ?? (summary.supplierName || 'Okänd leverantör'),
|
||||
total: summary.total,
|
||||
currency: summary.currency,
|
||||
}
|
||||
}
|
||||
case 'receipt': {
|
||||
const receipt = item.receipt as { merchant_name?: string; total_amount?: number; currency?: string } | undefined
|
||||
return {
|
||||
label: receipt?.merchant_name ?? 'Okänd handlare',
|
||||
total: receipt?.total_amount ?? 0,
|
||||
currency: receipt?.currency || 'SEK',
|
||||
}
|
||||
}
|
||||
case 'government_letter': {
|
||||
return {
|
||||
label: item.email_from ?? 'Okänd avsändare',
|
||||
total: 0,
|
||||
currency: 'SEK',
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return { label: 'Granska manuellt', total: 0, currency: 'SEK' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function DocumentInboxCard({ item, onClick }: DocumentInboxCardProps) {
|
||||
const confidence = getConfidenceLabel(item.confidence)
|
||||
const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
const docType = (item.document_type ?? 'supplier_invoice') as DocumentClassificationType
|
||||
const docTypeVariant = getDocumentTypeVariant(docType) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
|
||||
const fileName = (item.document as { file_name?: string } | undefined)?.file_name ?? 'Okänd fil'
|
||||
const { label: summaryLabel, total, currency } = getSummaryText(item)
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer transition-colors hover:bg-accent/50"
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
{item.source === 'email' ? (
|
||||
<Mail className="h-5 w-5 text-muted-foreground" />
|
||||
) : (
|
||||
<Upload className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{getDocumentIcon(docType)}
|
||||
<span className="text-sm font-medium truncate">{fileName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={`text-sm truncate ${summaryLabel === 'Granska manuellt' ? 'text-muted-foreground/60 italic' : 'text-muted-foreground'}`}>
|
||||
{summaryLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{total > 0 && (
|
||||
<span className="text-sm font-medium">{formatAmount(total, currency)}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant={docTypeVariant} className="text-[10px] px-1.5 py-0">
|
||||
{getDocumentTypeLabel(docType)}
|
||||
</Badge>
|
||||
{item.confidence != null && (
|
||||
<Badge variant={confidenceVariant} className="text-[10px] px-1.5 py-0">
|
||||
{confidence.label}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={statusVariant}>
|
||||
{getStatusLabel(item.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{formatRelativeTime(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { InvoiceInboxItem } from '@/types'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { CheckCircle2, Receipt, LinkIcon } from 'lucide-react'
|
||||
|
||||
interface ReceiptLineItem {
|
||||
id: string
|
||||
description: string
|
||||
line_total: number
|
||||
vat_rate: number | null
|
||||
is_business: boolean | null
|
||||
category: string | null
|
||||
bas_account: string | null
|
||||
}
|
||||
|
||||
interface ReceiptInboxDetailProps {
|
||||
item: InvoiceInboxItem | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
function formatAmount(amount: number, currency: string = 'SEK'): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export default function ReceiptInboxDetail({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: ReceiptInboxDetailProps) {
|
||||
const [lineItems, setLineItems] = useState<ReceiptLineItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [representationPersons, setRepresentationPersons] = useState<number | null>(null)
|
||||
const [representationPurpose, setRepresentationPurpose] = useState('')
|
||||
const [representationBusinessConnection, setRepresentationBusinessConnection] = useState('')
|
||||
|
||||
const receipt = item?.receipt as {
|
||||
id?: string
|
||||
merchant_name?: string
|
||||
total_amount?: number
|
||||
receipt_date?: string
|
||||
status?: string
|
||||
matched_transaction_id?: string
|
||||
} | undefined
|
||||
|
||||
// Fetch line items when dialog opens
|
||||
async function fetchLineItems() {
|
||||
if (!item?.linked_receipt_id) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/ext/receipt-ocr/${item.linked_receipt_id}`)
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data?.line_items) {
|
||||
setLineItems(data.line_items)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ok
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
if (isOpen && item?.linked_receipt_id) {
|
||||
fetchLineItems()
|
||||
}
|
||||
onOpenChange(isOpen)
|
||||
}
|
||||
|
||||
function toggleBusiness(lineItemId: string) {
|
||||
setLineItems((prev) =>
|
||||
prev.map((li) =>
|
||||
li.id === lineItemId
|
||||
? { ...li, is_business: li.is_business === true ? false : true }
|
||||
: li
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!item?.id || !item.linked_receipt_id) return
|
||||
setConfirming(true)
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
line_items: lineItems.map((li) => ({
|
||||
id: li.id,
|
||||
is_business: li.is_business,
|
||||
category: li.category,
|
||||
bas_account: li.bas_account,
|
||||
})),
|
||||
}
|
||||
|
||||
if (receipt?.matched_transaction_id) {
|
||||
body.matched_transaction_id = receipt.matched_transaction_id
|
||||
}
|
||||
if (representationPersons != null && representationPersons > 0) {
|
||||
body.representation_persons = representationPersons
|
||||
}
|
||||
if (representationPurpose) {
|
||||
body.representation_purpose = representationPurpose
|
||||
}
|
||||
if (representationBusinessConnection) {
|
||||
body.representation_business_connection = representationBusinessConnection
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`/api/extensions/ext/invoice-inbox/inbox/${item.id}/confirm-receipt`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
)
|
||||
|
||||
if (res.ok) {
|
||||
onConfirm()
|
||||
onOpenChange(false)
|
||||
}
|
||||
} catch {
|
||||
// ok
|
||||
} finally {
|
||||
setConfirming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const businessTotal = lineItems
|
||||
.filter((li) => li.is_business === true)
|
||||
.reduce((sum, li) => sum + li.line_total, 0)
|
||||
const privateTotal = lineItems
|
||||
.filter((li) => li.is_business === false)
|
||||
.reduce((sum, li) => sum + li.line_total, 0)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Receipt className="h-5 w-5" />
|
||||
Kvitto via e-post
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{receipt && (
|
||||
<div className="space-y-4">
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Handlare</span>
|
||||
<p className="font-medium">{receipt.merchant_name ?? 'Okänd'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Datum</span>
|
||||
<p className="font-medium">{receipt.receipt_date ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Totalbelopp</span>
|
||||
<p className="font-medium">
|
||||
{receipt.total_amount ? formatAmount(receipt.total_amount) : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Transaktionsmatch</span>
|
||||
<p className="font-medium flex items-center gap-1">
|
||||
{receipt.matched_transaction_id ? (
|
||||
<>
|
||||
<LinkIcon className="h-3 w-3 text-success" />
|
||||
<span className="text-success">Matchad</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Ingen match</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Line items with business/private toggle */}
|
||||
<div className="space-y-2">
|
||||
<Label>Artikelrader</Label>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Laddar...</p>
|
||||
) : lineItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Inga rader extraherade</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{lineItems.map((li) => (
|
||||
<div
|
||||
key={li.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{li.description}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatAmount(li.line_total)}
|
||||
{li.vat_rate != null && ` (${li.vat_rate}% moms)`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-muted-foreground">Företag</span>
|
||||
<Switch
|
||||
checked={li.is_business === true}
|
||||
onCheckedChange={() => toggleBusiness(li.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
{lineItems.length > 0 && (
|
||||
<div className="flex gap-4 text-sm">
|
||||
<Badge variant="default">Företag: {formatAmount(Math.round(businessTotal * 100) / 100)}</Badge>
|
||||
<Badge variant="secondary">Privat: {formatAmount(Math.round(privateTotal * 100) / 100)}</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Representation fields */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Representation (vid restaurangkvitto)
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="rep-persons" className="text-sm">
|
||||
Antal personer
|
||||
</Label>
|
||||
<Input
|
||||
id="rep-persons"
|
||||
type="number"
|
||||
min={0}
|
||||
value={representationPersons ?? ''}
|
||||
onChange={(e) =>
|
||||
setRepresentationPersons(
|
||||
e.target.value ? parseInt(e.target.value) : null
|
||||
)
|
||||
}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="rep-purpose" className="text-sm">
|
||||
Syfte
|
||||
</Label>
|
||||
<Input
|
||||
id="rep-purpose"
|
||||
value={representationPurpose}
|
||||
onChange={(e) => setRepresentationPurpose(e.target.value)}
|
||||
placeholder="T.ex. kundmöte"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="rep-connection" className="text-sm">
|
||||
Affärsmässig koppling (BFNAR)
|
||||
</Label>
|
||||
<Input
|
||||
id="rep-connection"
|
||||
value={representationBusinessConnection}
|
||||
onChange={(e) =>
|
||||
setRepresentationBusinessConnection(e.target.value)
|
||||
}
|
||||
placeholder="T.ex. potentiell kund, pågående projekt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={confirming}>
|
||||
<CheckCircle2 className="h-4 w-4 mr-1.5" />
|
||||
{confirming ? 'Bekräftar...' : 'Bekräfta kvitto'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { InvoiceInboxItem, Supplier, SupplierType } from '@/types'
|
||||
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
getStatusLabel,
|
||||
getStatusVariant,
|
||||
getConfidenceLabel,
|
||||
} from '@/lib/extensions/invoice-inbox-utils'
|
||||
import { Loader2, RefreshCw, Check, X, ChevronDown } from 'lucide-react'
|
||||
|
||||
export interface NewSupplierData {
|
||||
name: string
|
||||
supplier_type: SupplierType
|
||||
org_number: string
|
||||
vat_number: string
|
||||
bankgiro: string
|
||||
plusgiro: string
|
||||
default_expense_account: string
|
||||
default_currency: string
|
||||
}
|
||||
|
||||
interface InboxDetailDialogProps {
|
||||
item: InvoiceInboxItem | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: (itemId: string, supplierId?: string, newSupplierData?: NewSupplierData) => Promise<void>
|
||||
onReject: (itemId: string) => Promise<void>
|
||||
onReprocess: (itemId: string) => Promise<void>
|
||||
suppliers: Supplier[]
|
||||
}
|
||||
|
||||
function formatAmount(amount: number | null, currency: string = 'SEK'): string {
|
||||
if (amount == null) return '-'
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export default function InboxDetailDialog({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
onReject,
|
||||
onReprocess,
|
||||
suppliers,
|
||||
}: InboxDetailDialogProps) {
|
||||
const [loading, setLoading] = useState<'confirm' | 'reject' | 'reprocess' | null>(null)
|
||||
const [selectedSupplierId, setSelectedSupplierId] = useState<string | undefined>(undefined)
|
||||
const [supplierFormOpen, setSupplierFormOpen] = useState(false)
|
||||
const [newSupplier, setNewSupplier] = useState<NewSupplierData>({
|
||||
name: '',
|
||||
supplier_type: 'swedish_business',
|
||||
org_number: '',
|
||||
vat_number: '',
|
||||
bankgiro: '',
|
||||
plusgiro: '',
|
||||
default_expense_account: '6200',
|
||||
default_currency: 'SEK',
|
||||
})
|
||||
|
||||
// Pre-populate supplier form when item changes
|
||||
const extractionForEffect = item?.extracted_data as unknown as InvoiceExtractionResult | null
|
||||
useEffect(() => {
|
||||
if (!extractionForEffect?.supplier) return
|
||||
const s = extractionForEffect.supplier
|
||||
setNewSupplier({
|
||||
name: s.name ?? '',
|
||||
supplier_type: 'swedish_business',
|
||||
org_number: s.orgNumber ?? '',
|
||||
vat_number: s.vatNumber ?? '',
|
||||
bankgiro: s.bankgiro ?? '',
|
||||
plusgiro: s.plusgiro ?? '',
|
||||
default_expense_account: '6200',
|
||||
default_currency: extractionForEffect.invoice?.currency || 'SEK',
|
||||
})
|
||||
}, [extractionForEffect])
|
||||
|
||||
if (!item) return null
|
||||
|
||||
const extraction = item.extracted_data as unknown as InvoiceExtractionResult | null
|
||||
const currency = extraction?.invoice.currency || 'SEK'
|
||||
const confidence = getConfidenceLabel(item.confidence)
|
||||
const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
|
||||
const matchedSupplierName = (item.supplier as { name?: string } | undefined)?.name
|
||||
const supplierId = selectedSupplierId ?? item.matched_supplier_id ?? undefined
|
||||
const isCreatingNewSupplier = !supplierId
|
||||
|
||||
const canConfirm = item.status === 'ready' && extraction != null
|
||||
const canReprocess = item.status !== 'confirmed'
|
||||
const canReject = item.status !== 'confirmed' && item.status !== 'rejected'
|
||||
|
||||
function updateSupplierField<K extends keyof NewSupplierData>(field: K, value: NewSupplierData[K]) {
|
||||
setNewSupplier((prev) => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
async function handleAction(action: 'confirm' | 'reject' | 'reprocess') {
|
||||
setLoading(action)
|
||||
try {
|
||||
if (action === 'confirm') {
|
||||
await onConfirm(
|
||||
item!.id,
|
||||
supplierId,
|
||||
isCreatingNewSupplier ? newSupplier : undefined
|
||||
)
|
||||
} else if (action === 'reject') {
|
||||
await onReject(item!.id)
|
||||
} else {
|
||||
await onReprocess(item!.id)
|
||||
}
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<DialogTitle>Granska faktura</DialogTitle>
|
||||
<Badge variant={statusVariant}>
|
||||
{getStatusLabel(item.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Confidence */}
|
||||
{item.confidence != null && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">AI-konfidens</span>
|
||||
<Badge variant={confidenceVariant}>{confidence.label} ({Math.round(item.confidence * 100)}%)</Badge>
|
||||
</div>
|
||||
<Progress value={item.confidence * 100} className="h-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.error_message && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{item.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extraction && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{/* Supplier info */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Leverantör</h4>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Namn</span>
|
||||
<p className="font-medium">{extraction.supplier.name ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Org.nr</span>
|
||||
<p className="font-medium">{extraction.supplier.orgNumber ?? '-'}</p>
|
||||
</div>
|
||||
{extraction.supplier.bankgiro && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Bankgiro</span>
|
||||
<p className="font-medium">{extraction.supplier.bankgiro}</p>
|
||||
</div>
|
||||
)}
|
||||
{extraction.supplier.plusgiro && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Plusgiro</span>
|
||||
<p className="font-medium">{extraction.supplier.plusgiro}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Supplier match override */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm text-muted-foreground">
|
||||
Matchad leverantör
|
||||
{matchedSupplierName && (
|
||||
<span className="ml-1 text-xs">
|
||||
(auto: {matchedSupplierName})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<Select
|
||||
value={supplierId ?? '__new__'}
|
||||
onValueChange={(v) => setSelectedSupplierId(v === '__new__' ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Skapa ny leverantör" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__new__">Skapa ny leverantör</SelectItem>
|
||||
{suppliers.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
{s.org_number ? ` (${s.org_number})` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Inline new supplier form */}
|
||||
{isCreatingNewSupplier && (
|
||||
<div className="rounded-md border bg-muted/30 p-3 space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between text-sm font-medium"
|
||||
onClick={() => setSupplierFormOpen((o) => !o)}
|
||||
>
|
||||
<span>Granska leverantörsuppgifter</span>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${supplierFormOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{supplierFormOpen && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-xs">Namn</Label>
|
||||
<Input
|
||||
value={newSupplier.name}
|
||||
onChange={(e) => updateSupplierField('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Typ</Label>
|
||||
<Select
|
||||
value={newSupplier.supplier_type}
|
||||
onValueChange={(v) => updateSupplierField('supplier_type', v as SupplierType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="swedish_business">Svenskt företag</SelectItem>
|
||||
<SelectItem value="eu_business">EU-företag</SelectItem>
|
||||
<SelectItem value="non_eu_business">Utomeuropeiskt</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Org.nr</Label>
|
||||
<Input
|
||||
value={newSupplier.org_number}
|
||||
onChange={(e) => updateSupplierField('org_number', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Bankgiro</Label>
|
||||
<Input
|
||||
value={newSupplier.bankgiro}
|
||||
onChange={(e) => updateSupplierField('bankgiro', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Plusgiro</Label>
|
||||
<Input
|
||||
value={newSupplier.plusgiro}
|
||||
onChange={(e) => updateSupplierField('plusgiro', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Kostnadskonto</Label>
|
||||
<Input
|
||||
value={newSupplier.default_expense_account}
|
||||
onChange={(e) => updateSupplierField('default_expense_account', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Valuta</Label>
|
||||
<Input
|
||||
value={newSupplier.default_currency}
|
||||
onChange={(e) => updateSupplierField('default_currency', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Invoice details */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Fakturadetaljer</h4>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Fakturanummer</span>
|
||||
<p className="font-medium">{extraction.invoice.invoiceNumber ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Valuta</span>
|
||||
<p className="font-medium">{extraction.invoice.currency}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Fakturadatum</span>
|
||||
<p className="font-medium">{extraction.invoice.invoiceDate ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Förfallodatum</span>
|
||||
<p className="font-medium">{extraction.invoice.dueDate ?? '-'}</p>
|
||||
</div>
|
||||
{extraction.invoice.paymentReference && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">Betalningsreferens</span>
|
||||
<p className="font-medium font-mono">{extraction.invoice.paymentReference}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line items */}
|
||||
{extraction.lineItems.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Rader</h4>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead className="text-right w-16">Antal</TableHead>
|
||||
<TableHead className="text-right w-24">À-pris</TableHead>
|
||||
<TableHead className="text-right w-24">Belopp</TableHead>
|
||||
<TableHead className="text-right w-16">Moms</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{extraction.lineItems.map((line, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="text-sm">{line.description}</TableCell>
|
||||
<TableCell className="text-right text-sm">{line.quantity}</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
{line.unitPrice != null ? formatAmount(line.unitPrice, currency) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm font-medium">
|
||||
{formatAmount(line.lineTotal, currency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
{line.vatRate != null ? `${line.vatRate}%` : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Totals */}
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Netto</span>
|
||||
<span>{formatAmount(extraction.totals.subtotal, currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms</span>
|
||||
<span>{formatAmount(extraction.totals.vatAmount, currency)}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between font-medium text-base">
|
||||
<span>Totalt</span>
|
||||
<span>{formatAmount(extraction.totals.total, currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
{canReject && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('reject')}
|
||||
disabled={loading !== null}
|
||||
>
|
||||
{loading === 'reject' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Avvisa
|
||||
</Button>
|
||||
)}
|
||||
{canReprocess && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('reprocess')}
|
||||
disabled={loading !== null}
|
||||
>
|
||||
{loading === 'reprocess' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Bearbeta igen
|
||||
</Button>
|
||||
)}
|
||||
{canConfirm && (
|
||||
<Button
|
||||
onClick={() => handleAction('confirm')}
|
||||
disabled={loading !== null}
|
||||
>
|
||||
{loading === 'confirm' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Bekräfta
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { InvoiceInboxItem } from '@/types'
|
||||
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
getStatusLabel,
|
||||
getStatusVariant,
|
||||
getConfidenceLabel,
|
||||
formatExtractionSummary,
|
||||
} from '@/lib/extensions/invoice-inbox-utils'
|
||||
import { Mail, Upload, FileText } from 'lucide-react'
|
||||
|
||||
interface InboxItemCardProps {
|
||||
item: InvoiceInboxItem
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const now = new Date()
|
||||
const date = new Date(dateStr)
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
if (diffMin < 1) return 'Just nu'
|
||||
if (diffMin < 60) return `${diffMin} min sedan`
|
||||
const diffH = Math.floor(diffMin / 60)
|
||||
if (diffH < 24) return `${diffH} tim sedan`
|
||||
const diffD = Math.floor(diffH / 24)
|
||||
if (diffD === 1) return 'Igår'
|
||||
return `${diffD} dagar sedan`
|
||||
}
|
||||
|
||||
function formatAmount(amount: number, currency: string = 'SEK'): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export default function InboxItemCard({ item, onClick }: InboxItemCardProps) {
|
||||
const summary = formatExtractionSummary(item.extracted_data as unknown as InvoiceExtractionResult | null)
|
||||
const confidence = getConfidenceLabel(item.confidence)
|
||||
const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
|
||||
const fileName = (item.document as { file_name?: string } | undefined)?.file_name ?? 'Okänd fil'
|
||||
const supplierName = (item.supplier as { name?: string } | undefined)?.name ?? summary.supplierName
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer transition-colors hover:bg-accent/50"
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
{item.source === 'email' ? (
|
||||
<Mail className="h-5 w-5 text-muted-foreground" />
|
||||
) : (
|
||||
<Upload className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium truncate">{fileName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{supplierName ? (
|
||||
<span className="text-sm text-muted-foreground truncate">{supplierName}</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground/60 italic">Okänd leverantör</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{summary.total > 0 && (
|
||||
<span className="text-sm font-medium">{formatAmount(summary.total, summary.currency)}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.confidence != null && (
|
||||
<Badge variant={confidenceVariant} className="text-[10px] px-1.5 py-0">
|
||||
{confidence.label}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={statusVariant}>
|
||||
{getStatusLabel(item.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{formatRelativeTime(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { InvoiceInboxSettings } from '@/extensions/general/invoice-inbox/types'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Loader2, Copy, Check } from 'lucide-react'
|
||||
|
||||
interface InboxSettingsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
settings: InvoiceInboxSettings
|
||||
onSave: (settings: InvoiceInboxSettings) => Promise<void>
|
||||
}
|
||||
|
||||
export default function InboxSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
settings,
|
||||
onSave,
|
||||
}: InboxSettingsDialogProps) {
|
||||
const [local, setLocal] = useState<InvoiceInboxSettings>(settings)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// Reset local state when dialog opens with new settings
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
setLocal(settings)
|
||||
}
|
||||
onOpenChange(isOpen)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave(local)
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyEmail() {
|
||||
if (local.inboxEmail) {
|
||||
navigator.clipboard.writeText(local.inboxEmail)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Inställningar</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Automatisk bearbetning</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Analysera fakturor automatiskt vid uppladdning
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={local.autoProcessEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setLocal((prev) => ({ ...prev, autoProcessEnabled: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Automatisk leverantörsmatchning</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Matcha extraherade uppgifter mot befintliga leverantörer
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={local.autoMatchSupplierEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setLocal((prev) => ({ ...prev, autoMatchSupplierEnabled: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Matchningströskel</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lägsta konfidens för automatisk leverantörsmatchning (0-1)
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={local.supplierMatchThreshold}
|
||||
onChange={(e) =>
|
||||
setLocal((prev) => ({
|
||||
...prev,
|
||||
supplierMatchThreshold: Math.min(1, Math.max(0, parseFloat(e.target.value) || 0)),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{local.inboxEmail && (
|
||||
<div className="space-y-2">
|
||||
<Label>Inkorg-e-post</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Vidarebefodra fakturor till denna adress
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={local.inboxEmail}
|
||||
readOnly
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyEmail}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-success" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
Spara
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { InvoiceInboxItem } from '@/types'
|
||||
import { Upload, Loader2, FileUp, CheckCircle2, AlertCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface InboxUploadZoneProps {
|
||||
onUploadComplete: (item: InvoiceInboxItem | InvoiceInboxItem[]) => void
|
||||
isUploading: boolean
|
||||
setIsUploading: (v: boolean) => void
|
||||
}
|
||||
|
||||
interface FileProgress {
|
||||
name: string
|
||||
status: 'pending' | 'uploading' | 'done' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp']
|
||||
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
export default function InboxUploadZone({
|
||||
onUploadComplete,
|
||||
isUploading,
|
||||
setIsUploading,
|
||||
}: InboxUploadZoneProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [fileProgress, setFileProgress] = useState<FileProgress[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const uploadFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
setError(null)
|
||||
|
||||
// Validate all files first
|
||||
const validFiles: File[] = []
|
||||
for (const file of files) {
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
setError(`${file.name}: filtypen stöds inte. Välj PDF, JPEG, PNG eller WebP.`)
|
||||
return
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
setError(`${file.name}: filen är för stor. Max 10 MB.`)
|
||||
return
|
||||
}
|
||||
validFiles.push(file)
|
||||
}
|
||||
|
||||
if (validFiles.length === 0) return
|
||||
|
||||
setIsUploading(true)
|
||||
|
||||
// Show per-file progress for multi-file uploads
|
||||
if (validFiles.length > 1) {
|
||||
setFileProgress(validFiles.map((f) => ({ name: f.name, status: 'uploading' })))
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
|
||||
if (validFiles.length === 1) {
|
||||
// Single file: use legacy `file` key for backward compat
|
||||
formData.append('file', validFiles[0])
|
||||
} else {
|
||||
// Multiple files: use `files` key
|
||||
for (const file of validFiles) {
|
||||
formData.append('files', file)
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/inbox', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: 'Uppladdning misslyckades' }))
|
||||
setError(body.error ?? 'Uppladdning misslyckades')
|
||||
if (validFiles.length > 1) {
|
||||
setFileProgress((prev) => prev.map((f) => ({ ...f, status: 'error' as const })))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const body = await res.json()
|
||||
|
||||
if (validFiles.length === 1) {
|
||||
onUploadComplete(body.data)
|
||||
setFileProgress([])
|
||||
} else {
|
||||
// Mark individual files
|
||||
const items: InvoiceInboxItem[] = body.data || []
|
||||
const errors: string[] = body.errors || []
|
||||
|
||||
setFileProgress((prev) =>
|
||||
prev.map((fp, i) => {
|
||||
// Check if this file had an error
|
||||
const errMsg = errors.find((e) => e.startsWith(fp.name))
|
||||
if (errMsg) {
|
||||
return { ...fp, status: 'error' as const, error: errMsg }
|
||||
}
|
||||
return { ...fp, status: 'done' as const }
|
||||
})
|
||||
)
|
||||
|
||||
if (items.length > 0) {
|
||||
onUploadComplete(items)
|
||||
}
|
||||
|
||||
// Clear progress after a delay
|
||||
setTimeout(() => setFileProgress([]), 3000)
|
||||
}
|
||||
} catch {
|
||||
setError('Nätverksfel vid uppladdning')
|
||||
setFileProgress([])
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
},
|
||||
[onUploadComplete, setIsUploading]
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(false)
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
if (files.length > 0) uploadFiles(files)
|
||||
},
|
||||
[uploadFiles]
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(false)
|
||||
}, [])
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length > 0) uploadFiles(files)
|
||||
e.target.value = ''
|
||||
},
|
||||
[uploadFiles]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => !isUploading && inputRef.current?.click()}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors cursor-pointer',
|
||||
isDragOver
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50 hover:bg-accent/30',
|
||||
isUploading && 'pointer-events-none opacity-60'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="h-8 w-8 text-primary animate-spin mb-2" />
|
||||
<p className="text-sm text-muted-foreground">Laddar upp och analyserar...</p>
|
||||
</>
|
||||
) : isDragOver ? (
|
||||
<>
|
||||
<FileUp className="h-8 w-8 text-primary mb-2" />
|
||||
<p className="text-sm font-medium text-primary">Släpp filerna här</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-8 w-8 text-muted-foreground/60 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dra och släpp fakturor, eller{' '}
|
||||
<span className="font-medium text-primary">välj filer</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
||||
PDF, JPEG, PNG eller WebP (max 10 MB per fil)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fileProgress.length > 0 && (
|
||||
<div className="mt-3 space-y-1">
|
||||
{fileProgress.map((fp) => (
|
||||
<div key={fp.name} className="flex items-center gap-2 text-sm">
|
||||
{fp.status === 'uploading' && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
|
||||
{fp.status === 'done' && <CheckCircle2 className="h-3.5 w-3.5 text-success" />}
|
||||
{fp.status === 'error' && <AlertCircle className="h-3.5 w-3.5 text-destructive" />}
|
||||
<span className={cn(
|
||||
'truncate',
|
||||
fp.status === 'error' && 'text-destructive'
|
||||
)}>
|
||||
{fp.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive mt-2">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -81,18 +81,14 @@ export default function SIEPreviewStep({
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Start</p>
|
||||
<p className="font-medium">
|
||||
{preview.fiscalYearStart
|
||||
? new Date(preview.fiscalYearStart).toLocaleDateString('sv-SE')
|
||||
: 'Okänt'}
|
||||
{preview.fiscalYearStart ?? 'Okänt'}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Slut</p>
|
||||
<p className="font-medium">
|
||||
{preview.fiscalYearEnd
|
||||
? new Date(preview.fiscalYearEnd).toLocaleDateString('sv-SE')
|
||||
: 'Okänt'}
|
||||
{preview.fiscalYearEnd ?? 'Okänt'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { AlertTriangle, ExternalLink, Loader2 } from 'lucide-react'
|
||||
import { SupportLink } from '@/components/ui/support-link'
|
||||
|
||||
export function AccountDangerZone() {
|
||||
const router = useRouter()
|
||||
const [showDialog, setShowDialog] = useState(false)
|
||||
const [confirmText, setConfirmText] = useState('')
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
async function handleDelete() {
|
||||
if (confirmText !== 'RADERA') return
|
||||
setIsDeleting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/account/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ confirm: 'RADERA' }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Kunde inte radera kontot')
|
||||
}
|
||||
|
||||
router.push('/login')
|
||||
} catch {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4 border-t border-border/8 pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive/80">
|
||||
Radera konto
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||
<div className="flex gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="font-medium text-destructive">Denna åtgärd kan inte ångras</p>
|
||||
<p className="text-muted-foreground">
|
||||
All din data raderas permanent — bokföring, fakturor, verifikationer, dokument och inställningar.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Enligt bokföringslagen (BFL 7 kap. 2§) ska räkenskapsinformation bevaras i 7 år.
|
||||
Du ansvarar själv för att exportera och arkivera din bokföringsdata.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Har du frågor?{' '}
|
||||
<SupportLink variant="inline" subject="Fråga om kontoradering" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button variant="outline" className="w-full sm:w-auto" asChild>
|
||||
<Link href="/reports?type=sie">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Exportera bokföringsdata (SIE)
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setShowDialog(true)}
|
||||
>
|
||||
Radera mitt konto
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Dialog open={showDialog} onOpenChange={(open) => {
|
||||
setShowDialog(open)
|
||||
if (!open) setConfirmText('')
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Radera konto permanent</DialogTitle>
|
||||
<DialogDescription>
|
||||
All din data raderas permanent. Skriv <strong>RADERA</strong> nedan för att bekräfta.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delete-confirm">Bekräfta genom att skriva RADERA</Label>
|
||||
<Input
|
||||
id="delete-confirm"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="RADERA"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { setShowDialog(false); setConfirmText('') }}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={confirmText !== 'RADERA' || isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Raderar...
|
||||
</>
|
||||
) : (
|
||||
'Radera permanent'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
|
||||
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface BankDetailsFormProps {
|
||||
settings: CompanySettings
|
||||
}
|
||||
|
||||
export function BankDetailsForm({ settings }: BankDetailsFormProps) {
|
||||
const [bankgiroError, setBankgiroError] = useState<string | null>(null)
|
||||
const [clearingError, setClearingError] = useState<string | null>(null)
|
||||
const [accountNumberError, setAccountNumberError] = useState<string | null>(null)
|
||||
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Bankuppgifter
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Visas på dina fakturor
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Bank</Label>
|
||||
<BankNameCombobox
|
||||
defaultValue={settings.bank_name || ''}
|
||||
enableBankingEnabled={hasBankingExtension}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clearing_number">Clearing</Label>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
name="clearing_number"
|
||||
inputMode="numeric"
|
||||
placeholder="XXXX"
|
||||
maxLength={5}
|
||||
defaultValue={settings.clearing_number || ''}
|
||||
onChange={(e) => {
|
||||
e.target.value = e.target.value.replace(/\D/g, '')
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (!val) { setClearingError(null); return }
|
||||
setClearingError(!/^\d{4,5}$/.test(val) ? 'Måste vara 4-5 siffror' : null)
|
||||
}}
|
||||
/>
|
||||
{clearingError && <p className="text-xs text-destructive">{clearingError}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
name="account_number"
|
||||
inputMode="numeric"
|
||||
placeholder="XXXXXXX"
|
||||
maxLength={12}
|
||||
defaultValue={settings.account_number || ''}
|
||||
onChange={(e) => {
|
||||
e.target.value = e.target.value.replace(/\D/g, '')
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (!val) { setAccountNumberError(null); return }
|
||||
setAccountNumberError(!/^\d{6,12}$/.test(val) ? 'Måste vara 6-12 siffror' : null)
|
||||
}}
|
||||
/>
|
||||
{accountNumberError && <p className="text-xs text-destructive">{accountNumberError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs space-y-2">
|
||||
<Label htmlFor="bankgiro">Bankgiro</Label>
|
||||
<Input
|
||||
id="bankgiro"
|
||||
name="bankgiro"
|
||||
placeholder="XXX-XXXX"
|
||||
defaultValue={settings.bankgiro || ''}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (!val) { setBankgiroError(null); return }
|
||||
if (validateBankgiroNumber(val)) {
|
||||
e.target.value = formatBankgiroNumber(val)
|
||||
setBankgiroError(null)
|
||||
} else {
|
||||
setBankgiroError('Ogiltigt bankgironummer')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{bankgiroError && <p className="text-xs text-destructive">{bankgiroError}</p>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Validate bank fields from FormData. Returns error messages or null. */
|
||||
export function validateBankFields(formData: FormData): { field: string; message: string }[] {
|
||||
const errors: { field: string; message: string }[] = []
|
||||
const clearing = (formData.get('clearing_number') as string || '').trim()
|
||||
const account = (formData.get('account_number') as string || '').trim()
|
||||
const bankgiro = (formData.get('bankgiro') as string || '').trim()
|
||||
|
||||
if (clearing && !/^\d{4,5}$/.test(clearing)) {
|
||||
errors.push({ field: 'clearing_number', message: 'Clearingnummer måste vara 4-5 siffror' })
|
||||
}
|
||||
if (account && !/^\d{6,12}$/.test(account)) {
|
||||
errors.push({ field: 'account_number', message: 'Kontonummer måste vara 6-12 siffror' })
|
||||
}
|
||||
if (bankgiro && !validateBankgiroNumber(bankgiro)) {
|
||||
errors.push({ field: 'bankgiro', message: 'Ogiltigt bankgironummer' })
|
||||
}
|
||||
return errors
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface CompanyInfoFormProps {
|
||||
settings: CompanySettings
|
||||
}
|
||||
|
||||
export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Företagsuppgifter
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company_name">Företagsnamn</Label>
|
||||
<Input
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
defaultValue={settings.company_name || ''}
|
||||
disabled={settings.onboarding_complete === true}
|
||||
/>
|
||||
{settings.onboarding_complete && (
|
||||
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org_number">Organisationsnummer</Label>
|
||||
<Input
|
||||
id="org_number"
|
||||
name="org_number"
|
||||
defaultValue={settings.org_number || ''}
|
||||
disabled={settings.onboarding_complete === true}
|
||||
/>
|
||||
{settings.onboarding_complete && (
|
||||
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">Adress</Label>
|
||||
<Input
|
||||
id="address_line1"
|
||||
name="address_line1"
|
||||
defaultValue={settings.address_line1 || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">Postnummer</Label>
|
||||
<Input
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
defaultValue={settings.postal_code || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Input
|
||||
id="city"
|
||||
name="city"
|
||||
defaultValue={settings.city || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Telefon</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
defaultValue={settings.phone || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-post</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={settings.email || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">Webbplats</Label>
|
||||
<Input
|
||||
id="website"
|
||||
name="website"
|
||||
defaultValue={settings.website || ''}
|
||||
placeholder="https://"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface InvoiceSettingsFormProps {
|
||||
settings: CompanySettings
|
||||
}
|
||||
|
||||
export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Fakturainställningar
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
|
||||
<Input
|
||||
id="invoice_prefix"
|
||||
name="invoice_prefix"
|
||||
placeholder="t.ex. F-"
|
||||
defaultValue={settings.invoice_prefix || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="next_invoice_number">Nästa fakturanummer</Label>
|
||||
<Input
|
||||
id="next_invoice_number"
|
||||
name="next_invoice_number"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={settings.next_invoice_number || 1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_default_days">Betalningsvillkor (dagar)</Label>
|
||||
<Input
|
||||
id="invoice_default_days"
|
||||
name="invoice_default_days"
|
||||
type="number"
|
||||
min="0"
|
||||
defaultValue={settings.invoice_default_days || 30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_default_notes">Standardtext på fakturor</Label>
|
||||
<Textarea
|
||||
id="invoice_default_notes"
|
||||
name="invoice_default_notes"
|
||||
rows={3}
|
||||
placeholder="T.ex. betalningsvillkor, leveransinfo..."
|
||||
defaultValue={settings.invoice_default_notes || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Föreslås automatiskt vid ny faktura.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Upload, Trash2 } from 'lucide-react'
|
||||
|
||||
interface LogoUploadProps {
|
||||
logoUrl: string | null
|
||||
onUpdate: (logoUrl: string | null) => void
|
||||
}
|
||||
|
||||
export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
|
||||
const { toast } = useToast()
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [preview, setPreview] = useState<string | null>(logoUrl)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
setIsUploading(true)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/logo', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Uppladdning misslyckades')
|
||||
}
|
||||
|
||||
setPreview(result.data.logo_url)
|
||||
onUpdate(result.data.logo_url)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Kunde inte ladda upp',
|
||||
description: error instanceof Error ? error.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setIsUploading(false)
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setIsDeleting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/logo', { method: 'DELETE' })
|
||||
if (!response.ok) throw new Error()
|
||||
|
||||
setPreview(null)
|
||||
onUpdate(null)
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte ta bort logotyp', variant: 'destructive' })
|
||||
}
|
||||
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast({ title: 'Filen är för stor (max 2 MB)', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
handleUpload(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Logotyp
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Visas i sidhuvudet på dina fakturor. Max 2 MB, PNG/JPG/SVG.
|
||||
</p>
|
||||
|
||||
{preview ? (
|
||||
<div className="space-y-3">
|
||||
<div className="inline-block rounded-lg border border-border/60 bg-muted/30 p-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={preview}
|
||||
alt="Företagslogotyp"
|
||||
className="max-h-16 max-w-[200px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Upload className="mr-2 h-3.5 w-3.5" />}
|
||||
Byt logotyp
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
{isDeleting ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Trash2 className="mr-2 h-3.5 w-3.5" />}
|
||||
Ta bort
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="flex flex-col items-center justify-center w-full max-w-xs rounded-lg border-2 border-dashed border-border/60 py-8 px-4 text-center transition-colors hover:border-border hover:bg-muted/20 disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-6 w-6 text-muted-foreground animate-spin mb-2" />
|
||||
) : (
|
||||
<Upload className="h-6 w-6 text-muted-foreground/50 mb-2" />
|
||||
)}
|
||||
<Label className="text-sm text-muted-foreground cursor-pointer">
|
||||
{isUploading ? 'Laddar upp...' : 'Välj fil eller dra hit'}
|
||||
</Label>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface PdfPrintSettingsProps {
|
||||
settings: CompanySettings
|
||||
onUpdate: (updates: Partial<CompanySettings>) => void
|
||||
}
|
||||
|
||||
export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps) {
|
||||
const { toast } = useToast()
|
||||
const [lateFeeText, setLateFeeText] = useState(settings.invoice_late_fee_text || '')
|
||||
const [creditTermsText, setCreditTermsText] = useState(settings.invoice_credit_terms_text || '')
|
||||
|
||||
const saveToggle = useCallback(async (field: string, value: boolean) => {
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: value }),
|
||||
})
|
||||
if (!response.ok) throw new Error()
|
||||
onUpdate({ [field]: value } as Partial<CompanySettings>)
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte spara', variant: 'destructive' })
|
||||
}
|
||||
}, [onUpdate, toast])
|
||||
|
||||
const saveText = useCallback(async (field: string, value: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: value || null }),
|
||||
})
|
||||
if (!response.ok) throw new Error()
|
||||
onUpdate({ [field]: value || null } as Partial<CompanySettings>)
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte spara', variant: 'destructive' })
|
||||
}
|
||||
}, [onUpdate, toast])
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Utskrift & PDF
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Öresavrundning</Label>
|
||||
<p className="text-xs text-muted-foreground">Avrunda fakturatotal till hel krona</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.ore_rounding ?? true}
|
||||
onCheckedChange={(v) => saveToggle('ore_rounding', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Visa OCR-referens</Label>
|
||||
<p className="text-xs text-muted-foreground">Visa OCR-nummer på fakturautskrift</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_ocr ?? true}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_ocr', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Visa bankgiro</Label>
|
||||
<p className="text-xs text-muted-foreground">Visa bankgironummer på fakturautskrift</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_bankgiro ?? true}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_bankgiro', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Visa plusgiro</Label>
|
||||
<p className="text-xs text-muted-foreground">Visa plusgironummer på fakturautskrift</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_plusgiro ?? true}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_plusgiro', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_late_fee_text">Dröjsmålsränta</Label>
|
||||
<Textarea
|
||||
id="invoice_late_fee_text"
|
||||
rows={2}
|
||||
placeholder="T.ex. Vid betalning efter förfallodagen debiteras ränta enligt räntelagen."
|
||||
value={lateFeeText}
|
||||
onChange={(e) => setLateFeeText(e.target.value)}
|
||||
onBlur={() => saveText('invoice_late_fee_text', lateFeeText)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_credit_terms_text">Betalningsvillkor (fotnot)</Label>
|
||||
<Textarea
|
||||
id="invoice_credit_terms_text"
|
||||
rows={2}
|
||||
placeholder="T.ex. Betalning sker till angivet bankgiro."
|
||||
value={creditTermsText}
|
||||
onChange={(e) => setCreditTermsText(e.target.value)}
|
||||
onBlur={() => saveText('invoice_credit_terms_text', creditTermsText)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface PeriodLockingSettingsProps {
|
||||
settings: CompanySettings
|
||||
}
|
||||
|
||||
export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Periodlåsning
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bookkeeping_locked_through">Bokföring låst t.o.m.</Label>
|
||||
<Input
|
||||
id="bookkeeping_locked_through"
|
||||
name="bookkeeping_locked_through"
|
||||
type="date"
|
||||
defaultValue={settings.bookkeeping_locked_through || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Verifikationer med datum före detta datum kan inte skapas eller ändras.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auto_lock_period_days">Automatisk låsning efter</Label>
|
||||
<Select
|
||||
name="auto_lock_period_days"
|
||||
defaultValue={settings.auto_lock_period_days?.toString() || 'none'}
|
||||
>
|
||||
<SelectTrigger id="auto_lock_period_days">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Ingen automatisk låsning</SelectItem>
|
||||
<SelectItem value="30">30 dagar efter periodens slut</SelectItem>
|
||||
<SelectItem value="60">60 dagar efter periodens slut</SelectItem>
|
||||
<SelectItem value="90">90 dagar efter periodens slut</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Låser automatiskt perioder efter valt antal dagar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -254,7 +254,7 @@ export function SecuritySettings() {
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings?tab=security')}`)}
|
||||
onClick={() => router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings/account')}`)}
|
||||
>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
Aktivera 2FA
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2, Check } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
|
||||
interface SettingsFormWrapperProps {
|
||||
children: React.ReactNode
|
||||
onSave?: (formData: FormData) => Record<string, unknown>
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SettingsFormWrapper({ children, onSave, className }: SettingsFormWrapperProps) {
|
||||
const { toast } = useToast()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!onSave) return
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const updates = onSave(formData)
|
||||
|
||||
if (!updates || Object.keys(updates).length === 0) return
|
||||
|
||||
setIsSaving(true)
|
||||
setSaved(false)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Kunde inte spara inställningar')
|
||||
}
|
||||
|
||||
setSaved(true)
|
||||
timerRef.current = setTimeout(() => setSaved(false), 2000)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Kunde inte spara',
|
||||
description: error instanceof Error ? error.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setIsSaving(false)
|
||||
}, [onSave, toast])
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={className}>
|
||||
{children}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 mt-8">
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1.5 text-sm text-muted-foreground animate-in fade-in duration-200">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Sparat
|
||||
</span>
|
||||
)}
|
||||
<Button type="submit" disabled={isSaving} size="sm">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara ändringar'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function SettingsLoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-300">
|
||||
{[1, 2].map(i => (
|
||||
<div key={i} className="space-y-4">
|
||||
<div className="h-3.5 bg-muted rounded w-24 animate-pulse" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 bg-muted rounded w-20 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 bg-muted rounded w-28 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 bg-muted rounded w-16 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
|
||||
interface NavItem {
|
||||
href: string
|
||||
label: string
|
||||
show: boolean
|
||||
}
|
||||
|
||||
export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { company, isTeamMember } = useCompany()
|
||||
|
||||
const hasCompany = !!company
|
||||
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||
const hasMcpExtension = ENABLED_EXTENSION_IDS.has('mcp-server')
|
||||
|
||||
const items: NavItem[] = [
|
||||
{ href: '/settings/company', label: 'Företag', show: hasCompany },
|
||||
{ href: '/settings/invoicing', label: 'Fakturering', show: hasCompany },
|
||||
{ href: '/settings/bookkeeping', label: 'Bokföring', show: hasCompany },
|
||||
{ href: '/settings/tax', label: 'Skatt', show: hasCompany },
|
||||
{ href: '/settings/team', label: 'Lag', show: isTeamMember },
|
||||
{ href: '/settings/banking', label: 'Bank (PSD2)', show: hasCompany && !isSandbox && hasBankingExtension },
|
||||
{ href: '/settings/templates', label: 'Mallar', show: hasCompany },
|
||||
{ href: '/settings/account', label: 'Konto', show: true },
|
||||
{ href: '/settings/api', label: 'API', show: hasCompany && hasMcpExtension },
|
||||
].filter(item => item.show)
|
||||
|
||||
const activeHref = items.find(item => pathname.startsWith(item.href))?.href || items[0]?.href
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile: select dropdown */}
|
||||
<div className="sm:hidden">
|
||||
<Select value={activeHref} onValueChange={(v) => router.push(v)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map(item => (
|
||||
<SelectItem key={item.href} value={item.href}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Desktop: horizontal tabs with bottom border */}
|
||||
<nav
|
||||
className="hidden sm:block overflow-x-auto scrollbar-none border-b border-border"
|
||||
aria-label="Inställningar"
|
||||
>
|
||||
<ul className="flex gap-0 -mb-px">
|
||||
{items.map(item => {
|
||||
const isActive = pathname.startsWith(item.href)
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`block whitespace-nowrap px-3 py-2 text-sm transition-colors border-b-2 ${
|
||||
isActive
|
||||
? 'font-medium text-foreground border-foreground'
|
||||
: 'text-muted-foreground border-transparent hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Keep old name as alias for backward compat during transition
|
||||
export const SettingsSidebar = SettingsNav
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface TaxSettingsFormProps {
|
||||
settings: CompanySettings
|
||||
}
|
||||
|
||||
export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Preliminärskatt
|
||||
</h2>
|
||||
|
||||
<div className="max-w-xs space-y-2">
|
||||
<Label htmlFor="preliminary_tax_monthly">
|
||||
Månatlig preliminärskatt (F-skatt)
|
||||
</Label>
|
||||
<Input
|
||||
id="preliminary_tax_monthly"
|
||||
name="preliminary_tax_monthly"
|
||||
type="number"
|
||||
defaultValue={settings.preliminary_tax_monthly || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Belopp i SEK som betalas varje månad.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface VoucherSeries {
|
||||
voucher_series: string
|
||||
last_number: number
|
||||
fiscal_period_id: string
|
||||
}
|
||||
|
||||
export function VoucherSeriesManager() {
|
||||
const { company } = useCompany()
|
||||
const [series, setSeries] = useState<VoucherSeries[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const fetchSeries = useCallback(async () => {
|
||||
if (!company?.id) return
|
||||
const supabase = createClient()
|
||||
const { data } = await supabase
|
||||
.from('voucher_sequences')
|
||||
.select('voucher_series, last_number, fiscal_period_id')
|
||||
.eq('company_id', company.id)
|
||||
.order('voucher_series')
|
||||
setSeries(data || [])
|
||||
setIsLoading(false)
|
||||
}, [company?.id])
|
||||
|
||||
useEffect(() => { fetchSeries() }, [fetchSeries])
|
||||
|
||||
// Group by series letter, show the highest last_number
|
||||
const grouped = series.reduce<Record<string, number>>((acc, s) => {
|
||||
const existing = acc[s.voucher_series] || 0
|
||||
acc[s.voucher_series] = Math.max(existing, s.last_number)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const seriesEntries = Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Verifikationsserier
|
||||
</h2>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-32 animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-24 animate-pulse" />
|
||||
</div>
|
||||
) : seriesEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inga verifikationsserier ännu. Serie A skapas automatiskt vid första verifikationen.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Aktiva serier</Label>
|
||||
<div className="divide-y divide-border/8">
|
||||
{seriesEntries.map(([letter, lastNum]) => (
|
||||
<div key={letter} className="flex items-center justify-between py-2">
|
||||
<span className="text-sm font-medium tabular-nums">Serie {letter}</span>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
Senaste nr: {lastNum}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
export function useSettings() {
|
||||
const router = useRouter()
|
||||
const { company } = useCompany()
|
||||
const [settings, setSettings] = useState<CompanySettings | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
const supabase = createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) { router.push('/login'); return }
|
||||
|
||||
if (company?.id) {
|
||||
const { data } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', company.id)
|
||||
.single()
|
||||
setSettings(data)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}, [company?.id, router])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
}, [fetchSettings])
|
||||
|
||||
const updateSettings = useCallback((updates: Partial<CompanySettings>) => {
|
||||
setSettings(prev => prev ? { ...prev, ...updates } as CompanySettings : null)
|
||||
}, [])
|
||||
|
||||
return { settings, isLoading, updateSettings, refetch: fetchSettings }
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import DescribeTransactionDialog from './DescribeTransactionDialog'
|
||||
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import type { TransactionCategory, VatTreatment, BASAccount, EntityType } from '@/types'
|
||||
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types'
|
||||
|
||||
@@ -848,18 +847,16 @@ export default function SwipeCategorizationView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Describe transaction button — only when AI categorization is enabled */}
|
||||
{ENABLED_EXTENSION_IDS.has('ai-categorization') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowDescribeDialog(true)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<MessageSquareText className="mr-2 h-4 w-4" />
|
||||
Beskriv transaktion...
|
||||
</Button>
|
||||
)}
|
||||
{/* Describe transaction button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowDescribeDialog(true)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<MessageSquareText className="mr-2 h-4 w-4" />
|
||||
Beskriv transaktion...
|
||||
</Button>
|
||||
|
||||
{/* Categorization button */}
|
||||
<Button
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
|
||||
import type { CategorizationSuggestion } from './categorizer'
|
||||
import { categorizeTransactions, getSettings, saveSettings } from './index'
|
||||
|
||||
// ============================================================
|
||||
// /suggestions — GET: fetch stored suggestions
|
||||
// ============================================================
|
||||
|
||||
async function handleGetSuggestions(
|
||||
request: Request,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const idsParam = searchParams.get('transaction_ids')
|
||||
|
||||
if (!idsParam) {
|
||||
return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const transactionIds = idsParam.split(',').filter(Boolean).slice(0, 50)
|
||||
|
||||
// Read stored suggestions from extension_data
|
||||
const keys = transactionIds.map((id) => `suggestion:${id}`)
|
||||
|
||||
const { data: records } = await supabase
|
||||
.from('extension_data')
|
||||
.select('key, value')
|
||||
.eq('company_id', userId)
|
||||
.eq('extension_id', 'ai-categorization')
|
||||
.in('key', keys)
|
||||
|
||||
const suggestions: Record<string, CategorizationSuggestion> = {}
|
||||
if (records) {
|
||||
for (const record of records) {
|
||||
const txId = record.key.replace('suggestion:', '')
|
||||
suggestions[txId] = record.value as unknown as CategorizationSuggestion
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ suggestions })
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// /suggestions — POST: trigger on-demand categorization
|
||||
// ============================================================
|
||||
|
||||
async function handlePostSuggestions(
|
||||
request: Request,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
|
||||
const body = await request.json()
|
||||
const { transaction_ids } = body
|
||||
|
||||
if (!Array.isArray(transaction_ids) || transaction_ids.length === 0) {
|
||||
return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const ids = transaction_ids.slice(0, 50)
|
||||
|
||||
try {
|
||||
const suggestions = await categorizeTransactions(userId, ids)
|
||||
|
||||
// Group by transaction ID
|
||||
const grouped: Record<string, CategorizationSuggestion> = {}
|
||||
for (const s of suggestions) {
|
||||
grouped[s.transactionId] = s
|
||||
}
|
||||
|
||||
return NextResponse.json({ suggestions: grouped })
|
||||
} catch (error) {
|
||||
console.error('[ai-categorization] On-demand categorization failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'AI categorization failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// /settings — GET: get current settings
|
||||
// ============================================================
|
||||
|
||||
async function handleGetSettings(
|
||||
_request: Request,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const settings = await getSettings(userId)
|
||||
return NextResponse.json({ data: settings })
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// /settings — PUT/PATCH: update settings
|
||||
// ============================================================
|
||||
|
||||
async function handleUpdateSettings(
|
||||
request: Request,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const body = await request.json()
|
||||
|
||||
// Validate setting keys
|
||||
const allowedKeys = [
|
||||
'autoSuggestEnabled',
|
||||
'confidenceThreshold',
|
||||
'providerModel',
|
||||
]
|
||||
const filtered: Record<string, unknown> = {}
|
||||
for (const key of allowedKeys) {
|
||||
if (key in body) {
|
||||
filtered[key] = body[key]
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(filtered).length === 0) {
|
||||
return NextResponse.json({ error: 'No valid settings provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const settings = await saveSettings(userId, filtered)
|
||||
return NextResponse.json({ data: settings })
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Route definitions
|
||||
// ============================================================
|
||||
|
||||
export const aiCategorizationApiRoutes: ApiRouteDefinition[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/suggestions',
|
||||
handler: handleGetSuggestions,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/suggestions',
|
||||
handler: handlePostSuggestions,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/settings',
|
||||
handler: handleGetSettings,
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
path: '/settings',
|
||||
handler: handleUpdateSettings,
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
path: '/settings',
|
||||
handler: handleUpdateSettings,
|
||||
},
|
||||
]
|
||||
@@ -1,465 +0,0 @@
|
||||
/**
|
||||
* AI Categorization Engine
|
||||
*
|
||||
* SERVER-ONLY: Uses the Anthropic SDK and must only be imported
|
||||
* in server components or API routes.
|
||||
*
|
||||
* Provider-abstracted AI categorization for Swedish BAS account mapping.
|
||||
* 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 Anthropic from '@anthropic-ai/sdk'
|
||||
import { BOOKING_TEMPLATES, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
||||
import type { TransactionCategory, EntityType } from '@/types'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
export interface TransactionForCategorization {
|
||||
id: string
|
||||
description: string
|
||||
amount: number
|
||||
date: string
|
||||
merchant_name: string | null
|
||||
mcc_code: number | null
|
||||
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 {
|
||||
entityType: EntityType
|
||||
recentHistory: { description: string; category: string }[]
|
||||
}
|
||||
|
||||
export interface DocumentEnrichment {
|
||||
type: 'receipt' | 'supplier_invoice'
|
||||
merchantName?: string
|
||||
lineItems?: Array<{ description: string; amount: number; category?: string; accountSuggestion?: string }>
|
||||
vatBreakdown?: Array<{ rate: number; amount: number }>
|
||||
isReverseCharge?: boolean
|
||||
}
|
||||
|
||||
export interface EnrichedCategorizationContext extends CategorizationContext {
|
||||
candidateTemplates: BookingTemplate[]
|
||||
userAccountUsage: AccountUsageEntry[]
|
||||
merchantHistory: MerchantHistoryEntry[]
|
||||
documentData?: DocumentEnrichment
|
||||
}
|
||||
|
||||
export interface CategorizationSuggestion {
|
||||
transactionId: string
|
||||
category: TransactionCategory
|
||||
basAccount: string
|
||||
taxCode: string | null
|
||||
confidence: number
|
||||
reasoning: string
|
||||
isPrivate: boolean
|
||||
templateId?: string
|
||||
}
|
||||
|
||||
export interface TrackingContext {
|
||||
supabase: SupabaseClient
|
||||
userId: string
|
||||
companyId?: string
|
||||
}
|
||||
|
||||
export interface CategorizationProvider {
|
||||
categorize(
|
||||
transactions: TransactionForCategorization[],
|
||||
context: CategorizationContext | EnrichedCategorizationContext,
|
||||
tracking?: TrackingContext
|
||||
): Promise<CategorizationSuggestion[]>
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BAS Account + Category Mapping (used in prompt)
|
||||
// ============================================================
|
||||
|
||||
function getCategoryAccountMap(entityType: EntityType): Record<string, { account: string; label: string }> {
|
||||
const educationAccount = entityType === 'aktiebolag' ? '7610' : '6991'
|
||||
return {
|
||||
income_services: { account: '3001', label: 'Tjänsteförsäljning' },
|
||||
income_products: { account: '3001', label: 'Varuförsäljning' },
|
||||
income_other: { account: '3900', label: 'Övriga intäkter' },
|
||||
expense_equipment: { account: '5410', label: 'Förbrukningsinventarier' },
|
||||
expense_software: { account: '5420', label: 'Programvara' },
|
||||
expense_travel: { account: '5800', label: 'Resekostnader' },
|
||||
expense_office: { account: '5010', label: 'Lokalhyra/kontorskostnad' },
|
||||
expense_marketing: { account: '5910', label: 'Annonsering/marknadsföring' },
|
||||
expense_professional_services: { account: '6530', label: 'Redovisning/konsulttjänster' },
|
||||
expense_education: { account: educationAccount, label: 'Utbildning' },
|
||||
expense_representation: { account: '6071', label: 'Representation (mat/möte)' },
|
||||
expense_consumables: { account: '5460', label: 'Förbrukningsvaror' },
|
||||
expense_vehicle: { account: '5611', label: 'Bil & drivmedel' },
|
||||
expense_telecom: { account: '6200', label: 'Telefon & internet' },
|
||||
expense_bank_fees: { account: '6570', label: 'Bankavgifter' },
|
||||
expense_card_fees: { account: '6570', label: 'Kortavgifter' },
|
||||
expense_currency_exchange: { account: '7960', label: 'Valutakursförluster' },
|
||||
expense_interest: { account: '8410', label: 'Räntekostnader' },
|
||||
financial_loan_repayment: { account: '2350', label: 'Amortering lån (kreditinstitut)' },
|
||||
expense_other: { account: '6991', label: 'Övriga kostnader' },
|
||||
}
|
||||
}
|
||||
|
||||
/** Fallback template IDs when AI doesn't provide one */
|
||||
const CATEGORY_DEFAULT_TEMPLATES: Record<string, string> = {
|
||||
expense_representation: 'representation_external',
|
||||
expense_equipment: 'equipment_small',
|
||||
expense_software: 'it_saas_subscription',
|
||||
expense_travel: 'travel_transport',
|
||||
expense_office: 'office_supplies_general',
|
||||
expense_consumables: 'office_supplies_general',
|
||||
expense_vehicle: 'vehicle_fuel',
|
||||
expense_telecom: 'telecom_mobile',
|
||||
expense_marketing: 'marketing_online_ads_eu',
|
||||
expense_education: 'education_course',
|
||||
expense_professional_services: 'prof_accounting',
|
||||
}
|
||||
|
||||
/**
|
||||
* Build template reference from candidate templates (pre-filtered by embeddings)
|
||||
* or fall back to full template list if no candidates provided.
|
||||
*/
|
||||
function getTemplateReference(
|
||||
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')
|
||||
.map((t) => `${t.id}: ${t.name_sv} → ${t.debit_account}/${t.credit_account}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
const NON_DEDUCTIBLE_RULES = `
|
||||
MOMSREGLER FÖR SPECIFIKA KATEGORIER:
|
||||
- Representation/måltider: Max 300 kr/person exkl. moms (IL 16 kap 2§), konto 6071/6072
|
||||
- Gåvor: Reklamgåvor max 300 kr/mottagare, representationsgåvor max 180 kr
|
||||
- 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 (REQUIRED — must be from the provided templates list)' },
|
||||
category: { type: 'string', description: 'Transaction category (e.g. expense_representation, expense_equipment, expense_office)' },
|
||||
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', 'templateId', 'category', 'basAccount', 'confidence', 'reasoning', 'isPrivate'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['suggestions'],
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Anthropic Provider
|
||||
// ============================================================
|
||||
|
||||
const MAX_RETRIES = 3
|
||||
const RETRY_DELAY_MS = 1000
|
||||
const MAX_BATCH_SIZE = 20
|
||||
|
||||
export class AnthropicCategorizationProvider implements CategorizationProvider {
|
||||
private client: Anthropic
|
||||
private model: string
|
||||
|
||||
constructor(model = 'claude-haiku-4-5-20251001') {
|
||||
this.client = new Anthropic()
|
||||
this.model = model
|
||||
}
|
||||
|
||||
async categorize(
|
||||
transactions: TransactionForCategorization[],
|
||||
context: CategorizationContext | EnrichedCategorizationContext,
|
||||
tracking?: TrackingContext
|
||||
): Promise<CategorizationSuggestion[]> {
|
||||
// Cap batch size
|
||||
const batch = transactions.slice(0, MAX_BATCH_SIZE)
|
||||
if (batch.length === 0) return []
|
||||
|
||||
const enriched = isEnrichedContext(context) ? context : null
|
||||
const privateAccount = context.entityType === 'aktiebolag' ? '2893' : '2013'
|
||||
const categoryAccountMap = getCategoryAccountMap(context.entityType)
|
||||
|
||||
// Build template references — use candidate templates if available
|
||||
const hasExpenses = batch.some((t) => t.amount < 0)
|
||||
const hasIncome = batch.some((t) => t.amount > 0)
|
||||
const candidates = enriched?.candidateTemplates
|
||||
const templateRef = [
|
||||
hasExpenses ? `UTGIFTSMALLAR:\n${getTemplateReference('expense', candidates)}` : '',
|
||||
hasIncome ? `INTÄKTSMALLAR:\n${getTemplateReference('income', candidates)}` : '',
|
||||
].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')}`
|
||||
: ''
|
||||
|
||||
// Build document enrichment context (from linked receipt or supplier invoice)
|
||||
const documentContext = enriched?.documentData
|
||||
? buildDocumentContext(enriched.documentData)
|
||||
: ''
|
||||
|
||||
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.
|
||||
|
||||
BOKFÖRINGSMALLAR (id: namn → debitkonto/kreditkonto):
|
||||
${templateRef}
|
||||
|
||||
KATEGORIER (fallback om ingen mall matchar):
|
||||
${Object.entries(categoryAccountMap)
|
||||
.map(([cat, info]) => `- ${cat}: ${info.account} (${info.label})`)
|
||||
.join('\n')}
|
||||
|
||||
Företagsform: ${context.entityType === 'aktiebolag' ? 'Aktiebolag (AB)' : 'Enskild firma (EF)'}
|
||||
Privatkonto: ${privateAccount}
|
||||
|
||||
MOMSHANTERING:
|
||||
- Bankavgifter, kortavgifter, valutaväxling: MOMSFRIA
|
||||
- Övriga affärskostnader: Normalt 25% moms (ingående moms, MPI)
|
||||
- Intäkter: Normalt 25% moms (utgående moms, MP1)
|
||||
|
||||
${NON_DEDUCTIBLE_RULES}
|
||||
${documentContext}
|
||||
REGLER:
|
||||
1. Negativa belopp = utgifter, positiva = intäkter
|
||||
2. VIKTIGT: Dessa transaktioner kommer från företagets bankkonto/kort. Anta ALLTID att de är affärsrelaterade. Klassificera ALDRIG som "private" — det beslutet tar användaren själv.
|
||||
3. Ange confidence 0.0-1.0 baserat på hur säker du är på rätt affärskategori
|
||||
4. Ange kort reasoning på svenska
|
||||
5. Restauranger/mat/café → expense_representation (6071). Bygghandel/järnhandel → expense_equipment eller expense_consumables. Heminredning/kontorsvaror → expense_office.
|
||||
6. taxCode: "MPI" för avdragsgilla affärskostnader med moms, "MP1" för intäkter med moms, null för momsfria
|
||||
7. templateId är OBLIGATORISKT — välj alltid den mest passande mallen från listan ovan, även för alternativa förslag
|
||||
8. isPrivate ska ALLTID vara false — användaren avgör själv vad som är privat
|
||||
9. Ange TVÅ förslag per transaktion: ett primärt (mest troligt) och ett alternativt (näst mest troligt, annan kategori, lägre confidence). Båda ska vara affärskategorier.
|
||||
10. SKULDER: Konto 2440 (leverantörsskulder) ska BARA användas för leverantörsfakturor. Lån/amorteringar från banker eller kreditinstitut (Almi, Nordea, SEB, Handelsbanken, Swedbank, etc.) ska använda 2350 (skulder till kreditinstitut) via mallen financial_loan_repayment. Räntebetalningar ska använda 8410 (räntekostnader).`
|
||||
|
||||
const historyContext =
|
||||
context.recentHistory.length > 0
|
||||
? `\nAnvändarens senaste kategoriseringar (lär dig mönster):\n${context.recentHistory
|
||||
.slice(0, 30)
|
||||
.map((h) => `- "${h.description}" → ${h.category}`)
|
||||
.join('\n')}`
|
||||
: ''
|
||||
|
||||
const transactionList = batch
|
||||
.map(
|
||||
(t, i) =>
|
||||
`${i + 1}. ID: ${t.id}
|
||||
Beskrivning: ${t.description}
|
||||
Belopp: ${t.amount} ${t.currency}
|
||||
Datum: ${t.date}${t.merchant_name ? `\n Handlare: ${t.merchant_name}` : ''}${t.mcc_code ? `\n MCC: ${t.mcc_code}` : ''}`
|
||||
)
|
||||
.join('\n\n')
|
||||
|
||||
const userPrompt = `Kategorisera följande transaktioner med classify_transactions-verktyget.
|
||||
Ange TVÅ förslag per transaktion (primärt + alternativ med lägre confidence):
|
||||
${historyContext}${accountUsageContext}${merchantHistoryContext}
|
||||
|
||||
TRANSAKTIONER:
|
||||
${transactionList}`
|
||||
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const message = await this.client.messages.create({
|
||||
model: this.model,
|
||||
max_tokens: 4096,
|
||||
system: [
|
||||
{
|
||||
type: 'text',
|
||||
text: systemPrompt,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
tools: [CLASSIFY_TOOL],
|
||||
tool_choice: { type: 'tool', name: 'classify_transactions' },
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: userPrompt,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Track token usage (fire-and-forget)
|
||||
if (tracking && message.usage) {
|
||||
const { trackTokenUsage } = await import('@/lib/ai/usage-tracker')
|
||||
trackTokenUsage(tracking.supabase, tracking.userId, 'ai-categorization', {
|
||||
inputTokens: message.usage.input_tokens,
|
||||
outputTokens: message.usage.output_tokens,
|
||||
model: this.model,
|
||||
}, tracking.companyId)
|
||||
}
|
||||
|
||||
// Extract tool_use block from response
|
||||
const toolUseBlock = message.content.find(
|
||||
(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')
|
||||
}
|
||||
|
||||
const input = toolUseBlock.input as { suggestions?: unknown[] }
|
||||
return this.validateSuggestions(input.suggestions || [], batch, context.entityType)
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||||
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
await sleep(RETRY_DELAY_MS * (attempt + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`AI categorization failed after ${MAX_RETRIES} attempts: ${lastError?.message}`
|
||||
)
|
||||
}
|
||||
|
||||
private validateSuggestions(
|
||||
raw: unknown[],
|
||||
transactions: TransactionForCategorization[],
|
||||
entityType: EntityType
|
||||
): CategorizationSuggestion[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
|
||||
const categoryAccountMap = getCategoryAccountMap(entityType)
|
||||
const validTransactionIds = new Set(transactions.map((t) => t.id))
|
||||
const validCategories = new Set(Object.keys(categoryAccountMap).concat(['uncategorized']))
|
||||
const transactionMap = new Map(transactions.map((t) => [t.id, t]))
|
||||
|
||||
return raw
|
||||
.filter(
|
||||
(s): s is Record<string, unknown> =>
|
||||
s !== null && typeof s === 'object' && 'transactionId' in s
|
||||
)
|
||||
.filter((s) => validTransactionIds.has(s.transactionId as string))
|
||||
.map((s) => {
|
||||
// Never let AI classify as private — remap to expense_other
|
||||
let category = validCategories.has(s.category as string)
|
||||
? (s.category as TransactionCategory)
|
||||
: 'expense_other'
|
||||
if (category === 'private') {
|
||||
category = 'expense_other'
|
||||
}
|
||||
|
||||
// Enforce direction: positive amounts = income, negative = expense
|
||||
const tx = transactionMap.get(s.transactionId as string)
|
||||
if (tx) {
|
||||
if (tx.amount > 0 && category.startsWith('expense_')) {
|
||||
category = 'income_other' as TransactionCategory
|
||||
} else if (tx.amount < 0 && category.startsWith('income_')) {
|
||||
category = 'expense_other' as TransactionCategory
|
||||
}
|
||||
}
|
||||
|
||||
const accountInfo = categoryAccountMap[category]
|
||||
|
||||
return {
|
||||
transactionId: s.transactionId as string,
|
||||
category,
|
||||
basAccount: accountInfo?.account || (s.basAccount as string) || '6991',
|
||||
taxCode: (s.taxCode as string) || null,
|
||||
confidence: Math.max(0, Math.min(1, Number(s.confidence) || 0.5)),
|
||||
reasoning: (s.reasoning as string) || '',
|
||||
isPrivate: false,
|
||||
templateId: (s.templateId as string) || CATEGORY_DEFAULT_TEMPLATES[category] || undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function buildDocumentContext(doc: DocumentEnrichment): string {
|
||||
const parts: string[] = []
|
||||
|
||||
const typeLabel = doc.type === 'receipt' ? 'KVITTO' : 'LEVERANTÖRSFAKTURA'
|
||||
parts.push(`LÄNKAT DOKUMENT (${typeLabel}):`)
|
||||
|
||||
if (doc.merchantName) {
|
||||
parts.push(`Handlare/leverantör: ${doc.merchantName}`)
|
||||
}
|
||||
|
||||
if (doc.lineItems && doc.lineItems.length > 0) {
|
||||
parts.push('Rader:')
|
||||
for (const item of doc.lineItems) {
|
||||
let line = `- ${item.description}: ${item.amount} kr`
|
||||
if (item.accountSuggestion) line += ` (föreslaget konto: ${item.accountSuggestion})`
|
||||
if (item.category) line += ` [${item.category}]`
|
||||
parts.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.vatBreakdown && doc.vatBreakdown.length > 0) {
|
||||
parts.push('Momsfördelning:')
|
||||
for (const vat of doc.vatBreakdown) {
|
||||
parts.push(`- ${vat.rate}%: ${vat.amount} kr`)
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.isReverseCharge) {
|
||||
parts.push(`VIKTIGT: Omvänd skattskyldighet (reverse charge). Använd dubbelkontering:
|
||||
- Debitera 2645 (beräknad ingående moms) OCH kreditera 2614 (utgående moms, omvänd skattskyldighet)
|
||||
- Mallen "purchase_eu_service_reverse_charge" ska användas om tillgänglig`)
|
||||
}
|
||||
|
||||
return parts.join('\n') + '\n'
|
||||
}
|
||||
|
||||
function isEnrichedContext(
|
||||
ctx: CategorizationContext | EnrichedCategorizationContext
|
||||
): ctx is EnrichedCategorizationContext {
|
||||
return 'candidateTemplates' in ctx
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -1,400 +0,0 @@
|
||||
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||||
import { aiCategorizationApiRoutes } from './api-routes'
|
||||
import type { EventPayload } from '@/lib/events/types'
|
||||
import type { Transaction, EntityType } from '@/types'
|
||||
import {
|
||||
AnthropicCategorizationProvider,
|
||||
type CategorizationProvider,
|
||||
type TransactionForCategorization,
|
||||
type EnrichedCategorizationContext,
|
||||
type CategorizationSuggestion,
|
||||
type AccountUsageEntry,
|
||||
type MerchantHistoryEntry,
|
||||
} from './categorizer'
|
||||
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
||||
|
||||
// ============================================================
|
||||
// Settings
|
||||
// ============================================================
|
||||
|
||||
export interface AiCategorizationSettings {
|
||||
autoSuggestEnabled: boolean
|
||||
confidenceThreshold: number
|
||||
providerModel: string
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AiCategorizationSettings = {
|
||||
autoSuggestEnabled: true,
|
||||
confidenceThreshold: 0.7,
|
||||
providerModel: 'claude-haiku-4-5-20251001',
|
||||
}
|
||||
|
||||
/** Get settings via ExtensionContext (preferred in event handlers) */
|
||||
async function getSettingsViaCtx(ctx: ExtensionContext): Promise<AiCategorizationSettings> {
|
||||
const stored = await ctx.settings.get<Partial<AiCategorizationSettings>>()
|
||||
return { ...DEFAULT_SETTINGS, ...(stored || {}) }
|
||||
}
|
||||
|
||||
/** Get settings for external callers (settings routes, on-demand API) */
|
||||
export async function getSettings(userId: string): Promise<AiCategorizationSettings> {
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('company_id', userId)
|
||||
.eq('extension_id', 'ai-categorization')
|
||||
.eq('key', 'settings')
|
||||
.single()
|
||||
|
||||
if (!data?.value) return { ...DEFAULT_SETTINGS }
|
||||
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<AiCategorizationSettings>) }
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
userId: string,
|
||||
partial: Partial<AiCategorizationSettings>
|
||||
): Promise<AiCategorizationSettings> {
|
||||
const current = await getSettings(userId)
|
||||
const merged = { ...current, ...partial }
|
||||
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
await supabase
|
||||
.from('extension_data')
|
||||
.upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
extension_id: 'ai-categorization',
|
||||
key: 'settings',
|
||||
value: merged,
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Provider
|
||||
// ============================================================
|
||||
|
||||
let provider: CategorizationProvider | null = null
|
||||
|
||||
function getProvider(model?: string): CategorizationProvider {
|
||||
if (!provider) {
|
||||
provider = new AnthropicCategorizationProvider(model)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Public API — on-demand categorization
|
||||
// ============================================================
|
||||
|
||||
export async function categorizeTransactions(
|
||||
userId: string,
|
||||
transactionIds: string[]
|
||||
): Promise<CategorizationSuggestion[]> {
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
const settings = await getSettings(userId)
|
||||
|
||||
// Fetch transactions
|
||||
const { data: transactions } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, description, amount, date, merchant_name, mcc_code, currency')
|
||||
.eq('company_id', userId)
|
||||
.in('id', transactionIds)
|
||||
|
||||
if (!transactions || transactions.length === 0) return []
|
||||
|
||||
const batch: TransactionForCategorization[] = transactions.map((t) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
amount: t.amount,
|
||||
date: t.date,
|
||||
merchant_name: t.merchant_name,
|
||||
mcc_code: t.mcc_code,
|
||||
currency: t.currency,
|
||||
}))
|
||||
|
||||
const context = await buildEnrichedContext(userId, supabase, batch)
|
||||
|
||||
const aiProvider = getProvider(settings.providerModel)
|
||||
const suggestions = await aiProvider.categorize(batch, context, { supabase, userId })
|
||||
|
||||
// Store suggestions
|
||||
await storeSuggestions(userId, suggestions, supabase)
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event Handler
|
||||
// ============================================================
|
||||
|
||||
async function handleTransactionSynced(
|
||||
payload: EventPayload<'transaction.synced'>,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<void> {
|
||||
const { transactions: syncedTransactions, userId } = payload
|
||||
const log = ctx?.log ?? console
|
||||
|
||||
// Gate: Is autoSuggestEnabled?
|
||||
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
|
||||
if (!settings.autoSuggestEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
// Gate: Filter to uncategorized transactions only
|
||||
const uncategorized = syncedTransactions.filter(
|
||||
(t: Transaction) => t.is_business === null
|
||||
)
|
||||
if (uncategorized.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
log.info(`Auto-suggest triggered for ${uncategorized.length} uncategorized transactions`)
|
||||
|
||||
try {
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
|
||||
const batch: TransactionForCategorization[] = uncategorized.map((t: Transaction) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
amount: t.amount,
|
||||
date: t.date,
|
||||
merchant_name: t.merchant_name,
|
||||
mcc_code: t.mcc_code,
|
||||
currency: t.currency,
|
||||
}))
|
||||
|
||||
const context = await buildEnrichedContext(userId, supabase, batch)
|
||||
const aiProvider = getProvider(settings.providerModel)
|
||||
const suggestions = await aiProvider.categorize(batch, context, { supabase, userId })
|
||||
|
||||
// Store only suggestions above confidence threshold
|
||||
const qualifiedSuggestions = suggestions.filter(
|
||||
(s) => s.confidence >= settings.confidenceThreshold
|
||||
)
|
||||
|
||||
if (qualifiedSuggestions.length > 0) {
|
||||
await storeSuggestions(userId, qualifiedSuggestions, supabase)
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Generated ${suggestions.length} suggestions, ${qualifiedSuggestions.length} above threshold (${settings.confidenceThreshold})`
|
||||
)
|
||||
} catch (error) {
|
||||
log.error('handleTransactionSynced failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
async function buildEnrichedContext(
|
||||
userId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
supabase: any,
|
||||
transactions: TransactionForCategorization[]
|
||||
): Promise<EnrichedCategorizationContext> {
|
||||
// Fetch entity type
|
||||
const { data: companySettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
const entityType: EntityType = companySettings?.entity_type || 'enskild_firma'
|
||||
|
||||
// Fetch recent categorization history
|
||||
const { data: historicalTxns } = await supabase
|
||||
.from('transactions')
|
||||
.select('description, category')
|
||||
.eq('company_id', userId)
|
||||
.not('is_business', 'is', null)
|
||||
.neq('category', 'uncategorized')
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(50)
|
||||
|
||||
const recentHistory = (historicalTxns || []).map(
|
||||
(t: { description: string; category: string }) => ({
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
})
|
||||
)
|
||||
|
||||
// Fetch user's most-used accounts (top 30)
|
||||
const { data: accountUsageRows } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number')
|
||||
.eq('company_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('company_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 { findSimilarTemplates } = await import('./lib/template-embeddings')
|
||||
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(
|
||||
userId: string,
|
||||
suggestions: CategorizationSuggestion[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
supabase: any
|
||||
): Promise<void> {
|
||||
// Group suggestions by transactionId (AI now returns multiple per transaction)
|
||||
const grouped: Record<string, CategorizationSuggestion[]> = {}
|
||||
for (const suggestion of suggestions) {
|
||||
if (!grouped[suggestion.transactionId]) {
|
||||
grouped[suggestion.transactionId] = []
|
||||
}
|
||||
grouped[suggestion.transactionId].push(suggestion)
|
||||
}
|
||||
|
||||
for (const [txId, txSuggestions] of Object.entries(grouped)) {
|
||||
const { error } = await supabase.from('extension_data').upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
extension_id: 'ai-categorization',
|
||||
key: `suggestion:${txId}`,
|
||||
value: txSuggestions,
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
if (error) {
|
||||
console.error(`[ai-categorization] Failed to store suggestion for ${txId}:`, error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Extension Object
|
||||
// ============================================================
|
||||
|
||||
export const aiCategorizationExtension: Extension = {
|
||||
id: 'ai-categorization',
|
||||
name: 'AI Kategorisering',
|
||||
version: '1.0.0',
|
||||
sector: 'general',
|
||||
apiRoutes: aiCategorizationApiRoutes,
|
||||
eventHandlers: [
|
||||
{ eventType: 'transaction.synced', handler: handleTransactionSynced },
|
||||
],
|
||||
settingsPanel: {
|
||||
label: 'AI Kategorisering',
|
||||
path: '/settings/extensions/ai-categorization',
|
||||
},
|
||||
services: {
|
||||
findSimilarTemplates: async (...args: unknown[]) => {
|
||||
const { findSimilarTemplates } = await import('./lib/template-embeddings')
|
||||
return findSimilarTemplates(
|
||||
args[0] as Transaction,
|
||||
args[1] as EntityType | undefined,
|
||||
args[2] as number | undefined,
|
||||
args[3] as string | undefined
|
||||
)
|
||||
},
|
||||
seedAllTemplateEmbeddings: async () => {
|
||||
const { seedAllTemplateEmbeddings } = await import('./lib/template-embeddings')
|
||||
return seedAllTemplateEmbeddings()
|
||||
},
|
||||
getSchemaVersion: async () => {
|
||||
const { getSchemaVersion } = await import('./lib/template-embeddings')
|
||||
return getSchemaVersion()
|
||||
},
|
||||
categorizeTransactions: async (...args: unknown[]) => {
|
||||
return categorizeTransactions(args[0] as string, args[1] as string[])
|
||||
},
|
||||
analyzeDescription: async (...args: unknown[]) => {
|
||||
const { analyzeDescription } = await import('./lib/description-analyzer')
|
||||
return analyzeDescription(
|
||||
args[0] as import('./lib/description-analyzer').DescriptionAnalysisInput
|
||||
)
|
||||
},
|
||||
},
|
||||
async onInstall(ctx) {
|
||||
await ctx.settings.set('settings', DEFAULT_SETTINGS)
|
||||
},
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock server-only (no-op in tests)
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
// Mock Anthropic SDK
|
||||
const mockCreate = vi.fn()
|
||||
vi.mock('@anthropic-ai/sdk', () => {
|
||||
class MockAnthropic {
|
||||
messages = { create: mockCreate }
|
||||
}
|
||||
return { default: MockAnthropic }
|
||||
})
|
||||
|
||||
function makeToolResponse(input: Record<string, unknown>) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'analyze_description',
|
||||
input,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('description-analyzer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns correct result for an expense with standard VAT', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeToolResponse({
|
||||
debitAccount: '6071',
|
||||
creditAccount: '1930',
|
||||
vatTreatment: 'standard_25',
|
||||
category: 'expense_representation',
|
||||
confidence: 0.85,
|
||||
reasoning: 'Lunch med kund klassificeras som representation.',
|
||||
warnings: ['Max 300 kr/person for avdragsratt'],
|
||||
templateId: null,
|
||||
})
|
||||
)
|
||||
|
||||
const { analyzeDescription } = await import('../description-analyzer')
|
||||
|
||||
const result = await analyzeDescription({
|
||||
description: 'Lunch med kund',
|
||||
transactionAmount: -450,
|
||||
transactionDate: '2026-01-15',
|
||||
transactionDescription: 'RESTAURANT XYZ',
|
||||
merchantName: 'Restaurant XYZ',
|
||||
currency: 'SEK',
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
expect(result.debitAccount).toBe('6071')
|
||||
expect(result.creditAccount).toBe('1930')
|
||||
expect(result.vatTreatment).toBe('standard_25')
|
||||
expect(result.category).toBe('expense_representation')
|
||||
expect(result.confidence).toBe(0.85)
|
||||
expect(result.reasoning).toContain('representation')
|
||||
expect(result.warnings).toHaveLength(1)
|
||||
expect(result.templateId).toBeNull()
|
||||
})
|
||||
|
||||
it('returns correct result for income', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeToolResponse({
|
||||
debitAccount: '1930',
|
||||
creditAccount: '3001',
|
||||
vatTreatment: 'standard_25',
|
||||
category: 'income_services',
|
||||
confidence: 0.9,
|
||||
reasoning: 'Konsultarvode bokförs som tjänsteintäkt.',
|
||||
warnings: [],
|
||||
templateId: null,
|
||||
})
|
||||
)
|
||||
|
||||
const { analyzeDescription } = await import('../description-analyzer')
|
||||
|
||||
const result = await analyzeDescription({
|
||||
description: 'Konsultarvode',
|
||||
transactionAmount: 25000,
|
||||
transactionDate: '2026-01-15',
|
||||
transactionDescription: 'PAYMENT FROM CLIENT',
|
||||
merchantName: null,
|
||||
currency: 'SEK',
|
||||
entityType: 'aktiebolag',
|
||||
})
|
||||
|
||||
expect(result.debitAccount).toBe('1930')
|
||||
expect(result.creditAccount).toBe('3001')
|
||||
expect(result.category).toBe('income_services')
|
||||
expect(result.confidence).toBe(0.9)
|
||||
})
|
||||
|
||||
it('corrects category direction mismatch', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeToolResponse({
|
||||
debitAccount: '6071',
|
||||
creditAccount: '1930',
|
||||
vatTreatment: null,
|
||||
category: 'income_services', // Wrong direction for expense
|
||||
confidence: 0.7,
|
||||
reasoning: 'Test',
|
||||
warnings: [],
|
||||
templateId: null,
|
||||
})
|
||||
)
|
||||
|
||||
const { analyzeDescription } = await import('../description-analyzer')
|
||||
|
||||
const result = await analyzeDescription({
|
||||
description: 'Something',
|
||||
transactionAmount: -500,
|
||||
transactionDate: '2026-01-15',
|
||||
transactionDescription: 'PAYMENT',
|
||||
merchantName: null,
|
||||
currency: 'SEK',
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
// Income category should be corrected to expense for negative amount
|
||||
expect(result.category).toBe('expense_other')
|
||||
})
|
||||
|
||||
it('clamps confidence to [0, 1]', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeToolResponse({
|
||||
debitAccount: '6991',
|
||||
creditAccount: '1930',
|
||||
vatTreatment: null,
|
||||
category: 'expense_other',
|
||||
confidence: 1.5, // Over 1
|
||||
reasoning: 'Test',
|
||||
warnings: [],
|
||||
templateId: null,
|
||||
})
|
||||
)
|
||||
|
||||
const { analyzeDescription } = await import('../description-analyzer')
|
||||
|
||||
const result = await analyzeDescription({
|
||||
description: 'Something',
|
||||
transactionAmount: -100,
|
||||
transactionDate: '2026-01-15',
|
||||
transactionDescription: 'PAYMENT',
|
||||
merchantName: null,
|
||||
currency: 'SEK',
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
expect(result.confidence).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to default accounts for invalid account numbers', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeToolResponse({
|
||||
debitAccount: 'INVALID',
|
||||
creditAccount: 'bad',
|
||||
vatTreatment: null,
|
||||
category: 'expense_other',
|
||||
confidence: 0.5,
|
||||
reasoning: 'Test',
|
||||
warnings: [],
|
||||
templateId: null,
|
||||
})
|
||||
)
|
||||
|
||||
const { analyzeDescription } = await import('../description-analyzer')
|
||||
|
||||
const result = await analyzeDescription({
|
||||
description: 'Something',
|
||||
transactionAmount: -100,
|
||||
transactionDate: '2026-01-15',
|
||||
transactionDescription: 'PAYMENT',
|
||||
merchantName: null,
|
||||
currency: 'SEK',
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
// Should fall back to safe defaults
|
||||
expect(result.debitAccount).toBe('6991')
|
||||
expect(result.creditAccount).toBe('1930')
|
||||
})
|
||||
|
||||
it('enforces expense direction: credit account must be 1930', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeToolResponse({
|
||||
debitAccount: '5420',
|
||||
creditAccount: '2440', // Not 1930 for a bank transaction expense
|
||||
vatTreatment: 'standard_25',
|
||||
category: 'expense_software',
|
||||
confidence: 0.8,
|
||||
reasoning: 'Test',
|
||||
warnings: [],
|
||||
templateId: null,
|
||||
})
|
||||
)
|
||||
|
||||
const { analyzeDescription } = await import('../description-analyzer')
|
||||
|
||||
const result = await analyzeDescription({
|
||||
description: 'Software subscription',
|
||||
transactionAmount: -500,
|
||||
transactionDate: '2026-01-15',
|
||||
transactionDescription: 'PAYMENT',
|
||||
merchantName: null,
|
||||
currency: 'SEK',
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
expect(result.creditAccount).toBe('1930')
|
||||
})
|
||||
|
||||
it('rejects private category', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeToolResponse({
|
||||
debitAccount: '2013',
|
||||
creditAccount: '1930',
|
||||
vatTreatment: null,
|
||||
category: 'private',
|
||||
confidence: 0.9,
|
||||
reasoning: 'Test',
|
||||
warnings: [],
|
||||
templateId: null,
|
||||
})
|
||||
)
|
||||
|
||||
const { analyzeDescription } = await import('../description-analyzer')
|
||||
|
||||
const result = await analyzeDescription({
|
||||
description: 'Something',
|
||||
transactionAmount: -100,
|
||||
transactionDate: '2026-01-15',
|
||||
transactionDescription: 'PAYMENT',
|
||||
merchantName: null,
|
||||
currency: 'SEK',
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
expect(result.category).toBe('expense_other')
|
||||
})
|
||||
|
||||
it('throws after retries exhausted', async () => {
|
||||
mockCreate.mockRejectedValue(new Error('API error'))
|
||||
|
||||
const { analyzeDescription } = await import('../description-analyzer')
|
||||
|
||||
await expect(
|
||||
analyzeDescription({
|
||||
description: 'Something',
|
||||
transactionAmount: -100,
|
||||
transactionDate: '2026-01-15',
|
||||
transactionDescription: 'PAYMENT',
|
||||
merchantName: null,
|
||||
currency: 'SEK',
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
).rejects.toThrow('AI description analysis failed after 3 attempts')
|
||||
|
||||
// Should have retried 3 times (initial + 2 retries)
|
||||
expect(mockCreate).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('validates invalid VAT treatment to null', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeToolResponse({
|
||||
debitAccount: '6991',
|
||||
creditAccount: '1930',
|
||||
vatTreatment: 'invalid_vat',
|
||||
category: 'expense_other',
|
||||
confidence: 0.5,
|
||||
reasoning: 'Test',
|
||||
warnings: [],
|
||||
templateId: null,
|
||||
})
|
||||
)
|
||||
|
||||
const { analyzeDescription } = await import('../description-analyzer')
|
||||
|
||||
const result = await analyzeDescription({
|
||||
description: 'Something',
|
||||
transactionAmount: -100,
|
||||
transactionDate: '2026-01-15',
|
||||
transactionDescription: 'PAYMENT',
|
||||
merchantName: null,
|
||||
currency: 'SEK',
|
||||
entityType: 'enskild_firma',
|
||||
})
|
||||
|
||||
expect(result.vatTreatment).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,233 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { makeTransaction, createMockSupabase } from '@/tests/helpers'
|
||||
import { BOOKING_TEMPLATES } from '@/lib/bookkeeping/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 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')
|
||||
})
|
||||
|
||||
it('prepends user description when provided', async () => {
|
||||
const { buildTransactionQueryText } = await import('../template-embeddings')
|
||||
|
||||
const tx = makeTransaction({
|
||||
description: 'SWE REST 4521 STHLM',
|
||||
merchant_name: 'Unknown',
|
||||
amount: -450,
|
||||
})
|
||||
|
||||
const text = buildTransactionQueryText(tx, 'business lunch with client')
|
||||
|
||||
// User description should appear first
|
||||
expect(text.indexOf('business lunch with client')).toBe(0)
|
||||
// Transaction data should still be present
|
||||
expect(text).toContain('SWE REST 4521 STHLM')
|
||||
expect(text).toContain('Unknown')
|
||||
expect(text).toContain('utgift')
|
||||
})
|
||||
|
||||
it('behaves identically when userDescription is undefined', async () => {
|
||||
const { buildTransactionQueryText } = await import('../template-embeddings')
|
||||
|
||||
const tx = makeTransaction({
|
||||
description: 'SPOTIFY PREMIUM',
|
||||
merchant_name: 'Spotify',
|
||||
amount: -109,
|
||||
})
|
||||
|
||||
const withoutDesc = buildTransactionQueryText(tx)
|
||||
const withUndefined = buildTransactionQueryText(tx, undefined)
|
||||
|
||||
expect(withoutDesc).toBe(withUndefined)
|
||||
})
|
||||
})
|
||||
|
||||
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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,277 +0,0 @@
|
||||
/**
|
||||
* AI Description Analyzer
|
||||
*
|
||||
* SERVER-ONLY: Uses the Anthropic SDK and must only be imported
|
||||
* in server components or API routes.
|
||||
*
|
||||
* Interprets a user's plain-language description of a bank transaction
|
||||
* and returns a structured booking suggestion with Swedish accounting reasoning.
|
||||
* Uses Claude Haiku with structured tool outputs for reliable JSON.
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import type { TransactionCategory, VatTreatment, EntityType } from '@/types'
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
export interface DescriptionAnalysisInput {
|
||||
description: string
|
||||
transactionAmount: number
|
||||
transactionDate: string
|
||||
transactionDescription: string
|
||||
merchantName: string | null
|
||||
currency: string
|
||||
entityType: EntityType
|
||||
}
|
||||
|
||||
export interface DescriptionAnalysisResult {
|
||||
debitAccount: string
|
||||
creditAccount: string
|
||||
vatTreatment: VatTreatment | null
|
||||
category: TransactionCategory
|
||||
confidence: number
|
||||
reasoning: string
|
||||
warnings: string[]
|
||||
templateId: string | null
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Constants
|
||||
// ============================================================
|
||||
|
||||
const MAX_RETRIES = 2
|
||||
const RETRY_DELAY_MS = 500
|
||||
const MODEL = 'claude-haiku-4-5-20251001'
|
||||
|
||||
const VALID_CATEGORIES = new Set<string>([
|
||||
'income_services', 'income_products', 'income_other',
|
||||
'expense_equipment', 'expense_software', 'expense_travel',
|
||||
'expense_office', 'expense_marketing', 'expense_professional_services',
|
||||
'expense_education', 'expense_representation', 'expense_consumables',
|
||||
'expense_vehicle', 'expense_telecom', 'expense_bank_fees',
|
||||
'expense_card_fees', 'expense_currency_exchange', 'expense_other',
|
||||
])
|
||||
|
||||
const VALID_VAT_TREATMENTS = new Set<string>([
|
||||
'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt',
|
||||
])
|
||||
|
||||
// ============================================================
|
||||
// Tool Schema
|
||||
// ============================================================
|
||||
|
||||
const ANALYZE_TOOL: Anthropic.Tool = {
|
||||
name: 'analyze_description',
|
||||
description: 'Analyze a user description and return a structured booking suggestion for the transaction.',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
debitAccount: { type: 'string', description: 'BAS debit account number (4 digits)' },
|
||||
creditAccount: { type: 'string', description: 'BAS credit account number (4 digits)' },
|
||||
vatTreatment: {
|
||||
type: ['string', 'null'],
|
||||
description: 'VAT treatment: standard_25, reduced_12, reduced_6, reverse_charge, export, exempt, or null if exempt/no VAT',
|
||||
},
|
||||
category: { type: 'string', description: 'Transaction category (e.g. expense_representation, income_services)' },
|
||||
confidence: { type: 'number', description: 'Confidence score 0.0-1.0' },
|
||||
reasoning: { type: 'string', description: 'Explanation in Swedish of why this booking is correct' },
|
||||
warnings: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Warnings about deductibility limits, special rules, etc. (in Swedish)',
|
||||
},
|
||||
templateId: {
|
||||
type: ['string', 'null'],
|
||||
description: 'Matching booking template ID if applicable, or null',
|
||||
},
|
||||
},
|
||||
required: ['debitAccount', 'creditAccount', 'vatTreatment', 'category', 'confidence', 'reasoning', 'warnings', 'templateId'],
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// System Prompt
|
||||
// ============================================================
|
||||
|
||||
function buildSystemPrompt(entityType: EntityType): string {
|
||||
const privateAccount = entityType === 'aktiebolag' ? '2893' : '2013'
|
||||
const entityLabel = entityType === 'aktiebolag' ? 'Aktiebolag (AB)' : 'Enskild firma (EF)'
|
||||
|
||||
return `Du är expert på svensk bokföring enligt BAS-kontoplanen. Analysera användarens beskrivning av en banktransaktion och returnera ett bokföringsförslag.
|
||||
|
||||
VANLIGA BAS-KONTON:
|
||||
Utgifter: 5010 Lokalhyra | 5410 Förbrukningsinventarier | 5420 Programvara | 5460 Förbrukningsvaror | 5611 Bil/drivmedel | 5800 Resekostnader | 5910 Annonsering | 6071 Representation mat | 6200 Telefon/internet | 6530 Redovisning/konsult | 6570 Bankavgifter | 6991 Övriga kostnader | ${entityType === 'aktiebolag' ? '7610' : '6991'} Utbildning
|
||||
Intäkter: 3001 Försäljning 25% | 3002 Försäljning 12% | 3003 Försäljning 6% | 3305 Export | 3308 EU-tjänster | 3900 Övriga intäkter
|
||||
Moms: 2611 Utg moms 25% | 2621 Utg moms 12% | 2631 Utg moms 6% | 2641 Ing moms | 2645 Beräknad ing moms
|
||||
Skulder: 2350 Skulder till kreditinstitut (banklån, Almi) | 2440 Leverantörsskulder (ENBART för leverantörsfakturor)
|
||||
Övrigt: 1510 Kundfordringar | 1930 Företagskonto | 8410 Räntekostnader | ${privateAccount} Privat
|
||||
|
||||
MOMSREGLER:
|
||||
- standard_25: Normala varor/tjänster (25%)
|
||||
- reduced_12: Livsmedel, hotell, konstverk (12%)
|
||||
- reduced_6: Böcker, tidningar, kollektivtrafik, kultur (6%)
|
||||
- reverse_charge: Tjänsteköp från utlandet/EU
|
||||
- export: Försäljning utanför Sverige
|
||||
- exempt: Momsfritt (bank, försäkring, sjukvård, utbildning)
|
||||
|
||||
VARNINGSREGLER:
|
||||
- Representation/måltider: Max 300 kr/person exkl moms för avdragsrätt (IL 16 kap 2§)
|
||||
- Gåvor: Reklamgåvor max 300 kr, representationsgåvor max 180 kr
|
||||
- Blandad användning (telefon/dator): Bara yrkesmässig del avdragsgill
|
||||
- Bankavgifter, kortavgifter, valutaväxling: MOMSFRIA (exempt)
|
||||
|
||||
Företagsform: ${entityLabel}
|
||||
Privatkonto: ${privateAccount}
|
||||
|
||||
REGLER:
|
||||
1. Negativt belopp = utgift: debitera kostnadskonto, kreditera 1930
|
||||
2. Positivt belopp = intäkt: debitera 1930, kreditera intäktskonto
|
||||
3. Ge ett klart reasoning på svenska som förklarar valet
|
||||
4. Lägg till warnings för avdragsbegränsningar eller speciella regler
|
||||
5. templateId: null (vi matchar mallar separat)`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Analyzer
|
||||
// ============================================================
|
||||
|
||||
export async function analyzeDescription(
|
||||
input: DescriptionAnalysisInput
|
||||
): Promise<DescriptionAnalysisResult> {
|
||||
const client = new Anthropic()
|
||||
const isExpense = input.transactionAmount < 0
|
||||
|
||||
const userPrompt = `Transaktion:
|
||||
- Användarens beskrivning: "${input.description}"
|
||||
- Banktext: "${input.transactionDescription}"
|
||||
- Belopp: ${input.transactionAmount} ${input.currency}
|
||||
- Datum: ${input.transactionDate}${input.merchantName ? `\n- Handlare: ${input.merchantName}` : ''}
|
||||
|
||||
Analysera och returnera bokföringsförslag med analyze_description-verktyget.`
|
||||
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const message = await client.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: 1024,
|
||||
system: [
|
||||
{
|
||||
type: 'text',
|
||||
text: buildSystemPrompt(input.entityType),
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
tools: [ANALYZE_TOOL],
|
||||
tool_choice: { type: 'tool', name: 'analyze_description' },
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
})
|
||||
|
||||
const toolUseBlock = message.content.find(
|
||||
(block) => block.type === 'tool_use' && block.name === 'analyze_description'
|
||||
)
|
||||
|
||||
if (!toolUseBlock || toolUseBlock.type !== 'tool_use') {
|
||||
throw new Error('No tool_use block in AI response')
|
||||
}
|
||||
|
||||
return validateResult(toolUseBlock.input as Record<string, unknown>, isExpense, input.entityType)
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||||
if (attempt < MAX_RETRIES) {
|
||||
await sleep(RETRY_DELAY_MS * (attempt + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`AI description analysis failed after ${MAX_RETRIES + 1} attempts: ${lastError?.message}`
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Validation
|
||||
// ============================================================
|
||||
|
||||
function validateResult(
|
||||
raw: Record<string, unknown>,
|
||||
isExpense: boolean,
|
||||
entityType: EntityType
|
||||
): DescriptionAnalysisResult {
|
||||
const ACCOUNT_REGEX = /^\d{4}$/
|
||||
|
||||
// Validate accounts — default to safe fallbacks
|
||||
let debitAccount = typeof raw.debitAccount === 'string' && ACCOUNT_REGEX.test(raw.debitAccount)
|
||||
? raw.debitAccount
|
||||
: (isExpense ? '6991' : '1930')
|
||||
|
||||
let creditAccount = typeof raw.creditAccount === 'string' && ACCOUNT_REGEX.test(raw.creditAccount)
|
||||
? raw.creditAccount
|
||||
: (isExpense ? '1930' : '3001')
|
||||
|
||||
// Enforce direction: expenses debit expense account + credit 1930, income debit 1930 + credit revenue
|
||||
if (isExpense && creditAccount !== '1930') {
|
||||
creditAccount = '1930'
|
||||
}
|
||||
if (!isExpense && debitAccount !== '1930') {
|
||||
debitAccount = '1930'
|
||||
}
|
||||
|
||||
// Validate VAT treatment
|
||||
const rawVat = raw.vatTreatment as string | null
|
||||
const vatTreatment = rawVat && VALID_VAT_TREATMENTS.has(rawVat)
|
||||
? rawVat as VatTreatment
|
||||
: null
|
||||
|
||||
// Validate category with direction correction
|
||||
let category = VALID_CATEGORIES.has(raw.category as string)
|
||||
? (raw.category as TransactionCategory)
|
||||
: (isExpense ? 'expense_other' : 'income_other')
|
||||
|
||||
if (category === 'private') {
|
||||
category = isExpense ? 'expense_other' : 'income_other'
|
||||
}
|
||||
if (isExpense && category.startsWith('income_')) {
|
||||
category = 'expense_other'
|
||||
}
|
||||
if (!isExpense && category.startsWith('expense_')) {
|
||||
category = 'income_other'
|
||||
}
|
||||
|
||||
// Clamp confidence
|
||||
const confidence = Math.max(0, Math.min(1, Number(raw.confidence) || 0.5))
|
||||
|
||||
// Reasoning — must be a non-empty string
|
||||
const reasoning = typeof raw.reasoning === 'string' && raw.reasoning.length > 0
|
||||
? raw.reasoning
|
||||
: (isExpense ? 'Utgift bokförd på standardkonto' : 'Intäkt bokförd på standardkonto')
|
||||
|
||||
// Warnings
|
||||
const warnings = Array.isArray(raw.warnings)
|
||||
? (raw.warnings as unknown[]).filter((w): w is string => typeof w === 'string')
|
||||
: []
|
||||
|
||||
// Template ID
|
||||
const templateId = typeof raw.templateId === 'string' && raw.templateId.length > 0
|
||||
? raw.templateId
|
||||
: null
|
||||
|
||||
return {
|
||||
debitAccount,
|
||||
creditAccount,
|
||||
vatTreatment,
|
||||
category,
|
||||
confidence,
|
||||
reasoning,
|
||||
warnings,
|
||||
templateId,
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
/**
|
||||
* 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 '@/lib/bookkeeping/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 = 5
|
||||
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.
|
||||
* When userDescription is provided, it is prepended so it dominates
|
||||
* the semantic search (user intent > raw bank text).
|
||||
*/
|
||||
export function buildTransactionQueryText(
|
||||
transaction: Transaction,
|
||||
userDescription?: string
|
||||
): string {
|
||||
const parts: string[] = []
|
||||
|
||||
if (userDescription) {
|
||||
parts.push(userDescription)
|
||||
}
|
||||
|
||||
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,
|
||||
userDescription?: string
|
||||
): 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, userDescription)
|
||||
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 []
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"id": "ai-categorization",
|
||||
"sector": "general",
|
||||
"exportName": "aiCategorizationExtension",
|
||||
"entryPoint": "@/extensions/general/ai-categorization",
|
||||
"workspace": "@/components/extensions/general/AiCategorizationWorkspace",
|
||||
"requiredEnvVars": ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
||||
"optionalEnvVars": [],
|
||||
"npmDependencies": ["@anthropic-ai/sdk", "@langchain/openai"],
|
||||
"definition": {
|
||||
"name": "AI-kategorisering",
|
||||
"category": "operations",
|
||||
"icon": "Wand",
|
||||
"dataPattern": "core",
|
||||
"readsCoreTables": ["transactions"],
|
||||
"description": "AI-drivna kategoriförslag för transaktioner",
|
||||
"longDescription": "Använder AI för att automatiskt föreslå BAS-kontokategorier för dina banktransaktioner. Lär sig från dina tidigare bokföringsval."
|
||||
}
|
||||
}
|
||||
@@ -1,634 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
|
||||
import { generateChatResponse, streamRoutedResponse } from '@/extensions/general/ai-chat/chatbot/chain'
|
||||
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
|
||||
import type { ChatMessage, ChatRequest, SourceReference, ArtifactSpec } from '@/types/chat'
|
||||
|
||||
// Distributed rate limiting backed by Supabase extension_data table
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
interface RateLimitState {
|
||||
count: number
|
||||
window_start: number
|
||||
}
|
||||
|
||||
async function checkRateLimitDB(supabase: SupabaseClient, userId: string): Promise<boolean> {
|
||||
const now = Date.now()
|
||||
const WINDOW_MS = 60000
|
||||
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('company_id', userId)
|
||||
.eq('extension_id', 'ai-chat')
|
||||
.eq('key', 'rate_limit')
|
||||
.single()
|
||||
|
||||
const state = data?.value as RateLimitState | null
|
||||
|
||||
if (!state || now - state.window_start > WINDOW_MS) {
|
||||
// New window
|
||||
await supabase.from('extension_data').upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
extension_id: 'ai-chat',
|
||||
key: 'rate_limit',
|
||||
value: { count: 1, window_start: now },
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
if (state.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Increment count
|
||||
await supabase.from('extension_data').upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
extension_id: 'ai-chat',
|
||||
key: 'rate_limit',
|
||||
value: { count: state.count + 1, window_start: state.window_start },
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST / — Send a message and get a response
|
||||
// ============================================================
|
||||
|
||||
async function handlePostChat(
|
||||
request: Request,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
// Rate limiting
|
||||
if (!await checkRateLimitDB(supabase, userId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded. Please wait a moment.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body: ChatRequest = await request.json()
|
||||
const { message, session_id } = body
|
||||
|
||||
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Message is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let sessionId = session_id
|
||||
|
||||
// Create new session if not provided
|
||||
if (!sessionId) {
|
||||
const { data: newSession, error: sessionError } = await supabase
|
||||
.from('chat_sessions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
title: message.slice(0, 100), // Use first 100 chars of message as title
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (sessionError) {
|
||||
console.error('Error creating session:', sessionError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create chat session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
sessionId = newSession.id
|
||||
} else {
|
||||
// Verify session belongs to user
|
||||
const { data: existingSession } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select('id')
|
||||
.eq('id', sessionId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (!existingSession) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Session not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Save user message
|
||||
const { error: userMsgError } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
session_id: sessionId,
|
||||
user_id: userId,
|
||||
role: 'user',
|
||||
content: message.trim(),
|
||||
sources: [],
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (userMsgError) {
|
||||
console.error('Error saving user message:', userMsgError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save message' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get conversation history
|
||||
const { data: history } = await supabase
|
||||
.from('chat_messages')
|
||||
.select('role, content')
|
||||
.eq('session_id', sessionId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(CHATBOT_CONFIG.maxHistoryMessages)
|
||||
|
||||
const conversationHistory = (history || []) as ChatMessage[]
|
||||
|
||||
// Generate AI response
|
||||
const result = await generateChatResponse(message.trim(), conversationHistory, { supabase, userId })
|
||||
|
||||
// Save assistant message
|
||||
const { data: assistantMessage, error: assistantMsgError } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
session_id: sessionId,
|
||||
user_id: userId,
|
||||
role: 'assistant',
|
||||
content: result.content,
|
||||
sources: result.sources,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (assistantMsgError) {
|
||||
console.error('Error saving assistant message:', assistantMsgError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save response' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: assistantMessage,
|
||||
session_id: sessionId,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Chat error:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to process chat' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /stream — Streaming chat response via Server-Sent Events
|
||||
// ============================================================
|
||||
|
||||
async function handlePostStream(
|
||||
request: Request,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
if (!await checkRateLimitDB(supabase, userId)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
||||
{ status: 429, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body: ChatRequest = await request.json()
|
||||
const { message, session_id } = body
|
||||
|
||||
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Message is required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
let sessionId = session_id
|
||||
|
||||
// Create new session if not provided
|
||||
if (!sessionId) {
|
||||
const { data: newSession, error: sessionError } = await supabase
|
||||
.from('chat_sessions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
title: message.slice(0, 100),
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (sessionError) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to create session' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
sessionId = newSession.id
|
||||
} else {
|
||||
// Verify session belongs to user
|
||||
const { data: existingSession } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select('id')
|
||||
.eq('id', sessionId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (!existingSession) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Session not found' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Save user message
|
||||
await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
session_id: sessionId,
|
||||
user_id: userId,
|
||||
role: 'user',
|
||||
content: message.trim(),
|
||||
sources: [],
|
||||
})
|
||||
|
||||
// Get conversation history
|
||||
const { data: history } = await supabase
|
||||
.from('chat_messages')
|
||||
.select('role, content')
|
||||
.eq('session_id', sessionId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(CHATBOT_CONFIG.maxHistoryMessages)
|
||||
|
||||
const conversationHistory = (history || []) as ChatMessage[]
|
||||
|
||||
// Create streaming response
|
||||
const encoder = new TextEncoder()
|
||||
let fullContent = ''
|
||||
let sources: SourceReference[] = []
|
||||
let artifact: ArtifactSpec | null = null
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
// Send session ID first
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'session', session_id: sessionId })}\n\n`)
|
||||
)
|
||||
|
||||
// Stream the routed response (handles knowledge, data, and hybrid)
|
||||
for await (const event of streamRoutedResponse(
|
||||
message.trim(),
|
||||
conversationHistory,
|
||||
supabase,
|
||||
userId,
|
||||
sessionId
|
||||
)) {
|
||||
if (event.type === 'content') {
|
||||
fullContent += event.content
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'content', content: event.content })}\n\n`)
|
||||
)
|
||||
} else if (event.type === 'sources') {
|
||||
sources = event.sources
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'sources', sources: event.sources })}\n\n`)
|
||||
)
|
||||
} else if (event.type === 'tool_start') {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'tool_start', toolName: event.toolName })}\n\n`)
|
||||
)
|
||||
} else if (event.type === 'artifact') {
|
||||
artifact = event.artifact
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'artifact', artifact: event.artifact })}\n\n`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Save the complete assistant message (including artifact)
|
||||
const { data: savedMessage } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
session_id: sessionId,
|
||||
user_id: userId,
|
||||
role: 'assistant',
|
||||
content: fullContent,
|
||||
sources,
|
||||
...(artifact ? { artifact } : {}),
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
// Send done signal with message ID
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'done', message_id: savedMessage?.id })}\n\n`)
|
||||
)
|
||||
|
||||
controller.close()
|
||||
} catch (error) {
|
||||
console.error('Streaming error:', error)
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'error', error: 'Streaming failed' })}\n\n`)
|
||||
)
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Stream setup error:', err)
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to setup stream' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GET /sessions — List all chat sessions for the user
|
||||
// ============================================================
|
||||
|
||||
async function handleGetSessions(
|
||||
request: Request,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('company_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching sessions:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch sessions' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data, count })
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /sessions — Create a new chat session
|
||||
// ============================================================
|
||||
|
||||
async function handlePostSession(
|
||||
request: Request,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { title } = body
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chat_sessions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
title: title || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating session:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to create session' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GET /sessions/:id — Get a single chat session with messages
|
||||
// ============================================================
|
||||
|
||||
async function handleGetSession(
|
||||
request: Request,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get('_id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Session ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get session with messages
|
||||
const { data: session, error: sessionError } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (sessionError || !session) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get messages
|
||||
const { data: messages, error: messagesError } = await supabase
|
||||
.from('chat_messages')
|
||||
.select('*')
|
||||
.eq('session_id', id)
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
if (messagesError) {
|
||||
console.error('Error fetching messages:', messagesError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch messages' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
session,
|
||||
messages: messages || [],
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PATCH /sessions/:id — Update a chat session (e.g., rename)
|
||||
// ============================================================
|
||||
|
||||
async function handlePatchSession(
|
||||
request: Request,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get('_id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Session ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { title } = body
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chat_sessions')
|
||||
.update({ title })
|
||||
.eq('id', id)
|
||||
.eq('company_id', userId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating session:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to update session' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DELETE /sessions/:id — Delete a chat session and its messages
|
||||
// ============================================================
|
||||
|
||||
async function handleDeleteSession(
|
||||
request: Request,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<Response> {
|
||||
const userId = ctx!.userId
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get('_id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Session ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Delete session (messages will cascade delete due to FK)
|
||||
const { error } = await supabase
|
||||
.from('chat_sessions')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('company_id', userId)
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting session:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Route definitions
|
||||
// ============================================================
|
||||
|
||||
export const aiChatApiRoutes: ApiRouteDefinition[] = [
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/',
|
||||
handler: handlePostChat,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/stream',
|
||||
handler: handlePostStream,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/sessions',
|
||||
handler: handleGetSessions,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/sessions',
|
||||
handler: handlePostSession,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/sessions/:id',
|
||||
handler: handleGetSession,
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
path: '/sessions/:id',
|
||||
handler: handlePatchSession,
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/sessions/:id',
|
||||
handler: handleDeleteSession,
|
||||
},
|
||||
]
|
||||
@@ -1,133 +0,0 @@
|
||||
import { ChatAnthropic } from '@langchain/anthropic'
|
||||
import { createReactAgent } from '@langchain/langgraph/prebuilt'
|
||||
import { HumanMessage, AIMessage } from '@langchain/core/messages'
|
||||
import type { StructuredToolInterface } from '@langchain/core/tools'
|
||||
import { CHATBOT_CONFIG } from './config'
|
||||
import {
|
||||
SYSTEM_PROMPT_DATA,
|
||||
SYSTEM_PROMPT_HYBRID,
|
||||
formatConversationHistory,
|
||||
} from './prompts'
|
||||
import type { ChatMessage } from '@/types/chat'
|
||||
import type { RouteType } from './router'
|
||||
|
||||
export interface AgentStreamEvent {
|
||||
type: 'tool_start' | 'content' | 'done'
|
||||
toolName?: string
|
||||
content?: string
|
||||
toolResults?: ToolResultEntry[]
|
||||
}
|
||||
|
||||
export interface ToolResultEntry {
|
||||
toolName: string
|
||||
result: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the LangGraph agent with tool calling and stream events.
|
||||
*/
|
||||
export async function* streamAgentResponse(options: {
|
||||
query: string
|
||||
route: RouteType
|
||||
tools: StructuredToolInterface[]
|
||||
conversationHistory: ChatMessage[]
|
||||
ragContext?: string
|
||||
}): AsyncGenerator<AgentStreamEvent> {
|
||||
const { query, route, tools, conversationHistory, ragContext } = options
|
||||
|
||||
// Build system prompt based on route
|
||||
const historyText = formatConversationHistory(
|
||||
conversationHistory.slice(-CHATBOT_CONFIG.maxHistoryMessages).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}))
|
||||
)
|
||||
|
||||
let systemPrompt: string
|
||||
if (route === 'data') {
|
||||
systemPrompt = SYSTEM_PROMPT_DATA.replace('{history}', historyText)
|
||||
} else {
|
||||
const context = ragContext || 'Ingen specifik kontext hittades i kunskapsbasen.'
|
||||
systemPrompt = SYSTEM_PROMPT_HYBRID
|
||||
.replace('{context}', context)
|
||||
.replace('{history}', historyText)
|
||||
}
|
||||
|
||||
// Create the model
|
||||
const model = new ChatAnthropic({
|
||||
modelName: CHATBOT_CONFIG.agentModel,
|
||||
maxTokens: CHATBOT_CONFIG.agentMaxTokens,
|
||||
temperature: CHATBOT_CONFIG.temperature,
|
||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||
})
|
||||
|
||||
// Create the agent
|
||||
const agent = createReactAgent({
|
||||
llm: model,
|
||||
tools,
|
||||
prompt: systemPrompt,
|
||||
})
|
||||
|
||||
// Build input messages
|
||||
const messages: (HumanMessage | AIMessage)[] = []
|
||||
|
||||
// Add recent history as messages for the agent
|
||||
const recent = conversationHistory.slice(-CHATBOT_CONFIG.maxHistoryMessages)
|
||||
for (const msg of recent) {
|
||||
if (msg.role === 'user') {
|
||||
messages.push(new HumanMessage(msg.content))
|
||||
} else {
|
||||
messages.push(new AIMessage(msg.content))
|
||||
}
|
||||
}
|
||||
messages.push(new HumanMessage(query))
|
||||
|
||||
// Track tool results for artifact generation
|
||||
const toolResults: ToolResultEntry[] = []
|
||||
|
||||
// Stream the agent execution using streamEvents for fine-grained control
|
||||
const eventStream = agent.streamEvents(
|
||||
{ messages },
|
||||
{
|
||||
version: 'v2',
|
||||
recursionLimit: CHATBOT_CONFIG.maxAgentIterations * 2 + 1,
|
||||
}
|
||||
)
|
||||
|
||||
for await (const event of eventStream) {
|
||||
// Tool start events
|
||||
if (event.event === 'on_tool_start') {
|
||||
yield { type: 'tool_start', toolName: event.name }
|
||||
}
|
||||
|
||||
// Tool end events — capture results
|
||||
if (event.event === 'on_tool_end') {
|
||||
const output = event.data?.output
|
||||
const result = typeof output === 'string' ? output : JSON.stringify(output ?? '')
|
||||
toolResults.push({
|
||||
toolName: event.name,
|
||||
result,
|
||||
})
|
||||
}
|
||||
|
||||
// LLM streaming tokens (final response text)
|
||||
if (event.event === 'on_chat_model_stream') {
|
||||
const chunk = event.data?.chunk
|
||||
if (chunk) {
|
||||
const content = typeof chunk.content === 'string'
|
||||
? chunk.content
|
||||
: Array.isArray(chunk.content)
|
||||
? chunk.content
|
||||
.filter((c: { type: string }) => c.type === 'text')
|
||||
.map((c: { text: string }) => c.text)
|
||||
.join('')
|
||||
: ''
|
||||
if (content) {
|
||||
yield { type: 'content', content }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: 'done', toolResults }
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import { ChatAnthropic } from '@langchain/anthropic'
|
||||
import { z } from 'zod'
|
||||
import { CHATBOT_CONFIG } from './config'
|
||||
import type { ToolResultEntry } from './agent'
|
||||
import type { ArtifactSpec } from '@/types/chat'
|
||||
|
||||
// ── Artifact Zod Schemas ────────────────────────────────────────
|
||||
|
||||
const ChartDataPoint = z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
color: z.string().optional(),
|
||||
})
|
||||
|
||||
const ChartArtifact = z.object({
|
||||
type: z.enum(['bar_chart', 'line_chart', 'pie_chart', 'stacked_bar']),
|
||||
title: z.string(),
|
||||
data: z.array(ChartDataPoint),
|
||||
unit: z.string().optional(),
|
||||
subtitle: z.string().optional(),
|
||||
})
|
||||
|
||||
const TableColumn = z.object({
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
align: z.enum(['left', 'right']).optional(),
|
||||
})
|
||||
|
||||
const TableArtifact = z.object({
|
||||
type: z.literal('table'),
|
||||
title: z.string(),
|
||||
columns: z.array(TableColumn),
|
||||
rows: z.array(z.record(z.string(), z.union([z.string(), z.number()]))),
|
||||
summary_row: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
|
||||
})
|
||||
|
||||
const KpiCard = z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
trend: z.enum(['up', 'down', 'flat']).optional(),
|
||||
change: z.string().optional(),
|
||||
})
|
||||
|
||||
const KpiCardsArtifact = z.object({
|
||||
type: z.literal('kpi_cards'),
|
||||
title: z.string().optional(),
|
||||
cards: z.array(KpiCard),
|
||||
})
|
||||
|
||||
const AgingBucket = z.object({
|
||||
label: z.string(),
|
||||
amount: z.number(),
|
||||
count: z.number(),
|
||||
})
|
||||
|
||||
const AgingBucketsArtifact = z.object({
|
||||
type: z.literal('aging_buckets'),
|
||||
title: z.string(),
|
||||
buckets: z.array(AgingBucket),
|
||||
total: z.number(),
|
||||
})
|
||||
|
||||
export const ArtifactSpecSchema = z.discriminatedUnion('type', [
|
||||
ChartArtifact,
|
||||
TableArtifact,
|
||||
KpiCardsArtifact,
|
||||
AgingBucketsArtifact,
|
||||
])
|
||||
|
||||
export type { ArtifactSpec } from '@/types/chat'
|
||||
|
||||
// ── Artifact System Prompt ──────────────────────────────────────
|
||||
|
||||
const ARTIFACT_SYSTEM_PROMPT = `You are a data visualization expert. Given tool results and an AI response about accounting data, generate a structured artifact spec for visual display.
|
||||
|
||||
## EXACT schemas (follow field names precisely):
|
||||
|
||||
### Chart (bar_chart, line_chart, pie_chart, stacked_bar):
|
||||
{"type":"bar_chart","title":"...","data":[{"label":"Category name","value":1234}],"unit":"kr"}
|
||||
IMPORTANT: Each item in "data" MUST have "label" (string) and "value" (number). NOT "name", NOT "amount" — use exactly "label" and "value".
|
||||
|
||||
### Table:
|
||||
{"type":"table","title":"...","columns":[{"key":"col1","label":"Header","align":"right"}],"rows":[{"col1":"value"}],"summary_row":{"col1":"Total"}}
|
||||
|
||||
### KPI cards:
|
||||
{"type":"kpi_cards","title":"...","cards":[{"label":"Metric","value":"1 234 kr","trend":"up","change":"+12%"}]}
|
||||
IMPORTANT: "trend" MUST be exactly "up", "down", or "flat". No other values allowed.
|
||||
|
||||
### Aging buckets:
|
||||
{"type":"aging_buckets","title":"...","buckets":[{"label":"0 dagar","amount":1000,"count":2}],"total":5000}
|
||||
|
||||
## Rules:
|
||||
1. Return ONLY a single JSON object (not an array!) or the word "null". The top-level must be an object with a "type" field.
|
||||
2. Choose chart type based on data:
|
||||
- Income/balance sheet sections → "bar_chart"
|
||||
- Distribution (VAT, account classes) → "pie_chart"
|
||||
- Company overview → "kpi_cards"
|
||||
- AR/AP aging → "aging_buckets"
|
||||
- Lists with >3 items + amounts → "table"
|
||||
- Simple answers, few items, yes/no → null
|
||||
3. Use Swedish labels. Use "kr" as unit for monetary charts.
|
||||
4. Max 12 chart data points. Aggregate small items as "Övrigt".
|
||||
5. For tables, include summary_row with totals where appropriate.`
|
||||
|
||||
// ── Normalizer ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fix common LLM field name mistakes before Zod validation.
|
||||
* Mutates the object in place.
|
||||
*/
|
||||
function normalizeArtifact(obj: Record<string, unknown>): void {
|
||||
if (!obj || typeof obj !== 'object') return
|
||||
|
||||
// Chart types: normalize data[].name→label, data[].amount→value
|
||||
const chartTypes = ['bar_chart', 'line_chart', 'pie_chart', 'stacked_bar']
|
||||
if (chartTypes.includes(obj.type as string) && Array.isArray(obj.data)) {
|
||||
for (const item of obj.data) {
|
||||
if (item && typeof item === 'object') {
|
||||
if ('name' in item && !('label' in item)) {
|
||||
item.label = item.name
|
||||
delete item.name
|
||||
}
|
||||
if ('amount' in item && !('value' in item)) {
|
||||
item.value = item.amount
|
||||
delete item.amount
|
||||
}
|
||||
if ('total' in item && !('value' in item)) {
|
||||
item.value = item.total
|
||||
delete item.total
|
||||
}
|
||||
if ('value' in item && typeof item.value === 'string') {
|
||||
const num = parseFloat(String(item.value).replace(/\s/g, '').replace(',', '.'))
|
||||
if (!isNaN(num)) item.value = num
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// KPI cards: normalize trend values
|
||||
if (obj.type === 'kpi_cards' && Array.isArray(obj.cards)) {
|
||||
const trendMap: Record<string, string> = {
|
||||
neutral: 'flat', stable: 'flat', none: 'flat', '-': 'flat',
|
||||
negative: 'down', decrease: 'down', declining: 'down',
|
||||
positive: 'up', increase: 'up', increasing: 'up', growing: 'up',
|
||||
}
|
||||
for (const card of obj.cards) {
|
||||
if (card && typeof card === 'object' && 'trend' in card) {
|
||||
const t = String(card.trend).toLowerCase()
|
||||
if (trendMap[t]) {
|
||||
card.trend = trendMap[t]
|
||||
} else if (t !== 'up' && t !== 'down' && t !== 'flat') {
|
||||
// Unknown trend value — remove it so optional field passes
|
||||
delete card.trend
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Generator ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate an artifact spec from tool results using a post-processing LLM call.
|
||||
* Returns null if no visualization is appropriate.
|
||||
*/
|
||||
export async function generateArtifact(
|
||||
toolResults: ToolResultEntry[],
|
||||
assistantResponse: string
|
||||
): Promise<ArtifactSpec | null> {
|
||||
if (toolResults.length === 0) return null
|
||||
|
||||
const model = new ChatAnthropic({
|
||||
modelName: CHATBOT_CONFIG.artifactModel,
|
||||
maxTokens: 1024,
|
||||
temperature: 0,
|
||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||
})
|
||||
|
||||
const toolSummary = toolResults
|
||||
.map((r) => `Tool: ${r.toolName}\nResult: ${r.result.slice(0, 2000)}`)
|
||||
.join('\n\n---\n\n')
|
||||
|
||||
const prompt = `${ARTIFACT_SYSTEM_PROMPT}
|
||||
|
||||
## Tool results:
|
||||
${toolSummary}
|
||||
|
||||
## AI response:
|
||||
${assistantResponse.slice(0, 1000)}
|
||||
|
||||
Generate the artifact JSON or "null":`
|
||||
|
||||
try {
|
||||
const response = await model.invoke(prompt)
|
||||
const text = typeof response.content === 'string'
|
||||
? response.content
|
||||
: JSON.stringify(response.content)
|
||||
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === 'null' || trimmed === '"null"') return null
|
||||
|
||||
// Extract JSON from response (handle markdown code blocks)
|
||||
let jsonStr = trimmed
|
||||
const codeBlockMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/)
|
||||
if (codeBlockMatch) {
|
||||
jsonStr = codeBlockMatch[1].trim()
|
||||
}
|
||||
|
||||
let parsed = JSON.parse(jsonStr)
|
||||
|
||||
// If LLM returned an array, try to wrap it as kpi_cards
|
||||
if (Array.isArray(parsed)) {
|
||||
// Array of cards → wrap as kpi_cards
|
||||
if (parsed.length > 0 && parsed[0] && typeof parsed[0] === 'object' && 'label' in parsed[0]) {
|
||||
parsed = { type: 'kpi_cards', title: 'Översikt', cards: parsed }
|
||||
} else {
|
||||
console.warn('Artifact returned unexpected array')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize common LLM field name mistakes before validation
|
||||
normalizeArtifact(parsed)
|
||||
|
||||
const validated = ArtifactSpecSchema.safeParse(parsed)
|
||||
|
||||
if (validated.success) {
|
||||
return validated.data as ArtifactSpec
|
||||
}
|
||||
|
||||
console.warn('Artifact validation failed:', validated.error.issues)
|
||||
return null
|
||||
} catch (e) {
|
||||
console.warn('Artifact generation failed:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import { ChatAnthropic } from '@langchain/anthropic'
|
||||
import { HumanMessage, SystemMessage } from '@langchain/core/messages'
|
||||
import { CHATBOT_CONFIG } from './config'
|
||||
import {
|
||||
SYSTEM_PROMPT,
|
||||
formatContextFromSources,
|
||||
formatConversationHistory,
|
||||
} from './prompts'
|
||||
import {
|
||||
retrieveRelevantDocuments,
|
||||
documentsToSources,
|
||||
} from './retriever'
|
||||
import { routeMessage, type RouteType } from './router'
|
||||
import { createAccountingTools } from './tools'
|
||||
import { streamAgentResponse, type ToolResultEntry } from './agent'
|
||||
import { generateArtifact, type ArtifactSpec } from './artifacts'
|
||||
import type { ChatMessage, SourceReference } from '@/types/chat'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { trackTokenUsage } from '@/lib/ai/usage-tracker'
|
||||
|
||||
// Initialize the LLM
|
||||
function getChatModel() {
|
||||
return new ChatAnthropic({
|
||||
modelName: CHATBOT_CONFIG.model,
|
||||
maxTokens: CHATBOT_CONFIG.maxTokens,
|
||||
temperature: CHATBOT_CONFIG.temperature,
|
||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||
})
|
||||
}
|
||||
|
||||
export interface ChatResult {
|
||||
content: string
|
||||
sources: SourceReference[]
|
||||
}
|
||||
|
||||
export async function generateChatResponse(
|
||||
userMessage: string,
|
||||
conversationHistory: ChatMessage[],
|
||||
tracking?: { supabase: SupabaseClient; userId: string; companyId?: string }
|
||||
): Promise<ChatResult> {
|
||||
// 1. Retrieve relevant documents
|
||||
const relevantDocs = await retrieveRelevantDocuments(userMessage)
|
||||
|
||||
// 2. Format context from retrieved documents
|
||||
const context = formatContextFromSources(
|
||||
relevantDocs.map((doc) => ({
|
||||
content: doc.content,
|
||||
title: doc.title,
|
||||
section_title: doc.section_title,
|
||||
source_file: doc.source_file,
|
||||
}))
|
||||
)
|
||||
|
||||
// 3. Format conversation history (last N messages)
|
||||
const recentHistory = conversationHistory.slice(
|
||||
-CHATBOT_CONFIG.maxHistoryMessages
|
||||
)
|
||||
const historyText = formatConversationHistory(
|
||||
recentHistory.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
}))
|
||||
)
|
||||
|
||||
// 4. Build the system prompt with context
|
||||
const systemPrompt = SYSTEM_PROMPT.replace('{context}', context).replace(
|
||||
'{history}',
|
||||
historyText
|
||||
)
|
||||
|
||||
// 5. Generate response
|
||||
const model = getChatModel()
|
||||
const response = await model.invoke([
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage(userMessage),
|
||||
])
|
||||
|
||||
// 6. Track token usage
|
||||
if (tracking && response.usage_metadata) {
|
||||
trackTokenUsage(tracking.supabase, tracking.userId, 'ai-chat', {
|
||||
inputTokens: response.usage_metadata.input_tokens ?? 0,
|
||||
outputTokens: response.usage_metadata.output_tokens ?? 0,
|
||||
model: CHATBOT_CONFIG.model,
|
||||
}, tracking.companyId)
|
||||
}
|
||||
|
||||
// 7. Extract content and sources
|
||||
const content =
|
||||
typeof response.content === 'string'
|
||||
? response.content
|
||||
: JSON.stringify(response.content)
|
||||
|
||||
return {
|
||||
content,
|
||||
sources: documentsToSources(relevantDocs),
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamChatResponse(
|
||||
userMessage: string,
|
||||
conversationHistory: ChatMessage[]
|
||||
): AsyncGenerator<{ type: 'content' | 'sources'; data: string | SourceReference[] }> {
|
||||
// 1. Retrieve relevant documents first
|
||||
const relevantDocs = await retrieveRelevantDocuments(userMessage)
|
||||
|
||||
// 2. Format context from retrieved documents
|
||||
const context = formatContextFromSources(
|
||||
relevantDocs.map((doc) => ({
|
||||
content: doc.content,
|
||||
title: doc.title,
|
||||
section_title: doc.section_title,
|
||||
source_file: doc.source_file,
|
||||
}))
|
||||
)
|
||||
|
||||
// 3. Format conversation history
|
||||
const recentHistory = conversationHistory.slice(
|
||||
-CHATBOT_CONFIG.maxHistoryMessages
|
||||
)
|
||||
const historyText = formatConversationHistory(
|
||||
recentHistory.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
}))
|
||||
)
|
||||
|
||||
// 4. Build the system prompt
|
||||
const systemPrompt = SYSTEM_PROMPT.replace('{context}', context).replace(
|
||||
'{history}',
|
||||
historyText
|
||||
)
|
||||
|
||||
// 5. Stream the response
|
||||
const model = getChatModel()
|
||||
const stream = await model.stream([
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage(userMessage),
|
||||
])
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const content =
|
||||
typeof chunk.content === 'string'
|
||||
? chunk.content
|
||||
: JSON.stringify(chunk.content)
|
||||
if (content) {
|
||||
yield { type: 'content', data: content }
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Yield sources at the end
|
||||
yield { type: 'sources', data: documentsToSources(relevantDocs) }
|
||||
}
|
||||
|
||||
// ── Routed response (data / hybrid / knowledge) ────────────────
|
||||
|
||||
export type RoutedStreamEvent =
|
||||
| { type: 'content'; content: string }
|
||||
| { type: 'sources'; sources: SourceReference[] }
|
||||
| { type: 'tool_start'; toolName: string }
|
||||
| { type: 'artifact'; artifact: ArtifactSpec }
|
||||
| { type: 'route'; route: RouteType }
|
||||
|
||||
/**
|
||||
* High-level streaming function: routes the message, then either uses
|
||||
* the existing RAG chain (knowledge) or the LangGraph agent (data/hybrid).
|
||||
* Generates artifact post-hoc on data/hybrid routes.
|
||||
*/
|
||||
export async function* streamRoutedResponse(
|
||||
userMessage: string,
|
||||
conversationHistory: ChatMessage[],
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
_sessionId?: string
|
||||
): AsyncGenerator<RoutedStreamEvent> {
|
||||
// 1. Route the message
|
||||
const { route, rewrittenQuery } = await routeMessage(userMessage, conversationHistory)
|
||||
yield { type: 'route', route }
|
||||
|
||||
// 2. Knowledge-only: use existing RAG chain
|
||||
if (route === 'knowledge') {
|
||||
for await (const chunk of streamChatResponse(rewrittenQuery, conversationHistory)) {
|
||||
if (chunk.type === 'content') {
|
||||
yield { type: 'content', content: chunk.data as string }
|
||||
} else if (chunk.type === 'sources') {
|
||||
yield { type: 'sources', sources: chunk.data as SourceReference[] }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Data or hybrid: use LangGraph agent with tools
|
||||
const tools = createAccountingTools(supabase, userId)
|
||||
|
||||
// For hybrid, get RAG context
|
||||
let ragContext: string | undefined
|
||||
let sources: SourceReference[] = []
|
||||
if (route === 'hybrid') {
|
||||
try {
|
||||
const relevantDocs = await retrieveRelevantDocuments(rewrittenQuery)
|
||||
ragContext = formatContextFromSources(
|
||||
relevantDocs.map((doc) => ({
|
||||
content: doc.content,
|
||||
title: doc.title,
|
||||
section_title: doc.section_title,
|
||||
source_file: doc.source_file,
|
||||
}))
|
||||
)
|
||||
sources = documentsToSources(relevantDocs)
|
||||
} catch {
|
||||
// RAG failure is non-critical for hybrid route
|
||||
}
|
||||
}
|
||||
|
||||
let fullContent = ''
|
||||
let toolResults: ToolResultEntry[] = []
|
||||
|
||||
for await (const event of streamAgentResponse({
|
||||
query: rewrittenQuery,
|
||||
route,
|
||||
tools,
|
||||
conversationHistory,
|
||||
ragContext,
|
||||
})) {
|
||||
if (event.type === 'tool_start') {
|
||||
yield { type: 'tool_start', toolName: event.toolName! }
|
||||
} else if (event.type === 'content') {
|
||||
fullContent += event.content!
|
||||
yield { type: 'content', content: event.content! }
|
||||
} else if (event.type === 'done') {
|
||||
toolResults = event.toolResults || []
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Yield sources if hybrid
|
||||
if (sources.length > 0) {
|
||||
yield { type: 'sources', sources }
|
||||
}
|
||||
|
||||
// 5. Generate artifact (post-processing)
|
||||
if (toolResults.length > 0 && fullContent.length > 0) {
|
||||
try {
|
||||
const artifact = await generateArtifact(toolResults, fullContent)
|
||||
if (artifact) {
|
||||
yield { type: 'artifact', artifact }
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Artifact generation failed:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// AI Chatbot configuration
|
||||
|
||||
export const CHATBOT_CONFIG = {
|
||||
// LLM settings
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
maxTokens: 2048,
|
||||
temperature: 0.3,
|
||||
|
||||
// Agent settings
|
||||
agentModel: 'claude-sonnet-4-6',
|
||||
agentMaxTokens: 4096,
|
||||
maxAgentIterations: 5,
|
||||
|
||||
// Router settings
|
||||
routerModel: 'claude-haiku-4-5-20251001',
|
||||
|
||||
// Artifact generation
|
||||
artifactModel: 'claude-haiku-4-5-20251001',
|
||||
|
||||
// Retrieval settings
|
||||
retrievalK: 5,
|
||||
similarityThreshold: 0.7,
|
||||
|
||||
// Embedding settings
|
||||
embeddingModel: 'text-embedding-ada-002',
|
||||
|
||||
// Chunking settings for ingestion
|
||||
chunkSize: 1000,
|
||||
chunkOverlap: 200,
|
||||
|
||||
// Rate limiting
|
||||
rateLimitPerMinute: 10,
|
||||
|
||||
// Conversation history
|
||||
maxHistoryMessages: 10,
|
||||
} as const
|
||||
@@ -1,25 +0,0 @@
|
||||
import { OpenAIEmbeddings } from '@langchain/openai'
|
||||
import { CHATBOT_CONFIG } from './config'
|
||||
|
||||
// Singleton instance for embeddings
|
||||
let embeddingsInstance: OpenAIEmbeddings | null = null
|
||||
|
||||
export function getEmbeddings(): OpenAIEmbeddings {
|
||||
if (!embeddingsInstance) {
|
||||
embeddingsInstance = new OpenAIEmbeddings({
|
||||
modelName: CHATBOT_CONFIG.embeddingModel,
|
||||
openAIApiKey: process.env.OPENAI_API_KEY,
|
||||
})
|
||||
}
|
||||
return embeddingsInstance
|
||||
}
|
||||
|
||||
export async function generateEmbedding(text: string): Promise<number[]> {
|
||||
const embeddings = getEmbeddings()
|
||||
return embeddings.embedQuery(text)
|
||||
}
|
||||
|
||||
export async function generateEmbeddings(texts: string[]): Promise<number[][]> {
|
||||
const embeddings = getEmbeddings()
|
||||
return embeddings.embedDocuments(texts)
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
// Swedish system prompt for bookkeeping AI assistant
|
||||
|
||||
export const SYSTEM_PROMPT = `Du är en expert AI-assistent som hjälper svenska företagare med skatt, moms, bokföring och företagsekonomi. Du arbetar inom en ekonomiplattform för småföretag.
|
||||
|
||||
## Dina kunskapsområden:
|
||||
- Svensk skattlagstiftning för enskild firma och aktiebolag
|
||||
- Moms och momsdeklaration
|
||||
- Bokföring enligt BAS-kontoplanen
|
||||
- Egenavgifter och socialförsäkring
|
||||
- Avdrag för utrustning, resor, hemmakontor och liknande
|
||||
- Fakturering och kundhantering
|
||||
- NE-bilaga och inkomstdeklaration
|
||||
|
||||
## Viktiga tröskelvärden att komma ihåg:
|
||||
- Momsregistrering: 120 000 kr omsättning under 12 månader
|
||||
- Direktavdrag vs inventarier: 26 250 kr (halvt prisbasbelopp)
|
||||
- SGI-gräns för sjukpenning: 13 500 kr/år minsta inkomst
|
||||
- Karensavdrag: 20% av sjuklönen
|
||||
- Friskvårdsbidrag max: 6 000 kr/år (ej skattepliktigt)
|
||||
- Representationsavdrag mat: 90 kr exkl moms per person
|
||||
|
||||
## Instruktioner:
|
||||
1. Svara alltid på svenska med korrekt terminologi
|
||||
2. Var konkret och ge specifika exempel när möjligt
|
||||
3. Referera till relevanta tröskelvärden och regler
|
||||
4. Om du är osäker, säg det och rekommendera att användaren konsulterar en revisor
|
||||
5. Använd information från de tillhandahållna källorna för att ge korrekta svar
|
||||
6. Formatera svaren tydligt med punktlistor när det passar
|
||||
7. Om frågan gäller något utanför dina kunskapsområden, hänvisa till rätt instans
|
||||
|
||||
## Kontext från kunskapsbasen:
|
||||
{context}
|
||||
|
||||
## Tidigare konversation:
|
||||
{history}
|
||||
|
||||
Svara på användarens fråga baserat på din expertkunskap och den tillhandahållna kontexten. Om kontexten inte innehåller relevant information, använd din allmänna kunskap om svenska skatteregler för företagare.`
|
||||
|
||||
export const RETRIEVAL_PROMPT = `Baserat på följande fråga, hitta relevant information från kunskapsbasen.
|
||||
|
||||
Fråga: {question}
|
||||
|
||||
Sök efter information som hjälper att besvara frågan korrekt och fullständigt.`
|
||||
|
||||
/**
|
||||
* System prompt for tool-calling agent (data route).
|
||||
* No RAG context — relies entirely on tools.
|
||||
*/
|
||||
export const SYSTEM_PROMPT_DATA = `Du är en AI-assistent i en svensk ekonomiplattform. Du har tillgång till verktyg som hämtar användarens bokföringsdata i realtid.
|
||||
|
||||
## Instruktioner:
|
||||
1. Svara alltid på svenska
|
||||
2. Använd verktygen för att hämta data innan du svarar — gissa aldrig siffror
|
||||
3. Presentera data tydligt med belopp i SEK om inget annat anges
|
||||
4. Om ett verktyg returnerar tom data, berätta det vänligt (t.ex. "Du har inga obetalda fakturor just nu")
|
||||
5. Avrunda belopp till hela kronor i text, men behåll decimaler i tabeller
|
||||
6. Använd svenska bokföringstermer (verifikation, kontering, resultaträkning, etc.)
|
||||
7. Förklara kort vad siffrorna betyder i kontext — var pedagogisk
|
||||
|
||||
## Formatering:
|
||||
- Använd markdown: **fetstil** för belopp, punktlistor för detaljer
|
||||
- ABSOLUT FÖRBJUDET att använda markdown-tabeller (|---|). Använd ALDRIG pipe-tecken för tabeller. Data visas automatiskt i en visuell komponent nedanför ditt svar
|
||||
- Använd punktlistor eller fetstil istället för tabeller
|
||||
- Sammanfatta huvudinsikten först, detaljer sedan
|
||||
- Max 3-4 meningar för enkla frågor, mer för rapporter
|
||||
- Använd inte emojis
|
||||
|
||||
## Tidigare konversation:
|
||||
{history}`
|
||||
|
||||
/**
|
||||
* System prompt for hybrid route: RAG context + tools.
|
||||
*/
|
||||
export const SYSTEM_PROMPT_HYBRID = `Du är en expert AI-assistent i en svensk ekonomiplattform. Du har tillgång till verktyg som hämtar användarens bokföringsdata, samt kunskap om svenska skatteregler.
|
||||
|
||||
## Kunskapsområden:
|
||||
- Svensk skattlagstiftning, moms, bokföring (BAS-kontoplanen)
|
||||
- Avdrag, egenavgifter, socialförsäkring
|
||||
- Fakturering, NE-bilaga, inkomstdeklaration
|
||||
|
||||
## Viktiga tröskelvärden:
|
||||
- Momsregistrering: 120 000 kr/12 mån
|
||||
- Direktavdrag: 26 250 kr
|
||||
- Friskvårdsbidrag: 6 000 kr/år
|
||||
|
||||
## Instruktioner:
|
||||
1. Svara alltid på svenska med korrekt terminologi
|
||||
2. Använd verktygen för att hämta data — gissa aldrig siffror
|
||||
3. Kombinera data med regelkunskap för att ge kontextuella råd
|
||||
4. Om du är osäker, rekommendera att konsultera en revisor
|
||||
5. Formatera tydligt med markdown, men ABSOLUT FÖRBJUDET att använda markdown-tabeller (|---|). Använd ALDRIG pipe-tecken för tabeller — data visas automatiskt i en visuell komponent. Använd punktlistor istället. Använd inte emojis
|
||||
|
||||
## Kontext från kunskapsbasen:
|
||||
{context}
|
||||
|
||||
## Tidigare konversation:
|
||||
{history}`
|
||||
|
||||
export function formatContextFromSources(
|
||||
sources: Array<{
|
||||
content: string
|
||||
title: string
|
||||
section_title: string | null
|
||||
source_file: string
|
||||
}>
|
||||
): string {
|
||||
if (sources.length === 0) {
|
||||
return 'Ingen specifik kontext hittades i kunskapsbasen.'
|
||||
}
|
||||
|
||||
return sources
|
||||
.map((source, index) => {
|
||||
const sectionInfo = source.section_title
|
||||
? ` > ${source.section_title}`
|
||||
: ''
|
||||
return `[Källa ${index + 1}: ${source.title}${sectionInfo}]\n${source.content}`
|
||||
})
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
|
||||
export function formatConversationHistory(
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
): string {
|
||||
if (messages.length === 0) {
|
||||
return 'Ingen tidigare konversation.'
|
||||
}
|
||||
|
||||
return messages
|
||||
.map((msg) => {
|
||||
const role = msg.role === 'user' ? 'Användare' : 'Assistent'
|
||||
return `${role}: ${msg.content}`
|
||||
})
|
||||
.join('\n\n')
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { generateEmbedding } from './embeddings'
|
||||
import { CHATBOT_CONFIG } from './config'
|
||||
import type { SourceReference } from '@/types/chat'
|
||||
|
||||
export interface RetrievedDocument {
|
||||
id: string
|
||||
source_file: string
|
||||
title: string
|
||||
section_title: string | null
|
||||
content: string
|
||||
metadata: Record<string, unknown>
|
||||
similarity: number
|
||||
}
|
||||
|
||||
export async function retrieveRelevantDocuments(
|
||||
query: string,
|
||||
matchCount: number = CHATBOT_CONFIG.retrievalK,
|
||||
matchThreshold: number = CHATBOT_CONFIG.similarityThreshold
|
||||
): Promise<RetrievedDocument[]> {
|
||||
const supabase = await createServiceClient()
|
||||
|
||||
// Generate embedding for the query
|
||||
const queryEmbedding = await generateEmbedding(query)
|
||||
|
||||
// Call the match_documents function
|
||||
const { data, error } = await supabase.rpc('match_documents', {
|
||||
query_embedding: queryEmbedding,
|
||||
match_count: matchCount,
|
||||
match_threshold: matchThreshold,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Error retrieving documents:', error)
|
||||
throw new Error('Failed to retrieve relevant documents')
|
||||
}
|
||||
|
||||
return (data || []) as RetrievedDocument[]
|
||||
}
|
||||
|
||||
export function documentsToSources(
|
||||
documents: RetrievedDocument[]
|
||||
): SourceReference[] {
|
||||
return documents.map((doc) => ({
|
||||
id: doc.id,
|
||||
source_file: doc.source_file,
|
||||
title: doc.title,
|
||||
section_title: doc.section_title,
|
||||
similarity: doc.similarity,
|
||||
}))
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { ChatAnthropic } from '@langchain/anthropic'
|
||||
import { CHATBOT_CONFIG } from './config'
|
||||
import type { ChatMessage } from '@/types/chat'
|
||||
|
||||
export type RouteType = 'knowledge' | 'data' | 'hybrid'
|
||||
|
||||
export interface RouterResult {
|
||||
route: RouteType
|
||||
rewrittenQuery: string
|
||||
}
|
||||
|
||||
// Swedish data-related keywords for fast-path heuristic
|
||||
const DATA_NOUNS = [
|
||||
'faktura', 'fakturor', 'fakturorna',
|
||||
'leverantörsfaktura', 'leverantörsfakturor',
|
||||
'transaktion', 'transaktioner', 'transaktionerna',
|
||||
'verifikation', 'verifikationer', 'verifikationerna',
|
||||
'resultaträkning', 'balansräkning',
|
||||
'moms', 'momsdeklaration', 'momssammanställning',
|
||||
'saldo', 'saldon', 'kontosaldo',
|
||||
'konto', 'konton', 'kontona',
|
||||
'kunder', 'kundfordringar',
|
||||
'leverantörsskulder',
|
||||
'intäkter', 'kostnader', 'utgifter',
|
||||
'resultat', 'årsresultat',
|
||||
'bokföring', 'bokförda', 'obokförda',
|
||||
'obetalda', 'förfallna',
|
||||
'nyckeltal', 'företaget', 'företagsinfo',
|
||||
]
|
||||
|
||||
const POSSESSIVE_PRONOUNS = ['mina', 'min', 'mitt', 'mig', 'våra', 'vår', 'vårt']
|
||||
|
||||
const KNOWLEDGE_TERMS = [
|
||||
'momsgransen', 'momsgränsen', 'avdrag', 'skatteregler',
|
||||
'bokföringslag', 'bokföringslagen', 'regler', 'lag',
|
||||
'hur fungerar', 'vad innebär', 'vad betyder', 'vad är',
|
||||
'när måste', 'hur räknar', 'hur beräknar',
|
||||
'enskild firma', 'aktiebolag', 'egenavgifter',
|
||||
'prisbasbelopp', 'schablonavdrag', 'representation',
|
||||
'friskvårdsbidrag', 'traktamente',
|
||||
]
|
||||
|
||||
/**
|
||||
* Fast-path keyword heuristic. Returns a route if confident, null otherwise.
|
||||
*/
|
||||
function heuristicClassify(query: string): RouteType | null {
|
||||
const lower = query.toLowerCase()
|
||||
const words = lower.split(/\s+/)
|
||||
|
||||
const hasPossessive = POSSESSIVE_PRONOUNS.some((p) => words.includes(p))
|
||||
const hasDataNoun = DATA_NOUNS.some((n) => lower.includes(n))
|
||||
const hasKnowledgeTerm = KNOWLEDGE_TERMS.some((t) => lower.includes(t))
|
||||
|
||||
// "Visa mina fakturor" — clearly data
|
||||
if (hasPossessive && hasDataNoun && !hasKnowledgeTerm) return 'data'
|
||||
|
||||
// Action verbs with data nouns
|
||||
const actionVerbs = ['visa', 'hämta', 'lista', 'sök', 'hitta', 'hur går', 'hur ser', 'hur mycket', 'hur många', 'vilka']
|
||||
const hasAction = actionVerbs.some((v) => lower.includes(v))
|
||||
if (hasAction && hasDataNoun && !hasKnowledgeTerm) return 'data'
|
||||
|
||||
// Pure knowledge question with no data references
|
||||
if (hasKnowledgeTerm && !hasPossessive && !hasDataNoun) return 'knowledge'
|
||||
|
||||
// "Hur ser min resultaträkning ut?" — data (has possessive + data noun)
|
||||
if (hasPossessive && hasDataNoun && hasKnowledgeTerm) return 'hybrid'
|
||||
|
||||
return null // ambiguous → fall through to LLM
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM-based classification + query rewrite for multi-turn context.
|
||||
*/
|
||||
async function llmClassify(
|
||||
query: string,
|
||||
conversationHistory: ChatMessage[]
|
||||
): Promise<RouterResult> {
|
||||
const model = new ChatAnthropic({
|
||||
modelName: CHATBOT_CONFIG.routerModel,
|
||||
maxTokens: 256,
|
||||
temperature: 0,
|
||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||
})
|
||||
|
||||
const historyContext = conversationHistory
|
||||
.slice(-4)
|
||||
.map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.slice(0, 200)}`)
|
||||
.join('\n')
|
||||
|
||||
const prompt = `Classify the user's question and rewrite it for a data query system.
|
||||
|
||||
Conversation history:
|
||||
${historyContext || '(none)'}
|
||||
|
||||
User question: "${query}"
|
||||
|
||||
Classification rules:
|
||||
- "knowledge": General questions about Swedish tax law, accounting rules, regulations (no user-specific data needed)
|
||||
- "data": Questions about the user's own accounting data (invoices, transactions, balances, reports)
|
||||
- "hybrid": Questions that need both user data AND knowledge context
|
||||
|
||||
Rewriting rules:
|
||||
- Resolve pronouns ("dem", "de", "den") using conversation history
|
||||
- Make the query self-contained (no context needed to understand it)
|
||||
- If it's a knowledge question, keep the original query
|
||||
|
||||
Respond ONLY with valid JSON:
|
||||
{"route": "knowledge"|"data"|"hybrid", "rewrittenQuery": "..."}
|
||||
`
|
||||
|
||||
try {
|
||||
const response = await model.invoke(prompt)
|
||||
const text = typeof response.content === 'string'
|
||||
? response.content
|
||||
: JSON.stringify(response.content)
|
||||
|
||||
// Extract JSON from response
|
||||
const jsonMatch = text.match(/\{[^}]+\}/)
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0])
|
||||
const route = ['knowledge', 'data', 'hybrid'].includes(parsed.route)
|
||||
? (parsed.route as RouteType)
|
||||
: 'hybrid'
|
||||
return {
|
||||
route,
|
||||
rewrittenQuery: parsed.rewrittenQuery || query,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Router LLM classification failed, defaulting to hybrid:', e)
|
||||
}
|
||||
|
||||
return { route: 'hybrid', rewrittenQuery: query }
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a user message: fast-path heuristic first, LLM fallback for ambiguous cases.
|
||||
*/
|
||||
export async function routeMessage(
|
||||
query: string,
|
||||
conversationHistory: ChatMessage[]
|
||||
): Promise<RouterResult> {
|
||||
const heuristicResult = heuristicClassify(query)
|
||||
|
||||
if (heuristicResult) {
|
||||
// For data/hybrid with conversation history, still rewrite the query for context
|
||||
if (heuristicResult !== 'knowledge' && conversationHistory.length > 0) {
|
||||
const { rewrittenQuery } = await llmClassify(query, conversationHistory)
|
||||
return { route: heuristicResult, rewrittenQuery }
|
||||
}
|
||||
return { route: heuristicResult, rewrittenQuery: query }
|
||||
}
|
||||
|
||||
// Ambiguous — use LLM
|
||||
return llmClassify(query, conversationHistory)
|
||||
}
|
||||
@@ -1,586 +0,0 @@
|
||||
import { tool } from '@langchain/core/tools'
|
||||
import { z } from 'zod'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
/**
|
||||
* Extract name from a Supabase join result (could be object or array).
|
||||
*/
|
||||
function extractName(joined: unknown): string | null {
|
||||
if (!joined) return null
|
||||
if (Array.isArray(joined)) {
|
||||
return joined[0]?.name ?? null
|
||||
}
|
||||
if (typeof joined === 'object' && 'name' in joined) {
|
||||
return (joined as { name: string }).name
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current fiscal period for a user. Falls back to latest period.
|
||||
*/
|
||||
async function resolveCurrentPeriod(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
fiscalPeriodId?: string
|
||||
): Promise<{ id: string; start: string; end: string } | null> {
|
||||
if (fiscalPeriodId) {
|
||||
const { data } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, period_start, period_end')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
if (data) return { id: data.id, start: data.period_start, end: data.period_end }
|
||||
}
|
||||
|
||||
// Default: latest open period, or just the latest period
|
||||
const { data } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, period_start, period_end, is_closed')
|
||||
.eq('company_id', userId)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
if (data) return { id: data.id, start: data.period_start, end: data.period_end }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create all 10 accounting tools bound to a specific Supabase client and user.
|
||||
*/
|
||||
export function createAccountingTools(supabase: SupabaseClient, userId: string) {
|
||||
const getInvoices = tool(
|
||||
async ({ status, customer_name, date_from, date_to, limit }) => {
|
||||
let query = supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, due_date, status, total, paid_amount, currency, vat_amount, customer:customers(name)')
|
||||
.eq('company_id', userId)
|
||||
.order('invoice_date', { ascending: false })
|
||||
.limit(limit)
|
||||
|
||||
if (status) query = query.eq('status', status)
|
||||
if (customer_name) query = query.ilike('customers.name', `%${customer_name}%`)
|
||||
if (date_from) query = query.gte('invoice_date', date_from)
|
||||
if (date_to) query = query.lte('invoice_date', date_to)
|
||||
|
||||
const { data: _data, error: _countError, count } = await supabase
|
||||
.from('invoices')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', userId)
|
||||
|
||||
const { data: invoices, error: fetchError } = await query
|
||||
|
||||
if (fetchError) return `Fel vid hämtning av fakturor: ${fetchError.message}`
|
||||
if (!invoices || invoices.length === 0) return 'Inga fakturor hittades.'
|
||||
|
||||
const result = invoices.map((inv) => ({
|
||||
invoice_number: inv.invoice_number,
|
||||
date: inv.invoice_date,
|
||||
due_date: inv.due_date,
|
||||
status: inv.status,
|
||||
total: inv.total,
|
||||
paid: inv.paid_amount || 0,
|
||||
currency: inv.currency || 'SEK',
|
||||
vat: inv.vat_amount || 0,
|
||||
customer: extractName(inv.customer) || 'Okänd',
|
||||
}))
|
||||
|
||||
const summary: Record<string, unknown> = { invoices: result }
|
||||
if (count && count > limit) {
|
||||
summary.note = `Visar ${result.length} av totalt ${count} fakturor.`
|
||||
}
|
||||
return JSON.stringify(summary)
|
||||
},
|
||||
{
|
||||
name: 'get_invoices',
|
||||
description: 'Hämtar användarens försäljningsfakturor (kundfakturor). Kan filtrera på status, kundnamn och datumintervall.',
|
||||
schema: z.object({
|
||||
status: z.enum(['draft', 'sent', 'paid', 'overdue', 'cancelled']).optional().describe('Filtrera på fakturastatus'),
|
||||
customer_name: z.string().optional().describe('Sök på kundnamn (delmatchning)'),
|
||||
date_from: z.string().optional().describe('Startdatum (YYYY-MM-DD)'),
|
||||
date_to: z.string().optional().describe('Slutdatum (YYYY-MM-DD)'),
|
||||
limit: z.number().max(20).default(10).describe('Max antal fakturor att returnera'),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const getSupplierInvoices = tool(
|
||||
async ({ status, supplier_name, overdue_only, limit }) => {
|
||||
let query = supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number, invoice_date, due_date, status, total, remaining_amount, currency, vat_amount, supplier:suppliers(name)')
|
||||
.eq('company_id', userId)
|
||||
.order('invoice_date', { ascending: false })
|
||||
.limit(limit)
|
||||
|
||||
if (status) query = query.eq('status', status)
|
||||
if (overdue_only) query = query.eq('status', 'overdue')
|
||||
if (supplier_name) query = query.ilike('suppliers.name', `%${supplier_name}%`)
|
||||
|
||||
const { data: invoices, error } = await query
|
||||
|
||||
if (error) return `Fel vid hämtning av leverantörsfakturor: ${error.message}`
|
||||
if (!invoices || invoices.length === 0) return 'Inga leverantörsfakturor hittades.'
|
||||
|
||||
const result = invoices.map((inv) => ({
|
||||
number: inv.supplier_invoice_number,
|
||||
date: inv.invoice_date,
|
||||
due_date: inv.due_date,
|
||||
status: inv.status,
|
||||
total: inv.total,
|
||||
remaining: inv.remaining_amount || 0,
|
||||
currency: inv.currency || 'SEK',
|
||||
vat: inv.vat_amount || 0,
|
||||
supplier: extractName(inv.supplier) || 'Okänd',
|
||||
}))
|
||||
|
||||
return JSON.stringify({ supplier_invoices: result })
|
||||
},
|
||||
{
|
||||
name: 'get_supplier_invoices',
|
||||
description: 'Hämtar användarens leverantörsfakturor (inköpsfakturor). Kan filtrera på status, leverantörsnamn och förfallodag.',
|
||||
schema: z.object({
|
||||
status: z.enum(['registered', 'approved', 'partially_paid', 'paid', 'overdue', 'cancelled']).optional().describe('Filtrera på status'),
|
||||
supplier_name: z.string().optional().describe('Sök på leverantörsnamn (delmatchning)'),
|
||||
overdue_only: z.boolean().optional().describe('Visa bara förfallna fakturor'),
|
||||
limit: z.number().max(20).default(10).describe('Max antal fakturor'),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const getAccountBalances = tool(
|
||||
async ({ account_numbers, account_class, fiscal_period_id }) => {
|
||||
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
|
||||
if (!period) return 'Ingen räkenskapsperiod hittades.'
|
||||
|
||||
const { generateTrialBalance } = await import('@/lib/reports/trial-balance')
|
||||
const { rows } = await generateTrialBalance(supabase, userId, period.id)
|
||||
|
||||
let filtered = rows
|
||||
if (account_numbers && account_numbers.length > 0) {
|
||||
filtered = rows.filter((r) => account_numbers.includes(r.account_number))
|
||||
} else if (account_class) {
|
||||
filtered = rows.filter((r) => r.account_class === account_class)
|
||||
}
|
||||
|
||||
if (filtered.length === 0) return 'Inga konton med saldo hittades.'
|
||||
|
||||
const result = filtered.map((r) => ({
|
||||
account: r.account_number,
|
||||
name: r.account_name,
|
||||
debit: r.closing_debit,
|
||||
credit: r.closing_credit,
|
||||
balance: r.closing_debit - r.closing_credit,
|
||||
}))
|
||||
|
||||
return JSON.stringify({
|
||||
period: `${period.start} – ${period.end}`,
|
||||
accounts: result,
|
||||
total_debit: Math.round(result.reduce((s, r) => s + r.debit, 0) * 100) / 100,
|
||||
total_credit: Math.round(result.reduce((s, r) => s + r.credit, 0) * 100) / 100,
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'get_account_balances',
|
||||
description: 'Hämtar saldon för BAS-konton. Kan filtrera på kontonummer eller kontoklass (1=tillgångar, 2=skulder, 3=intäkter, 4-7=kostnader, 8=finansiella).',
|
||||
schema: z.object({
|
||||
account_numbers: z.array(z.string()).optional().describe('Specifika kontonummer att hämta'),
|
||||
account_class: z.number().min(1).max(8).optional().describe('Kontoklass 1-8'),
|
||||
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const getTransactions = tool(
|
||||
async ({ uncategorized_only, description, date_from, date_to, limit }) => {
|
||||
let query = supabase
|
||||
.from('transactions')
|
||||
.select('id, date, description, amount, currency, category, is_business, merchant_name, journal_entry_id')
|
||||
.eq('company_id', userId)
|
||||
.order('date', { ascending: false })
|
||||
.limit(limit)
|
||||
|
||||
if (uncategorized_only) query = query.is('journal_entry_id', null)
|
||||
if (description) query = query.ilike('description', `%${description}%`)
|
||||
if (date_from) query = query.gte('date', date_from)
|
||||
if (date_to) query = query.lte('date', date_to)
|
||||
|
||||
const { data: transactions, error } = await query
|
||||
|
||||
if (error) return `Fel vid hämtning av transaktioner: ${error.message}`
|
||||
if (!transactions || transactions.length === 0) return 'Inga transaktioner hittades.'
|
||||
|
||||
const result = transactions.map((tx) => ({
|
||||
date: tx.date,
|
||||
description: tx.description,
|
||||
amount: tx.amount,
|
||||
currency: tx.currency || 'SEK',
|
||||
category: tx.category,
|
||||
is_business: tx.is_business,
|
||||
merchant: tx.merchant_name,
|
||||
booked: !!tx.journal_entry_id,
|
||||
}))
|
||||
|
||||
return JSON.stringify({ transactions: result })
|
||||
},
|
||||
{
|
||||
name: 'get_transactions',
|
||||
description: 'Hämtar användarens banktransaktioner. Kan filtrera på obokförda, beskrivning (textsökning) och datumintervall.',
|
||||
schema: z.object({
|
||||
uncategorized_only: z.boolean().optional().describe('Visa bara obokförda transaktioner'),
|
||||
description: z.string().optional().describe('Sök i beskrivning (delmatchning)'),
|
||||
date_from: z.string().optional().describe('Startdatum (YYYY-MM-DD)'),
|
||||
date_to: z.string().optional().describe('Slutdatum (YYYY-MM-DD)'),
|
||||
limit: z.number().max(20).default(10).describe('Max antal transaktioner'),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const getJournalEntries = tool(
|
||||
async ({ limit, fiscal_period_id, account_number, description }) => {
|
||||
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
|
||||
|
||||
let query = supabase
|
||||
.from('journal_entries')
|
||||
.select('id, voucher_number, entry_date, description, status, source_type')
|
||||
.eq('company_id', userId)
|
||||
.eq('status', 'posted')
|
||||
.order('voucher_number', { ascending: false })
|
||||
.limit(limit)
|
||||
|
||||
if (period) query = query.eq('fiscal_period_id', period.id)
|
||||
if (description) query = query.ilike('description', `%${description}%`)
|
||||
|
||||
const { data: entries, error } = await query
|
||||
|
||||
if (error) return `Fel vid hämtning av verifikationer: ${error.message}`
|
||||
if (!entries || entries.length === 0) return 'Inga verifikationer hittades.'
|
||||
|
||||
// Fetch lines for these entries
|
||||
const entryIds = entries.map((e) => e.id)
|
||||
const { data: lines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('journal_entry_id, account_number, debit_amount, credit_amount, line_description')
|
||||
.in('journal_entry_id', entryIds)
|
||||
|
||||
// If filtering by account, only include entries with matching lines
|
||||
let filteredEntries = entries
|
||||
if (account_number && lines) {
|
||||
const matchingEntryIds = new Set(
|
||||
lines.filter((l) => l.account_number === account_number).map((l) => l.journal_entry_id)
|
||||
)
|
||||
filteredEntries = entries.filter((e) => matchingEntryIds.has(e.id))
|
||||
}
|
||||
|
||||
const linesByEntry = new Map<string, typeof lines>()
|
||||
for (const line of lines || []) {
|
||||
const group = linesByEntry.get(line.journal_entry_id) || []
|
||||
group.push(line)
|
||||
linesByEntry.set(line.journal_entry_id, group)
|
||||
}
|
||||
|
||||
const result = filteredEntries.map((e) => ({
|
||||
voucher: e.voucher_number,
|
||||
date: e.entry_date,
|
||||
description: e.description,
|
||||
source: e.source_type,
|
||||
lines: (linesByEntry.get(e.id) || []).map((l) => ({
|
||||
account: l.account_number,
|
||||
debit: l.debit_amount,
|
||||
credit: l.credit_amount,
|
||||
text: l.line_description,
|
||||
})),
|
||||
}))
|
||||
|
||||
return JSON.stringify({ journal_entries: result })
|
||||
},
|
||||
{
|
||||
name: 'get_journal_entries',
|
||||
description: 'Hämtar bokförda verifikationer med konteringsrader. Kan filtrera på kontonummer, beskrivning och räkenskapsperiod.',
|
||||
schema: z.object({
|
||||
limit: z.number().max(20).default(10).describe('Max antal verifikationer'),
|
||||
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID'),
|
||||
account_number: z.string().optional().describe('Filtrera på kontonummer i rader'),
|
||||
description: z.string().optional().describe('Sök i beskrivning (delmatchning)'),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const getIncomeStatement = tool(
|
||||
async ({ fiscal_period_id }) => {
|
||||
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
|
||||
if (!period) return 'Ingen räkenskapsperiod hittades.'
|
||||
|
||||
const { generateIncomeStatement } = await import('@/lib/reports/income-statement')
|
||||
const report = await generateIncomeStatement(supabase, userId, period.id)
|
||||
|
||||
const sections = [
|
||||
...report.revenue_sections.map((s) => ({
|
||||
category: 'Intäkter',
|
||||
title: s.title,
|
||||
amount: s.subtotal,
|
||||
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
|
||||
})),
|
||||
...report.expense_sections.map((s) => ({
|
||||
category: 'Kostnader',
|
||||
title: s.title,
|
||||
amount: s.subtotal,
|
||||
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
|
||||
})),
|
||||
...report.financial_sections.map((s) => ({
|
||||
category: 'Finansiella poster',
|
||||
title: s.title,
|
||||
amount: s.subtotal,
|
||||
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
|
||||
})),
|
||||
]
|
||||
|
||||
return JSON.stringify({
|
||||
period: `${period.start} – ${period.end}`,
|
||||
total_revenue: report.total_revenue,
|
||||
total_expenses: report.total_expenses,
|
||||
total_financial: report.total_financial,
|
||||
net_result: report.net_result,
|
||||
sections,
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'get_income_statement',
|
||||
description: 'Hämtar resultaträkning med intäkter, kostnader och årets resultat. Visar alla kontona grupperade i sektioner.',
|
||||
schema: z.object({
|
||||
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const getBalanceSheet = tool(
|
||||
async ({ fiscal_period_id }) => {
|
||||
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
|
||||
if (!period) return 'Ingen räkenskapsperiod hittades.'
|
||||
|
||||
const { generateBalanceSheet } = await import('@/lib/reports/balance-sheet')
|
||||
const report = await generateBalanceSheet(supabase, userId, period.id)
|
||||
|
||||
const sections = [
|
||||
...report.asset_sections.map((s) => ({
|
||||
category: 'Tillgångar',
|
||||
title: s.title,
|
||||
amount: s.subtotal,
|
||||
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
|
||||
})),
|
||||
...report.equity_liability_sections.map((s) => ({
|
||||
category: 'Eget kapital & skulder',
|
||||
title: s.title,
|
||||
amount: s.subtotal,
|
||||
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
|
||||
})),
|
||||
]
|
||||
|
||||
return JSON.stringify({
|
||||
period: `${period.start} – ${period.end}`,
|
||||
total_assets: report.total_assets,
|
||||
total_equity_liabilities: report.total_equity_liabilities,
|
||||
balanced: Math.abs(report.total_assets - report.total_equity_liabilities) < 0.01,
|
||||
sections,
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'get_balance_sheet',
|
||||
description: 'Hämtar balansräkning med tillgångar, eget kapital och skulder.',
|
||||
schema: z.object({
|
||||
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const getVatSummary = tool(
|
||||
async ({ fiscal_period_id }) => {
|
||||
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
|
||||
if (!period) return 'Ingen räkenskapsperiod hittades.'
|
||||
|
||||
// Get company settings for moms period type
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('moms_period')
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
const periodType = settings?.moms_period || 'quarterly'
|
||||
const startDate = new Date(period.start)
|
||||
const year = startDate.getFullYear()
|
||||
let periodNum = 1
|
||||
if (periodType === 'monthly') {
|
||||
periodNum = startDate.getMonth() + 1
|
||||
} else if (periodType === 'quarterly') {
|
||||
periodNum = Math.ceil((startDate.getMonth() + 1) / 3)
|
||||
}
|
||||
|
||||
const { calculateVatDeclaration, getVatDeclarationSummary } = await import('@/lib/reports/vat-declaration')
|
||||
const declaration = await calculateVatDeclaration(supabase, userId, periodType, year, periodNum)
|
||||
const summary = getVatDeclarationSummary(declaration)
|
||||
|
||||
return JSON.stringify({
|
||||
period: `${period.start} – ${period.end}`,
|
||||
output_vat_25: declaration.rutor.ruta10,
|
||||
output_vat_12: declaration.rutor.ruta11,
|
||||
output_vat_6: declaration.rutor.ruta12,
|
||||
total_output_vat: summary.totalOutputVat,
|
||||
input_vat: summary.totalInputVat,
|
||||
vat_to_pay: summary.vatToPay,
|
||||
is_refund: summary.isRefund,
|
||||
domestic_taxable_sales: declaration.rutor.ruta05,
|
||||
revenue_basis_25: declaration.breakdown.invoices.base25,
|
||||
revenue_basis_12: declaration.breakdown.invoices.base12,
|
||||
revenue_basis_6: declaration.breakdown.invoices.base6,
|
||||
invoice_count: declaration.invoiceCount,
|
||||
transaction_count: declaration.transactionCount,
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'get_vat_summary',
|
||||
description: 'Hämtar momssammanställning med utgående moms, ingående moms och moms att betala/återfå.',
|
||||
schema: z.object({
|
||||
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const getCompanyOverview = tool(
|
||||
async () => {
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', userId)
|
||||
.single()
|
||||
|
||||
if (!settings) return 'Inga företagsinställningar hittades.'
|
||||
|
||||
// Get quick KPIs
|
||||
const period = await resolveCurrentPeriod(supabase, userId)
|
||||
|
||||
const [
|
||||
{ count: invoiceCount },
|
||||
{ count: unpaidCount },
|
||||
{ count: txCount },
|
||||
{ count: unbookedCount },
|
||||
] = await Promise.all([
|
||||
supabase.from('invoices').select('id', { count: 'exact', head: true }).eq('company_id', userId),
|
||||
supabase.from('invoices').select('id', { count: 'exact', head: true }).eq('company_id', userId).in('status', ['sent', 'overdue']),
|
||||
supabase.from('transactions').select('id', { count: 'exact', head: true }).eq('company_id', userId),
|
||||
supabase.from('transactions').select('id', { count: 'exact', head: true }).eq('company_id', userId).is('journal_entry_id', null),
|
||||
])
|
||||
|
||||
let netResult: number | null = null
|
||||
if (period) {
|
||||
try {
|
||||
const { generateIncomeStatement } = await import('@/lib/reports/income-statement')
|
||||
const report = await generateIncomeStatement(supabase, userId, period.id)
|
||||
netResult = report.net_result
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
company: {
|
||||
name: settings.company_name,
|
||||
entity_type: settings.entity_type,
|
||||
org_number: settings.org_number,
|
||||
vat_registered: settings.vat_registered,
|
||||
accounting_method: settings.accounting_method,
|
||||
moms_period: settings.moms_period,
|
||||
},
|
||||
kpis: {
|
||||
total_invoices: invoiceCount || 0,
|
||||
unpaid_invoices: unpaidCount || 0,
|
||||
total_transactions: txCount || 0,
|
||||
unbooked_transactions: unbookedCount || 0,
|
||||
...(netResult !== null ? { net_result: netResult } : {}),
|
||||
...(period ? { current_period: `${period.start} – ${period.end}` } : {}),
|
||||
},
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'get_company_overview',
|
||||
description: 'Hämtar företagsinformation och nyckeltal (KPIs): antal fakturor, obetalda fakturor, transaktioner, obokförda transaktioner, årets resultat.',
|
||||
schema: z.object({}),
|
||||
}
|
||||
)
|
||||
|
||||
const getAgingReport = tool(
|
||||
async ({ type, limit }) => {
|
||||
if (type === 'receivable') {
|
||||
const { generateARLedger } = await import('@/lib/reports/ar-ledger')
|
||||
const report = await generateARLedger(supabase, userId)
|
||||
|
||||
if (report.entries.length === 0) return 'Inga utestående kundfordringar.'
|
||||
|
||||
const entries = report.entries.slice(0, limit).map((e) => ({
|
||||
name: e.customer_name,
|
||||
current: e.current,
|
||||
'1_30': e.days_1_30,
|
||||
'31_60': e.days_31_60,
|
||||
'61_90': e.days_61_90,
|
||||
'90_plus': e.days_90_plus,
|
||||
total: e.total_outstanding,
|
||||
}))
|
||||
|
||||
return JSON.stringify({
|
||||
type: 'receivable',
|
||||
total_outstanding: report.total_outstanding,
|
||||
total_current: report.total_current,
|
||||
total_overdue: report.total_overdue,
|
||||
unpaid_count: report.unpaid_count,
|
||||
entries,
|
||||
})
|
||||
} else {
|
||||
const { generateSupplierLedger } = await import('@/lib/reports/supplier-ledger')
|
||||
const report = await generateSupplierLedger(supabase, userId)
|
||||
|
||||
if (report.entries.length === 0) return 'Inga utestående leverantörsskulder.'
|
||||
|
||||
const entries = report.entries.slice(0, limit).map((e) => ({
|
||||
name: e.supplier_name,
|
||||
current: e.current,
|
||||
'1_30': e.days_1_30,
|
||||
'31_60': e.days_31_60,
|
||||
'61_90': e.days_61_90,
|
||||
'90_plus': e.days_90_plus,
|
||||
total: e.total_outstanding,
|
||||
}))
|
||||
|
||||
return JSON.stringify({
|
||||
type: 'payable',
|
||||
total_outstanding: report.total_outstanding,
|
||||
total_current: report.total_current,
|
||||
total_overdue: report.total_overdue,
|
||||
unpaid_count: report.unpaid_count,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_aging_report',
|
||||
description: 'Hämtar åldersanalys för kundfordringar (receivable) eller leverantörsskulder (payable). Visar utestående belopp uppdelat i ålderskategorier.',
|
||||
schema: z.object({
|
||||
type: z.enum(['receivable', 'payable']).describe("'receivable' för kundfordringar, 'payable' för leverantörsskulder"),
|
||||
limit: z.number().max(20).default(10).describe('Max antal poster'),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
return [
|
||||
getInvoices,
|
||||
getSupplierInvoices,
|
||||
getAccountBalances,
|
||||
getTransactions,
|
||||
getJournalEntries,
|
||||
getIncomeStatement,
|
||||
getBalanceSheet,
|
||||
getVatSummary,
|
||||
getCompanyOverview,
|
||||
getAgingReport,
|
||||
]
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { CallbackHandler } from '@langfuse/langchain'
|
||||
|
||||
let langfuseConfigured: boolean | null = null
|
||||
|
||||
function isLangfuseConfigured(): boolean {
|
||||
if (langfuseConfigured !== null) return langfuseConfigured
|
||||
langfuseConfigured = !!(
|
||||
process.env.LANGFUSE_SECRET_KEY &&
|
||||
process.env.LANGFUSE_PUBLIC_KEY
|
||||
)
|
||||
return langfuseConfigured
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Langfuse callback handler for LangChain tracing.
|
||||
* Returns null if Langfuse is not configured (graceful degradation).
|
||||
*/
|
||||
export function createTraceHandler(options: {
|
||||
sessionId?: string
|
||||
userId?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}): CallbackHandler | null {
|
||||
if (!isLangfuseConfigured()) return null
|
||||
|
||||
try {
|
||||
return new CallbackHandler({
|
||||
sessionId: options.sessionId,
|
||||
userId: options.userId,
|
||||
})
|
||||
} catch {
|
||||
console.warn('Failed to create Langfuse handler, tracing disabled')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush Langfuse handler. Safe to call with null.
|
||||
*/
|
||||
export async function flushTrace(handler: CallbackHandler | null): Promise<void> {
|
||||
if (!handler) return
|
||||
try {
|
||||
// Langfuse CallbackHandler may expose flush via different methods
|
||||
if ('shutdownAsync' in handler && typeof handler.shutdownAsync === 'function') {
|
||||
await handler.shutdownAsync()
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — tracing failure should never block response
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import { aiChatApiRoutes } from './api-routes'
|
||||
|
||||
/**
|
||||
* AI Chat Extension
|
||||
*
|
||||
* Provides an AI-powered chatbot assistant for Swedish tax and bookkeeping
|
||||
* questions. Uses RAG (Retrieval Augmented Generation) with a knowledge base
|
||||
* of Swedish tax laws, regulations, and best practices.
|
||||
*
|
||||
* Components:
|
||||
* - chatbot/: Chain, config, prompts, embeddings, retriever
|
||||
* - ingestion/: CLI tool for ingesting knowledge base documents
|
||||
*/
|
||||
export const aiChatExtension: Extension = {
|
||||
id: 'ai-chat',
|
||||
name: 'AI-assistent',
|
||||
version: '1.0.0',
|
||||
sector: 'general',
|
||||
apiRoutes: aiChatApiRoutes,
|
||||
settingsPanel: {
|
||||
label: 'AI-assistent',
|
||||
path: '/settings/extensions/ai-chat',
|
||||
},
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
/**
|
||||
* Knowledge Base Ingestion Script
|
||||
*
|
||||
* Loads markdown files from dev_docs/ai_knowledge_base/,
|
||||
* chunks them by sections, generates embeddings, and stores in Supabase.
|
||||
*
|
||||
* Run with: npx tsx lib/ai/ingestion/ingest.ts
|
||||
*/
|
||||
|
||||
import * as dotenv from 'dotenv'
|
||||
dotenv.config({ path: '.env.local' })
|
||||
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { OpenAIEmbeddings } from '@langchain/openai'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
// Configuration
|
||||
// NOTE: The ai_knowledge_base directory must be created and populated before running ingestion.
|
||||
// Create dev_docs/ai_knowledge_base/ and add markdown files to ingest.
|
||||
const DOCS_DIR = path.join(process.cwd(), 'dev_docs', 'ai_knowledge_base')
|
||||
const CHUNK_SIZE = 1000
|
||||
const CHUNK_OVERLAP = 200
|
||||
const EMBEDDING_MODEL = 'text-embedding-ada-002'
|
||||
|
||||
// Initialize clients
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
)
|
||||
|
||||
const embeddings = new OpenAIEmbeddings({
|
||||
modelName: EMBEDDING_MODEL,
|
||||
openAIApiKey: process.env.OPENAI_API_KEY,
|
||||
})
|
||||
|
||||
interface DocumentChunk {
|
||||
source_file: string
|
||||
title: string
|
||||
section_title: string | null
|
||||
content: string
|
||||
content_hash: string
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface Section {
|
||||
title: string
|
||||
content: string
|
||||
level: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a markdown file into sections based on headings
|
||||
*/
|
||||
function parseMarkdownSections(content: string): Section[] {
|
||||
const lines = content.split('\n')
|
||||
const sections: Section[] = []
|
||||
let currentSection: Section | null = null
|
||||
let contentBuffer: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
// Check for headings (H1, H2, H3)
|
||||
const h1Match = line.match(/^# (.+)$/)
|
||||
const h2Match = line.match(/^## (.+)$/)
|
||||
const h3Match = line.match(/^### (.+)$/)
|
||||
|
||||
if (h1Match || h2Match || h3Match) {
|
||||
// Save previous section
|
||||
if (currentSection && contentBuffer.length > 0) {
|
||||
currentSection.content = contentBuffer.join('\n').trim()
|
||||
if (currentSection.content) {
|
||||
sections.push(currentSection)
|
||||
}
|
||||
}
|
||||
|
||||
// Start new section
|
||||
const title = h1Match?.[1] || h2Match?.[1] || h3Match?.[1] || ''
|
||||
const level = h1Match ? 1 : h2Match ? 2 : 3
|
||||
currentSection = { title, content: '', level }
|
||||
contentBuffer = []
|
||||
} else {
|
||||
contentBuffer.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last section
|
||||
if (currentSection && contentBuffer.length > 0) {
|
||||
currentSection.content = contentBuffer.join('\n').trim()
|
||||
if (currentSection.content) {
|
||||
sections.push(currentSection)
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into chunks while preserving context
|
||||
*/
|
||||
function chunkText(text: string, maxSize: number, overlap: number): string[] {
|
||||
if (text.length <= maxSize) {
|
||||
return [text]
|
||||
}
|
||||
|
||||
const chunks: string[] = []
|
||||
let start = 0
|
||||
|
||||
while (start < text.length) {
|
||||
let end = start + maxSize
|
||||
|
||||
// Try to break at a natural point (paragraph, sentence, or word)
|
||||
if (end < text.length) {
|
||||
// Look for paragraph break
|
||||
const paragraphBreak = text.lastIndexOf('\n\n', end)
|
||||
if (paragraphBreak > start + maxSize / 2) {
|
||||
end = paragraphBreak
|
||||
} else {
|
||||
// Look for sentence break
|
||||
const sentenceBreak = text.lastIndexOf('. ', end)
|
||||
if (sentenceBreak > start + maxSize / 2) {
|
||||
end = sentenceBreak + 1
|
||||
} else {
|
||||
// Look for word break
|
||||
const wordBreak = text.lastIndexOf(' ', end)
|
||||
if (wordBreak > start + maxSize / 2) {
|
||||
end = wordBreak
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chunks.push(text.slice(start, end).trim())
|
||||
start = end - overlap
|
||||
if (start < 0) start = 0
|
||||
if (end >= text.length) break
|
||||
}
|
||||
|
||||
return chunks.filter((c) => c.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract metadata from content (trigger words, categories, etc.)
|
||||
*/
|
||||
function extractMetadata(
|
||||
content: string,
|
||||
_sectionTitle: string
|
||||
): Record<string, unknown> {
|
||||
const metadata: Record<string, unknown> = {}
|
||||
|
||||
// Extract scenario IDs (e.g., "Scenario 001")
|
||||
const scenarioMatches = content.match(/Scenario\s+(\d{3})/gi)
|
||||
if (scenarioMatches) {
|
||||
metadata.scenarios = scenarioMatches.map((m) =>
|
||||
m.replace(/Scenario\s+/i, '')
|
||||
)
|
||||
}
|
||||
|
||||
// Extract mentioned thresholds/amounts
|
||||
const amountMatches = content.match(/(\d+[\s\d]*)\s*(kr|SEK|kronor)/gi)
|
||||
if (amountMatches) {
|
||||
metadata.amounts = amountMatches.slice(0, 5) // Limit to first 5
|
||||
}
|
||||
|
||||
// Extract platform mentions
|
||||
const platforms = [
|
||||
'YouTube',
|
||||
'Twitch',
|
||||
'Instagram',
|
||||
'TikTok',
|
||||
'Patreon',
|
||||
'Spotify',
|
||||
'Adtraction',
|
||||
]
|
||||
const mentionedPlatforms = platforms.filter((p) =>
|
||||
content.toLowerCase().includes(p.toLowerCase())
|
||||
)
|
||||
if (mentionedPlatforms.length > 0) {
|
||||
metadata.platforms = mentionedPlatforms
|
||||
}
|
||||
|
||||
// Extract categories based on keywords
|
||||
const categories: string[] = []
|
||||
if (/moms|vat/i.test(content)) categories.push('moms')
|
||||
if (/skatt|deklaration/i.test(content)) categories.push('skatt')
|
||||
if (/avdrag/i.test(content)) categories.push('avdrag')
|
||||
if (/bokför|konto|bas/i.test(content)) categories.push('bokföring')
|
||||
if (/sgi|sjuk|föräldra|pension/i.test(content))
|
||||
categories.push('socialförsäkring')
|
||||
if (/ef|enskild firma/i.test(content)) categories.push('enskild_firma')
|
||||
if (/ab|aktiebolag/i.test(content)) categories.push('aktiebolag')
|
||||
|
||||
if (categories.length > 0) {
|
||||
metadata.categories = categories
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate content hash for deduplication
|
||||
*/
|
||||
function generateHash(content: string): string {
|
||||
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single markdown file
|
||||
*/
|
||||
function processFile(filePath: string): DocumentChunk[] {
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
const fileName = path.basename(filePath)
|
||||
const sections = parseMarkdownSections(content)
|
||||
|
||||
const chunks: DocumentChunk[] = []
|
||||
|
||||
// Get document title from first H1
|
||||
const documentTitle =
|
||||
sections.find((s) => s.level === 1)?.title || fileName.replace('.md', '')
|
||||
|
||||
for (const section of sections) {
|
||||
// Skip empty sections
|
||||
if (!section.content || section.content.length < 50) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Chunk large sections
|
||||
const textChunks = chunkText(section.content, CHUNK_SIZE, CHUNK_OVERLAP)
|
||||
|
||||
for (let i = 0; i < textChunks.length; i++) {
|
||||
const chunkContent = textChunks[i]
|
||||
const sectionTitle =
|
||||
section.level === 1
|
||||
? null
|
||||
: textChunks.length > 1
|
||||
? `${section.title} (del ${i + 1}/${textChunks.length})`
|
||||
: section.title
|
||||
|
||||
chunks.push({
|
||||
source_file: fileName,
|
||||
title: documentTitle,
|
||||
section_title: sectionTitle,
|
||||
content: chunkContent,
|
||||
content_hash: generateHash(chunkContent),
|
||||
metadata: extractMetadata(chunkContent, section.title),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
/**
|
||||
* Main ingestion function
|
||||
*/
|
||||
async function ingest() {
|
||||
console.log('Starting knowledge base ingestion...')
|
||||
console.log(`Reading files from: ${DOCS_DIR}`)
|
||||
|
||||
if (!fs.existsSync(DOCS_DIR)) {
|
||||
console.error(`Error: Knowledge base directory not found: ${DOCS_DIR}`)
|
||||
console.error('Create dev_docs/ai_knowledge_base/ and add markdown files before running ingestion.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Get all markdown files
|
||||
const files = fs
|
||||
.readdirSync(DOCS_DIR)
|
||||
.filter((f) => f.endsWith('.md'))
|
||||
.sort()
|
||||
|
||||
console.log(`Found ${files.length} markdown files`)
|
||||
|
||||
// Process all files
|
||||
const allChunks: DocumentChunk[] = []
|
||||
for (const file of files) {
|
||||
const filePath = path.join(DOCS_DIR, file)
|
||||
console.log(`Processing: ${file}`)
|
||||
const chunks = processFile(filePath)
|
||||
allChunks.push(...chunks)
|
||||
console.log(` -> ${chunks.length} chunks`)
|
||||
}
|
||||
|
||||
console.log(`Total chunks: ${allChunks.length}`)
|
||||
|
||||
// Clear existing documents (optional - comment out for incremental updates)
|
||||
console.log('Clearing existing documents...')
|
||||
const { error: deleteError } = await supabase
|
||||
.from('knowledge_documents')
|
||||
.delete()
|
||||
.neq('id', '00000000-0000-0000-0000-000000000000') // Delete all
|
||||
|
||||
if (deleteError) {
|
||||
console.error('Error clearing documents:', deleteError)
|
||||
// Continue anyway
|
||||
}
|
||||
|
||||
// Generate embeddings in batches
|
||||
const BATCH_SIZE = 20
|
||||
let processed = 0
|
||||
|
||||
for (let i = 0; i < allChunks.length; i += BATCH_SIZE) {
|
||||
const batch = allChunks.slice(i, i + BATCH_SIZE)
|
||||
const contents = batch.map((c) => c.content)
|
||||
|
||||
console.log(
|
||||
`Generating embeddings for batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(allChunks.length / BATCH_SIZE)}...`
|
||||
)
|
||||
|
||||
// Generate embeddings
|
||||
const embeddingVectors = await embeddings.embedDocuments(contents)
|
||||
|
||||
// Prepare records for insertion
|
||||
const records = batch.map((chunk, idx) => ({
|
||||
source_file: chunk.source_file,
|
||||
title: chunk.title,
|
||||
section_title: chunk.section_title,
|
||||
content: chunk.content,
|
||||
content_hash: chunk.content_hash,
|
||||
embedding: embeddingVectors[idx],
|
||||
metadata: chunk.metadata,
|
||||
}))
|
||||
|
||||
// Insert into Supabase
|
||||
const { error: insertError } = await supabase
|
||||
.from('knowledge_documents')
|
||||
.insert(records)
|
||||
|
||||
if (insertError) {
|
||||
console.error('Error inserting batch:', insertError)
|
||||
throw insertError
|
||||
}
|
||||
|
||||
processed += batch.length
|
||||
console.log(` Inserted ${processed}/${allChunks.length} documents`)
|
||||
}
|
||||
|
||||
console.log('\nIngestion complete!')
|
||||
console.log(`Total documents inserted: ${allChunks.length}`)
|
||||
|
||||
// Verify
|
||||
const { count } = await supabase
|
||||
.from('knowledge_documents')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
|
||||
console.log(`Documents in database: ${count}`)
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
ingest().catch((error) => {
|
||||
console.error('Ingestion failed:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"id": "ai-chat",
|
||||
"sector": "general",
|
||||
"exportName": "aiChatExtension",
|
||||
"entryPoint": "@/extensions/general/ai-chat",
|
||||
"workspace": "@/components/extensions/general/AiChatWorkspace",
|
||||
"requiredEnvVars": ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
||||
"optionalEnvVars": ["LANGFUSE_SECRET_KEY", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_BASE_URL"],
|
||||
"npmDependencies": ["@langchain/anthropic", "@langchain/core", "langchain", "@langchain/openai", "@langchain/langgraph", "@langfuse/core", "@langfuse/langchain"],
|
||||
"definition": {
|
||||
"name": "AI-assistent",
|
||||
"category": "operations",
|
||||
"icon": "MessageSquare",
|
||||
"dataPattern": "both",
|
||||
"readsCoreTables": ["invoices", "supplier_invoices", "transactions", "journal_entries", "journal_entry_lines", "fiscal_periods", "company_settings", "customers", "suppliers", "chart_of_accounts"],
|
||||
"hasOwnData": true,
|
||||
"description": "AI-assistent för skatte- och bokföringsfrågor med tillgång till din data",
|
||||
"longDescription": "Ställ frågor om skatt, bokföring och företagande till en AI-assistent som förstår svensk redovisning. Kan hämta och visualisera din bokföringsdata — fakturor, transaktioner, resultaträkning, balansräkning och mer.",
|
||||
"quickAction": {
|
||||
"label": "AI-assistent",
|
||||
"description": "Fråga om bokföring",
|
||||
"icon": "MessageSquare",
|
||||
"event": "open-ai-chat"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export const enableBankingExtension: Extension = {
|
||||
|
||||
settingsPanel: {
|
||||
label: 'Bankintegration (PSD2)',
|
||||
path: '/settings?tab=banking',
|
||||
path: '/settings/banking',
|
||||
},
|
||||
|
||||
apiRoutes: [
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { createMockSupabase } from '@/tests/helpers'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/invoice-analyzer', () => ({
|
||||
analyzeInvoice: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/supplier-matcher', () => ({
|
||||
matchSupplier: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock api-routes to avoid transitive server-only import from document-analyzer
|
||||
vi.mock('../api-routes', () => ({
|
||||
invoiceInboxApiRoutes: [],
|
||||
}))
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { invoiceInboxExtension, getSettings, saveSettings } from '../index'
|
||||
|
||||
const mockCreateClient = vi.mocked(createClient)
|
||||
|
||||
describe('Invoice Inbox Extension', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
describe('Extension metadata', () => {
|
||||
it('has correct id and version', () => {
|
||||
expect(invoiceInboxExtension.id).toBe('invoice-inbox')
|
||||
expect(invoiceInboxExtension.name).toBe('Invoice Inbox')
|
||||
expect(invoiceInboxExtension.version).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('has event handler for document.uploaded', () => {
|
||||
expect(invoiceInboxExtension.eventHandlers).toHaveLength(1)
|
||||
expect(invoiceInboxExtension.eventHandlers![0].eventType).toBe('document.uploaded')
|
||||
})
|
||||
|
||||
it('has settings panel', () => {
|
||||
expect(invoiceInboxExtension.settingsPanel).toEqual({
|
||||
label: 'Invoice Inbox',
|
||||
path: '/settings/extensions/invoice-inbox',
|
||||
})
|
||||
})
|
||||
|
||||
it('has onInstall hook', () => {
|
||||
expect(invoiceInboxExtension.onInstall).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSettings', () => {
|
||||
it('returns default settings when no data exists', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
mockResult({ data: null, error: null })
|
||||
|
||||
const settings = await getSettings('user-1')
|
||||
|
||||
expect(settings).toEqual({
|
||||
autoProcessEnabled: true,
|
||||
autoMatchSupplierEnabled: true,
|
||||
supplierMatchThreshold: 0.7,
|
||||
inboxEmail: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('merges stored settings with defaults', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
mockResult({
|
||||
data: { value: { inboxEmail: 'test@inbox.example.com' } },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const settings = await getSettings('user-1')
|
||||
|
||||
expect(settings.inboxEmail).toBe('test@inbox.example.com')
|
||||
expect(settings.autoProcessEnabled).toBe(true) // default
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveSettings', () => {
|
||||
it('merges partial settings with current', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
|
||||
// First call for getSettings (inside saveSettings)
|
||||
mockResult({ data: null, error: null })
|
||||
|
||||
const settings = await saveSettings('user-1', { inboxEmail: 'new@inbox.com' })
|
||||
|
||||
expect(settings.inboxEmail).toBe('new@inbox.com')
|
||||
expect(settings.autoProcessEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,227 +0,0 @@
|
||||
import { analyzeInvoice } from './lib/invoice-analyzer'
|
||||
import { matchSupplier } from './lib/supplier-matcher'
|
||||
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||||
import type { EventPayload } from '@/lib/events/types'
|
||||
import type { InvoiceInboxSettings } from './types'
|
||||
import { invoiceInboxApiRoutes } from './api-routes'
|
||||
|
||||
// ============================================================
|
||||
// Settings
|
||||
// ============================================================
|
||||
|
||||
const DEFAULT_SETTINGS: InvoiceInboxSettings = {
|
||||
autoProcessEnabled: true,
|
||||
autoMatchSupplierEnabled: true,
|
||||
supplierMatchThreshold: 0.7,
|
||||
inboxEmail: null,
|
||||
}
|
||||
|
||||
/** Get settings via ExtensionContext (preferred in event handlers) */
|
||||
async function getSettingsViaCtx(ctx: ExtensionContext): Promise<InvoiceInboxSettings> {
|
||||
const stored = await ctx.settings.get<Partial<InvoiceInboxSettings>>()
|
||||
return { ...DEFAULT_SETTINGS, ...(stored || {}) }
|
||||
}
|
||||
|
||||
/** Get settings for external callers (settings routes, API routes) */
|
||||
export async function getSettings(userId: string): Promise<InvoiceInboxSettings> {
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('company_id', userId)
|
||||
.eq('extension_id', 'invoice-inbox')
|
||||
.eq('key', 'settings')
|
||||
.single()
|
||||
|
||||
if (!data?.value) return { ...DEFAULT_SETTINGS }
|
||||
|
||||
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<InvoiceInboxSettings>) }
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
userId: string,
|
||||
partial: Partial<InvoiceInboxSettings>
|
||||
): Promise<InvoiceInboxSettings> {
|
||||
const current = await getSettings(userId)
|
||||
const merged = { ...current, ...partial }
|
||||
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
await supabase
|
||||
.from('extension_data')
|
||||
.upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
extension_id: 'invoice-inbox',
|
||||
key: 'settings',
|
||||
value: merged,
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event Handlers
|
||||
// ============================================================
|
||||
|
||||
const INVOICE_MIME_TYPES = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
]
|
||||
|
||||
/**
|
||||
* When a PDF/image is uploaded via the document archive, check if it should
|
||||
* be auto-processed as a supplier invoice.
|
||||
*/
|
||||
async function handleDocumentUploaded(
|
||||
payload: EventPayload<'document.uploaded'>,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<void> {
|
||||
const { document, userId, companyId } = payload
|
||||
const log = ctx?.log ?? console
|
||||
|
||||
// Gate: Is it a supported file type?
|
||||
if (!document.mime_type || !INVOICE_MIME_TYPES.includes(document.mime_type)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Gate: Is autoProcessEnabled?
|
||||
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
|
||||
if (!settings.autoProcessEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
// Gate: Was this document already processed as an inbox item?
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: existing } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id')
|
||||
.eq('company_id', userId)
|
||||
.eq('document_id', document.id)
|
||||
.limit(1)
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
log.info(`Auto-process triggered for document ${document.id}`)
|
||||
|
||||
try {
|
||||
// Create inbox item
|
||||
const { data: inboxItem, error: insertError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
status: 'processing',
|
||||
source: 'upload',
|
||||
document_id: document.id,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (insertError || !inboxItem) {
|
||||
log.error('Failed to create inbox item:', insertError)
|
||||
return
|
||||
}
|
||||
|
||||
// Download file from storage
|
||||
const { data: fileData, error: downloadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.download(document.storage_path)
|
||||
|
||||
if (downloadError || !fileData) {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ status: 'error', error_message: 'Failed to download document' })
|
||||
.eq('id', inboxItem.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to base64
|
||||
const arrayBuffer = await fileData.arrayBuffer()
|
||||
const base64 = Buffer.from(arrayBuffer).toString('base64')
|
||||
|
||||
// Analyze invoice
|
||||
const extraction = await analyzeInvoice(base64, document.mime_type)
|
||||
|
||||
// Supplier matching
|
||||
let matchedSupplierId: string | null = null
|
||||
if (settings.autoMatchSupplierEnabled) {
|
||||
const { data: suppliers } = await supabase
|
||||
.from('suppliers')
|
||||
.select('*')
|
||||
.eq('company_id', userId)
|
||||
|
||||
if (suppliers && suppliers.length > 0) {
|
||||
const match = matchSupplier(extraction, suppliers)
|
||||
if (match && match.confidence >= settings.supplierMatchThreshold) {
|
||||
matchedSupplierId = match.supplierId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update inbox item with extracted data
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
extracted_data: extraction as unknown as Record<string, unknown>,
|
||||
confidence: extraction.confidence,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
})
|
||||
.eq('id', inboxItem.id)
|
||||
|
||||
// Fetch updated item
|
||||
const { data: updatedItem } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
.eq('id', inboxItem.id)
|
||||
.single()
|
||||
|
||||
if (updatedItem) {
|
||||
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
|
||||
await emit({
|
||||
type: 'supplier_invoice.extracted',
|
||||
payload: {
|
||||
inboxItem: updatedItem,
|
||||
confidence: extraction.confidence,
|
||||
userId,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
log.info(`Invoice ${inboxItem.id} processed (confidence: ${extraction.confidence})`)
|
||||
} catch (error) {
|
||||
log.error('handleDocumentUploaded failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Extension Object
|
||||
// ============================================================
|
||||
|
||||
export const invoiceInboxExtension: Extension = {
|
||||
id: 'invoice-inbox',
|
||||
name: 'Invoice Inbox',
|
||||
version: '1.0.0',
|
||||
sector: 'general',
|
||||
apiRoutes: invoiceInboxApiRoutes,
|
||||
eventHandlers: [
|
||||
{ eventType: 'document.uploaded', handler: handleDocumentUploaded },
|
||||
],
|
||||
settingsPanel: {
|
||||
label: 'Invoice Inbox',
|
||||
path: '/settings/extensions/invoice-inbox',
|
||||
},
|
||||
async onInstall(ctx) {
|
||||
await ctx.settings.set('settings', DEFAULT_SETTINGS)
|
||||
},
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// Mock server-only
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '../email-handler'
|
||||
import type { ResendInboundPayload } from '../../types'
|
||||
|
||||
describe('Email Handler', () => {
|
||||
describe('parseInboundPayload', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(parseInboundPayload(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for non-object input', () => {
|
||||
expect(parseInboundPayload('string')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when from is missing', () => {
|
||||
expect(parseInboundPayload({ to: 'test@example.com' })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when to is missing', () => {
|
||||
expect(parseInboundPayload({ from: 'test@example.com' })).toBeNull()
|
||||
})
|
||||
|
||||
it('parses valid payload', () => {
|
||||
const result = parseInboundPayload({
|
||||
from: 'supplier@example.com',
|
||||
to: 'inbox@mycompany.com',
|
||||
subject: 'Faktura F-001',
|
||||
html: '<p>Attached</p>',
|
||||
text: 'Attached',
|
||||
attachments: [{ filename: 'invoice.pdf', content_type: 'application/pdf', content: 'base64data' }],
|
||||
created_at: '2024-06-15T10:00:00Z',
|
||||
})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.from).toBe('supplier@example.com')
|
||||
expect(result!.to).toBe('inbox@mycompany.com')
|
||||
expect(result!.subject).toBe('Faktura F-001')
|
||||
expect(result!.attachments).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('handles missing optional fields', () => {
|
||||
const result = parseInboundPayload({
|
||||
from: 'a@b.com',
|
||||
to: 'c@d.com',
|
||||
})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.subject).toBe('')
|
||||
expect(result!.html).toBeNull()
|
||||
expect(result!.text).toBeNull()
|
||||
expect(result!.attachments).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractAttachments', () => {
|
||||
it('filters to supported file types only', () => {
|
||||
const payload: ResendInboundPayload = {
|
||||
from: 'a@b.com',
|
||||
to: 'c@d.com',
|
||||
subject: 'Test',
|
||||
html: null,
|
||||
text: null,
|
||||
created_at: '2024-06-15T10:00:00Z',
|
||||
attachments: [
|
||||
{ filename: 'invoice.pdf', content_type: 'application/pdf', content: 'base64' },
|
||||
{ filename: 'photo.jpg', content_type: 'image/jpeg', content: 'base64' },
|
||||
{ filename: 'doc.docx', content_type: 'application/vnd.openxmlformats', content: 'base64' },
|
||||
{ filename: 'sheet.xlsx', content_type: 'application/vnd.ms-excel', content: 'base64' },
|
||||
{ filename: 'scan.png', content_type: 'image/png', content: 'base64' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = extractAttachments(payload)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map(a => a.content_type)).toEqual([
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
])
|
||||
})
|
||||
|
||||
it('filters out attachments without content', () => {
|
||||
const payload: ResendInboundPayload = {
|
||||
from: 'a@b.com',
|
||||
to: 'c@d.com',
|
||||
subject: 'Test',
|
||||
html: null,
|
||||
text: null,
|
||||
created_at: '2024-06-15T10:00:00Z',
|
||||
attachments: [
|
||||
{ filename: 'invoice.pdf', content_type: 'application/pdf', content: '' },
|
||||
{ filename: 'photo.jpg', content_type: 'image/jpeg', content: 'base64data' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = extractAttachments(payload)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveUserFromEmail', () => {
|
||||
it('returns null when no extension data found', async () => {
|
||||
const mockClient = {
|
||||
from: vi.fn().mockReturnValue({
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({ data: null, error: { message: 'not found' } }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
|
||||
const result = await resolveUserFromEmail('test@inbox.com', mockClient)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns user_id when email matches', async () => {
|
||||
const mockClient = {
|
||||
from: vi.fn().mockReturnValue({
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ user_id: 'user-1', company_id: 'company-1', value: { inboxEmail: 'test@inbox.com' } },
|
||||
{ user_id: 'user-2', company_id: 'company-2', value: { inboxEmail: 'other@inbox.com' } },
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
|
||||
const result = await resolveUserFromEmail('test@inbox.com', mockClient)
|
||||
expect(result).toEqual({ userId: 'user-1', companyId: 'company-1' })
|
||||
})
|
||||
|
||||
it('handles case-insensitive email matching', async () => {
|
||||
const mockClient = {
|
||||
from: vi.fn().mockReturnValue({
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ user_id: 'user-1', company_id: 'company-1', value: { inboxEmail: 'Test@Inbox.Com' } },
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
|
||||
const result = await resolveUserFromEmail('test@inbox.com', mockClient)
|
||||
expect(result).toEqual({ userId: 'user-1', companyId: 'company-1' })
|
||||
})
|
||||
|
||||
it('returns null when no matching email', async () => {
|
||||
const mockClient = {
|
||||
from: vi.fn().mockReturnValue({
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ user_id: 'user-1', value: { inboxEmail: 'other@inbox.com' } },
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
|
||||
const result = await resolveUserFromEmail('notfound@inbox.com', mockClient)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user