Merge remote-tracking branch 'origin/main' into export-biz
This commit is contained in:
@@ -5,7 +5,9 @@
|
||||
* in server components or API routes.
|
||||
*
|
||||
* Provider-abstracted AI categorization for Swedish BAS account mapping.
|
||||
* Default implementation uses Claude Haiku for cost efficiency.
|
||||
* Uses Claude Haiku with structured tool outputs for reliable JSON.
|
||||
* Accepts pre-filtered candidate templates from embedding search (Tier 2)
|
||||
* instead of dumping all ~100 templates into the prompt.
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
@@ -27,11 +29,29 @@ export interface TransactionForCategorization {
|
||||
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 EnrichedCategorizationContext extends CategorizationContext {
|
||||
candidateTemplates: BookingTemplate[]
|
||||
userAccountUsage: AccountUsageEntry[]
|
||||
merchantHistory: MerchantHistoryEntry[]
|
||||
}
|
||||
|
||||
export interface CategorizationSuggestion {
|
||||
transactionId: string
|
||||
category: TransactionCategory
|
||||
@@ -46,7 +66,7 @@ export interface CategorizationSuggestion {
|
||||
export interface CategorizationProvider {
|
||||
categorize(
|
||||
transactions: TransactionForCategorization[],
|
||||
context: CategorizationContext
|
||||
context: CategorizationContext | EnrichedCategorizationContext
|
||||
): Promise<CategorizationSuggestion[]>
|
||||
}
|
||||
|
||||
@@ -67,36 +87,89 @@ function getCategoryAccountMap(entityType: EntityType): Record<string, { account
|
||||
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_other: { account: '6991', label: 'Övriga kostnader' },
|
||||
private: { account: '2013', label: 'Privat uttag (EF) / Skuld till ägare (AB)' },
|
||||
}
|
||||
}
|
||||
|
||||
/** Fallback template IDs when AI doesn't provide one */
|
||||
const CATEGORY_DEFAULT_TEMPLATES: Record<string, string> = {
|
||||
expense_representation: 'representation_external',
|
||||
expense_equipment: 'equipment_tools',
|
||||
expense_software: 'software_subscription',
|
||||
expense_travel: 'travel_domestic',
|
||||
expense_office: 'office_supplies_general',
|
||||
expense_consumables: 'office_supplies_general',
|
||||
expense_vehicle: 'travel_fuel',
|
||||
expense_telecom: 'telecom_mobile',
|
||||
expense_marketing: 'marketing_advertising',
|
||||
expense_education: 'education_course',
|
||||
expense_professional_services: 'consulting_accounting',
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an abbreviated template reference for the AI prompt.
|
||||
* Filters by direction (expense/income) to keep prompt concise.
|
||||
* 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'): string {
|
||||
return BOOKING_TEMPLATES
|
||||
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 = `
|
||||
ICKE-AVDRAGSGILLA KOSTNADER (svensk skatterätt):
|
||||
- Kläder: Normalt inte avdragsgilla (RÅ 1988 ref. 35)
|
||||
- Gym/träning: Inte avdragsgilla som personlig kostnad (IL 9 kap 2§)
|
||||
- Kosmetika/hudvård: Normalt inte avdragsgillt
|
||||
- Frisör: Normalt privat kostnad
|
||||
- Representation/måltider: Max 300 kr/person exkl. moms (IL 16 kap 2§)
|
||||
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
|
||||
// ============================================================
|
||||
@@ -116,23 +189,40 @@ export class AnthropicCategorizationProvider implements CategorizationProvider {
|
||||
|
||||
async categorize(
|
||||
transactions: TransactionForCategorization[],
|
||||
context: CategorizationContext
|
||||
context: CategorizationContext | EnrichedCategorizationContext
|
||||
): 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 based on batch direction (most transactions will be same direction)
|
||||
// Build template references — use candidate templates if available
|
||||
const hasExpenses = batch.some((t) => t.amount < 0)
|
||||
const hasIncome = batch.some((t) => t.amount > 0)
|
||||
const candidates = enriched?.candidateTemplates
|
||||
const templateRef = [
|
||||
hasExpenses ? `UTGIFTSMALLAR:\n${getTemplateReference('expense')}` : '',
|
||||
hasIncome ? `INTÄKTSMALLAR:\n${getTemplateReference('income')}` : '',
|
||||
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')}`
|
||||
: ''
|
||||
|
||||
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.
|
||||
|
||||
@@ -156,12 +246,14 @@ ${NON_DEDUCTIBLE_RULES}
|
||||
|
||||
REGLER:
|
||||
1. Negativa belopp = utgifter, positiva = intäkter
|
||||
2. Markera transaktioner som troligen är privata med isPrivate: true
|
||||
3. Ange confidence 0.0-1.0 baserat på hur säker du är
|
||||
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. Om en transaktion liknar privat konsumtion (kläder, gym, etc.), sätt category: "private"
|
||||
6. taxCode: "MPI" för avdragsgilla affärskostnader med moms, "MP1" för intäkter med moms, null för momsfria/privata
|
||||
7. Ange templateId om en bokföringsmall matchar (föredra mallar framför generiska kategorier)`
|
||||
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.`
|
||||
|
||||
const historyContext =
|
||||
context.recentHistory.length > 0
|
||||
@@ -181,29 +273,12 @@ REGLER:
|
||||
)
|
||||
.join('\n\n')
|
||||
|
||||
const userPrompt = `Kategorisera följande transaktioner:
|
||||
${historyContext}
|
||||
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}
|
||||
|
||||
Returnera ett JSON-objekt med följande struktur:
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"transactionId": "id",
|
||||
"category": "expense_software",
|
||||
"templateId": "it_saas_subscription",
|
||||
"basAccount": "5420",
|
||||
"taxCode": "MPI",
|
||||
"confidence": 0.9,
|
||||
"reasoning": "Spotify-prenumeration, typisk programvarukostnad",
|
||||
"isPrivate": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
${transactionList}`
|
||||
|
||||
let lastError: Error | null = null
|
||||
|
||||
@@ -212,7 +287,15 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
const message = await this.client.messages.create({
|
||||
model: this.model,
|
||||
max_tokens: 4096,
|
||||
system: systemPrompt,
|
||||
system: [
|
||||
{
|
||||
type: 'text',
|
||||
text: systemPrompt,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
tools: [CLASSIFY_TOOL],
|
||||
tool_choice: { type: 'tool', name: 'classify_transactions' },
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
@@ -221,33 +304,20 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
],
|
||||
})
|
||||
|
||||
const content = message.content[0]
|
||||
if (content.type !== 'text') {
|
||||
throw new Error('Unexpected response type from AI')
|
||||
// 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')
|
||||
}
|
||||
|
||||
// Strip markdown code blocks if present
|
||||
let jsonText = content.text.trim()
|
||||
if (jsonText.startsWith('```json')) {
|
||||
jsonText = jsonText.slice(7)
|
||||
} else if (jsonText.startsWith('```')) {
|
||||
jsonText = jsonText.slice(3)
|
||||
}
|
||||
if (jsonText.endsWith('```')) {
|
||||
jsonText = jsonText.slice(0, -3)
|
||||
}
|
||||
jsonText = jsonText.trim()
|
||||
|
||||
const parsed = JSON.parse(jsonText)
|
||||
return this.validateSuggestions(parsed.suggestions || [], batch, context.entityType)
|
||||
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')
|
||||
|
||||
// Don't retry on parse errors
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(`Failed to parse AI response: ${lastError.message}`)
|
||||
}
|
||||
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
await sleep(RETRY_DELAY_MS * (attempt + 1))
|
||||
}
|
||||
@@ -269,6 +339,7 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
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(
|
||||
@@ -277,9 +348,23 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
)
|
||||
.filter((s) => validTransactionIds.has(s.transactionId as string))
|
||||
.map((s) => {
|
||||
const category = validCategories.has(s.category as string)
|
||||
// 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]
|
||||
|
||||
@@ -290,13 +375,19 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
taxCode: (s.taxCode as string) || null,
|
||||
confidence: Math.max(0, Math.min(1, Number(s.confidence) || 0.5)),
|
||||
reasoning: (s.reasoning as string) || '',
|
||||
isPrivate: category === 'private' || Boolean(s.isPrivate),
|
||||
templateId: (s.templateId as string) || undefined,
|
||||
isPrivate: false,
|
||||
templateId: (s.templateId as string) || CATEGORY_DEFAULT_TEMPLATES[category] || undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@ import {
|
||||
AnthropicCategorizationProvider,
|
||||
type CategorizationProvider,
|
||||
type TransactionForCategorization,
|
||||
type CategorizationContext,
|
||||
type EnrichedCategorizationContext,
|
||||
type CategorizationSuggestion,
|
||||
type AccountUsageEntry,
|
||||
type MerchantHistoryEntry,
|
||||
} from './categorizer'
|
||||
import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings'
|
||||
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
||||
|
||||
// ============================================================
|
||||
// Settings
|
||||
@@ -117,7 +121,7 @@ export async function categorizeTransactions(
|
||||
currency: t.currency,
|
||||
}))
|
||||
|
||||
const context = await buildContext(userId, supabase)
|
||||
const context = await buildEnrichedContext(userId, supabase, batch)
|
||||
|
||||
const aiProvider = getProvider(settings.providerModel)
|
||||
const suggestions = await aiProvider.categorize(batch, context)
|
||||
@@ -168,7 +172,7 @@ async function handleTransactionSynced(
|
||||
currency: t.currency,
|
||||
}))
|
||||
|
||||
const context = await buildContext(userId, supabase)
|
||||
const context = await buildEnrichedContext(userId, supabase, batch)
|
||||
const aiProvider = getProvider(settings.providerModel)
|
||||
const suggestions = await aiProvider.categorize(batch, context)
|
||||
|
||||
@@ -194,7 +198,11 @@ async function handleTransactionSynced(
|
||||
// ============================================================
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function buildContext(userId: string, supabase: any): Promise<CategorizationContext> {
|
||||
async function buildEnrichedContext(
|
||||
userId: string,
|
||||
supabase: any,
|
||||
transactions: TransactionForCategorization[]
|
||||
): Promise<EnrichedCategorizationContext> {
|
||||
// Fetch entity type
|
||||
const { data: companySettings } = await supabase
|
||||
.from('company_settings')
|
||||
@@ -221,7 +229,92 @@ async function buildContext(userId: string, supabase: any): Promise<Categorizati
|
||||
})
|
||||
)
|
||||
|
||||
return { entityType, recentHistory }
|
||||
// Fetch user's most-used accounts (top 30)
|
||||
const { data: accountUsageRows } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number')
|
||||
.eq('user_id', userId)
|
||||
|
||||
const accountCounts = new Map<string, number>()
|
||||
if (accountUsageRows) {
|
||||
for (const row of accountUsageRows as { account_number: string }[]) {
|
||||
accountCounts.set(row.account_number, (accountCounts.get(row.account_number) || 0) + 1)
|
||||
}
|
||||
}
|
||||
const userAccountUsage: AccountUsageEntry[] = Array.from(accountCounts.entries())
|
||||
.map(([account_number, count]) => ({ account_number, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 30)
|
||||
|
||||
// Fetch merchant history for this batch's merchants
|
||||
const merchantNames = [...new Set(
|
||||
transactions
|
||||
.map((t) => t.merchant_name)
|
||||
.filter((n): n is string => n !== null && n.length > 0)
|
||||
)]
|
||||
|
||||
let merchantHistory: MerchantHistoryEntry[] = []
|
||||
if (merchantNames.length > 0) {
|
||||
const { data: merchantRows } = await supabase
|
||||
.from('transactions')
|
||||
.select('merchant_name, category, template_id')
|
||||
.eq('user_id', userId)
|
||||
.not('is_business', 'is', null)
|
||||
.neq('category', 'uncategorized')
|
||||
.in('merchant_name', merchantNames)
|
||||
.limit(200)
|
||||
|
||||
if (merchantRows) {
|
||||
const merchantMap = new Map<string, MerchantHistoryEntry>()
|
||||
for (const row of merchantRows as { merchant_name: string; category: string; template_id: string | null }[]) {
|
||||
const key = `${row.merchant_name}:${row.category}`
|
||||
const existing = merchantMap.get(key)
|
||||
if (existing) {
|
||||
existing.count++
|
||||
} else {
|
||||
merchantMap.set(key, {
|
||||
merchant_name: row.merchant_name,
|
||||
category: row.category,
|
||||
template_id: row.template_id,
|
||||
count: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
merchantHistory = Array.from(merchantMap.values())
|
||||
.sort((a, b) => b.count - a.count)
|
||||
}
|
||||
}
|
||||
|
||||
// Find candidate templates via embedding search
|
||||
// Use a representative subset of transactions to find candidates
|
||||
const representativeTransactions = transactions.slice(0, 5)
|
||||
const candidateMap = new Map<string, BookingTemplate>()
|
||||
|
||||
for (const tx of representativeTransactions) {
|
||||
try {
|
||||
const matches = await findSimilarTemplates(
|
||||
tx as unknown as Transaction,
|
||||
entityType
|
||||
)
|
||||
for (const m of matches) {
|
||||
if (!candidateMap.has(m.template.id)) {
|
||||
candidateMap.set(m.template.id, m.template)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Embedding search failed — continue without candidates
|
||||
}
|
||||
}
|
||||
|
||||
const candidateTemplates = Array.from(candidateMap.values())
|
||||
|
||||
return {
|
||||
entityType,
|
||||
recentHistory,
|
||||
candidateTemplates,
|
||||
userAccountUsage,
|
||||
merchantHistory,
|
||||
}
|
||||
}
|
||||
|
||||
async function storeSuggestions(
|
||||
@@ -230,16 +323,28 @@ async function storeSuggestions(
|
||||
// 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) {
|
||||
await supabase.from('extension_data').upsert(
|
||||
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:${suggestion.transactionId}`,
|
||||
value: suggestion,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Invoice, Deadline, DeadlineStatus } from '@/types'
|
||||
import { isInvoiceOverdue, isSameDay, formatDateISO, STATUS_COLORS } from '@/lib/calendar/utils'
|
||||
|
||||
interface CalendarDayCellProps {
|
||||
date: Date
|
||||
currentMonth: number
|
||||
invoices: Invoice[]
|
||||
deadlines: Deadline[]
|
||||
onDayClick: (date: Date) => void
|
||||
}
|
||||
|
||||
export function CalendarDayCell({
|
||||
date,
|
||||
currentMonth,
|
||||
invoices,
|
||||
deadlines,
|
||||
onDayClick,
|
||||
}: CalendarDayCellProps) {
|
||||
const dateStr = formatDateISO(date)
|
||||
const isCurrentMonth = date.getMonth() === currentMonth
|
||||
const isToday = isSameDay(date, new Date())
|
||||
|
||||
// Filter invoices and deadlines for this day
|
||||
const dayInvoices = invoices.filter(inv => inv.due_date === dateStr)
|
||||
const dayDeadlines = deadlines.filter(d => d.due_date === dateStr)
|
||||
|
||||
// Count invoices by status
|
||||
const overdueInvoices = dayInvoices.filter(isInvoiceOverdue)
|
||||
const paidInvoices = dayInvoices.filter(inv => inv.status === 'paid')
|
||||
const pendingInvoices = dayInvoices.filter(
|
||||
inv => inv.status !== 'paid' && inv.status !== 'cancelled' && inv.status !== 'credited' && !isInvoiceOverdue(inv)
|
||||
)
|
||||
|
||||
// Group deadlines by status
|
||||
const deadlinesByStatus = dayDeadlines.reduce((acc, d) => {
|
||||
if (d.is_completed) {
|
||||
acc.completed = (acc.completed || 0) + 1
|
||||
} else if (d.status) {
|
||||
acc[d.status] = (acc[d.status] || 0) + 1
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, number>)
|
||||
|
||||
const hasItems = dayInvoices.length > 0 || dayDeadlines.length > 0
|
||||
const hasOverdue = overdueInvoices.length > 0 || deadlinesByStatus.overdue > 0
|
||||
const hasActionNeeded = deadlinesByStatus.action_needed > 0
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onDayClick(date)}
|
||||
className={cn(
|
||||
'min-h-[80px] p-1 border-b border-r text-left transition-colors',
|
||||
'hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-inset',
|
||||
!isCurrentMonth && 'bg-muted/30 text-muted-foreground',
|
||||
isToday && 'bg-primary/5',
|
||||
hasOverdue && 'border-l-2 border-l-destructive',
|
||||
!hasOverdue && hasActionNeeded && 'border-l-2 border-l-orange-500'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'text-sm font-medium mb-1',
|
||||
isToday && 'text-primary font-bold'
|
||||
)}>
|
||||
{date.getDate()}
|
||||
</div>
|
||||
|
||||
{hasItems && (
|
||||
<div className="space-y-0.5">
|
||||
{/* Overdue invoices */}
|
||||
{overdueInvoices.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-destructive" />
|
||||
<span className="text-xs text-destructive truncate">
|
||||
{overdueInvoices.length} förfallen
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending invoices */}
|
||||
{pendingInvoices.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-primary" />
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{pendingInvoices.length} faktura
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paid invoices */}
|
||||
{paidInvoices.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-success" />
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{paidInvoices.length} betald
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overdue deadlines */}
|
||||
{deadlinesByStatus.overdue > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.overdue.dot)} />
|
||||
<span className="text-xs text-destructive truncate">
|
||||
{deadlinesByStatus.overdue} försenad
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action needed deadlines */}
|
||||
{deadlinesByStatus.action_needed > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.action_needed.dot)} />
|
||||
<span className="text-xs text-orange-700 truncate">
|
||||
{deadlinesByStatus.action_needed} åtgärd
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upcoming deadlines */}
|
||||
{deadlinesByStatus.upcoming > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.upcoming.dot)} />
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{deadlinesByStatus.upcoming} deadline
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* In progress deadlines */}
|
||||
{deadlinesByStatus.in_progress > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.in_progress.dot)} />
|
||||
<span className="text-xs text-yellow-700 truncate">
|
||||
{deadlinesByStatus.in_progress} pågår
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submitted deadlines */}
|
||||
{deadlinesByStatus.submitted > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.submitted.dot)} />
|
||||
<span className="text-xs text-purple-700 truncate">
|
||||
{deadlinesByStatus.submitted} inskickad
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed/Confirmed deadlines */}
|
||||
{(deadlinesByStatus.completed > 0 || deadlinesByStatus.confirmed > 0) && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.confirmed.dot)} />
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{(deadlinesByStatus.completed || 0) + (deadlinesByStatus.confirmed || 0)} klar
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
'use client'
|
||||
|
||||
import { Invoice, Deadline } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus } from 'lucide-react'
|
||||
import {
|
||||
formatDateISO,
|
||||
isSameDay,
|
||||
isInvoiceOverdue,
|
||||
isDeadlineOverdue,
|
||||
getTimeSlots,
|
||||
formatDayViewHeader,
|
||||
DEADLINE_TYPE_LABELS,
|
||||
PRIORITY_LABELS,
|
||||
} from '@/lib/calendar/utils'
|
||||
|
||||
interface CalendarDayViewProps {
|
||||
date: Date
|
||||
invoices: Invoice[]
|
||||
deadlines: Deadline[]
|
||||
onAddDeadline: (date: Date) => void
|
||||
}
|
||||
|
||||
export function CalendarDayView({
|
||||
date,
|
||||
invoices,
|
||||
deadlines,
|
||||
onAddDeadline,
|
||||
}: CalendarDayViewProps) {
|
||||
const timeSlots = getTimeSlots(8, 20)
|
||||
const today = new Date()
|
||||
const isToday = isSameDay(date, today)
|
||||
const dateStr = formatDateISO(date)
|
||||
|
||||
// Filter items for this day
|
||||
const dayInvoices = invoices.filter(inv => inv.due_date === dateStr)
|
||||
const dayDeadlines = deadlines.filter(d => d.due_date === dateStr)
|
||||
|
||||
// Categorize items
|
||||
const activeInvoices = dayInvoices.filter(inv =>
|
||||
inv.status !== 'paid' && inv.status !== 'cancelled' && inv.status !== 'credited'
|
||||
)
|
||||
const paidInvoices = dayInvoices.filter(inv => inv.status === 'paid')
|
||||
const activeDeadlines = dayDeadlines.filter(d => !d.is_completed)
|
||||
const completedDeadlines = dayDeadlines.filter(d => d.is_completed)
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className={cn(
|
||||
'p-4 border-b flex items-center justify-between',
|
||||
isToday && 'bg-primary/10'
|
||||
)}>
|
||||
<h3 className={cn(
|
||||
'text-lg font-semibold capitalize',
|
||||
isToday && 'text-primary'
|
||||
)}>
|
||||
{formatDayViewHeader(date)}
|
||||
{isToday && <span className="ml-2 text-sm font-normal text-muted-foreground">(Idag)</span>}
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onAddDeadline(date)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Lägg till deadline
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* All-day events section */}
|
||||
<div className="border-b">
|
||||
<div className="p-2 bg-muted/30">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">Hela dagen</div>
|
||||
|
||||
{/* Active invoices */}
|
||||
{activeInvoices.length > 0 && (
|
||||
<div className="space-y-1 mb-2">
|
||||
{activeInvoices.map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className={cn(
|
||||
'p-2 rounded-md',
|
||||
isInvoiceOverdue(invoice)
|
||||
? 'bg-destructive/10 border border-destructive/30'
|
||||
: 'bg-primary/10 border border-primary/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
'w-3 h-3 rounded-full flex-shrink-0',
|
||||
isInvoiceOverdue(invoice) ? 'bg-destructive' : 'bg-primary'
|
||||
)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn(
|
||||
'text-sm font-medium',
|
||||
isInvoiceOverdue(invoice) ? 'text-destructive' : 'text-primary'
|
||||
)}>
|
||||
Faktura {invoice.invoice_number}
|
||||
{isInvoiceOverdue(invoice) && (
|
||||
<span className="ml-2 text-xs bg-destructive/20 px-1.5 py-0.5 rounded">
|
||||
Förfallen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{invoice.customer?.name} • {(invoice.total_sek || invoice.total).toLocaleString('sv-SE')} kr
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paid invoices */}
|
||||
{paidInvoices.length > 0 && (
|
||||
<div className="space-y-1 mb-2">
|
||||
{paidInvoices.map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className="p-2 rounded-md bg-success/10 border border-success/30"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-success flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-success">
|
||||
Faktura {invoice.invoice_number}
|
||||
<span className="ml-2 text-xs bg-success/20 px-1.5 py-0.5 rounded">
|
||||
Betald
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{invoice.customer?.name} • {(invoice.total_sek || invoice.total).toLocaleString('sv-SE')} kr
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active deadlines */}
|
||||
{activeDeadlines.length > 0 && (
|
||||
<div className="space-y-1 mb-2">
|
||||
{activeDeadlines.map((deadline) => (
|
||||
<div
|
||||
key={deadline.id}
|
||||
className={cn(
|
||||
'p-2 rounded-md',
|
||||
isDeadlineOverdue(deadline)
|
||||
? 'bg-destructive/10 border border-destructive/30'
|
||||
: 'bg-warning/10 border border-warning/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
'w-3 h-3 rounded-sm flex-shrink-0',
|
||||
isDeadlineOverdue(deadline) ? 'bg-destructive' : 'bg-warning'
|
||||
)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn(
|
||||
'text-sm font-medium',
|
||||
isDeadlineOverdue(deadline) ? 'text-destructive' : 'text-warning-foreground'
|
||||
)}>
|
||||
{deadline.title}
|
||||
{isDeadlineOverdue(deadline) && (
|
||||
<span className="ml-2 text-xs bg-destructive/20 px-1.5 py-0.5 rounded">
|
||||
Försenad
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{DEADLINE_TYPE_LABELS[deadline.deadline_type] || deadline.deadline_type}
|
||||
{deadline.priority !== 'normal' && (
|
||||
<span className={cn(
|
||||
'ml-2 px-1.5 py-0.5 rounded',
|
||||
deadline.priority === 'critical' && 'bg-red-100 text-red-700',
|
||||
deadline.priority === 'important' && 'bg-orange-100 text-orange-700'
|
||||
)}>
|
||||
{PRIORITY_LABELS[deadline.priority]}
|
||||
</span>
|
||||
)}
|
||||
{deadline.customer?.name && ` • ${deadline.customer.name}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed deadlines */}
|
||||
{completedDeadlines.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{completedDeadlines.map((deadline) => (
|
||||
<div
|
||||
key={deadline.id}
|
||||
className="p-2 rounded-md bg-success/10 border border-success/30 opacity-60"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-sm bg-success flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-success line-through">
|
||||
{deadline.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{DEADLINE_TYPE_LABELS[deadline.deadline_type] || deadline.deadline_type}
|
||||
<span className="ml-2 bg-success/20 px-1.5 py-0.5 rounded">Klar</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{dayInvoices.length === 0 && dayDeadlines.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground py-4 text-center">
|
||||
Inga händelser för denna dag
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time grid */}
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{timeSlots.map((time) => (
|
||||
<button
|
||||
key={time}
|
||||
onClick={() => onAddDeadline(date)}
|
||||
className="w-full flex border-b last:border-b-0 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="w-16 p-2 border-r text-xs text-muted-foreground text-right pr-2 flex-shrink-0">
|
||||
{time}
|
||||
</div>
|
||||
<div className="flex-1 min-h-[40px]" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { Invoice, Deadline } from '@/types'
|
||||
import { SWEDISH_DAYS, getMonthGrid } from '@/lib/calendar/utils'
|
||||
import { CalendarDayCell } from './CalendarDayCell'
|
||||
|
||||
interface CalendarGridProps {
|
||||
year: number
|
||||
month: number
|
||||
invoices: Invoice[]
|
||||
deadlines: Deadline[]
|
||||
onDayClick: (date: Date) => void
|
||||
}
|
||||
|
||||
export function CalendarGrid({
|
||||
year,
|
||||
month,
|
||||
invoices,
|
||||
deadlines,
|
||||
onDayClick,
|
||||
}: CalendarGridProps) {
|
||||
const weeks = getMonthGrid(year, month)
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
{/* Day headers */}
|
||||
<div className="grid grid-cols-7 bg-muted">
|
||||
{SWEDISH_DAYS.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="py-2 text-center text-sm font-medium text-muted-foreground border-r last:border-r-0"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Week rows */}
|
||||
{weeks.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="grid grid-cols-7">
|
||||
{week.map((date, dayIndex) => (
|
||||
<CalendarDayCell
|
||||
key={dayIndex}
|
||||
date={date}
|
||||
currentMonth={month}
|
||||
invoices={invoices}
|
||||
deadlines={deadlines}
|
||||
onDayClick={onDayClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CalendarViewMode } from '@/types'
|
||||
import {
|
||||
SWEDISH_MONTHS,
|
||||
getWeekNumber,
|
||||
formatDayViewHeader,
|
||||
} from '@/lib/calendar/utils'
|
||||
import { ViewModeSelector } from './ViewModeSelector'
|
||||
|
||||
interface CalendarHeaderProps {
|
||||
year: number
|
||||
month: number
|
||||
currentDate?: Date
|
||||
viewMode?: CalendarViewMode
|
||||
onPrevious: () => void
|
||||
onNext: () => void
|
||||
onToday: () => void
|
||||
onViewModeChange?: (mode: CalendarViewMode) => void
|
||||
}
|
||||
|
||||
export function CalendarHeader({
|
||||
year,
|
||||
month,
|
||||
currentDate,
|
||||
viewMode = 'month',
|
||||
onPrevious,
|
||||
onNext,
|
||||
onToday,
|
||||
onViewModeChange,
|
||||
}: CalendarHeaderProps) {
|
||||
// Generate title based on view mode
|
||||
const getTitle = () => {
|
||||
switch (viewMode) {
|
||||
case 'week':
|
||||
if (currentDate) {
|
||||
const weekNum = getWeekNumber(currentDate)
|
||||
return `Vecka ${weekNum}, ${currentDate.getFullYear()}`
|
||||
}
|
||||
return `${SWEDISH_MONTHS[month]} ${year}`
|
||||
|
||||
case 'day':
|
||||
if (currentDate) {
|
||||
return formatDayViewHeader(currentDate)
|
||||
}
|
||||
return `${SWEDISH_MONTHS[month]} ${year}`
|
||||
|
||||
case 'month':
|
||||
default:
|
||||
return `${SWEDISH_MONTHS[month]} ${year}`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" onClick={onPrevious}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={onNext}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onToday}>
|
||||
Idag
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold capitalize">
|
||||
{getTitle()}
|
||||
</h2>
|
||||
|
||||
{onViewModeChange && (
|
||||
<ViewModeSelector
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={onViewModeChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Spacer when no view mode selector */}
|
||||
{!onViewModeChange && <div />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client'
|
||||
|
||||
import { Invoice, Deadline } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
getWeekDays,
|
||||
formatWeekDayHeader,
|
||||
formatDateISO,
|
||||
isSameDay,
|
||||
isInvoiceOverdue,
|
||||
isDeadlineOverdue,
|
||||
getTimeSlots,
|
||||
} from '@/lib/calendar/utils'
|
||||
|
||||
interface CalendarWeekViewProps {
|
||||
currentDate: Date
|
||||
invoices: Invoice[]
|
||||
deadlines: Deadline[]
|
||||
onDayClick: (date: Date) => void
|
||||
}
|
||||
|
||||
export function CalendarWeekView({
|
||||
currentDate,
|
||||
invoices,
|
||||
deadlines,
|
||||
onDayClick,
|
||||
}: CalendarWeekViewProps) {
|
||||
const weekDays = getWeekDays(currentDate)
|
||||
const timeSlots = getTimeSlots(8, 20)
|
||||
const today = new Date()
|
||||
|
||||
// Group invoices and deadlines by date for quick lookup
|
||||
const getItemsForDate = (date: Date) => {
|
||||
const dateStr = formatDateISO(date)
|
||||
const dayInvoices = invoices.filter(inv => inv.due_date === dateStr)
|
||||
const dayDeadlines = deadlines.filter(d => d.due_date === dateStr)
|
||||
return { invoices: dayInvoices, deadlines: dayDeadlines }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
{/* Header row with day names and dates */}
|
||||
<div className="grid grid-cols-[60px_repeat(7,1fr)] bg-muted border-b">
|
||||
<div className="p-2 border-r" /> {/* Empty corner cell */}
|
||||
{weekDays.map((date, index) => {
|
||||
const isToday = isSameDay(date, today)
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onDayClick(date)}
|
||||
className={cn(
|
||||
'p-2 text-center border-r last:border-r-0 hover:bg-muted/80 transition-colors',
|
||||
isToday && 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'text-sm font-medium',
|
||||
isToday && 'text-primary font-bold'
|
||||
)}>
|
||||
{formatWeekDayHeader(date)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* All-day events section */}
|
||||
<div className="grid grid-cols-[60px_repeat(7,1fr)] border-b bg-muted/30">
|
||||
<div className="p-2 border-r text-xs text-muted-foreground">
|
||||
Heldag
|
||||
</div>
|
||||
{weekDays.map((date, index) => {
|
||||
const items = getItemsForDate(date)
|
||||
const isToday = isSameDay(date, today)
|
||||
|
||||
// Filter to only show non-timed items (all invoices and deadlines without times are "all-day")
|
||||
const allDayInvoices = items.invoices.filter(inv =>
|
||||
inv.status !== 'paid' && inv.status !== 'cancelled' && inv.status !== 'credited'
|
||||
)
|
||||
const allDayDeadlines = items.deadlines.filter(d => !d.is_completed)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'min-h-[60px] p-1 border-r last:border-r-0',
|
||||
isToday && 'bg-primary/5'
|
||||
)}
|
||||
>
|
||||
{/* Invoice indicators */}
|
||||
{allDayInvoices.map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className={cn(
|
||||
'text-xs p-1 mb-1 rounded truncate',
|
||||
isInvoiceOverdue(invoice)
|
||||
? 'bg-destructive/20 text-destructive'
|
||||
: 'bg-primary/20 text-primary'
|
||||
)}
|
||||
title={`Faktura ${invoice.invoice_number} - ${invoice.customer?.name || ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-current flex-shrink-0" />
|
||||
<span className="truncate">F#{invoice.invoice_number}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Deadline indicators */}
|
||||
{allDayDeadlines.map((deadline) => (
|
||||
<div
|
||||
key={deadline.id}
|
||||
className={cn(
|
||||
'text-xs p-1 mb-1 rounded truncate',
|
||||
isDeadlineOverdue(deadline)
|
||||
? 'bg-destructive/20 text-destructive'
|
||||
: 'bg-warning/20 text-warning-foreground'
|
||||
)}
|
||||
title={deadline.title}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-current flex-shrink-0" />
|
||||
<span className="truncate">{deadline.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Time grid */}
|
||||
<div className="max-h-[500px] overflow-y-auto">
|
||||
{timeSlots.map((time, timeIndex) => (
|
||||
<div key={time} className="grid grid-cols-[60px_repeat(7,1fr)] border-b last:border-b-0">
|
||||
<div className="p-2 border-r text-xs text-muted-foreground text-right pr-2">
|
||||
{time}
|
||||
</div>
|
||||
{weekDays.map((date, dayIndex) => {
|
||||
const isToday = isSameDay(date, today)
|
||||
return (
|
||||
<button
|
||||
key={dayIndex}
|
||||
onClick={() => onDayClick(date)}
|
||||
className={cn(
|
||||
'min-h-[40px] border-r last:border-r-0 hover:bg-muted/50 transition-colors',
|
||||
isToday && 'bg-primary/5'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Invoice, Deadline } from '@/types'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import {
|
||||
isInvoiceOverdue,
|
||||
isDeadlineOverdue,
|
||||
formatDateISO,
|
||||
DEADLINE_TYPE_LABELS,
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_COLORS,
|
||||
} from '@/lib/calendar/utils'
|
||||
import { FileText, CheckCircle, Clock, AlertTriangle, Plus } from 'lucide-react'
|
||||
|
||||
interface DayDetailModalProps {
|
||||
date: Date | null
|
||||
invoices: Invoice[]
|
||||
deadlines: Deadline[]
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onAddDeadline: (date: Date) => void
|
||||
onToggleDeadline: (deadline: Deadline) => void
|
||||
}
|
||||
|
||||
export function DayDetailModal({
|
||||
date,
|
||||
invoices,
|
||||
deadlines,
|
||||
open,
|
||||
onOpenChange,
|
||||
onAddDeadline,
|
||||
onToggleDeadline,
|
||||
}: DayDetailModalProps) {
|
||||
if (!date) return null
|
||||
|
||||
const dateStr = formatDateISO(date)
|
||||
const dayInvoices = invoices.filter(inv => inv.due_date === dateStr)
|
||||
const dayDeadlines = deadlines.filter(d => d.due_date === dateStr)
|
||||
|
||||
const formattedDate = date.toLocaleDateString('sv-SE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="capitalize">{formattedDate}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Invoices section */}
|
||||
{dayInvoices.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">
|
||||
Fakturor ({dayInvoices.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{dayInvoices.map((invoice) => {
|
||||
const overdue = isInvoiceOverdue(invoice)
|
||||
const paid = invoice.status === 'paid'
|
||||
return (
|
||||
<Link
|
||||
key={invoice.id}
|
||||
href={`/invoices/${invoice.id}`}
|
||||
className="block"
|
||||
>
|
||||
<div
|
||||
className={`p-3 rounded-lg border transition-colors hover:bg-muted/50 ${
|
||||
overdue ? 'border-destructive/50 bg-destructive/5' :
|
||||
paid ? 'border-success/50 bg-success/5' :
|
||||
'border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
{overdue ? (
|
||||
<AlertTriangle className="h-4 w-4 text-destructive mt-0.5" />
|
||||
) : paid ? (
|
||||
<CheckCircle className="h-4 w-4 text-success mt-0.5" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{invoice.invoice_number}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(invoice.customer as { name: string })?.name || 'Okänd kund'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-sm">
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
</p>
|
||||
<Badge
|
||||
variant={
|
||||
overdue ? 'destructive' :
|
||||
paid ? 'success' :
|
||||
'secondary'
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{overdue ? 'Förfallen' : paid ? 'Betald' : 'Väntande'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deadlines section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Deadlines ({dayDeadlines.length})
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onAddDeadline(date)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Lägg till
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{dayDeadlines.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
Inga deadlines denna dag
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{dayDeadlines.map((deadline) => {
|
||||
const overdue = isDeadlineOverdue(deadline)
|
||||
const completed = deadline.is_completed
|
||||
const priorityStyle = PRIORITY_COLORS[deadline.priority]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={deadline.id}
|
||||
className={`p-3 rounded-lg border transition-colors ${
|
||||
overdue && !completed
|
||||
? 'border-destructive/50 bg-destructive/5'
|
||||
: completed
|
||||
? 'border-success/50 bg-success/5'
|
||||
: `${priorityStyle.border} ${priorityStyle.bg}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<button
|
||||
onClick={() => onToggleDeadline(deadline)}
|
||||
className="mt-0.5"
|
||||
>
|
||||
{completed ? (
|
||||
<CheckCircle className="h-4 w-4 text-success" />
|
||||
) : overdue ? (
|
||||
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||
) : (
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
<div>
|
||||
<p className={`font-medium text-sm ${completed ? 'line-through text-muted-foreground' : ''}`}>
|
||||
{deadline.title}
|
||||
</p>
|
||||
{deadline.due_time && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
kl. {deadline.due_time.slice(0, 5)}
|
||||
</p>
|
||||
)}
|
||||
{deadline.customer && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{deadline.customer.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{DEADLINE_TYPE_LABELS[deadline.deadline_type]}
|
||||
</Badge>
|
||||
{!completed && deadline.priority !== 'normal' && (
|
||||
<Badge
|
||||
variant={deadline.priority === 'critical' ? 'destructive' : 'warning'}
|
||||
className="text-xs"
|
||||
>
|
||||
{PRIORITY_LABELS[deadline.priority]}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{dayInvoices.length === 0 && dayDeadlines.length === 0 && (
|
||||
<div className="text-center py-6">
|
||||
<Clock className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inga händelser denna dag
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => onAddDeadline(date)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Lägg till deadline
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Invoice, Deadline, CalendarViewMode } from '@/types'
|
||||
import {
|
||||
getWeekStart,
|
||||
} from '@/lib/calendar/utils'
|
||||
import { CalendarHeader } from './CalendarHeader'
|
||||
import { CalendarGrid } from './CalendarGrid'
|
||||
import { CalendarWeekView } from './CalendarWeekView'
|
||||
import { CalendarDayView } from './CalendarDayView'
|
||||
import { DayDetailModal } from './DayDetailModal'
|
||||
import { DeadlineForm } from '@/components/deadlines/DeadlineForm'
|
||||
|
||||
interface PaymentCalendarProps {
|
||||
invoices: Invoice[]
|
||||
deadlines: Deadline[]
|
||||
customers: { id: string; name: string }[]
|
||||
onDeadlineCreate: (data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'>) => Promise<void>
|
||||
onDeadlineToggle: (deadline: Deadline) => Promise<void>
|
||||
}
|
||||
|
||||
export function PaymentCalendar({
|
||||
invoices,
|
||||
deadlines,
|
||||
customers,
|
||||
onDeadlineCreate,
|
||||
onDeadlineToggle,
|
||||
}: PaymentCalendarProps) {
|
||||
const today = new Date()
|
||||
const [year, setYear] = useState(today.getFullYear())
|
||||
const [month, setMonth] = useState(today.getMonth())
|
||||
const [viewMode, setViewMode] = useState<CalendarViewMode>('month')
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(today)
|
||||
const [showDayModal, setShowDayModal] = useState(false)
|
||||
const [modalDate, setModalDate] = useState<Date | null>(null)
|
||||
const [showDeadlineForm, setShowDeadlineForm] = useState(false)
|
||||
const [deadlineFormDate, setDeadlineFormDate] = useState<Date | null>(null)
|
||||
|
||||
// Navigation handlers based on view mode
|
||||
const handlePrevious = useCallback(() => {
|
||||
if (viewMode === 'month') {
|
||||
if (month === 0) {
|
||||
setMonth(11)
|
||||
setYear(year - 1)
|
||||
} else {
|
||||
setMonth(month - 1)
|
||||
}
|
||||
} else if (viewMode === 'week') {
|
||||
const newDate = new Date(selectedDate)
|
||||
newDate.setDate(newDate.getDate() - 7)
|
||||
setSelectedDate(newDate)
|
||||
setYear(newDate.getFullYear())
|
||||
setMonth(newDate.getMonth())
|
||||
} else if (viewMode === 'day') {
|
||||
const newDate = new Date(selectedDate)
|
||||
newDate.setDate(newDate.getDate() - 1)
|
||||
setSelectedDate(newDate)
|
||||
setYear(newDate.getFullYear())
|
||||
setMonth(newDate.getMonth())
|
||||
}
|
||||
}, [viewMode, month, year, selectedDate])
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (viewMode === 'month') {
|
||||
if (month === 11) {
|
||||
setMonth(0)
|
||||
setYear(year + 1)
|
||||
} else {
|
||||
setMonth(month + 1)
|
||||
}
|
||||
} else if (viewMode === 'week') {
|
||||
const newDate = new Date(selectedDate)
|
||||
newDate.setDate(newDate.getDate() + 7)
|
||||
setSelectedDate(newDate)
|
||||
setYear(newDate.getFullYear())
|
||||
setMonth(newDate.getMonth())
|
||||
} else if (viewMode === 'day') {
|
||||
const newDate = new Date(selectedDate)
|
||||
newDate.setDate(newDate.getDate() + 1)
|
||||
setSelectedDate(newDate)
|
||||
setYear(newDate.getFullYear())
|
||||
setMonth(newDate.getMonth())
|
||||
}
|
||||
}, [viewMode, month, year, selectedDate])
|
||||
|
||||
const handleToday = useCallback(() => {
|
||||
const today = new Date()
|
||||
setYear(today.getFullYear())
|
||||
setMonth(today.getMonth())
|
||||
setSelectedDate(today)
|
||||
}, [])
|
||||
|
||||
const handleViewModeChange = useCallback((mode: CalendarViewMode) => {
|
||||
setViewMode(mode)
|
||||
// When switching to week view, ensure selectedDate is set properly
|
||||
if (mode === 'week') {
|
||||
const weekStart = getWeekStart(selectedDate)
|
||||
setSelectedDate(weekStart)
|
||||
}
|
||||
}, [selectedDate])
|
||||
|
||||
const handleDayClick = useCallback((date: Date) => {
|
||||
if (viewMode === 'month') {
|
||||
// In month view, clicking a day shows the modal
|
||||
setModalDate(date)
|
||||
setShowDayModal(true)
|
||||
} else if (viewMode === 'week') {
|
||||
// In week view, clicking a day switches to day view
|
||||
setSelectedDate(date)
|
||||
setViewMode('day')
|
||||
setYear(date.getFullYear())
|
||||
setMonth(date.getMonth())
|
||||
}
|
||||
}, [viewMode])
|
||||
|
||||
const handleAddDeadline = useCallback((date: Date) => {
|
||||
setDeadlineFormDate(date)
|
||||
setShowDayModal(false)
|
||||
setShowDeadlineForm(true)
|
||||
}, [])
|
||||
|
||||
const handleDeadlineFormClose = useCallback(() => {
|
||||
setShowDeadlineForm(false)
|
||||
setDeadlineFormDate(null)
|
||||
}, [])
|
||||
|
||||
const handleDeadlineSubmit = useCallback(async (data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'>) => {
|
||||
await onDeadlineCreate(data)
|
||||
handleDeadlineFormClose()
|
||||
}, [onDeadlineCreate])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<CalendarHeader
|
||||
year={year}
|
||||
month={month}
|
||||
currentDate={selectedDate}
|
||||
viewMode={viewMode}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onToday={handleToday}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
/>
|
||||
|
||||
{/* Month View */}
|
||||
{viewMode === 'month' && (
|
||||
<CalendarGrid
|
||||
year={year}
|
||||
month={month}
|
||||
invoices={invoices}
|
||||
deadlines={deadlines}
|
||||
onDayClick={handleDayClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Week View */}
|
||||
{viewMode === 'week' && (
|
||||
<CalendarWeekView
|
||||
currentDate={selectedDate}
|
||||
invoices={invoices}
|
||||
deadlines={deadlines}
|
||||
onDayClick={handleDayClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Day View */}
|
||||
{viewMode === 'day' && (
|
||||
<CalendarDayView
|
||||
date={selectedDate}
|
||||
invoices={invoices}
|
||||
deadlines={deadlines}
|
||||
onAddDeadline={handleAddDeadline}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Day detail modal (for month view clicks) */}
|
||||
<DayDetailModal
|
||||
date={modalDate}
|
||||
invoices={invoices}
|
||||
deadlines={deadlines}
|
||||
open={showDayModal}
|
||||
onOpenChange={setShowDayModal}
|
||||
onAddDeadline={handleAddDeadline}
|
||||
onToggleDeadline={onDeadlineToggle}
|
||||
/>
|
||||
|
||||
{/* Deadline form dialog */}
|
||||
<DeadlineForm
|
||||
open={showDeadlineForm}
|
||||
onOpenChange={handleDeadlineFormClose}
|
||||
onSubmit={handleDeadlineSubmit}
|
||||
initialDate={deadlineFormDate}
|
||||
customers={customers}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Invoice } from '@/types'
|
||||
import { calculatePeriodSummary, SWEDISH_MONTHS } from '@/lib/calendar/utils'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { TrendingUp, Clock, CheckCircle, AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface PaymentSummaryCardProps {
|
||||
invoices: Invoice[]
|
||||
year: number
|
||||
month: number
|
||||
}
|
||||
|
||||
export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCardProps) {
|
||||
const summary = calculatePeriodSummary(invoices)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">
|
||||
Sammanfattning {SWEDISH_MONTHS[month]} {year}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Expected */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Förväntat</p>
|
||||
<p className="text-sm font-medium">{summary.pendingCount} fakturor</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg font-semibold">{formatCurrency(summary.totalExpected)}</p>
|
||||
</div>
|
||||
|
||||
{/* Overdue */}
|
||||
{summary.overdueCount > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
|
||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Förfallen</p>
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{summary.overdueCount} fakturor
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-destructive">
|
||||
{formatCurrency(summary.totalOverdue)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paid */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
|
||||
<CheckCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Betald</p>
|
||||
<p className="text-sm font-medium">{summary.paidCount} fakturor</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-success">
|
||||
{formatCurrency(summary.totalPaid)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pending */}
|
||||
{summary.pendingCount > 0 && summary.overdueCount < summary.pendingCount && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Väntande</p>
|
||||
<p className="text-sm font-medium">
|
||||
{summary.pendingCount - summary.overdueCount} fakturor
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg font-semibold">
|
||||
{formatCurrency(summary.totalExpected - summary.totalOverdue)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { CalendarViewMode } from '@/types'
|
||||
import { VIEW_MODE_LABELS } from '@/lib/calendar/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ViewModeSelectorProps {
|
||||
viewMode: CalendarViewMode
|
||||
onViewModeChange: (mode: CalendarViewMode) => void
|
||||
}
|
||||
|
||||
export function ViewModeSelector({ viewMode, onViewModeChange }: ViewModeSelectorProps) {
|
||||
const modes: CalendarViewMode[] = ['month', 'week', 'day']
|
||||
|
||||
return (
|
||||
<div className="inline-flex rounded-md border">
|
||||
{modes.map((mode) => (
|
||||
<Button
|
||||
key={mode}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onViewModeChange(mode)}
|
||||
className={cn(
|
||||
'rounded-none border-r last:border-r-0 px-3',
|
||||
viewMode === mode && 'bg-muted'
|
||||
)}
|
||||
>
|
||||
{VIEW_MODE_LABELS[mode]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
|
||||
export const calendarExtension: Extension = {
|
||||
id: 'calendar',
|
||||
name: 'Kalender',
|
||||
version: '1.0.0',
|
||||
}
|
||||
@@ -152,7 +152,7 @@ export function createTaxDeadlinePayload(
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `tax-deadline-${deadlineId}`,
|
||||
data: {
|
||||
url: '/calendar',
|
||||
url: '/deadlines',
|
||||
type: 'tax_deadline',
|
||||
id: deadlineId,
|
||||
},
|
||||
|
||||
@@ -28,6 +28,10 @@ const BAS_ACCOUNT_MAPPING: Record<TransactionCategory, string> = {
|
||||
expense_marketing: '5910', // Annonsering
|
||||
expense_professional_services: '6530', // Redovisningstjänster
|
||||
expense_education: '6991', // Övriga avdragsgilla kostnader
|
||||
expense_representation: '6071', // Representation
|
||||
expense_consumables: '5460', // Förbrukningsvaror
|
||||
expense_vehicle: '5611', // Drivmedel bil
|
||||
expense_telecom: '6200', // Telefon och internet
|
||||
expense_bank_fees: '6570', // Bankavgifter
|
||||
expense_card_fees: '6570', // Kortavgifter
|
||||
expense_currency_exchange: '7960', // Valutakursförluster
|
||||
@@ -273,6 +277,10 @@ export const CATEGORY_LABELS: Record<TransactionCategory, string> = {
|
||||
expense_marketing: 'Marknadsföring',
|
||||
expense_professional_services: 'Konsulttjänster',
|
||||
expense_education: 'Utbildning',
|
||||
expense_representation: 'Representation',
|
||||
expense_consumables: 'Material',
|
||||
expense_vehicle: 'Bil & drivmedel',
|
||||
expense_telecom: 'Telefon & internet',
|
||||
expense_bank_fees: 'Bankavgift',
|
||||
expense_card_fees: 'Kortavgift',
|
||||
expense_currency_exchange: 'Valutaväxling',
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||||
import type { EventPayload } from '@/lib/events/types'
|
||||
|
||||
// ============================================================
|
||||
// Settings
|
||||
// ============================================================
|
||||
|
||||
export interface UserDescriptionMatchSettings {
|
||||
batchApplyEnabled: boolean
|
||||
minConfidenceThreshold: number
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: UserDescriptionMatchSettings = {
|
||||
batchApplyEnabled: true,
|
||||
minConfidenceThreshold: 0.55,
|
||||
}
|
||||
|
||||
export async function getSettings(userId: string): Promise<UserDescriptionMatchSettings> {
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', userId)
|
||||
.eq('extension_id', 'user-description-match')
|
||||
.eq('key', 'settings')
|
||||
.single()
|
||||
|
||||
if (!data?.value) return { ...DEFAULT_SETTINGS }
|
||||
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<UserDescriptionMatchSettings>) }
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
userId: string,
|
||||
partial: Partial<UserDescriptionMatchSettings>
|
||||
): Promise<UserDescriptionMatchSettings> {
|
||||
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: 'user-description-match',
|
||||
key: 'settings',
|
||||
value: merged,
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event Handler
|
||||
// ============================================================
|
||||
|
||||
async function handleTransactionCategorized(
|
||||
payload: EventPayload<'transaction.categorized'>,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<void> {
|
||||
const { transaction, userId } = payload
|
||||
const log = ctx?.log ?? console
|
||||
|
||||
if (!transaction.merchant_name) return
|
||||
|
||||
const settings = ctx
|
||||
? { ...(DEFAULT_SETTINGS), ...(await ctx.settings.get<Partial<UserDescriptionMatchSettings>>() || {}) }
|
||||
: await getSettings(userId)
|
||||
|
||||
if (!settings.batchApplyEnabled) return
|
||||
|
||||
try {
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
|
||||
// Check if the rule that fired was a user-described rule
|
||||
const { data: rule } = await supabase
|
||||
.from('mapping_rules')
|
||||
.select('id, user_description, merchant_pattern')
|
||||
.eq('user_id', userId)
|
||||
.eq('source', 'user_description')
|
||||
.ilike('merchant_pattern', transaction.merchant_name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
if (!rule) return
|
||||
|
||||
// Count uncategorized siblings
|
||||
const { count } = await supabase
|
||||
.from('transactions')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.eq('merchant_name', transaction.merchant_name)
|
||||
.is('journal_entry_id', null)
|
||||
|
||||
if (!count || count === 0) return
|
||||
|
||||
// Store batch hint for the UI
|
||||
await supabase.from('extension_data').upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
extension_id: 'user-description-match',
|
||||
key: `batch_hint:${transaction.merchant_name}`,
|
||||
value: {
|
||||
merchant_name: transaction.merchant_name,
|
||||
uncategorized_count: count,
|
||||
user_description: rule.user_description,
|
||||
},
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
|
||||
log.info(`[user-description-match] Batch hint stored: ${count} uncategorized for ${transaction.merchant_name}`)
|
||||
} catch (error) {
|
||||
log.error('[user-description-match] handleTransactionCategorized failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Extension Object
|
||||
// ============================================================
|
||||
|
||||
export const userDescriptionMatchExtension: Extension = {
|
||||
id: 'user-description-match',
|
||||
name: 'Beskrivningsmatchning',
|
||||
version: '1.0.0',
|
||||
eventHandlers: [
|
||||
{ eventType: 'transaction.categorized', handler: handleTransactionCategorized },
|
||||
],
|
||||
settingsPanel: {
|
||||
label: 'Beskrivningsmatchning',
|
||||
path: '/settings/extensions/user-description-match',
|
||||
},
|
||||
async onInstall(ctx) {
|
||||
await ctx.settings.set('settings', DEFAULT_SETTINGS)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user