diff --git a/app/(dashboard)/deadlines/page.tsx b/app/(dashboard)/deadlines/page.tsx new file mode 100644 index 00000000..1be6fde3 --- /dev/null +++ b/app/(dashboard)/deadlines/page.tsx @@ -0,0 +1,241 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import Link from 'next/link' +import { createClient } from '@/lib/supabase/client' +import { useToast } from '@/components/ui/use-toast' +import { DeadlineList } from '@/components/deadlines/DeadlineList' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { AlertTriangle, ArrowRight } from 'lucide-react' +import type { Deadline } from '@/types' + +export default function DeadlinesPage() { + const [deadlines, setDeadlines] = useState([]) + const [customers, setCustomers] = useState<{ id: string; name: string }[]>([]) + const [overdueInvoices, setOverdueInvoices] = useState<{ count: number; total: number }>({ count: 0, total: 0 }) + const [isLoading, setIsLoading] = useState(true) + const { toast } = useToast() + const supabase = createClient() + + const fetchData = useCallback(async () => { + setIsLoading(true) + + try { + // Fetch deadlines with customer names + const { data: deadlinesData, error: deadlinesError } = await supabase + .from('deadlines') + .select('*, customer:customers(name)') + .order('due_date', { ascending: true }) + + if (deadlinesError) throw deadlinesError + + // Fetch customers for the form + const { data: customersData, error: customersError } = await supabase + .from('customers') + .select('id, name') + .order('name', { ascending: true }) + + if (customersError) throw customersError + + // Fetch overdue invoices summary + const today = new Date().toISOString().split('T')[0] + const { data: overdueData, error: overdueError } = await supabase + .from('invoices') + .select('total_sek, total') + .in('status', ['sent', 'unpaid']) + .lt('due_date', today) + + if (overdueError) throw overdueError + + const overdueCount = overdueData?.length || 0 + const overdueTotal = (overdueData || []).reduce( + (sum, inv) => sum + (inv.total_sek || inv.total || 0), + 0 + ) + + setDeadlines(deadlinesData || []) + setCustomers(customersData || []) + setOverdueInvoices({ count: overdueCount, total: overdueTotal }) + } catch { + toast({ + title: 'Fel', + description: 'Kunde inte hamta data', + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + }, [supabase, toast]) + + useEffect(() => { + fetchData() + }, [fetchData]) + + const handleDeadlineCreate = async ( + data: Omit + ) => { + try { + const response = await fetch('/api/deadlines', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + + if (!response.ok) { + const result = await response.json() + throw new Error(result.error || 'Failed to create deadline') + } + + toast({ + title: 'Deadline skapad', + description: 'Din deadline har sparats', + }) + + fetchData() + } catch (error) { + toast({ + title: 'Fel', + description: error instanceof Error ? error.message : 'Kunde inte skapa deadline', + variant: 'destructive', + }) + throw error + } + } + + const handleDeadlineToggle = async (deadline: Deadline) => { + try { + const response = await fetch(`/api/deadlines/${deadline.id}/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_completed: !deadline.is_completed }), + }) + + if (!response.ok) { + const result = await response.json() + throw new Error(result.error || 'Failed to toggle deadline') + } + + toast({ + title: deadline.is_completed ? 'Markerad som ej klar' : 'Markerad som klar', + }) + + fetchData() + } catch (error) { + toast({ + title: 'Fel', + description: error instanceof Error ? error.message : 'Kunde inte uppdatera deadline', + variant: 'destructive', + }) + } + } + + const handleDeadlineEdit = async (deadline: Deadline) => { + try { + const response = await fetch(`/api/deadlines/${deadline.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(deadline), + }) + + if (!response.ok) { + const result = await response.json() + throw new Error(result.error || 'Failed to edit deadline') + } + + toast({ + title: 'Deadline uppdaterad', + description: 'Dina andringar har sparats', + }) + + fetchData() + } catch (error) { + toast({ + title: 'Fel', + description: error instanceof Error ? error.message : 'Kunde inte uppdatera deadline', + variant: 'destructive', + }) + } + } + + const handleDeadlineDelete = async (deadline: Deadline) => { + try { + const response = await fetch(`/api/deadlines/${deadline.id}`, { + method: 'DELETE', + }) + + if (!response.ok) { + const result = await response.json() + throw new Error(result.error || 'Failed to delete deadline') + } + + toast({ + title: 'Deadline borttagen', + }) + + fetchData() + } catch (error) { + toast({ + title: 'Fel', + description: error instanceof Error ? error.message : 'Kunde inte ta bort deadline', + variant: 'destructive', + }) + } + } + + if (isLoading) { + return ( +
+
+

Deadlines

+
+
+
+
+
+
+ ) + } + + return ( +
+
+

Deadlines

+
+ + {overdueInvoices.count > 0 && ( + + + +
+
+ +
+

Forfallna fakturor

+

+ {overdueInvoices.count} st totalt{' '} + {overdueInvoices.total.toLocaleString('sv-SE')} kr +

+
+
+
+ {overdueInvoices.count} + +
+
+
+
+ + )} + + +
+ ) +} diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index 2c8c6102..52f46137 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -252,6 +252,13 @@ export default async function DashboardPage() { streak_count: streakCount, } + // Fetch enabled extension toggles + const { data: enabledToggles } = await supabase + .from('extension_toggles') + .select('sector_slug, extension_slug') + .eq('user_id', user.id) + .eq('enabled', true) + return ( ) } diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 5bba42f0..f41ff124 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -19,11 +19,12 @@ import InboxZeroState from '@/components/transactions/InboxZeroState' import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog' import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog' import QuickReviewDialog from '@/components/transactions/QuickReviewDialog' +import DescribeTransactionDialog from '@/components/transactions/DescribeTransactionDialog' import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types' import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping' import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types' import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment } from '@/types' -import type { SuggestedCategory } from '@/lib/transactions/category-suggestions' +import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions' export default function TransactionsPage() { const [transactions, setTransactions] = useState([]) @@ -33,6 +34,7 @@ export default function TransactionsPage() { const [isCreating, setIsCreating] = useState(false) const [showSwipeView, setShowSwipeView] = useState(false) const [categorySuggestions, setCategorySuggestions] = useState>({}) + const [templateSuggestions, setTemplateSuggestions] = useState>({}) const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false) const [processingId, setProcessingId] = useState(null) @@ -57,6 +59,10 @@ export default function TransactionsPage() { const [quickReviewCategory, setQuickReviewCategory] = useState(null) const [quickReviewLabel, setQuickReviewLabel] = useState('') + // Describe dialog + const [describeDialogOpen, setDescribeDialogOpen] = useState(false) + const [describeDialogTransaction, setDescribeDialogTransaction] = useState(null) + // Entity type for tooltip context const [entityType, setEntityType] = useState('enskild_firma') @@ -130,6 +136,9 @@ export default function TransactionsPage() { if (data.suggestions) { setCategorySuggestions(data.suggestions) } + if (data.template_suggestions) { + setTemplateSuggestions(data.template_suggestions) + } } catch { // Non-critical } @@ -409,13 +418,29 @@ export default function TransactionsPage() { async function handleBatchCategorize(category: TransactionCategory, vatTreatment?: VatTreatment) { const ids = Array.from(selectedIds) setBatchProgress({ done: 0, total: ids.length }) + let successes = 0 + const failures: string[] = [] for (let i = 0; i < ids.length; i++) { - await handleCategorize(ids[i], true, category, vatTreatment) + const result = await handleCategorize(ids[i], true, category, vatTreatment) + if (result) { + successes++ + } else { + const tx = transactions.find((t) => t.id === ids[i]) + failures.push(tx?.description || ids[i]) + } setBatchProgress({ done: i + 1, total: ids.length }) } setBatchProgress(null) setShowBatchSelector(false) - toast({ title: 'Klart', description: `${ids.length} transaktioner bokförda` }) + if (failures.length === 0) { + toast({ title: 'Klart', description: `${successes} transaktioner bokförda` }) + } else { + toast({ + title: 'Delvis klart', + description: `${successes} lyckades, ${failures.length} misslyckades: ${failures.slice(0, 3).join(', ')}${failures.length > 3 ? '...' : ''}`, + variant: 'destructive', + }) + } exitBatchMode() } @@ -468,12 +493,40 @@ export default function TransactionsPage() { return journalEntryId } + function openDescribeDialog(transaction: TransactionWithInvoice) { + setDescribeDialogTransaction(transaction) + setDescribeDialogOpen(true) + } + + function handleDescribeCategorized(transactionId: string, journalEntryId: string | null) { + setExitingIds((prev) => new Set(prev).add(transactionId)) + setTimeout(() => { + setTransactions((prev) => + prev.map((t) => + t.id === transactionId + ? { ...t, is_business: true, journal_entry_id: journalEntryId } + : t + ) + ) + setExitingIds((prev) => { + const next = new Set(prev) + next.delete(transactionId) + return next + }) + }, 350) + } + + function handleBatchApplied() { + fetchTransactions() + } + // Swipe view if (showSwipeView && uncategorizedTransactions.length > 0) { return ( setShowSwipeView(false)} @@ -527,6 +580,7 @@ export default function TransactionsPage() { key={transaction.id} transaction={transaction} suggestions={categorySuggestions[transaction.id]} + templateSuggestions={templateSuggestions[transaction.id]} processingId={processingId} isBatchMode={isBatchMode} isSelected={selectedIds.has(transaction.id)} @@ -535,6 +589,7 @@ export default function TransactionsPage() { onMarkPrivate={handleMarkPrivate} onOpenMatchDialog={openMatchDialog} onOpenCategoryDialog={openCategoryDialog} + onOpenDescribe={openDescribeDialog} onOpenQuickReview={handleOpenQuickReview} onToggleSelect={toggleBatchSelect} /> @@ -603,6 +658,14 @@ export default function TransactionsPage() { onConfirm={handleQuickReviewConfirm} /> + + diff --git a/app/api/admin/seed-template-embeddings/route.ts b/app/api/admin/seed-template-embeddings/route.ts new file mode 100644 index 00000000..31ba15ef --- /dev/null +++ b/app/api/admin/seed-template-embeddings/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server' +import { seedAllTemplateEmbeddings, getSchemaVersion } from '@/lib/bookkeeping/template-embeddings' + +export async function POST(request: Request) { + const authHeader = request.headers.get('authorization') + const cronSecret = process.env.CRON_SECRET + + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const { seeded, errors } = await seedAllTemplateEmbeddings() + + return NextResponse.json({ + success: errors.length === 0, + seeded, + errors, + schema_version: getSchemaVersion(), + }) + } catch (error) { + return NextResponse.json( + { error: `Seeding failed: ${error instanceof Error ? error.message : 'Unknown error'}` }, + { status: 500 } + ) + } +} diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts index 3a8e0e2b..cb836f2b 100644 --- a/app/api/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -72,6 +72,8 @@ describe('POST /api/invoices/[id]/send', () => { unit: 'tim', unit_price: 1000, line_total: 10000, + vat_rate: 25, + vat_amount: 2500, created_at: '2024-06-15T14:30:00Z', }, ], diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts index d82bcf18..d7e68c95 100644 --- a/app/api/invoices/__tests__/route.test.ts +++ b/app/api/invoices/__tests__/route.test.ts @@ -318,6 +318,8 @@ describe('POST /api/invoices (create credit note)', () => { unit: 'tim', unit_price: 1000, line_total: 10000, + vat_rate: 25, + vat_amount: 2500, created_at: '2024-06-15T14:30:00Z', }, ] @@ -385,6 +387,8 @@ describe('POST /api/invoices (create credit note)', () => { unit: 'st', unit_price: 1000, line_total: 1000, + vat_rate: 25, + vat_amount: 250, created_at: '2024-06-15T14:30:00Z', }, ], diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index a4eff82b..ff8da182 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -160,7 +160,9 @@ describe('POST /api/transactions/[id]/categorize', () => { 'GitHub', '6200', '1930', - false + false, + undefined, + undefined ) expect(emitSpy).toHaveBeenCalledWith( expect.objectContaining({ type: 'transaction.categorized' }) diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index 128151dc..f193c7bd 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -256,7 +256,9 @@ export async function POST( transaction.merchant_name, mappingResult.debit_account, mappingResult.credit_account, - !is_business + !is_business, + body.user_description, + body.template_id ) } catch (err) { console.error('Failed to save mapping rule:', err) diff --git a/app/api/transactions/[id]/describe/__tests__/route.test.ts b/app/api/transactions/[id]/describe/__tests__/route.test.ts new file mode 100644 index 00000000..f492c80f --- /dev/null +++ b/app/api/transactions/[id]/describe/__tests__/route.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + createMockRouteParams, + createQueuedMockSupabase, + makeTransaction, + parseJsonResponse, +} from '@/tests/helpers' + +// Mock init +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +// Mock template embeddings +const mockFindSimilarTemplates = vi.fn().mockResolvedValue([]) +vi.mock('@/lib/bookkeeping/template-embeddings', () => ({ + findSimilarTemplates: (...args: unknown[]) => mockFindSimilarTemplates(...args), +})) + +// Mock Supabase +const mockCreateClient = vi.fn() +vi.mock('@/lib/supabase/server', () => ({ + createClient: (...args: unknown[]) => mockCreateClient(...args), +})) + +describe('POST /api/transactions/[id]/describe', () => { + let POST: typeof import('../route').POST + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../route') + POST = mod.POST + }) + + it('returns 401 when not authenticated', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: null }, + error: { message: 'Not authenticated' }, + }) + mockCreateClient.mockResolvedValue(supabase) + + const req = createMockRequest('/api/transactions/test-id/describe', { + method: 'POST', + body: { description: 'business lunch' }, + }) + + const res = await POST(req, createMockRouteParams({ id: 'test-id' })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(401) + expect(body).toHaveProperty('error', 'Unauthorized') + }) + + it('returns 400 for invalid body (description too short)', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase) + + const req = createMockRequest('/api/transactions/test-id/describe', { + method: 'POST', + body: { description: 'ab' }, + }) + + const res = await POST(req, createMockRouteParams({ id: 'test-id' })) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(400) + }) + + it('returns 404 when transaction not found', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + enqueueMany([ + // transaction fetch + { data: null, error: { message: 'Not found' } }, + ]) + mockCreateClient.mockResolvedValue(supabase) + + const req = createMockRequest('/api/transactions/nonexistent/describe', { + method: 'POST', + body: { description: 'business lunch' }, + }) + + const res = await POST(req, createMockRouteParams({ id: 'nonexistent' })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(404) + expect(body).toHaveProperty('error', 'Transaction not found') + }) + + it('returns template candidates on happy path', async () => { + const tx = makeTransaction({ + id: 'tx-1', + merchant_name: 'Restaurant XYZ', + amount: -450, + }) + + mockFindSimilarTemplates.mockResolvedValueOnce([ + { + template: { + id: 'restaurant_dining', + name_sv: 'Restaurangbesök', + name_en: 'Restaurant dining', + group: 'representation', + debit_account: '6071', + credit_account: '1930', + description_sv: 'Representation - restaurang', + }, + confidence: 0.82, + }, + ]) + + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + enqueueMany([ + // transaction fetch + { data: tx, error: null }, + // company_settings + { data: { entity_type: 'enskild_firma' }, error: null }, + // batch candidate count + { data: null, error: null, count: 3 }, + ]) + mockCreateClient.mockResolvedValue(supabase) + + const req = createMockRequest('/api/transactions/tx-1/describe', { + method: 'POST', + body: { description: 'business lunch with client' }, + }) + + const res = await POST(req, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ data: Record }>(res) + + expect(status).toBe(200) + expect(body.data.templates).toHaveLength(1) + expect(body.data.needs_more_detail).toBe(false) + 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([ + { + template: { + id: 'misc', + name_sv: 'Diverse', + name_en: 'Miscellaneous', + group: 'other', + debit_account: '6991', + credit_account: '1930', + description_sv: 'Okategoriserad utgift', + }, + confidence: 0.4, + }, + ]) + + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + enqueueMany([ + // transaction fetch + { data: tx, error: null }, + // company_settings + { data: { entity_type: 'enskild_firma' }, error: null }, + ]) + mockCreateClient.mockResolvedValue(supabase) + + const req = createMockRequest('/api/transactions/tx-2/describe', { + method: 'POST', + body: { description: 'some kind of payment' }, + }) + + const res = await POST(req, createMockRouteParams({ id: 'tx-2' })) + const { status, body } = await parseJsonResponse<{ data: Record }>(res) + + expect(status).toBe(200) + expect(body.data.needs_more_detail).toBe(true) + }) +}) diff --git a/app/api/transactions/[id]/describe/route.ts b/app/api/transactions/[id]/describe/route.ts new file mode 100644 index 00000000..1d9d55a5 --- /dev/null +++ b/app/api/transactions/[id]/describe/route.ts @@ -0,0 +1,98 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { DescribeTransactionSchema } from '@/lib/api/schemas' +import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings' +import type { Transaction, EntityType } from '@/types' + +ensureInitialized() + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const supabase = await createClient() + const { id } = await params + + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const validation = await validateBody(request, DescribeTransactionSchema) + if (!validation.success) return validation.response + const { description } = validation.data + + // Fetch the transaction (validates ownership) + const { data: transaction, error: fetchError } = await supabase + .from('transactions') + .select('*') + .eq('id', id) + .eq('user_id', user.id) + .single() + + if (fetchError || !transaction) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) + } + + // Fetch entity type + const { data: settings } = await supabase + .from('company_settings') + .select('entity_type') + .eq('user_id', user.id) + .single() + + const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' + + // Run embedding search with user description dominating the query + const templates = await findSimilarTemplates( + transaction as Transaction, + entityType, + 10, + description + ) + + // Flag if top confidence is too low + const needsMoreDetail = templates.length === 0 || templates[0].confidence < 0.55 + + // Count uncategorized sibling transactions from same merchant + let batchCandidateCount = 0 + if (transaction.merchant_name) { + const { count } = await supabase + .from('transactions') + .select('id', { count: 'exact', head: true }) + .eq('user_id', user.id) + .eq('merchant_name', transaction.merchant_name) + .is('journal_entry_id', null) + .neq('id', id) + + batchCandidateCount = count || 0 + } + + return NextResponse.json({ + data: { + templates: templates.map((m) => ({ + template_id: m.template.id, + name_sv: m.template.name_sv, + name_en: m.template.name_en, + group: m.template.group, + debit_account: m.template.debit_account, + credit_account: m.template.credit_account, + confidence: m.confidence, + description_sv: m.template.description_sv, + vat_rate: m.template.vat_rate, + vat_treatment: m.template.vat_treatment, + deductibility: m.template.deductibility, + deductibility_note_sv: m.template.deductibility_note_sv || null, + special_rules_sv: m.template.special_rules_sv || null, + risk_level: m.template.risk_level, + })), + needs_more_detail: needsMoreDetail, + user_description: description, + batch_candidate_count: batchCandidateCount, + merchant_name: transaction.merchant_name, + }, + }) +} diff --git a/app/api/transactions/batch-describe/__tests__/route.test.ts b/app/api/transactions/batch-describe/__tests__/route.test.ts new file mode 100644 index 00000000..04346cb1 --- /dev/null +++ b/app/api/transactions/batch-describe/__tests__/route.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + createQueuedMockSupabase, + makeTransaction, + parseJsonResponse, +} from '@/tests/helpers' +import { eventBus } from '@/lib/events' + +// Mock init +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +// Mock booking templates +vi.mock('@/lib/bookkeeping/booking-templates', () => ({ + getTemplateById: vi.fn((id: string) => { + if (id === 'office_supplies') { + return { + id: 'office_supplies', + name_sv: 'Kontorsmaterial', + name_en: 'Office supplies', + group: 'office', + debit_account: '6110', + credit_account: '1930', + fallback_category: 'expense_office', + default_private: false, + vat_treatment: 'standard_25', + vat_rate: 0.25, + deductibility: 'full', + risk_level: 'LOW', + requires_review: false, + entity_applicability: 'all', + direction: 'expense', + } + } + return null + }), + buildMappingResultFromTemplate: vi.fn(() => ({ + rule: null, + debit_account: '6110', + credit_account: '1930', + risk_level: 'LOW', + confidence: 1.0, + requires_review: false, + default_private: false, + vat_lines: [], + description: 'Kontorsmaterial', + })), +})) + +// Mock transaction entries +const mockCreateTransactionJournalEntry = vi.fn().mockResolvedValue({ id: 'je-1' }) +vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ + createTransactionJournalEntry: (...args: unknown[]) => mockCreateTransactionJournalEntry(...args), +})) + +// Mock mapping engine +const mockSaveUserMappingRule = vi.fn().mockResolvedValue(undefined) +vi.mock('@/lib/bookkeeping/mapping-engine', () => ({ + saveUserMappingRule: (...args: unknown[]) => mockSaveUserMappingRule(...args), +})) + +// Mock Supabase — set up once, re-configure per test via auth mock + queue +const mockCreateClient = vi.fn() +vi.mock('@/lib/supabase/server', () => ({ + createClient: (...args: unknown[]) => mockCreateClient(...args), +})) + +describe('POST /api/transactions/batch-describe', () => { + let POST: typeof import('../route').POST + + beforeEach(async () => { + vi.clearAllMocks() + eventBus.clear() + const mod = await import('../route') + POST = mod.POST + }) + + it('returns 401 when not authenticated', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: null }, + error: { message: 'Not authenticated' }, + }) + mockCreateClient.mockResolvedValue(supabase) + + const req = createMockRequest('/api/transactions/batch-describe', { + method: 'POST', + body: { + merchant_name: 'Staples', + template_id: 'office_supplies', + is_business: true, + }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(401) + expect(body).toHaveProperty('error', 'Unauthorized') + }) + + it('returns 400 for invalid template_id', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase) + + const req = createMockRequest('/api/transactions/batch-describe', { + method: 'POST', + body: { + merchant_name: 'Staples', + template_id: 'nonexistent_template', + is_business: true, + }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(400) + expect(body).toHaveProperty('error', 'Invalid template_id') + }) + + it('applies template to uncategorized merchant transactions', async () => { + const tx1 = makeTransaction({ id: 'tx-1', merchant_name: 'Staples', amount: -299 }) + const tx2 = makeTransaction({ id: 'tx-2', merchant_name: 'Staples', amount: -150 }) + + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + enqueueMany([ + // company_settings + { data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }, + // fetch uncategorized transactions + { data: [tx1, tx2], error: null }, + // fiscal period upsert for tx1 + { data: null, error: null }, + // transaction update for tx1 + { data: null, error: null }, + // fiscal period upsert for tx2 + { data: null, error: null }, + // transaction update for tx2 + { data: null, error: null }, + ]) + mockCreateClient.mockResolvedValue(supabase) + + const req = createMockRequest('/api/transactions/batch-describe', { + method: 'POST', + body: { + merchant_name: 'Staples', + template_id: 'office_supplies', + is_business: true, + user_description: 'office supplies purchase', + }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse<{ data: { applied: number; errors: string[] } }>(res) + + expect(status).toBe(200) + expect(body.data.applied).toBe(2) + expect(body.data.errors).toHaveLength(0) + + // Verify journal entries were created + expect(mockCreateTransactionJournalEntry).toHaveBeenCalledTimes(2) + + // Verify mapping rule was saved with user description + expect(mockSaveUserMappingRule).toHaveBeenCalledWith( + 'user-1', + 'Staples', + '6110', + '1930', + false, + 'office supplies purchase', + 'office_supplies' + ) + }) + + it('returns 0 applied when no uncategorized transactions exist', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + enqueueMany([ + // company_settings + { data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }, + // fetch uncategorized transactions — empty + { data: [], error: null }, + ]) + mockCreateClient.mockResolvedValue(supabase) + + const req = createMockRequest('/api/transactions/batch-describe', { + method: 'POST', + body: { + merchant_name: 'Unknown Merchant', + template_id: 'office_supplies', + is_business: true, + }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse<{ data: { applied: number } }>(res) + + expect(status).toBe(200) + expect(body.data.applied).toBe(0) + }) +}) diff --git a/app/api/transactions/batch-describe/route.ts b/app/api/transactions/batch-describe/route.ts new file mode 100644 index 00000000..ceaf1d08 --- /dev/null +++ b/app/api/transactions/batch-describe/route.ts @@ -0,0 +1,171 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { eventBus } from '@/lib/events' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { BatchDescribeSchema } from '@/lib/api/schemas' +import { getTemplateById, buildMappingResultFromTemplate } from '@/lib/bookkeeping/booking-templates' +import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' +import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine' +import type { Transaction, EntityType, TransactionCategory } from '@/types' + +ensureInitialized() + +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 validation = await validateBody(request, BatchDescribeSchema) + if (!validation.success) return validation.response + const { merchant_name, template_id, is_business, user_description } = validation.data + + // Look up the template + const template = getTemplateById(template_id) + if (!template) { + return NextResponse.json({ error: 'Invalid template_id' }, { status: 400 }) + } + + // Fetch entity type and fiscal year start + const { data: settings } = await supabase + .from('company_settings') + .select('entity_type, fiscal_year_start_month') + .eq('user_id', user.id) + .single() + + const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' + const fiscalYearStartMonth: number = settings?.fiscal_year_start_month ?? 1 + + // Fetch all uncategorized transactions from the specified merchant (limit 50) + const { data: transactions, error: fetchError } = await supabase + .from('transactions') + .select('*') + .eq('user_id', user.id) + .eq('merchant_name', merchant_name) + .is('journal_entry_id', null) + .order('date', { ascending: true }) + .limit(50) + + if (fetchError || !transactions || transactions.length === 0) { + return NextResponse.json({ + data: { applied: 0, errors: [] }, + }) + } + + const finalCategory: TransactionCategory = is_business + ? template.fallback_category + : 'private' + + let applied = 0 + const errors: string[] = [] + + for (const tx of transactions) { + try { + const mappingResult = buildMappingResultFromTemplate( + template, + tx as Transaction, + entityType + ) + + // Ensure fiscal period exists + const txDate = new Date(tx.date) + const txMonth = txDate.getMonth() + 1 + const txYear = txDate.getFullYear() + + let periodStartYear: number + if (fiscalYearStartMonth === 1) { + periodStartYear = txYear + } else if (txMonth >= fiscalYearStartMonth) { + periodStartYear = txYear + } else { + periodStartYear = txYear - 1 + } + + const startMonth = String(fiscalYearStartMonth).padStart(2, '0') + const periodStart = `${periodStartYear}-${startMonth}-01` + const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1 + const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1 + const lastDay = new Date(endYear, endMonth, 0).getDate() + const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` + const periodName = fiscalYearStartMonth === 1 + ? `Räkenskapsår ${periodStartYear}` + : `Räkenskapsår ${periodStartYear}/${endYear}` + + await supabase + .from('fiscal_periods') + .upsert({ + user_id: user.id, + name: periodName, + period_start: periodStart, + period_end: periodEnd, + }, { onConflict: 'user_id,period_start,period_end' }) + + // Create journal entry + let journalEntryId: string | null = null + try { + const journalEntry = await createTransactionJournalEntry( + user.id, + tx as Transaction, + mappingResult + ) + if (journalEntry) { + journalEntryId = journalEntry.id + } + } catch (err) { + console.error(`[batch-describe] Journal entry failed for ${tx.id}:`, err) + } + + // Update the transaction + await supabase + .from('transactions') + .update({ + is_business, + category: finalCategory, + journal_entry_id: journalEntryId, + }) + .eq('id', tx.id) + + await eventBus.emit({ + type: 'transaction.categorized', + payload: { + transaction: tx as Transaction, + account: mappingResult.debit_account, + taxCode: mappingResult.vat_lines[0]?.account_number || '', + userId: user.id, + }, + }) + + applied++ + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error' + errors.push(`${tx.id}: ${msg}`) + } + } + + // Save a mapping rule for future auto-categorization + if (applied > 0) { + try { + const sampleTx = transactions[0] as Transaction + const sampleResult = buildMappingResultFromTemplate(template, sampleTx, entityType) + await saveUserMappingRule( + user.id, + merchant_name, + sampleResult.debit_account, + sampleResult.credit_account, + !is_business, + user_description, + template_id + ) + } catch { + // Non-critical + } + } + + return NextResponse.json({ + data: { applied, errors }, + }) +} diff --git a/app/api/transactions/suggest-categories/route.ts b/app/api/transactions/suggest-categories/route.ts index 5f2e219a..0dc27903 100644 --- a/app/api/transactions/suggest-categories/route.ts +++ b/app/api/transactions/suggest-categories/route.ts @@ -3,6 +3,24 @@ import { NextResponse } from 'next/server' import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions' import type { Transaction, TransactionCategory, EntityType } from '@/types' +// Minimum confidence threshold — below this, suggestions are considered weak +// and we trigger on-demand AI categorization to get better results. +const WEAK_SUGGESTION_THRESHOLD = 0.55 + +/** + * Check if suggestions are "weak" — only history-based fallbacks + * with no strong rule/pattern/AI match. + */ +function hasWeakSuggestions(result: SuggestedCategory[]): boolean { + if (result.length === 0) return true + // Weak if the best suggestion is below threshold + const bestConfidence = Math.max(...result.map((s) => s.confidence)) + if (bestConfidence < WEAK_SUGGESTION_THRESHOLD) return true + // Weak if all suggestions are from history only (no rule/pattern/ai match) + if (result.every((s) => s.source === 'history')) return true + return false +} + /** * POST /api/transactions/suggest-categories * Batch endpoint for getting category suggestions for multiple transactions @@ -70,11 +88,18 @@ export async function POST(request: Request) { .eq('extension_id', 'ai-categorization') .in('key', aiKeys) - const aiSuggestionsMap: Record = {} + type AiSuggestion = { category: string; basAccount: string; confidence: number; reasoning: string } + const aiSuggestionsMap: Record = {} if (aiRecords) { for (const record of aiRecords) { const txId = record.key.replace('suggestion:', '') - aiSuggestionsMap[txId] = record.value as { category: string; basAccount: string; confidence: number; reasoning: string } + 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] + } } } @@ -86,9 +111,10 @@ export async function POST(request: Request) { .single() const entityType = (settings?.entity_type as EntityType) || undefined - // Generate suggestions for each transaction + // Generate initial suggestions for each transaction const suggestions: Record = {} const template_suggestions: Record = {} + const needsAiIds: string[] = [] for (const tx of transactions) { let result = getSuggestedCategories( @@ -97,14 +123,74 @@ export async function POST(request: Request) { categoryHistory ) - // Merge AI suggestions if available - const aiSuggestion = aiSuggestionsMap[tx.id] - if (aiSuggestion) { - result = mergeAiSuggestions(result, [aiSuggestion]) + // Merge pre-computed AI suggestions if available + const aiSuggestions = aiSuggestionsMap[tx.id] + if (aiSuggestions && aiSuggestions.length > 0) { + result = mergeAiSuggestions(result, aiSuggestions, tx.amount) + } else if (hasWeakSuggestions(result)) { + // No pre-computed AI suggestion AND rule-based suggestions are weak — + // mark this transaction for on-demand AI categorization + needsAiIds.push(tx.id) } suggestions[tx.id] = result - template_suggestions[tx.id] = getSuggestedTemplates(tx as Transaction, entityType) + template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType) + } + + // Trigger on-demand AI categorization for transactions with weak suggestions + if (needsAiIds.length > 0) { + console.log( + `[suggest-categories] ${needsAiIds.length} transactions have weak suggestions, triggering on-demand AI:`, + needsAiIds.map((id) => { + const tx = transactions.find((t) => t.id === id) + return tx + ? { id, description: tx.description, merchant_name: tx.merchant_name, amount: tx.amount } + : { id } + }) + ) + + try { + const { categorizeTransactions } = await import( + '@/extensions/general/ai-categorization' + ) + const aiResults = await categorizeTransactions(user.id, needsAiIds) + + console.log( + '[suggest-categories] AI categorization results:', + aiResults.map((r) => ({ + id: r.transactionId, + category: r.category, + basAccount: r.basAccount, + confidence: r.confidence, + reasoning: r.reasoning, + isPrivate: r.isPrivate, + templateId: r.templateId, + })) + ) + + // Group AI results by transactionId (AI now returns 2 per transaction) + const groupedAi: Record = {} + for (const aiResult of aiResults) { + if (!groupedAi[aiResult.transactionId]) { + groupedAi[aiResult.transactionId] = [] + } + groupedAi[aiResult.transactionId].push({ + category: aiResult.category, + basAccount: aiResult.basAccount, + confidence: aiResult.confidence, + reasoning: aiResult.reasoning, + }) + } + + for (const [txId, aiSuggestions] of Object.entries(groupedAi)) { + const existing = suggestions[txId] || [] + const tx = transactions.find((t) => t.id === txId) + suggestions[txId] = mergeAiSuggestions(existing, aiSuggestions, tx?.amount) + } + } catch (err) { + // AI categorization is non-blocking — log and continue with existing suggestions + console.error('[suggest-categories] On-demand AI categorization failed:', err) + } } return NextResponse.json({ suggestions, template_suggestions }) diff --git a/app/page.tsx b/app/page.tsx index 80b5e80d..1cf62e08 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -198,6 +198,13 @@ export default async function RootPage() { streak_count: streakCount, } + // Fetch enabled extension toggles + const { data: enabledToggles } = await supabase + .from('extension_toggles') + .select('sector_slug, extension_slug') + .eq('user_id', user.id) + .eq('enabled', true) + return (
@@ -220,6 +227,7 @@ export default async function RootPage() { receiptQueue, missingUnderlagCount, }} + enabledExtensions={enabledToggles || []} />
diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index de63de7d..579f17c9 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -206,8 +206,11 @@ export default function JournalEntryList({ periodId }: Props) {
- {isExpanded && lines.length > 0 && ( + {isExpanded && ( + {lines.length === 0 ? ( +

Inga kontorader hittades för denna verifikation.

+ ) : ( @@ -261,6 +264,7 @@ export default function JournalEntryList({ periodId }: Props) {
+ )} { + setLiveExtensions(enabledExtensions ?? []) + }, [enabledExtensions]) + + useEffect(() => { + const handler = ((e: CustomEvent<{ sector_slug: string; extension_slug: string; enabled: boolean }>) => { + setLiveExtensions(prev => { + if (e.detail.enabled) { + if (prev.some(x => x.sector_slug === e.detail.sector_slug && x.extension_slug === e.detail.extension_slug)) return prev + return [...prev, { sector_slug: e.detail.sector_slug, extension_slug: e.detail.extension_slug }] + } + return prev.filter(x => !(x.sector_slug === e.detail.sector_slug && x.extension_slug === e.detail.extension_slug)) + }) + }) as EventListener + window.addEventListener('extension-toggle-changed', handler) + return () => window.removeEventListener('extension-toggle-changed', handler) + }, []) const entityType = (settings?.entity_type as EntityType) || 'enskild_firma' const preliminaryTaxMonthly = settings?.preliminary_tax_monthly || 0 @@ -205,7 +228,15 @@ export default function DashboardContent({ firstName, settings, summary, onboard const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS) const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS - const openAiChat = () => window.dispatchEvent(new Event('open-ai-chat')) + // Build extension quick actions from enabled extensions + const extensionQuickActions: (QuickActionDefinition & { key: string })[] = liveExtensions + .map(toggle => { + const def = getExtensionDefinition(toggle.sector_slug, toggle.extension_slug) + if (!def?.quickAction) return null + return { ...def.quickAction, key: `${toggle.sector_slug}/${toggle.extension_slug}` } + }) + .filter((a): a is QuickActionDefinition & { key: string } => a !== null) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) // Quick action items const quickActions = [ @@ -242,7 +273,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard const todoItems: { label: string; href: string; count: number; variant: 'destructive' | 'warning' | 'default' }[] = [] if (passedDeadlines.length > 0) { - todoItems.push({ label: 'passerade deadlines', href: '/calendar', count: passedDeadlines.length, variant: 'destructive' }) + todoItems.push({ label: 'passerade deadlines', href: '/deadlines', count: passedDeadlines.length, variant: 'destructive' }) } if (summary.overdueInvoicesCount > 0) { todoItems.push({ label: 'förfallna fakturor', href: '/invoices?status=unpaid', count: summary.overdueInvoicesCount, variant: 'destructive' }) @@ -431,18 +462,42 @@ export default function DashboardContent({ firstName, settings, summary, onboard ) })} - {/* AI assistant quick action */} - + {/* Extension quick actions */} + {extensionQuickActions.map((action) => { + const Icon = resolveIcon(action.icon) + if (action.href) { + return ( + +
+
+ +
+
+

{action.label}

+

{action.description}

+
+
+ + ) + } + return ( + + ) + })}
@@ -453,6 +508,13 @@ export default function DashboardContent({ firstName, settings, summary, onboard )} + {/* Tax todo widget — visible when there are incomplete tax deadlines */} + {summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && ( +
+ +
+ )} + {/* Alerts section — always visible */} {alertItems.length > 0 && (
diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index f244cbf2..e29394fa 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -46,7 +46,7 @@ interface NavItem { // All nav items for sidebar and mobile drawer const navItems: NavItem[] = [ { href: '/', label: 'Översikt', icon: LayoutDashboard, group: 'main' }, - { href: '/calendar', label: 'Kalender', icon: Calendar, group: 'main' }, + { href: '/deadlines', label: 'Deadlines', icon: Calendar, group: 'main' }, { href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' }, { href: '/customers', label: 'Kunder', icon: Users, group: 'finans' }, { href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'finans' }, diff --git a/components/calendar/DeadlineCard.tsx b/components/deadlines/DeadlineCard.tsx similarity index 100% rename from components/calendar/DeadlineCard.tsx rename to components/deadlines/DeadlineCard.tsx diff --git a/components/calendar/DeadlineFilters.tsx b/components/deadlines/DeadlineFilters.tsx similarity index 100% rename from components/calendar/DeadlineFilters.tsx rename to components/deadlines/DeadlineFilters.tsx diff --git a/components/calendar/DeadlineForm.tsx b/components/deadlines/DeadlineForm.tsx similarity index 100% rename from components/calendar/DeadlineForm.tsx rename to components/deadlines/DeadlineForm.tsx diff --git a/components/calendar/DeadlineList.tsx b/components/deadlines/DeadlineList.tsx similarity index 100% rename from components/calendar/DeadlineList.tsx rename to components/deadlines/DeadlineList.tsx diff --git a/components/calendar/TaxTodoWidget.tsx b/components/deadlines/TaxTodoWidget.tsx similarity index 98% rename from components/calendar/TaxTodoWidget.tsx rename to components/deadlines/TaxTodoWidget.tsx index 8295d3b6..8aa96d44 100644 --- a/components/calendar/TaxTodoWidget.tsx +++ b/components/deadlines/TaxTodoWidget.tsx @@ -231,9 +231,9 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)

)} - + diff --git a/components/calendar/UpcomingDeadlinesWidget.tsx b/components/deadlines/UpcomingDeadlinesWidget.tsx similarity index 98% rename from components/calendar/UpcomingDeadlinesWidget.tsx rename to components/deadlines/UpcomingDeadlinesWidget.tsx index 2f347de2..5cd60601 100644 --- a/components/calendar/UpcomingDeadlinesWidget.tsx +++ b/components/deadlines/UpcomingDeadlinesWidget.tsx @@ -201,9 +201,9 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang ) })} - + diff --git a/components/deadlines/index.ts b/components/deadlines/index.ts new file mode 100644 index 00000000..3118eff9 --- /dev/null +++ b/components/deadlines/index.ts @@ -0,0 +1,6 @@ +export { DeadlineCard } from './DeadlineCard' +export { DeadlineFilters } from './DeadlineFilters' +export { DeadlineForm } from './DeadlineForm' +export { DeadlineList } from './DeadlineList' +export { UpcomingDeadlinesWidget } from './UpcomingDeadlinesWidget' +export { TaxTodoWidget } from './TaxTodoWidget' diff --git a/app/(dashboard)/calendar/page.tsx b/components/extensions/general/CalendarWorkspace.tsx similarity index 74% rename from app/(dashboard)/calendar/page.tsx rename to components/extensions/general/CalendarWorkspace.tsx index 8759de79..2dc563ba 100644 --- a/app/(dashboard)/calendar/page.tsx +++ b/components/extensions/general/CalendarWorkspace.tsx @@ -3,10 +3,11 @@ import { useState, useEffect, useCallback } from 'react' import { createClient } from '@/lib/supabase/client' import { useToast } from '@/components/ui/use-toast' -import { PaymentCalendar } from '@/components/calendar/PaymentCalendar' +import { PaymentCalendar } from '@/extensions/general/calendar/components/PaymentCalendar' +import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' import type { Invoice, Deadline } from '@/types' -export default function CalendarPage() { +export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) { const [invoices, setInvoices] = useState([]) const [deadlines, setDeadlines] = useState([]) const [customers, setCustomers] = useState<{ id: string; name: string }[]>([]) @@ -18,7 +19,6 @@ export default function CalendarPage() { setIsLoading(true) try { - // Fetch invoices with customer names const { data: invoicesData, error: invoicesError } = await supabase .from('invoices') .select('*, customer:customers(name)') @@ -26,7 +26,6 @@ export default function CalendarPage() { if (invoicesError) throw invoicesError - // Fetch deadlines with customer names const { data: deadlinesData, error: deadlinesError } = await supabase .from('deadlines') .select('*, customer:customers(name)') @@ -34,7 +33,6 @@ export default function CalendarPage() { if (deadlinesError) throw deadlinesError - // Fetch customers for the form const { data: customersData, error: customersError } = await supabase .from('customers') .select('id, name') @@ -45,10 +43,10 @@ export default function CalendarPage() { setInvoices(invoicesData || []) setDeadlines(deadlinesData || []) setCustomers(customersData || []) - } catch (error) { + } catch { toast({ title: 'Fel', - description: 'Kunde inte hämta data', + description: 'Kunde inte hamta data', variant: 'destructive', }) } finally { @@ -101,7 +99,7 @@ export default function CalendarPage() { }) fetchData() - } catch (error) { + } catch { toast({ title: 'Fel', description: 'Kunde inte uppdatera deadline', @@ -112,31 +110,20 @@ export default function CalendarPage() { if (isLoading) { return ( -
-
-

Kalender

-
-
-
-
-
+
+
+
) } return ( -
-
-

Kalender

-
- - -
+ ) } diff --git a/components/extensions/general/UserDescriptionMatchWorkspace.tsx b/components/extensions/general/UserDescriptionMatchWorkspace.tsx new file mode 100644 index 00000000..5d49b6d1 --- /dev/null +++ b/components/extensions/general/UserDescriptionMatchWorkspace.tsx @@ -0,0 +1,15 @@ +'use client' + +import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' +import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState' +import { TextSearch } from 'lucide-react' + +export default function UserDescriptionMatchWorkspace({ userId }: WorkspaceComponentProps) { + return ( + } + /> + ) +} diff --git a/components/transactions/DescribeTransactionDialog.tsx b/components/transactions/DescribeTransactionDialog.tsx new file mode 100644 index 00000000..45b40d84 --- /dev/null +++ b/components/transactions/DescribeTransactionDialog.tsx @@ -0,0 +1,539 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent } from '@/components/ui/card' +import { Textarea } from '@/components/ui/textarea' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { useToast } from '@/components/ui/use-toast' +import { formatCurrency, formatDate } from '@/lib/utils' +import { + ArrowUpRight, + ArrowDownRight, + Loader2, + Search, + ArrowLeft, + Check, + CheckCircle2, + AlertTriangle, +} from 'lucide-react' +import JournalEntryPreview from './JournalEntryPreview' +import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names' +import type { TransactionWithInvoice } from './transaction-types' + +interface TemplateMatch { + template_id: string + name_sv: string + name_en: string + group: string + debit_account: string + credit_account: string + confidence: number + description_sv: string + vat_rate: number + vat_treatment: string | null + deductibility: 'full' | 'non_deductible' | 'conditional' + deductibility_note_sv: string | null + special_rules_sv: string | null + risk_level: string +} + +interface DescribeResult { + templates: TemplateMatch[] + needs_more_detail: boolean + user_description: string + batch_candidate_count: number + merchant_name: string | null +} + +interface DescribeTransactionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + transaction: TransactionWithInvoice | null + onCategorized: (transactionId: string, journalEntryId: string | null) => void + onBatchApplied?: (count: number) => void +} + +type Step = 'describe' | 'pick' | 'batch' + +function getExamplePrompts(transaction: TransactionWithInvoice): string[] { + const desc = (transaction.description || '').toLowerCase() + const isExpense = transaction.amount < 0 + + if (!isExpense) { + return ['Konsultarvode', 'Forsaljning av varor', 'Aterbetalning'] + } + + // Contextual suggestions based on description keywords + if (desc.includes('restaurang') || desc.includes('lunch') || desc.includes('middag') || desc.includes('mat')) { + return ['Lunch med kund', 'Personalmiddag', 'Fika till kontoret'] + } + if (desc.includes('hotel') || desc.includes('hotell') || desc.includes('boende') || desc.includes('resa')) { + return ['Tjansteresa', 'Hotell konferens', 'Flygbiljett'] + } + if (desc.includes('uber') || desc.includes('taxi') || desc.includes('bolt') || desc.includes('sj ')) { + return ['Taxi till kund', 'Tjansteresa', 'Pendling'] + } + if (desc.includes('google') || desc.includes('meta') || desc.includes('facebook') || desc.includes('linkedin')) { + return ['Online-annonsering', 'SaaS-prenumeration', 'Marknadsforingskampanj'] + } + if (desc.includes('amazon') || desc.includes('aws') || desc.includes('azure') || desc.includes('cloud')) { + return ['Serverhosting', 'SaaS-prenumeration', 'Kontorsmaterial'] + } + + // Generic expense suggestions + return ['Kontorsmaterial', 'SaaS-prenumeration', 'Konsulttjanst', 'Reklam'] +} + +export default function DescribeTransactionDialog({ + open, + onOpenChange, + transaction, + onCategorized, + onBatchApplied, +}: DescribeTransactionDialogProps) { + const { toast } = useToast() + const [step, setStep] = useState('describe') + const [description, setDescription] = useState('') + const [isSearching, setIsSearching] = useState(false) + const [isBooking, setIsBooking] = useState(false) + const [isBatchApplying, setIsBatchApplying] = useState(false) + const [describeResult, setDescribeResult] = useState(null) + const [selectedTemplateId, setSelectedTemplateId] = useState(null) + + function resetState() { + setStep('describe') + setDescription('') + setIsSearching(false) + setIsBooking(false) + setIsBatchApplying(false) + setDescribeResult(null) + setSelectedTemplateId(null) + } + + function handleOpenChange(isOpen: boolean) { + if (!isOpen) { + resetState() + } + onOpenChange(isOpen) + } + + async function handleSearch() { + if (!transaction || description.trim().length < 3) return + + setIsSearching(true) + try { + const response = await fetch(`/api/transactions/${transaction.id}/describe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ description: description.trim() }), + }) + const result = await response.json() + if (!response.ok) { + toast({ + title: 'Fel', + description: result.error || 'Kunde inte soka mallar', + variant: 'destructive', + }) + setIsSearching(false) + return + } + + setDescribeResult(result.data) + setSelectedTemplateId(null) + setStep('pick') + } catch { + toast({ + title: 'Fel', + description: 'Nagot gick fel vid sokning', + variant: 'destructive', + }) + } + setIsSearching(false) + } + + async function handleBook() { + if (!transaction || !selectedTemplateId || !describeResult) return + + setIsBooking(true) + try { + const response = await fetch(`/api/transactions/${transaction.id}/categorize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + is_business: true, + template_id: selectedTemplateId, + user_description: describeResult.user_description, + }), + }) + const result = await response.json() + if (!response.ok) { + toast({ + title: 'Fel', + description: result.error || 'Kunde inte bokfora transaktion', + variant: 'destructive', + }) + setIsBooking(false) + return + } + + if (describeResult.batch_candidate_count > 0) { + setStep('batch') + setIsBooking(false) + onCategorized(transaction.id, result.journal_entry_id || null) + } else { + toast({ title: 'Bokford', description: 'Transaktion bokford och verifikation skapad' }) + onCategorized(transaction.id, result.journal_entry_id || null) + handleOpenChange(false) + } + } catch { + toast({ + title: 'Fel', + description: 'Nagot gick fel vid bokforing', + variant: 'destructive', + }) + setIsBooking(false) + } + } + + async function handleBatchApply() { + if (!describeResult || !selectedTemplateId) return + + setIsBatchApplying(true) + try { + const response = await fetch('/api/transactions/batch-describe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + merchant_name: describeResult.merchant_name, + template_id: selectedTemplateId, + is_business: true, + user_description: describeResult.user_description, + }), + }) + const result = await response.json() + if (!response.ok) { + toast({ + title: 'Fel', + description: result.error || 'Kunde inte bokfora batch', + variant: 'destructive', + }) + setIsBatchApplying(false) + return + } + + const applied = result.data?.applied || 0 + const errors = result.data?.errors || [] + if (errors.length > 0) { + toast({ + title: 'Delvis klart', + description: `${applied} lyckades, ${errors.length} misslyckades`, + variant: 'destructive', + }) + } else { + toast({ + title: 'Klart', + description: `${applied} transaktioner bokforda`, + }) + } + onBatchApplied?.(applied) + handleOpenChange(false) + } catch { + toast({ + title: 'Fel', + description: 'Nagot gick fel vid batchbokforing', + variant: 'destructive', + }) + setIsBatchApplying(false) + } + } + + function handleSkipBatch() { + toast({ title: 'Bokford', description: 'Transaktion bokford och verifikation skapad' }) + handleOpenChange(false) + } + + if (!transaction) return null + + const isIncome = transaction.amount > 0 + + return ( + + + + + {step === 'describe' && 'Beskriv transaktion'} + {step === 'pick' && 'Valj mall'} + {step === 'batch' && 'Bokfor liknande'} + + + {step === 'describe' && 'Beskriv vad transaktionen galler sa hittar vi ratt bokforingsmall'} + {step === 'pick' && 'Valj den mall som stammer bast'} + {step === 'batch' && 'Transaktion bokford!'} + + + + {/* Transaction summary - shown in describe and pick steps */} + {(step === 'describe' || step === 'pick') && ( +
+
+ {isIncome ? ( + + ) : ( + + )} +
+
+

{transaction.description}

+

{formatDate(transaction.date)}

+
+

+ {isIncome ? '+' : ''} + {formatCurrency(transaction.amount, transaction.currency)} +

+
+ )} + + {/* Step 1: Describe */} + {step === 'describe' && ( +
+